首页 JavaScript JavaScript 元编程简介

JavaScript 元编程简介

0 1.7K

替换《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 警告不要使用evalFunction构造函数可以从字符串生成 JavaScript 函数,就像eval那样,但Function构造函数没有eval的风险。

不仅是 JavaScript,Python 也提供了类似 JavaScript eval函数的eval函数。事实上,很多编程语言,尤其是解释型语言,都提供了在运行时生成程序的类似eval的内置函数。

总结一下,宏和字符串执行都是元编程提供的强大工具,可以在编译期或运行时生成代码。

反射

不同于代码生成,反射是一种改变代码底层结构的能力。反射既可以发生在编译期,也可以发生在运行时,但现在我们说的是 JavaScript,所以主要关注运行时的反射。但是,注意我们这里讨论的概念,对于编译期反射也是适用的。

前面说过,反射会改变代码底层结构,所以我们将其分为三部分:内省、代言和修改。

内省

内省是分析程序的过程。如果你能够知道程序做了什么,那么就可以按照你的喜好去修改程序。即使一些编程语言并不支持代码生成或者代码修改的能力,但它们通常会支持内省。

一个内省的简单例子是,JavaScript 中的typeofinstanceof关键字。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函数中使用typeofinstanceof运算符去判断参数value的数据类型。这是内省最基础的形式。但是,一个专门设计用于元编程的语言通常会提供更多强大的内省工具。

你可以使用in运算符去检查一个属性是不是存在于某一对象。全局函数isNan检查一个对象是不是NaNObject提供了几个静态函数,用于检查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 中,某些代理行为是有可能实现的,比如使用gettersetter属性描述符,但其代价是修改了target对象。Proxy提供了一种更清晰的方法,并且不会修改原始对象(target)。

修改

修改是指改变程序行为的能力。利用代理,我们可以通过在targetreceiver之间添加拦截逻辑,修改 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函数。默认情况下,ObjectvalueOf函数,其返回值是对象本身。

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],其中keysymbol

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

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2