诺亚方舟

沉淀

从$a+$a+$a++出发探讨PHP解析器

很高兴八月份的第一篇文章能够用来阐述这个问题,因为这个问题已经困扰了有十几天,一直探索找不到原因直到台风“nida”降临调休了一天,在家里搞明白了这个问题。废话不多说,先描述发现的问题:

1
2
3
4
5
6
$a = 1;
echo $a+$a++;
//输出3
$a = 1;
echo $a+$a+$a++;
//输出3

这里第二个表达式明显$a++的优先级丢失了,所以结果是1+1+1。当时LZ就楞逼了,PHP在什么情况下才能“正常”使用优先级….我们先用vld扩展,看看Zend底层是以什么顺序来处理上面两个表达式的。

图1

图1

从图1可以看到Zend先对变量!0进行赋值1,然后对变量!0进行自增并返回给~1(从这个opcode砸门是看不出前置或是后置自增的),然后再把两个变量相加后赋予操作数~2,输出。大概的流程就是先自增后相加。

图2

图2

图2的程序b中,同样的先对变量!0赋值1,然后相加赋予临时变量~1,再对!0自增,把返回值赋予临时变量~2,把~1和~2相加输出,整个流程就是先相加,然后自增,再相加,也就是跟我们之前猜测的1+1+1一致(后置自增符$a++是作为影响数而不是返回值)。

看到这里,LZ开始对PHP的解析器产生怀疑,一条表达式中为什么并没有采取优先级去处理,特意在c下面试了,a=1的情况下,c中a+a++返回值为2,a+a+a++返回值为3,由此可见,底层同样是c,那么是PHP的解析器导致结果的不同。ok,进入文章的主题。PHP解析器是由语法分析器和语法编译器组成的,词法解析器用的是re2c,语法编译器使用的是yacc/bison(bison作为yacc的升级,向上兼容yacc)。 LZ大学没学好编译原理,因此对这两个工具都是从网上学来的皮毛知识,下面做点介绍。

——————————————————–分–割–线—————————————————————–

re2c和bison都是通用的解析器,根据文件规则可以生成词法和语法解析的c程序(php的词法、语法文件在源文件下 源文件/Zend/zend_language_scanner.l 源文件/Zend/zend_language_parser.y)。re2c作为一个词法解析器,负责把PHP脚本的语言按照规则转换为一个个token,以栈的形式提供给语法解析器bison解析,bison会根据token文法和定义好的优先级解析成对应语义,也就是把语法解析出来的opcode交给Zend,再由Zend调用映射好的handler方法执行程序。关于re2c和bison的写法,网上已经有大神非常精致的博文了,这里不再重复累述,可以参看下面LZ两篇整理的文章PHP-Zend引擎剖析之词法分析PHP-Zend引擎剖析之语法分析

——————————————————–分–割–线—————————————————————–

接下来LZ介绍PHP底层提供了一个对外的接口,token_get_all(),结合token_name(),这个函数可以获取到PHP词法解析完成返回的token列表,具体的定义可以去看PHP手册。那么接下来LZ想做的事情就是把re2c解析出来的token列出来,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
$r = array();
$b = token_get_all('<?php $a=1;echo $a+$a++;');
//注意,这里只能用单引号括起来,如果用双引号$a会直接被解析为变量,可以自己试试看
//$b = token_get_all('<?php $a=1;echo $a+$a+$a++;'); 
foreach($b as $k => $v){
    if(is_array($b[$k])){
        $r[$k] = token_name($b[$k][0]);
    }else{
        $r[$k] = $b[$k];
    }   
}
print_r($r);

这里我们先把第4行注释掉,看看返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Array
(
    [0] => T_OPEN_TAG
    [1] => T_VARIABLE
    [2] => =
    [3] => T_LNUMBER
    [4] => ;
    [5] => T_ECHO
    [6] => T_WHITESPACE
    [7] => T_VARIABLE
    [8] => +
    [9] => T_VARIABLE
    [10] => T_INC
    [11] => ;
)

