Gong Yong的Blog

为PHP添加新的语法特性

这篇文章用一个简单的示例揭示了为PHP添加新的语法特性的整个过程,当然所添加的特性也只是一个小特性,没有什么实用价值,但是对于哪些想尽可能深入了解PHP底层知识的人而言却是非常有价值的。由于这篇文章基本上谈论的都是PHP的底层的东西,所以会涉及到一些C代码,以及对PHP内部的知识的运用,所以希望读者在阅读之前能够对两块东西有一些了解。

我们所要添加的特性是一个“in”操作符,如果你熟悉Python的话,你应该对这个操作符的作用会比较熟悉。我们先用几行代码来展示它的功能:

$words = ['hello', 'world', 'foo', 'bar'];
var_dump('hello' in $words); // true
var_dump('foo' in $words); // true
var_dump('blub' in $words); // false
$string = 'PHP is fun!';
var_dump('PHP' in $string); // true
var_dump('Python' in $string); // false

从上面的代码可以看出“in”操作符可以用于判断一个参数是否在数组中,这跟in_array的功能一样(只是没有恼人的needle/haystack问题),它还可以判断一个字符串是否是另外一个字符串的子串,这个跟false !== strpos($str2, $str1)的作用是一样的。

准备工作

为了能够添加这个小特性,我们必须得修改PHP的源码,修改完之后我们还需要对它进行编译,为了能够编译PHP源码,我们首先需要安装一些必须的工具。由于我们的编译是最小化编译,不会安装任何扩展,所以我们只需要“re2c”和“bison”这两个工具。如果你的系统没有安装这两个工具,你可以使用包管理工具来安装,在Ubuntu上可以通过下面的命令来安装:

$ sudo apt-get install re2c
$ sudo apt-get install bison

下一步就使用git来clone PHP的源码库:

// 获取源码
$ git clone http://git.php.net/repository/php-src.git
$ cd php-src
// 为in操作符的开发建一个分支
$ git checkout -b addInOperator
// 生成./configure脚本
$ ./buildconf
// 设置为debug模式,以及开启线程安全选项
$ ./configure --disable-all --enable-debug --enable-maintainer-zts
// 开始编译(4代表你的机器是4核的)
$ make -j4

上面的命令从http://git.php.net/repository/php-src.git获取了PHP的源代码,并且创建了一个addInOperator的分支用于添加我们的新功能。另外需要注意到./configure命令中的--disable-all选项,这个选项会禁止安装所有非必须的扩展。

编译成功后,在sapi/cli下面会出现PHP的二进制程序,这就是我们要使用的程序,你可以测试下:

$ sapi/cli/php -v
$ sapi/cli/php -r 'echo "Hallo World!";'

我们修改了PHP源码后,你就可以使用这个二进制程序来运行新添加的特性的代码了,在我们开始之前,我们先看一下PHP是怎么运行你的脚本程序的。

PHP脚本的生命周期

一个PHP脚本的执行可以分成三个步骤:

  1. Tokenization (分词)
  2. Parsing & Compilation (语法解析和编译)
  3. Execution (执行)

下面我会介绍PHP二进制程序在每个步骤对你的PHP脚本做了什么,它是怎么实现这几个步骤的,以及我们需要修改哪些源文件来实现我们的“in”操作符的功能。

Tokenization

首先PHP会读取你的脚本的源代码(用PHP语言写的代码),然后把你的代码会被分解成更小的单元,这些单元也被称为“tokens(符号)”。例如 <?php echo "Hello World!"这段PHP代码会被分解为下面的tokens:

T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ("Hello World!")
';'

从上面可以看到PHP代码被分解为几个带有不同含义的语义符号,这个过程被称为tokenization,通常也叫做lexing(词法分词)或者scanning(扫描)。扫码器位于zend_language_scanner.l中,这个文件位于PHP源码的Zend/目录下。

如果你向下滚动一下这个文件(大约在1000行),你可以看到一大片的token的定义,它们看起来是下面这个样子:

<ST_IN_SCRIPTING>"exit" {
return T_EXIT;
}

