替换《reflect-metadata 包以及 ECMAScript 提案》
按照维基百科的定义:
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 也是一种元语言,用于生成字节码。
一种编程语言可以维护其自身程序的,被称为同像性语言。更详细地阐述可以阅读这篇文章。在同像性语言中,语言本身可以检测自身,修改自身的特定部分,这些在我们日常使用的语言中并不常见,除非你是一个 Lisp 开发者。
在元编程上下文中,“程序”这个词往往会有一点错误的印象。对我们来说,程序是由纯文本格式编写的一段代码。我们可以把代码编译成二进制可执行文件(例如 .exe 文件),然后原生运行;也可以在一个解释器(或 VM),比如 JavaScript 引擎中直接运行(比如 JavaScript 代码)。
元编程的概念分为两个不同的类型:编译期和运行时。编译期是代码被编译成另外一种低级或高级编程语言的时期。例如,TypeScript 编译成 JavaScript。运行期则是代码运行的时期。例如,JavaScript 程序在 Node (VM)中运行。
一旦代码在解释器(或虚拟机)中被执行,程序这个词就变成了正在解释执行的运行时的代码的逻辑行为。例如,简单的 JavaScript 代码可以在运行时创建很多复杂的实体,例如函数、类、对象等,这些实体可能有非常复杂的行为。
从技术上说,如果一个编程语言能够在运行时改变其行为,那么,它就可以被称为支持元编程。类似的,如果一个程序可以在运行时改变自己的行为,那么,这个程序也就支持元编程。
现在你可以把钱押在 JavaScript 上面,因为 JavaScript 可以在运行时改变其行为,所以也就是支持元编程。别担心,我们会在后面详细介绍在 JavaScript 中如何进行元编程。
程序在运行时维护自身或其它程序行为的操作被称为猴子补丁 monkey patching。简单来说,猴子补丁就是在运行时对程序的特定部分进行微调,以便符合期望的结果,无需修改程序的源代码。
我们看 JavaScript 程序的一个小例子。这里,我们在运行时修改了 JavaScript 的一个内部实现。
// add `toStartCase` method to `string`s String.prototype.toStartCase = function() { let [ first, ...rest ] = this; return first.toUpperCase() + rest.join( '' ); } // modify `toLowerCase` method of `string`s String.prototype.toLowerCase = function() { return null; // not allowed } // convert `hello` string to start case var hello = 'hello world'; console.log( hello.toStartCase() ); // Hello world console.log( hello.toLowerCase() ); // null
上面的代码,我们在运行时改变了String
类的行为,影响了string
类型,方法是通过猴子补丁,因为我们并没有修改 V8 String
类的源代码。
由于元编程在编译期和运行时具有不同的含义,元编程也可以因此分为两部分:代码生成和反射。
代码生成是利用编程语言的能力生成程序代码。因此,有时候元编程也被称为编写程序的程序。反射则是程序修改自身或其它程序的能力。代码生成和反射更细的分类如下:
元编程 | ||||
代码生成 | 反射 | |||
字符串执行 Eval | 宏 Macros | 内省 Introspection | 代理 Intercession | 修改 Modification |
接下来我们会详细介绍这些概念,但是需要注意的是,不要死板地理解“编译期”和“运行时”这样的术语,因为也有一些编程语言可以在运行时生成代码,也可以在编译期实现反射。
代码生成
代码生成是元编程的重要概念之一。代码生成意味着可以向已有程序添加功能,这既可以发生在编译期,也可以发生在运行时。
这里还要提醒一下,不要搞混“代码”一词,仅仅把代码生成当做是生成程序功能,比如向程序文件添加额外的代码(纯文本)或者在运行时添加编译后的代码。
由于代码生成既可以发生在编译期,又可以发生在运行时,所以,代码生成通常分为两种类型:宏和字符串执行。
宏
如果你熟悉 C 或 C++,就应该知道什么是宏。宏就是一段代码片段(通常是一个单词),可以在编译期被展开成很多行代码。在将程序编译成低级语言之前,预处理器会将这些宏展开,然后将处理过之后的文件提交给编译期。所以,编译器只会收到合法的程序代码。
如果你对 C++ 的宏感兴趣,可以阅读这篇文章。
JavaScript 语言并不存在宏,因为 JavaScript 不需要编译成机器码之后再发送给 JavaScript 引擎。相反地,JavaScript 引擎会自己完成编译这件事,我们称之为即时(Just-In-Time,JIT)编译。
如果存在另外一种更高级的语言可以生成 JavaScript 代码,宏就是有可能存在的了。例如,TypeScript 可以有宏,但不幸的是,它并没有提供这一功能。Sweet.js 是 GitHub 上面的一个开源项目,它可以将类似 JavaScript 语言的代码编译成 JavaScript 语言,而它提供了宏。
你可以按照文档安装 CLI 工具,将 .sjs 文件编译成 .js 文件。这里,.sjs 文件是可以包含宏的 JavaScript 文件,而 .js 文件是编译之后的最终文件,也就是纯粹的 JavaScript 代码。
// define a macro syntax DEBUG_FUNC = function() { return #`console.log('A function was called.');`; }; // sample functions function func_a() { DEBUG_FUNC; console.log( 'I am result of the function "a".' ); } function func_b() { DEBUG_FUNC; console.log( 'I am result of the function "b".' ); } // call sample functions func_a(); func_b();
在上面的例子中,program.sjs 文件包含DEBUG_FUNC
宏。在 Sweet.js 中,宏就是使用syntax
关键字定义的函数,就像var
关键字。这个函数必须返回#
开头的模板字符串。这个模板字符串包含真正的 JavaScript 代码,会替换宏出现的地方。
sjs -o program.js program.sjs
在这篇文档可以了解 Sweet.js 文件的编译过程。使用上面的代码,program.sjs 程序编译生成 program.js 文件。生成的文件就像下面:
function func_a() { console.log("A function was called."); console.log('I am result of the function "a".'); } function func_b() { console.log("A function was called."); console.log('I am result of the function "b".'); } func_a(); func_b();
注意,上面DEBUG_FUNC
(包括后面的;
)在编译期被替换成了由宏函数返回的真正的代码。现在,你知道宏的威力了。
利用宏,JavaScript(带有一些小的修改)可以生成合法的程序。所以,根据元编程理论,在 Sweet.js 环境中,JavaScript 就是它自己的元语言。
字符串执行
如果你是一个 JavaScript 开发者,你可能会用过eval
函数。这个函数接受一个字符串作为参数,如果这个字符串是一段 JavaScript 代码,这个字符串就会被执行。所以,从技术上说,我们可以在运行时生成 JavaScript 代码。
eval(` function sayHello() { console.log( "Hello World" ); } `); // call `sayHello` function as if it is defined sayHello(); // Hello World /*------*/ // dynamic function generator function generator( a, b, opeation ) { if( opeation === 'ADD' ) { return eval( "() => a + b" ); } else { return eval( "() => a - b" ); } } const operate = generator(5, 3, 'ADD'); console.log( "operate() =>", operate() ); // operate() => 8
正如看到的那样,eval
是一个强大的工具,可以从简单的表示 JavaScript 代码的字符串动态执行或生成代码。你可以将eval(str)
放在程序的任意位置,然后假定由str
字符串提供的代码就出现在eval()
调用的地方。
eval
看起来是很强大,但对于刚入职的新手来说,很多都被要求不允许使用eval
。为什么呢?因为eval
是一把双刃剑。它将你的程序暴露给攻击者。
MDN 警告不要使用eval
。Function
构造函数可以从字符串生成 JavaScript 函数,就像eval
那样,但Function
构造函数没有eval
的风险。
不仅是 JavaScript,Python 也提供了类似 JavaScript eval
函数的eval
函数。事实上,很多编程语言,尤其是解释型语言,都提供了在运行时生成程序的类似eval
的内置函数。
总结一下,宏和字符串执行都是元编程提供的强大工具,可以在编译期或运行时生成代码。
反射
不同于代码生成,反射是一种改变代码底层结构的能力。反射既可以发生在编译期,也可以发生在运行时,但现在我们说的是 JavaScript,所以主要关注运行时的反射。但是,注意我们这里讨论的概念,对于编译期反射也是适用的。
前面说过,反射会改变代码底层结构,所以我们将其分为三部分:内省、代言和修改。
内省
内省是分析程序的过程。如果你能够知道程序做了什么,那么就可以按照你的喜好去修改程序。即使一些编程语言并不支持代码生成或者代码修改的能力,但它们通常会支持内省。
一个内省的简单例子是,JavaScript 中的typeof
或instanceof
关键字。typeof
返回一个值(或者返回一个值的表达式)的当前数据类型;instanceof
返回true
或者false
,用于判断一个左值是否是一个右值类的实例。下面看一个例子。
class Employee { constructor( name, salary ) { this.name = name; this.salary = salary; } } // introspection using `typeof` and `instanceof` function coerce( value ) { if( typeof value === 'string' ) { return parseInt( value ); } else if( typeof value === 'boolean' ) { return value === true ? 1 : 0; } else if( value instanceof Employee ) { return value.salary; } else { return value; // possibly `number` } } console.log( 1 + coerce( true ) ); // 2 console.log( 1 + coerce( 3 ) ); // 4 console.log( 1 + coerce( '20 items' ) ); // 21 console.log( 1 + coerce( new Employee( 'Ross', 100 ) ) ); // 101
上面的例子,我们在coerce
函数中使用typeof
和instanceof
运算符去判断参数value
的数据类型。这是内省最基础的形式。但是,一个专门设计用于元编程的语言通常会提供更多强大的内省工具。
你可以使用in
运算符去检查一个属性是不是存在于某一对象。全局函数isNan
检查一个对象是不是NaN
。Object
提供了几个静态函数,用于检查Object
类型的值,比如Object.isFrozen(value)
检查value
是不是被冻结的,Object.keys(value)
返回对象value
所有属性的名字。
在 ES5 中,我们只有这些运算符和方法。在 ES2015(ES6)中,JavaScript 引入了Reflect
对象,提供了若干用于内省的静态函数(类似Object
)。我们在另外的文章中详细介绍了Reflect
。
代理
代理可以介入 JavaScript 函数,修改内置函数的标准行为。JavaScript 提供了强大的工具Proxy
。
ES2015(ES6)引入了Proxy
类,以一种更优雅的方式拦截(干预)JavaScript 对象的操作。简答来说,Proxy
将原对象包装起来。
var targetWithProxy = new Proxy(target, handler);
这里,target
是对象,handler
是拦截器。handler
是一个具有额外字段的普通 JavaScript 对象。例如,handler.get
是一个函数,用于在访问target.prop
时返回自定义值(这里的prop
是任意属性)。
// target object var target = { name: 'Ross', salary: 200 }; // proxy wrapped around the `target` var targetWithProxy = new Proxy(target, { get: function(target, prop){ return prop === 'salary' ? target[prop] + 100 : null; } }); // access `target` through proxy console.log( 'proxy: ', targetWithProxy.salary ); // 300 console.log( 'proxy: ', targetWithProxy.name ); // null console.log( 'proxy: ', targetWithProxy.missing ); // null // access `target` console.log( '\ntarget: ', target.salary ); // 200 console.log( 'target: ', target.name ); // 'Ross' console.log( 'target: ', target.missing ); // undefined
代理给非公开数据提供了一个抽象层。例如上面的代码,我们给target
对象提供了一层抽象,定义了如何对外展示数据。
在 ES5 中,某些代理行为是有可能实现的,比如使用getter
和setter
属性描述符,但其代价是修改了target
对象。Proxy
提供了一种更清晰的方法,并且不会修改原始对象(target
)。
修改
修改是指改变程序行为的能力。利用代理,我们可以通过在target
和receiver
之间添加拦截逻辑,修改 JavaScript 的标准行为,而不需要改变target
对象。利用修改,我们直接改变target
的行为,以便满足receiver
。
函数重写是修改的一个例子。例如,一个函数原本设计为某种行为,我们可以通过某些条件来对其进行重写。例如:
function helloTwice( name ) { // override function implementation if( helloTwice.counter >= 2 ) { console.log( 'sorry!' ); helloTwice = function() { console.log( 'sorry!' ); } } else { // say hello console.log( 'Hello, ' + name + '.' ); // increment the counter if( helloTwice.counter === undefined ) { helloTwice.counter = 1; } else { helloTwice.counter = helloTwice.counter + 1; } } } helloTwice( 'Ross' ); // Hello, Ross helloTwice( 'Ross' ); // Hello, Ross helloTwice( 'Ross' ); // sorry! helloTwice( 'Ross' ); // sorry!
在上面代码中,我们创建了一个函数,该函数重写了自身的逻辑。这个例子看起来很生硬,那么,我们来看一个更有实际意义的例子。
var ross = { name: 'Ross', salary: 200 }; var monica = { name: 'Monica', salary: 300 }; var joey = { name: 'Joey', salary: 50 }; /*--------*/ // mutate `ross` ross.name = 'Jack'; // success console.log( 'ross.name =>', ross.name ); // ross.name => Jack /*--------*/ // make `name` property readonly Object.defineProperty( monica, 'name', { writable: false } ); // mutate `monica` monica.name = 'Judy'; // invalid console.log( 'monica.name =>', monica.name ); // monica.name => Monica /*--------*/ // freeze `joey` Object.freeze( joey ); // mutate `joey` joey.name = 'Mary-Angela'; // invalid console.log( 'joey.name =>', joey.name ); // joey.name => Joey
上面的例子,我们使用Object.defineProperty()
函数修改name
属性的默认属性描述符,以便将其修改为只读。你也可以使用Object.freeze()
函数将整个对象锁住,禁止任何修改。
修改过程中可能会有代理的行为。设置对象的属性描述符为writable: false
,这个过程就是针对对象的修改(内部实现),我们拦截了赋值的操作。
valueOf
函数将对象转换为基本数据类型。如果一个对象或者其原型链上具有valueOf
函数,当对这个对象进行算术运算时,JavaScript 会自动调用valueOf
函数。默认情况下,Object
有valueOf
函数,其返回值是对象本身。
class Employee { constructor( name, salary ) { this.name = name; this.salary = salary; } } // default JavaScript behaviour const emp1 = new Employee( 'Ross', 100 ); console.log( 'default =>', emp1 / 10 ); // default => NaN /*------*/ // `valueOf` at the class level Employee.prototype.valueOf = function() { return this.salary; } const emp2 = new Employee( 'Monica', 200 ); console.log( 'class-level =>', emp2 / 10 ); // class-level => 20 /*------*/ const emp3 = new Employee( 'Jack', 300 ); // `valueOf` at the object level emp3.valueOf = function() { return this.salary; } console.log( 'object-level =>', emp3 / 10 ); // object-level => 30
上面的代码,emp1 / 10
返回NaN
,因为一个对象不能被自然数除。但是,后来我们给Employee
类添加了valueOf
函数,返回了对象的salary
字段值。因此,emp2 / 10
返回值是 20,因为emp2.salary
是 200。类似的,emp3 / 10
返回 30,因为我们直接给emp3
增加了valueOf
函数。
上面的每一步我们都是使用的 JavaScript 的标准操作,只不过将这些操作按照我们的要求进行了修改。
ES2015(ES6)引入了新的基本数据类型sumbol
。这种类型与我们前面见到的都不一样,它不能被表示为字面量形式。symbol
只能通过Symbol
函数构造。
var sym1 = Symbol(); var sym2 = Symbol(); var sym3 = Symbol('description'); // description for debugging aid sym1 === sym2 // false sym1 === sym2 // false typeof sym1 // 'symbol' console.log( sym1 ); // 'Symbol()' console.log( sym3 ); // 'Symbol(description)'
简单来说,symbol
产生的是唯一值,可以作为普通对象的键,表示为obj[key]
,其中key
是symbol
。
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
是唯一的,也就不能创建重复的值。每一个新的符号都是新的,这意味着如果你要是用之前的符号,你必须将其保存到某一变量,然后通过该变量引用到之前的符号。
在前面valueOf
的例子中,如果不小心就会引入错误。因为valueOf
是一个string
属性。即emp3['valueOf']
,任何人都可能不小心覆盖其实现,或者有人不知道究竟valueOf
是干什么用的,错误地将其重写为他自己认为的版本。
因为symbol
只能作为对象的键,JavaScript 提供了一些全局的symbol
,作为一些标准的 JavaScript 操作的键。这些symbol
对开发者来说是众所周知的,所以它们被称为“众所周知的symbol
”。这些符号通过Symbol
函数的静态属性暴露出来。
其中一个众所周知的符号是Symbol.toPrimitive
,可以作为对象转换为基本数据类型的键。是的,你没想错,它就是为了替代前面提到的valueOf
函数。
class Employee { constructor( name, salary ) { this.name = name; this.salary = salary; } } // default JavaScript behaviour const emp1 = new Employee( 'Ross', 100 ); console.log( 'default =>', emp1 / 10 ); // default => NaN /*------*/ // `toPrimitive` at the class level Employee.prototype[ Symbol.toPrimitive ] = function() { return this.salary; } const emp2 = new Employee( 'Monica', 200 ); console.log( 'class-level =>', emp2 / 10 ); // class-level => 20 /*------*/ const emp3 = new Employee( 'Jack', 300 ); // normally this would be a bug (but toPrimitive's here) Employee.prototype.valueOf = null; // `toPrimitive` at the object level emp3[ Symbol.toPrimitive ] = function() { return this.salary; } console.log( 'object-level =>', emp3 / 10 ); // object-level => 30
toPrimirive
函数不仅仅返回对象的数值。阅读这里了解更多。
JavaScript 提供了很多类似的符号,用于拦截和修改 JavaScript 默认行为。我们会在另外的文章中介绍更多。