循环语句

循环语句通常由条件表达式和普通表达式构成。在 PHP 中,循环语句有 foreach、while、for、do while,下面分别介绍。

foreach语句

foreach 是 PHP 提供的遍历数组或对象的方式,语法简洁明了:

<?php
foreach(expression as key => value ){
    statement
}
//还有一种写法  foreach(expression  as value )

foreach 结构由 3 部分组成,即 expression(可以是数组或者对象)和 foreach 变量 key、value。这里不论是数组还是对象,本质上是对 HashTable 进行遍历,因为 PHP 7 中的数组实现就是 HashTable,而遍历对象实际上是对对象的属性进行遍历。

foreach 对应的 Token 是 T_FOREACH, as 对应的 Token 为 T_AS。同样,根据 Token 查看其语法规则:

|   T_FOREACH '(' expr T_AS foreach_variable ')' foreach_statement
{ $$ = zend_ast_create(ZEND_AST_FOREACH, $3, $5, NULL, $7); }
|   T_FOREACH '(' expr T_AS foreach_variable T_DOUBLE_ARROW foreach_variable ')'
foreach_statement
{ $$ = zend_ast_create(ZEND_AST_FOREACH, $3, $7, $5, $9); }

可以看出,foreach 关键字会创建一个 kind 为 ZEND_AST_FOREACH 的节点。以该节点为根,共有 4 个孩子,分别对应为 expression(需要遍历的变量)、variable1(foreach 变量 key)、variable(foreach 变量 value)和 statement(普通表达式)。生成的 AST 结构如图12-3所示。

image 2024 06 10 23 05 45 166
Figure 1. 图12-3 foreach语法AST示意图

节点存在的顺序是固定的。如果 foreach 中不需要 key,生成的 AST 中的 child1 节点为空。

foreach 遍历的过程是对变量 key 和 value 不断赋值,并且记录当前遍历位置的过程。可能读者已经猜到,与条件判断语句类似,foreach 语法的关键点仍然是跳转——如何在一次遍历之后,跳转到下次遍历要开始的位置。编译的过程大致如下:

  1. 编译 expression(对应图12-3中根节点的 child0)生成复制数组或对象的 opcode FE_RESET_R。这里如果 value 是引用类型,会生成 opcode ZEND_FE_RESET_RW。如果数组或者对象为空,opcode 还需要记录需要跳过的 opcode 数。

  2. 编译 value(对应图12-3中根节点的 child2)生成 opcode 为 FE_FETCH_R 的操作。如果 value 是引用类型,同样会生成 opcode ZEND_FE_FETCH_RW。此时 opcode 还需要记录遍历指针到达末尾需要跳过的 opcode 数。

  3. 如果存在 key(对应图12-3中根节点的 child1)则编译一条 ASSIGN,对 key 进行赋值操作。

  4. 编译循环体中的普通表达式列表 statement。

  5. 生成一条 ZEND_JMP。因为一次循环结束后,需要跳回遍历开始的位置,然后重新走流程。

  6. 全部 opcode 条数已经确定,开始设置步骤 1)和步骤 2)中需要跳过的 opcode 数。编译 foreach 的函数是 zend_compile_foreach,位于 zend/zend_compile.c 文件中,主要流程如下:

void zend_compile_foreach(zend_AST *AST) /* {{{ */
{
    if (by_ref) {
        value_AST = value_AST->child[0];
    }
    if (by_ref && is_variable) {
        zend_compile_var(&expr_node, expr_AST, BP_VAR_W);
    } else {
        zend_compile_expr(&expr_node, expr_AST);
    }
    /*如果是引用会编译一条ZEND_SEPARATE opcode*/
    if (by_ref) {
        zend_separate_if_call_and_write(&expr_node, expr_AST, BP_VAR_W);
    }
    opnum_reset = get_next_op_number(CG(active_op_array));
    opline = zend_emit_op(&reset_node, by_ref ? ZEND_FE_RESET_RW : ZEND_FE_RESET_
        R, &expr_node, NULL);
    zend_begin_loop(ZEND_FE_FREE, &reset_node);
    opnum_fetch = get_next_op_number(CG(active_op_array));
    // 编译生成FE_FETCH_R opcode
    opline  =  zend_emit_op(NULL,  by_ref  ?  ZEND_FE_FETCH_RW  :  ZEND_FE_FETCH_R,
        &reset_node, NULL);
    if (key_AST) {
        opline = &CG(active_op_array)->opcodes[opnum_fetch];
        zend_make_tmp_result(&key_node, opline);
        /*编译一条ASSIGN对key赋值*/
        zend_emit_assign_znode(key_AST, &key_node);
    }
    /*编译循环体里面的普通表达式列表*/
    zend_compile_stmt(stmt_AST);
    /*生成zend_jmp opcode,在一次循环结束后跳到遍历开始位置*/
    zend_emit_jump(opnum_fetch);
    /*设置跳过的opcode数*/
    opline = &CG(active_op_array)->opcodes[opnum_reset];
    opline->op2.opline_num = get_next_op_number(CG(active_op_array));
    opline = &CG(active_op_array)->opcodes[opnum_fetch];
    opline->extended_value = get_next_op_number(CG(active_op_array));

    zend_end_loop(opnum_fetch, &reset_node);
    opline = zend_emit_op(NULL, ZEND_FE_FREE, &reset_node, NULL);
}