上面这段符号的定义很容易理解:如果在PHP代码中碰到“exit”语句,lexer(词法分析器)会把它标记为T_EXIT。“<”和“>”之间的内容就是你要匹配的文字的状态。“ST_IN_SCRIPTING”是PHP代码的常见状态(normal state)。PHP还有一些其他的状态,例如ST_DOUBLE_QUOTE(字符串中的双引号),ST_HEREDOC(字符串的heredoc),等等。

另外一个你需要注意的是这个过程中还会指定一个语义值(semantic value)(也被称为“lower value”,或者是简写为“lval”)。我们看一个示例:

<ST_IN_SCRIPTING,ST_VAR_OFFSET>{LABEL} {
zend_copy_value(zendlval, yytext, yyleng);
zendlval->type = IS_STRING;
return T_STRING;
}

{LABEL} ”表示一个PHP标识符(它们的定义规则是符合正则表达式 [a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]* 的字符串),上面的代码会返回一个T_STRING的token。此外它还会把token的文本拷贝到zendlval中。所以如果lexer碰到一个标识符,假设是FooBarClass,它会把FooBarClass当作是lval。这个过程也会应用于字符串、数字和变量名,等等。

幸运的是我们的“in”操作符的实现不需要用到太多lexer的深层知识。我们只需要把下面的代码片段添加到文件的某个地方(可以放到exit这个token的上面)。

<ST_IN_SCRIPTING>"in" {
return T_IN;
}

下面我们需要让引擎知道我们添加了一个新token。我们打开zend_language_parser.y,然后把下面一行代码插入到这个文件中,可以放到跟上面的代码类似的位置。

%token T_IN "in (T_IN)"

现在你需要使用 make -j4命令重新编译PHP源码(注意你应该在源码的顶层目录上运行这个命令,如果你使用之前的命令从代码库中clone的代码的话,那么这个目录应该是php-src目录下,而不是在Zend/目录下)。编译完之后会生成一个新的lexer,它是由re2c生成的。你可以使用下面的命令来测试你的修改是否生效:

$ sapi/cli/php -r 'in'

上面的命令会导致一个漂亮的解析错误:

Parse error: syntax error, unexpected 'in' (T_IN) in Command line code on line 1

最后还有一件事要完成,你需要重新生成会被tokenizer extension(这个扩展会把内部的lexer应用于PHP用户代码)使用的数据。要完成这一个步,你要使用cd命令切换到ext/tokenizer这个目录下,然后执行./tokenizer_data_gen.sh命令。

执行完后,你可以执行git diff --stat来查看你的源码的变化,这个命令的输出看起来是下面这个样子:

Zend/zend_language_parser.y       |    1 +
Zend/zend_language_scanner.c | 1765 +++++++++++++++++++------------------
Zend/zend_language_scanner.l | 4 +
Zend/zend_language_scanner_defs.h | 2 +-
ext/tokenizer/tokenizer_data.c | 4 +-
5 files changed, 904 insertions(+), 872 deletions(-)

zend_language_scanner.c文件就是re2c实际生成的lexer。git diff命令会显示lexer中发生的所有修改,从上面来看我们这些小小的修改导致了lexer的改动还挺大的,但这些工作都是由re2c自动完成,你完全可以不用在意。

Parsing & Compilation

既然现在PHP已经可以把PHP用户代码(用PHP语言写的代码)转换为有意义的token了,那么PHP还必须能够识别更大的结构,像“这是一个if代码块”或者“你刚刚在此定义了一个函数”。这个过程被称为parsing(语法解析),它被定义在zend_language_parser.y文件中。再次强调这只是一个定义文件,真正的parser(语法解析器)是由bison生成出来的。

为了理解parser到底是怎么工作的,我们来看一个示例:

class_statement:
variable_modifiers { CG(access_type) = Z_LVAL($1.u.constant); } class_variable_declaration ';'
| class_constant_declaration ';'
| trait_use_statement
| method_modifiers function is_reference T_STRING { zend_do_begin_function_declaration(&$2, &$4, 1, $3.op_type, &$1 TSRMLS_CC); } '('
parameter_list ')' method_body { zend_do_abstract_method(&$4, &$1, &$9 TSRMLS_CC); zend_do_end_function_declaration(&$2 TSRMLS_CC); }
;

