替换《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 默认行为。我们会在另外的文章中介绍更多。