关于 opcode 对应的 handler,这里不再赘述。

Zend 引擎在执行时,首先重置 expression 中遍历的位置,这时如果发现遍历的数组或者对象(expression)为空,跳过循环体 statement,直接跳转到结束的位置;反之对 key 和 value 进行赋值,开始执行循环体 statement 部分。循环体 statement 执行完再执行 ZEND_JMP,跳到循环体开始的位置,开始下次遍历,最终达到遍历数组或者对象中各个元素的目的。其执行流程如图12-4所示。

image 2024 06 10 23 07 48 975
Figure 2. 图12-4 foreach执行流程示意图

while语句

while 语句也是一种常用的循环语句,关键字 while 生成的 Token 是 T_WHILE。while 语法规则相对简单:

    |   T_WHILE '(' expr ')' while_statement
        { $$ = zend_ast_create(ZEND_AST_WHILE, $3, $5); }
    while_statement:
        statement { $$ = $1; }
    |   ':' inner_statement_list T_ENDWHILE '; ' { $$ = $2; }
;

while 语法会创建一个 kind 为 ZEND_AST_WHILE 的 AST 节点。以其为根,有两个孩子节点。child0 用来记录条件表达式(condition), child1 节点存储普通循环体 statement。AST 示意图如图12-5所示。

condition 语句一般会生成 kind 为 ZEND_AST_BINARY_OP 节点的子树。该节点在编译时会生成临时变量(true 或 false)。引擎根据临时变量结果,确定下一个要执行的 opcode 的位置。如果临时变量结果为 true,则执行 while 循环体的 statement;否则,跳出循环。这里与跳转逻辑相似。

condition 不同,生成的子节点的 kind 也是不同的。读者可以自行尝试,观察变化。

while 语法的编译过程大致如下。

  1. 编译条件表达式(condition)部分,同时,需要生成一条 ZEND_JMP 的 opcode,跳转到条件表达式(condition)生成的 opcode 的位置。目前还不能确定下一个 opcode 的位置(jmp_offset),需要在编译完循环体 statement 后更新。

  2. 编译循环体 statement,编译后会更新步骤 1)中 jmp_offset 的值。

  3. 编译条件表达式 condition。

  4. 编译生成 ZEND_JMPNZ 的 opcode,如果条件成立跳到循环体开始的位置,否则继续执行。从上面的 AST 可以看出,while 的语法相对简单,因此它的编译过程也相对简单,编译函数是 zend_compile_while,同样定义在 zend/zend_compile.c 文件中。为了方便读者理解,这里只展示函数的主要流程,具体如下:

void zend_compile_while(zend_AST *AST) /* {{{ */
{
    opnum_jmp = zend_emit_jump(0);
    zend_begin_loop(ZEND_NOP, NULL);
    opnum_start = get_next_op_number(CG(active_op_array));
    zend_compile_stmt(stmt_AST);  /*编译条件表达式*/

    opnum_cond = get_next_op_number(CG(active_op_array));
    zend_update_jump_target(opnum_jmp, opnum_cond);  /*更新zend_jmp跳的位置*/
    zend_compile_expr(&cond_node, cond_AST);   /*编译条件表达式*/
  /*编译ZEND_JMPNZ */
    zend_emit_cond_jump(ZEND_JMPNZ, &cond_node, opnum_start);

    zend_end_loop(opnum_cond, NULL);
}

关于 while 语句的执行跳转逻辑,这里不再详述。

for语句

for 语句的 PHP 代码示例如下:

<?php
for( expressions1 ; expressions2 ; expressions3 ) {
    statement
}

