「这是我参与2022首次更文挑战的第11天,活动详情查看:2022首次更文挑战」。
前言
今天,我们将会了解到什么是“元编程”并且我们将会在JavaScript中使用老和新的API实现元编程的概念。
根据wikipedia:
“Metaprogramming” is a programming technique in which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyze, or transform other programs, and even modify itself while running.
元编程是一种编程技术,其中计算机程序能够将其他程序视为它的数据。这意味着可以设计一个程序来读取、生成、分析或转换其 他程序,甚至在运行时修改自己。
上述的定义非常准备但是也过于冗长了。事实上,它几乎总结了大部分的概念,让我们来探索他们中的一些概念。
在我们开始谈论元编程之前,我先声明以下几点:
元编程对于不同的人和不同的编程语言来说意味着不同的东西。 因为不是编程语言功能,所以它肯定没有标准化。因此,如果您不同意我的解释,请不要在评论区引战。让我们将注意力集中在元编程的定义上。
什么是元编程
当你编写一个包含一些逻辑并且在运行时会产生写输出的程序。在运行时(当程序正在执行时),我们的程序会使用由用户提供或者是通过远程网络调用获取的一些数据,并根据我们的意愿进行转换。因此,简而言之,程序操纵数据以实现所需的结果。
元编程是一种操纵其他程序的程序。现在让我们进行更深层次的探索。正如我们刚刚那样,一个程序能够操纵数据;因此有能力操纵其他程序的程序是元编程。在技术上,元编程能够接受其他程序作为其数据并操纵它。
我们将更详细的讨论“操纵”的真实意图,但简而言之,它意味着通过各种手段检查和修改程序的行为。它还可能意味着通过各种手段在程序中注入新行为,这意味着在程序中可以生成新代码。
一种操作以完全不同的编程语言编写的其他程序的编程语言称为元编程语言。如果您使用TypeScript,那么您才意识到它是一种元编程语言,因为它会产生用JavaScript编写的程序。Java也是一种元编程语言,因为它会生成字节码(一种语言形式)。
一种能够操作自身的编程语言被称为“homoiconic language”。如果你不知道什么是“homoiconic language”,我推荐阅读一下这篇文章(Concept of the Day: Homoiconicity - DEV Community)。在一门“homoiconic language”中,程序本身可以检查自己并修改自己的某些部分,除非您是LISP开发人员,否则我们在日常编程中没有看到太多这样的操作。
“program”这个词在元编程的背景下留下了错误的印象并且我会告诉你为什么。对我们来说,程序是一种以纯文本格式编写的代码。我们可以编译代码以创建二进制可执行文件(.exe文件),然后本地运行它,我们也可以在代码解释器(VM)中运行它,比如JavaScript引擎。
元编程概念分为两种不同的类别。编译时和运行时。
编译时意味着代码被编译成低级或高级编程语言。例如,TypeScript被编译为JavaScript。运行时是代码正在运行的时候。例如,当JavaScript程序在NodeJs(VM)内运行时。
一旦代码在解释器(或虚拟机)内执行,程序通常表示从代码(纯文本)解释的运行时代码的逻辑行为。例如,简单的JavaScript代码可以在运行时在运行时创建许多复杂的实体(例如函数,类,对象等),这些实体可以具有复杂的行为。
在技术上,如果编程语言具有在运行时更改其行为的能力,那么可以说是支持元编程的。简单地说,如果程序可以在运行时操纵本身,那么它支持元编程。
因为JavaScript肯定可以在运行时修改自身的行为,所以它绝对支持元编程。别担心,我们将花费很多时间讨论在JavaScript中元编程是如何工作的。
在运行时操纵自身的行为或者其他程序的行为的程序能力被称为“monkey patching”。Monkey Patching简单的来说就是在运行时调整程序的某些部分,而不需要去修改程序的源代码。
接下来,让我们看一个简单的JavaScript程序,我们将会操纵JavaScript运行时的内部实现。
正如上面的这个例子,我们通过使用 monkey patching的方式在运行时修改了 String 类的行为,但是我们并没有修改String类在 V8引擎中的源代码。
由于元编程在编译时和运行时代表不同的事物,元编程被分为两个部分:代码生成和反射。
代码生成简单的说就是编程语言生成程序代码的能力。这就是为什么有时元编程是概括为写入程序的程序。另一方面,反射是程序操纵自己或其他程序的能力。它们可以进一步分类如下所示。
我们将更详细地讨论这些概念,但从这里开始,不再区分编译时或运行时在元编程中的区别,因为某些编程语言也可以在运行时进行代码生成,一些语言也可以在在编译时进行反射。
代码生成
代码生成是元编程中的一个重要概念。代码生成基本上意味着在编译时或者在运行时能够添加程序到现有的程序中。
由于代码生成可以发生在编译时和运行时,他们被分成了两类:macros和eval
Macros
如果你是C/C++开发者,那么你应该对macro很熟悉了。macro就是一段代码(通常是一个词语),它可以在编译时被扩展为多行代码。在编译程序为低层次的语言前,预处理器扩展这些macro并且将其送至编译器。所以编译器总是会得到有意义的程序代码。
在JavaScript中,我们不能使用macros, 因为我们不能将JavaScript编译为机器码并送至JavaScript引擎中,这些工作都是由JavaScript引擎自己完成的,这就是我们所说的Just-In-Time(JIT)编译。
如果有另一种方式可以从其他的高等级语言产生JavaScript语言,那么使用macro就是可能的。例如,TypeScript就可以用macro,但是实际上,TypeScript中并没有macro。Sweet.js是包含有macro,并且能够将其编译为JavaScript。
“Hygienic Macros for JavaScript! Macros allow you to build the language of your dreams. Sweeten JavaScript by defining a new syntax for your code.” — Sweet.js
Eval
如果你是一名JavaScript的开发者,那么你大概率使用过 eval 函数,该函数接受一个字符串并且把字符串当做JavaScript源代码执行。所以从技术上来说我们可以在运行时产生JavaScript 代码
正如你看到的这样, eval 是一个能够动态生成代码并执行的强大工具。但是当你加入一个公司时没有人告诉新员工去使用eval。为什么?因为 eval 是一把双刃剑,它很可能为黑客侵入你的网站大开方便之门。
正如你在MDN中看到的那样Function - JavaScript | MDN (mozilla.org),Function构造函数可以帮助你用字符串来生成JavaScript函数,但是它并没有类似于eval 那样的风险。
不仅仅是JavaScript,Python也同样拥有类似于 eval 函数这样的功能,事实上,大部分的编程语言,尤其是解释之星的语言,都有一些内置的 eval 函数可以在运行时产生代码。
总的来说,macros 和 eval 都是很棒的元编程工具。
反射
反射,与“代码生成”不同,是改变编程语言底层机制的处理过程。反射能够在运行时和编译时进行,但是我们在JavaScript中讨论的都是运行时的反射。当然对于编译型语言来说,这里讨论的概念也同样适用。
正如我们所知,反射都是关于改变语言的底层机制,它已被分为三大类:
- introspection
- intercession
- modification
Introspection (内省)
introspection是分析程序的过程。如果你能够说出一个程序是干嘛的,那么你就能根据自己的喜好去修改它。即时一些编程语言并不支持代码生成或者是代码修改的特性,但是他们通常都支持introspection
一个简单introspection的例子就是在JavaScript使用 typeof 或者是 instanceof 操作符。typeof 返回一个值得数据类型, instanceof 返回 true 或者 false,如果表达式的左值是表达式右值的类的实例。让我们具体看一下这个例子。
在上面这个程序中,我们在coerce函数中使用了 typeof 和 instanceof 操作符来嗅探传入的参数 value 的数据类型。这就是一个instrospection基本的使用例子。但是,一门为元编程设计的语言通常会提供更为强大的内省能力。
你可以使用 in 操作符来检查一个属性是否在一个对象中存在。isNaN 全局函数检查一个对象是否是 NaN 类型的数据。在 Object 对象上还有一些内置的静态方法来检查 Object 的类型,比如 Object.isFrozen(value) 可以检查 value 是否被冻结,或者 Object.keys(value) 可以得到对象上的属性名。
直到ES5,我还只有这些操作符和一些方法可以做到内省。但是在ES6中,JavaScript提供了 Reflect 对象,并且提供了一些静态方法来实现内省。由于我们有另一篇文章专门介绍 Reflect,我们将在那儿讨论Reflect中的方法。
Intercession
intercession是在JavaScript中干预并修改程序的标准行为的过程。JavaScript为intercession提供了一个很好的工具,其中之一就是 Proxy。
在ES6中,提供了Proxy 类,它可以拦截JavaScript的一些操作。简单的来说,Proxy就是为对象包装了拦截逻辑。
var targetWithProxy = new Proxy(target, handler);
其中, target 是一个对象, handler 是拦截器。handler 同样也是一个普通的JavaScript对象但是其有一些有意义的属性。例如: handler.get 是一个当 target.prop 被读取时能够返回自定义值的函数。
代理是提供对你的非公共数据提供抽象的好方法。例如,在上面的程序中,我们已经为目标对象提供了抽象,并定制它应该如何向公众呈现。
一些intercession在ES5中可以使用对象描述符中的 getter 或者 setter 来使用,但是它会导致对象目标的变化。Proxy 提供了一种更加干净的方法来实现 intercession,并且它不会修改原始对象(target)。
Modification
modification指的是通过改变程序行为。在 intercession的例子中,我们仅仅通过在 target和 receiver中增加了拦截的逻辑,我们并没有对 target 产生侵害。在 modification的例子中,我们改变了target 本身的行为,
重写一个函数的实现是一个关于modification的很好的例子。让我们看下面这个例子:
在上面的这个例子中,我们创建一个能够重写自己的函数。这是一个侵害原函数很严重的例子,但是我们还有其他的更具有说明意义的例子。
在上面的例子中,我们使用了 Object.defineProperty() 方法来改变对应属性的默认属性描述符并使该属性变为只读的。你也可以使用Object.freeze()方法来冻结整个对象来避免任何的改动。
一些 intercession 可能在 modification的过程中也会发生。比如我们为属性描述符设置了 writable: false,因此该对象变为了不可变的。我们在读取这个值时相当于是拦截了对象的赋值行为。
valueOf 方法是用于将一个对象强制转换为基础类型值得方法。所以如果有一个对象或者其原型链上拥有 valueOf 方法,当你尝试在这个对象上执行一些数学运算,该方法就会被调用。在JavaScript中,valueOf 默认会返回其对象本身。
正如你在上述例子中看到的那样, 由于一个对象不能够与一个自然数进行除法,所以emp1/10 的结果是 NaN。但是后续我们为 Employee 类增加了valueOf 方法,并且返回了该类的 salary 值,所以 emp2 / 10 相当于 200 / 10。类似的, emp3 / 10 返回30,因为我们为 emp3 对象本身增加了 valueOf 方法。
在上面的例子中的每一次,我们拦截了一个对象被JavaScript操作的方式并且根据我们的喜好改变它。但是我们没有修改对象的值,所以这是intercession。
在ES6中,JavaScript提供了一种新的基本数据类型,就是 Symbol。这是一种我们之前没有见过并且不能够用字面量的形式来表示的数据类型。它仅仅只能够通过调用Symbol 函数来构造。
var sym1 = Symbol();
var sym2 = Symbol();
var sym3 = Symbol('description'); // description for debugging aidsym1 === sym2 // false
sym1 === sym2 // falsetypeof sym1 // 'symbol' console.log( sym1 ); // 'Symbol()'
console.log( sym3 ); // 'Symbol(description)'
简单的说,Symbol会产生一个独一无二的值,我们通过可以将其用作对象的key值。
var key = Symbol();var obj = {
name: 'Ross',
[key]: 200
};console.log( obj.name ); // 'Ross'
console.log( obj[key] ); // 200
Object.keys(obj); // ["name"] obj[key] = 300;
由于他们都是独一无二的,所以没有其他的方法能够拷贝symbol。每一个symbol都是独一无二的,所以这也意味着,如果你想使用相同的symbol,那么你必须要使用一个变量来储存它的值。
在 valueOf 的例子中,你可以发现一些问题。由于 valueOf 是一个字符串属性,任何人都可能不小心的覆盖它。
由于Symbol也可以用作对象键,因此JavaScript提供了一些全局Symbol,该Symbol应该用作某些标准JavaScript操作的对象键。由于这些Symbol是开发人员所熟知的,因此它们被称为“众所周知的Symbol”。这些众所周知的Symbol被视为Symbol函数的静态属性。
“众所周知的Symbol”的其中之一就是 Symbol.toPrimitive,它表示返回对象基础类型值函数的键名。是的,使用Symbol.toPrimitive是更好的替代valueOf的方法。
JavaScript提供了许多如此众所周知的Symbol来拦截和修改对象周围的默认JavaScript行为。我们将在Symbol文章中谈论它。
装饰器是元编程中使用反射完美示例。您可以在类的顶部放入诸如@Abstract等的注释类型,它将修改该类,使其可以在另一个类中继承,但无法实例化以创建对象。可以参考这篇文章A minimal guide to JavaScript (ECMAScript) Decorators and Property Descriptor of the Object | by Uday Hiwarale | JsPoint | Medium
一个新的ECMAScript提案为ReflectAPI推出了新方法,这些方法将纯粹用于元编程。该提案和polyfill已经通过reflect-metadata包实现。由于这种包在设计装饰器时也大量用于TypeScript,因此讨论它也是非常有必要的。可以参考这篇文章:Introduction to “reflect-metadata” package and its ECMAScript proposal | by Uday Hiwarale | JsPoint | Medium
原文地址
A brief introduction to Metaprogramming in JavaScript | by Uday Hiwarale | JsPoint | Medium