实际上我们可以把re2c匹配到的每个关键词和对应的token打印出来,这里由于篇幅问题,我们只打印出token,提取出有效的token序列:T_VARIABLE + T_VARIABLE T_INC。注释掉第2行,恢复第4行,看看跑出来的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Array
(
    [0] => T_OPEN_TAG
    [1] => T_VARIABLE
    [2] => =
    [3] => T_LNUMBER
    [4] => ;
    [5] => T_ECHO
    [6] => T_WHITESPACE
    [7] => T_VARIABLE
    [8] => +
    [9] => T_VARIABLE
    [10] => +
    [11] => T_VARIABLE
    [12] => T_INC
    [13] => ;
)

提取出来就是:T_VARIABLE + T_VARIABLE + T_VARIABLE T_INC。这两个token序列都比较简单,只有变量相加、自增的操作,其中T_VARIABLE的值为1。这些解析器token解释可以看下面参考文章的内容。词法解析的过程完成后,接下来我们只要匹配到bison符号表就可以知道Zend底层是怎么处理这两个token序列顺序的了,先规定一下序列编号然后接着往下看:

1
2
序列一:T_VARIABLE + T_VARIABLE T_INC
序列二:T_VARIABLE + T_VARIABLE + T_VARIABLE T_INC

 

我们知道,词法解析器是由语法解析器调用并返回token的,序列一首先拿到的token是T_VARIABLE,在php的bison解析配置文件中,可以看到T_VARIABLE是一个终结符,

%token T_VARIABLE

存在着几个可规约的语法选择(global_var、static_var_list、class_variable_declaration、lexical_var_list、compound_variable、encaps_var、encaps_var_offset),根据Look-Ahead规则,接着往下读,是一个+,此时发现一个规约规则:T_VARIABLE->compound_variable->reference_variable->base_variable->base_variable_with_function_calls->variable->r_variable->expr(麻蛋这个规则挺深,LZ找了好久才发现),不归约T_VARIABLE,接着往下读,此时已读的token序列是T_VARIABLE+T_VARIABLE,此时我们可以看到一个文法规则:

expr_without_variable:

expr ‘+’ expr { zend_do_binary_op(ZEND_ADD, &$$, &$1, &$3 TSRMLS_CC); }

这时候并不会立刻就规约,而是继续预读下一个token, T_INC,在bison前导规定里面可以看到,这个操作符的优先级比+高(在下面的优先级越高),有这样一条语法(rw_variable可由variable规约而来):

expr_without_variable:

rw_variable T_INC,因此先规约后面的token,整个序列变为T_VARIABLE+expr_without_variable->T_VARIABLE+expr->expr+expr->expr_without_variable(expr_without_variable可以规约为expr),这里大概就是序列一的整个计算过程,也就是我们看到的先自增再相加,也就是$a+$a++=>2+1。

接下来我们看序列二,序列二前三个token是一样的情况,唯一不同的在于预读第四个token依旧为+,这个时候两个+是同优先级的操作符,因此当读取完前三个token后就会规约为expr_without_variable,接着移近,expr_without_variable+,接着移近expr_without_variable+T_VARIABLE,这个时候细心的童鞋就会发现已经变为序列一了,接下去就是自增,然后相加,整一个计算流程就是先相加,再自增,再相加结果,由于是一个后置自增,所以实现出来的计算就是$a+$a+$a++=>1+1+1。

总结: 看到这里,可以验证跟我们前面用vld扩展查看的结果是一样的,之所以出现$a+$a++和$a+$a+$a++结果一样的原因是PHP语法解析器搞的小文章,这样的小文章是很难留意到的,日常开发中我们应该尽量避免一个变量在同个表达式里面自增操作和其他运算同时出现,否则复杂逻辑可能导致出现不可预估的后果,一段可读性强的程序才是编程正道。

参考文章:

解析器代号列表

php–token_get_all(),token_name()和解析器代号

 

 

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注

您可以使用这些HTML标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>