for 语句由两大部分组成,即多个表达式 expressions 和循环体 statement。for 关键字生成的 Token 为 T_FOR,它的语法规则如下:

    T_FOR '(' for_exprs '; ' for_exprs '; ' for_exprs ')' for_statement
    { $$ = zend_ast_create(ZEND_AST_FOR, $3, $5, $7, $9); }
for_statement:
    statement { $$ = $1; }
        ':' inner_statement_list T_ENDFOR '; ' { $$ = $2; }

for 语法结构生成的 AST 根节点是 kind 为 ZEND_AST_FOR 的节点。其有 4 个子节点,其中前三个节点都是表达式列表(expressions),第四个节点保存循环体 statement 信息。这里值得注意的是,这 4 个节点都是 zend_ast_list 类型,意味着它们都支持多个表达式。本书第 10 章介绍过 AST 中 list 节点和普通节点的区别,读者可以回读了解。for 语法结构生成的 AST 示意图如图12-6所示。

image 2024 06 10 23 11 22 696
Figure 3. 图12-6 for语法AST示意图

Zend 引擎编译 for 语法结构从 ZEND_AST_FOR 节点的 child0 开始。这里需要注意的是,前三个子节点虽都是条件表达式,却有所区别。child0 用来初始化,child1 用来条件判断,child2 用来做循环控制。编译过程大致如下:

  1. 编译 child0 对应的表达式,生成 ZEND_ASSGIN 的 opcode,该过程会生成临时变量。

  2. 生成 ZEND_JMP 的 opcode,用来跳转到条件判断的位置(编译过程可以看出为何初始化之后不是条件判断的opcode)。当前阶段不能确定跳转的位置,需要后续更新。

  3. 编译循环体。

  4. 编译 child2 对应的表达式 expressions。

  5. 编译 child1 对应的表达式列表 expressions, for 语句的判断条件在这里编译,并更新步骤 2)中 jmp_offset 的值。

  6. 生成 ZEND_JMPNZ。依据步骤 6)中条件判断的结果,决定下一条要执行的 opcode 的位置。for 语法结构的编译实现过程简化如下:

void zend_compile_for(zend_AST *AST) /* {{{ */
{
    //编译第一个for_exprs
    zend_compile_expr_list(&result, init_AST);
    zend_do_free(&result);
    /*生成ZEND_JMP*/
    opnum_jmp = zend_emit_jump(0);
    zend_begin_loop(ZEND_NOP, NULL);
    /*编译循环体*/
    opnum_start = get_next_op_number(CG(active_op_array));
    zend_compile_stmt(stmt_AST);
    opnum_loop = get_next_op_number(CG(active_op_array));
    /*编译第三个for_exprs*/
    zend_compile_expr_list(&result, loop_AST);
    zend_do_free(&result);
    /*更新zend_jmp需要跳过的opcode数*/
    zend_update_jump_target_to_next(opnum_jmp);
    /*编译第二个for_exprs*/
    zend_compile_expr_list(&result, cond_AST);
    zend_do_extended_info();
    /*生成ZEND_JMPNZ */
    zend_emit_cond_jump(ZEND_JMPNZ, &result, opnum_start);

    zend_end_loop(opnum_loop, NULL);
}

for 语法的执行过程类似 while 语法的执行过程,首先通过 ZEND_JMP 跳到条件判断的 opcode 位置,如果条件返回 true 则跳转到循环体开始的位置,这里不再赘述。for 语句执行过程如图12-7所示。

image 2024 06 10 23 12 32 406
Figure 4. 图12-7 for语法执行过程示意图

do while语句

do while 与 while 基本一样,不同的是 do while 是先执行循环体,再判断 while 条件表达式,决定是否继续执行循环体。根据 while 的实现,不难想象 do while 的基本流程。以如下 PHP 代码为例:

<?php
do{
    //$i++; echo $i;
    statement
}while( condition );   //$i < 10

我们直接看下它生成的 AST,与 while 语法生成的 AST 做下对比。

在实际编译的 PHP 代码中以注释为例,这里为了便于理解用 condition 代替条件,用 statement 代替循环体。

对比图12-5中 while 结构生成的 AST,可以发现 do while 结构中循环体 statement 生成的节点在左边 child0 位置,而条件 condition 生成的节点在右边 child1 位置。相同的是这两棵树下面只有两个节点。do while 语法编译的过程跟 while 语法的流程基本一样,这里不再展开介绍。

image 2024 06 10 23 14 02 759
Figure 5. 图12-8 do while语法示意图

do while 的执行示意图如图12-9所示。

image 2024 06 10 23 14 33 496
Figure 6. 图12-9 do while执行示意图