现在我们先把大括号中的东西去掉,上面的代码就变成了下面的样子:

class_statement:
variable_modifiers class_variable_declaration ';'
| class_constant_declaration ';'
| trait_use_statement
| method_modifiers function is_reference T_STRING '(' parameter_list ')' method_body
;

把它翻译成英文就是:

A class statement is
a variable declaration (with access modifier)
or a class constant declaration
or a trait use statement
or a method (with method modifier, optional return-by-ref, method name, parameter list and method body)
.

如果你想搞清楚“method modifier(方法修饰符)”到底是什么,你可以去看method_modifier的定义,对于其他的语法元素的理解也是如此。这个过程简洁明了。

现在我们要让parser可以支持我们的“in”操作符,你只需要在“expr_without_variable”中添加一个新的“expr T_IN expr”规则:

expr_without_variable:
...
| expr T_IN expr
...
;

再次运行make -j4编译PHP源码,此时bison会重新构建(rebuild)你修改过的parser,不过这次编译不会成功,会输出下面这段难以理解的错误提示:

conflicts: 87 shift/reduce
/some/path/php-src/Zend/zend_language_parser.y: expected 3 shift/reduce conflicts
make: *** [/some/path/php-src/Zend/zend_language_parser.c] Error 1

上面输出的“shift/reduc”冲突表示parser在有些情况下不确定到底该干什么。PHP语法本身有三种shift/reduce冲突(例如elseif/else的模糊性)。剩下的84种冲突都是因为新添加的规则导致的。

冲突的原因是我们没有指定“in”操作符怎么跟其他的操作符配合。例如:

// 如果你写成下面的代码
$foo in $bar && $someOtherCond
// PHP是应该将它解析为:
($foo in $bar) && $someOtherCond
// 还是:
$foo in ($bar && $someOtherCond)

上面的情况也被称为“操作符优先级“。还有一个概念是“操作符的结合性(operator associativity)”,这会用于界定 $foo in $bar in $baz 会被怎么解析。

为了解决上面出现的这种shift/reduce冲突,你只需要找到parser中的下面这行代码,然后把T_IN加在这行代码后面:

%nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL

这表示“in”操作符的优先级跟“<”和“>”这两种形式的比较操作符相同,并且它不具有可结合性。下面的代码解释了这是什么意思:

$foo in $bar && $someOtherCond
// 会被解释为:
($foo in $bar) && $someOtherCond
// 这是因为”&&”的优先级比“in”要低
$foo in ['abc', 'def'] + ['ghi', 'jkl']
// 会被解释为:
$foo in (['abc', 'def'] + ['ghi', 'jkl'])
// 这是因为”+”的优先级比”in”要高
$foo in $bar in $baz
// 这会报解析错误,因为”in”不具有结合性

如果你再次运行make -j4命令,这次将不会出错。编译成功后如果你执行像 sapi/cli/php -r '"foo" in "bar";'的命令,它不会输出任何东西,但会提示内存泄漏错误:

[Thu Jul 26 22:33:14 2012]  Script:  '-'
Zend/zend_language_scanner.l(876) : Freeing 0xB777E7AC (4 bytes), script=-
=== Total 1 memory leaks detected ===

这是正常情况,因为我们还没有告诉parser应该怎么处理“in”操作符的运算。这就是上面我们展示的大括号里面的代码所负责的工作。你只需要把之前添加的expr T_IN expr这个规则修改为:

expr T_IN expr { zend_do_binary_op(ZEND_IN, &$$, &$1, &$3 TSRMLS_CC); }

大括号中的部分被称为一个语义动作(semantic action),它会在parser匹配到这个规则(或者是这个规则的一部分)时执行。大括号中出现的看起来有些奇怪的变量$$, $1$3都是节点(node)。例如$1表示第一个expr,$3表示第二个expr$3代表的是这个规则中的第三个元素),$$则表示结果节点(result node)。

编译器指令(compiler instructions)被定义在zend_compile.c这个文件中(它的头文件是zend_compile.h)。例如zend_do_binary_op的代码看起来如下所示:

void zend_do_binary_op(zend_uchar op, znode *result, const znode *op1, const znode *op2 TSRMLS_DC)
{
zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC);
opline->opcode = op;
opline->result_type = IS_TMP_VAR;
opline->result.var = get_temporary_variable(CG(active_op_array));
SET_NODE(opline->op1, op1);
SET_NODE(opline->op2, op2);
GET_NODE(result, opline->result);
}

这段代码很容易理解,我们会在下一节讲解。这里需要强调的最后一点是大多数时候当你添加了新的语法后,你需要添加你自己的zend_do_*函数。而添加一个新的二位操作符是少数不用这么做的情况。如果你必须添加一个新的zend_do_*函数,你只需要学习下现有的这种函数就可以。绝大多数都很简单。

Execution

在上一节中我已经提到过compiler(编译器)会生成opcodes。我们现在仔细看看opcodes到底是什么样子的(请查看zend_compile.h文件)。

struct _zend_op {
opcode_handler_t handler;
znode_op op1;
znode_op op2;
znode_op result;
ulong extended_value;
uint lineno;
zend_uchar opcode;
zend_uchar op1_type;
zend_uchar op2_type;
zend_uchar result_type;
};

我们先简单的了解下上面的结构体中每个字段的含义:

译注:关于上面的这些字段的含义,可以进一步参考我翻译的另外一篇关于zend执行引擎的文章

*_types字段可以表示5种基本类型:

我们再来看看跟操作数相关的znode_op的情况:

typedef union _znode_op {
zend_uint constant;
zend_uint var;
zend_uint num;
zend_ulong hash;
zend_uint opline_num;
zend_op *jmp_addr;
zval *zv;
zend_literal *literal;
void *ptr;
} znode_op;

你可以看到操作数字段的类型是一个联合体(union),联合体可以根据上下文表示其中的某个字段(而且只能表示一个)。例如zv被用于保存IS_CONST类型的zvalvar被用于保存IS_CVIS_VARIS_TMP_VAR变量的序号(number)。其他字段会被用在不同的特殊环境中。例如jmp_addr会跟JMP*系列指令一起使用(这些指令会用在条件语句和循环语句中)。其他的字段都只会在编译期使用,不会用在执行期(例如常量)。

现在我们已经大概清楚每个ops(操作数)大概是个什么样子了,现在唯一的问题是它们会被存放在哪里:对于每个函数(以及文件),PHP都会创建一个zend_op_array的结构体,它们会保存opcode和其他一些信息。我不想在此探讨这个结构体中的每个字段的含义,你只需要知道这个结构体的存在就可以了。

现在我们回到“in”操作符的实现上。我们已经为编译器创建了一个ZEND_IN的opcode。现在我们要定义这个opcode的行为。

这是在zend_vm_def.h文件中定义的。如果你打开这个文件,你会看到很多类似于下面这段代码的结构的定义:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
USE_OPLINE
zend_free_op free_op1, free_op2;
SAVE_OPLINE();
fast_add_function(&EX_T(opline->result.var).tmp_var,
GET_OP1_ZVAL_PTR(BP_VAR_R),
GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
FREE_OP1();
FREE_OP2();
CHECK_EXCEPTION();
ZEND_VM_NEXT_OPCODE();
}

定义ZEND_IN这个opcode的行为的代码跟上面的代码非常类似,所以我们有必要深入探讨下上面每行代码所代表的含义:

// 头部定义了四个东西
// 1. 这个opcode的ID是1
// 2. 这个opcode的名称是ZEND_ADD
// 3. 这个opcode的第一个操作数可以接受CONST,TMP,VAR和CV四种类型
// 4. 这个opcode的第二个操作数也可以接受CONST,TMP,VAR和CV四种类型
ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
// USE_OPLINE表示我们想以`opline`的形式访问这个zend_op
// 这对于所有要访问的操作数或者需要设定一个返回值的opcode来说是必须的
USE_OPLINE
// 对于每个会被访问的操作数,都必须定义一个free_op*变量
// 它用于表示这个操作数是否需要释放
zend_free_op free_op1, free_op2;
// SAVE_OPLINE() 是实际用于把zend_op保存到`opline`的代码
// USE_OPLINE 仅仅只是声明
SAVE_OPLINE();
// 调用快速加法函数(fast add function)
fast_add_function(
// 这行代码告诉这个函数把结果保存在临时结果变量中
// EX_T 会以 opline->result.var表示的ID来访问临时变量
&EX_T(opline->result.var).tmp_var,
// 获取第一个操作数进行读操作 (BP_VAR_R中的R表示读操作)
GET_OP1_ZVAL_PTR(BP_VAR_R),
// 获取第二个操作数进行读操作
GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
// 释放这两个操作数 (如果有必要的话)
FREE_OP1();
FREE_OP2();
// 检查异常。异常实际上会在任何地方发生,所以你基本上必须在所有opcode中检查它们。如果你不确定
// 那就尽管加上这行检查的代码
CHECK_EXCEPTION();
// 跳转到下一个opcode的执行
ZEND_VM_NEXT_OPCODE();
}

你可能已经注意到上面的代码中有很多以UPPERCASE_STUFF这种形式出现的大写字母。这是因为zend_vm_def.h这个文件只是一个定义文件。真正的Zend VM的代码就是从它生成的,它们被保存在zend_vm_execute.h中(非常大的一个文件)。PHP有三个不同的虚拟机种类,一般被称为CALL(默认的),GOTOSWITCH。因为它们的实现细节各不相同,所以定义文件中有很多类似USE_OPLINE的伪代码,这些代码之后会被不同的具体实现替代。

更进一步,所生成的VM会为所有操作数的不同类型的每一种情况的组合创建一个专有的实现。所以最终生成出来的VM的代码中不会只有一个ZEND_ADD函数,而是每种操作数的组合都会有一个函数,对于ZEND_ADD而言会生成ZEND_ADD_CONST_CONSTZEND_ADD_CONST_TMPZEND_ADD_CONST_VAR。。。这些函数。(译注:再次强调上面提到的我翻译的zend执行引擎的文章对这些知识都有更进一步的论述)。

现在为了实现ZEND_IN这个opcode,你应该在zend_vm_def.h这个文件的尾部添加一个定义新的opcode的架子(definition skeleton):

// 159 这个数字是我的PHP源码中的下一个没被用到opcode的号码. 你也许可能要选择一个更大的数字
ZEND_VM_HANDLER(159, ZEND_IN, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
USE_OPLINE
zend_free_op free_op1, free_op2;
zval *op1, *op2;
SAVE_OPLINE();
op1 = GET_OP1_ZVAL_PTR(BP_VAR_R);
op2 = GET_OP2_ZVAL_PTR(BP_VAR_R);
/* TODO */
FREE_OP1();
FREE_OP2();
CHECK_EXCEPTION();
ZEND_VM_NEXT_OPCODE();
}

上面的代码没进行任何操作,只是获取操作数,然后再马上把它们释放掉。

为了生成一个新的VM,你需要在Zend/目录下执行php zend_vm_gen.php这个命令。(如果这个命令执行的时候输出很多关于/e修饰符已被废弃的警告,你不要管它们)。之后你只用返回上一级目录,执行make -j4进行重新编译。

如果一切进展顺利,你就可以在命令行中执行sapi/cli/php -r '"foo" in "bar";'这个命令了,此时应该不会输出任何错误(不过也不会输出任何有用的信息)。

我们终于到了实现真正的逻辑的时候了。我们先处理字符串的情况:

if (Z_TYPE_P(op2) == IS_STRING) {
zval op1_copy;
int use_copy;
/* 把needle转换为字符串 */
zend_make_printable_zval(op1, &op1_copy, &use_copy);
if (use_copy) {
op1 = &op1_copy;
}
if (Z_STRLEN_P(op1) == 0) {
/* 如果needles为空,则直接返回 true */
ZVAL_TRUE(&EX_T(opline->result.var).tmp_var);
} else {
char *found = zend_memnstr(
Z_STRVAL_P(op2), /* haystack */
Z_STRVAL_P(op1), /* needle */
Z_STRLEN_P(op1), /* needle length */
Z_STRVAL_P(op2) + Z_STRLEN_P(op2) /* haystack end ptr */
);
ZVAL_BOOL(&EX_T(opline->result.var).tmp_var, found != NULL);
}
/* 释放拷贝 */
if (use_copy) {
zval_dtor(&op1_copy);
}
}

上面的代码中最难理解的部分是把needle转换为一个字符串。这是用zend_make_printable_zval这个函数完成的。这个函数可能会创建一个新的zval,也可能不会。这也是为什么我们会把op1_copyuse_copy传给它的原因。如果这个函数拷贝了它的值,我们只需要把它放到op1变量中(所以我们不用在任何地方都要处理两个变量了)。最后这个拷贝需要被释放(最后三行代码做的事情)。

其他的代码都很好理解。我们使用zend_memnstr这个函数来判断haystack是否包含needle。如果needle是一个空字符串,我们只需要直接返回true(显然空字符串是所有字符串的一部分)。

现在当你把上面的代码添加到/* TODO */后,重新执行zend_vm_gen.php,以及再次使用make -j4进行编译,此时你已经搞定了in操作符的一半功能了:

$ sapi/cli/php -r 'var_dump("foo" in "bar");'
bool(false)
$ sapi/cli/php -r 'var_dump("foo" in "foobar");'
bool(true)
$ sapi/cli/php -r 'var_dump("foo" in "hallo foo world");'
bool(true)
$ sapi/cli/php -r 'var_dump(2 in "123");'
bool(true)
$ sapi/cli/php -r 'var_dump(5 in "123");'
bool(false)
$ sapi/cli/php -r 'var_dump("" in "test");'
bool(true)

下面我们再来实现数组的行为:

else if (Z_TYPE_P(op2) == IS_ARRAY) {
HashPosition pos;
zval **value;
/* 先假设这个值并不在数组中 */
ZVAL_FALSE(&EX_T(opline->result.var).tmp_var);
/* 遍历数组 */
zend_hash_internal_pointer_reset_ex(Z_ARRVAL_P(op2), &pos);
while (zend_hash_get_current_data_ex(Z_ARRVAL_P(op2), (void **) &value, &pos) == SUCCESS) {
zval result;
/* 使用==进行比较 */
if (is_equal_function(&result, op1, *value TSRMLS_CC) == SUCCESS && Z_LVAL(result)) {
ZVAL_TRUE(&EX_T(opline->result.var).tmp_var);
break;
}
zend_hash_move_forward_ex(Z_ARRVAL_P(op2), &pos);
}
}

先对haystack做了一个简单的遍历,每次遍历都会跟needle进行比较。我们使用==操作符进行比较。如果你想使用===进行比较,则需要把is_equal_function替换为is_identical_function

再次运行zend_vm_gen.php,然后执行make -j4进行编译,成功后in操作符的功能就完全实现了:

$ sapi/cli/php -r 'var_dump("test" in []);'
bool(false)
$ sapi/cli/php -r 'var_dump("test" in ["foo", "bar"]);'
bool(false)
$ sapi/cli/php -r 'var_dump("test" in ["foo", "test", "bar"]);'
bool(true)
$ sapi/cli/php -r 'var_dump(0 in ["foo"]);'
bool(true) // because we're comparing using ==

最后要做的一件事就是处理当第二个操作数既不是数组也不是字符串的情况。在此我只做一个简单的处理:抛出一个警告,然后返回false

else {
zend_error(E_WARNING, "Right operand of in has to be either string or array");
ZVAL_FALSE(&EX_T(opline->result.var).tmp_var);
}

再次运行zend_vm_gen.php,然后编译:

$ sapi/cli/php -r 'var_dump("foo" in new stdClass);'
Warning: Right operand of in has to be either string or array in Command line code on line 1
bool(false)

这也许不是最佳的做法,因为我们也许应该允许 2 in 123或者 3.14 in 3.141的情况。不过我也懒得再去实现它了:)。