Skip to content
This repository has been archived by the owner on Sep 5, 2022. It is now read-only.

Latest commit

 

History

History
365 lines (322 loc) · 14.5 KB

奇怪的循环.md

File metadata and controls

365 lines (322 loc) · 14.5 KB

让我们跟上一章节一样,以一段神奇的PHP代码开始。

<?php
/** author:[email protected] */
/*** for_1.php */
$array=array("lucy"=>1983,"lilei"=>1982,"hanmeimei"=>1981);
foreach($array as $k=>$v){
    print current($array)."\n";
}

运行一下:

$php for_1.php
1982
1982
1982

很奇怪,程序一直输出着第二项的值。现在我们有两个头大的问题:

  • 为什么程序每次输出的是相同的值?
  • 为什么程序每次都是输出的第二项的值?

为了解决这两个问题,我们先复习一下相关的数据结构和函数。

###foreach php官网对foreach的说明如下: http://cn2.php.net/manual/zh/control-structures.foreach.php foreach 语法结构提供了遍历数组的简单方式:

foreach (array_expression as $value)
    statement
foreach (array_expression as $key => $value)
    statement
  • 第一种格式遍历给定的 array_expression 数组。每次循环中,当前单元的值被赋给 $value 并且数组内部的指针向前移一步(因此下一次循环中将会得到下一个单元)。
  • 第二种格式做同样的事,只除了当前单元的键名也会在每次循环中被赋给变量 $key。 另外官网还有一段小说明,也是值得注意的:
Note:
当 foreach 开始执行时,数组内部的指针会自动指向第一个单元。这意味着不需要在 foreach 循环之前调用 reset()。
由于 foreach 依赖内部数组指针,在循环中修改其值将可能导致意外的行为。

这一段其实很重,揭示了foreach与while循环极大的不同。后面会讲到.

###current 再来复习一下current在官网的说明:

current — 返回数组中的当前单元

mixed current ( array &$array )
每个数组中都有一个内部的指针指向它“当前的”单元,初始指向插入到数组中的第一个单元。

读完这个说明,我们知道了,数组都有一个内部指针,标记着该数组当前的位置。正是因为如此,我们才可能 完成foreach这个语法,才能使用next() end()等函数。

foreach结构的opcode

下面我们使用vld工具来查看一下刚才的for_1.php的opcode:

$php -dvld.active=1 ./for_1.php
function name:  (null)
number of ops:  22
compiled vars:  !0 = $array, !1 = $k, !2 = $v
line     # *  op                           fetch          ext  return  operands
---------------------------------------------------------------------------------
   2     0  >   EXT_STMT
         1      INIT_ARRAY                                       ~0      1983, 'lucy'
         2      ADD_ARRAY_ELEMENT                                ~0      1982, 'lilei'
         3      ADD_ARRAY_ELEMENT                                ~0      1981, 'hanmeimei'
         4      ASSIGN                                                   !0, ~0
   3     5      EXT_STMT
         6    > FE_RESET                                         $2      !0, ->20
         7  > > FE_FETCH                                         $3      $2, ->20
         8  >   ZEND_OP_DATA                                     ~5
         9      ASSIGN                                                   !2, $3
        10      ASSIGN                                                   !1, ~5
   4    11      EXT_STMT
        12      EXT_FCALL_BEGIN
        13      SEND_REF                                                 !0
        14      DO_FCALL                                      1  $7      'current'
        15      EXT_FCALL_END
        16      CONCAT                                           ~8      $7, '%0A'
        17      PRINT                                            ~9      ~8
        18      FREE                                                     ~9
   5    19    > JMP                                                      ->7
        20  >   SWITCH_FREE                                              $2
   6    21    > RETURN                                                   1

branch: #  0; line:     2-    3; sop:     0; eop:     6; out1:   7; out2:  20
branch: #  7; line:     3-    3; sop:     7; eop:     7; out1:   8; out2:  20
branch: #  8; line:     3-    5; sop:     8; eop:    19; out1:   7
branch: # 20; line:     5-    6; sop:    20; eop:    21
path #1: 0, 7, 8, 7, 20,
path #2: 0, 7, 20,
path #3: 0, 20,
1982
1982
1982

我们可以理清一下基本的结构,opcode 的#0 到#4是在进行数组的初始化[INIT_ARRAY],#6 reset了数组[FE_RESET], #7至#19之间是循环体,运行到#19后就跳转到了#7,而在#7如果取数据失败,就会跳到#20[SWITCH_FREE]。 而#20是一个释放资源的语句, 整个运行时是这样的轨迹: 先对数组做RESET,如果失败,跳到循环结束后一行。RESET之后,产生了一个临时变量$2。 以下为循环体:

  • 从数组的拷贝变量$2中取一行数据;如果取数据失败,跳到循环结束后的一行;
  • 上一步这个"取一行数据"的过程,就顺带将数组$2的指针移向了下一位.
  • 执行相关的循环体操作。在循环体中,调用current时,传入的参数是$array,在OPcode中是$0 (不是$2哦。$2是foreach开始时,生成的临时变量); 循环结束时,调用了SWITCH_FREE,传的参数是$2,就是刚才foreach运行之初生成的临时变量。

###在执行foreach时,C语言层的代码流程:

foreach(){}的编译 oreach编译调用了zend_do_foreach_begin(), ($array as $k=>$v) 编译调用了zend_do_foreach_cont() foreach大括号结束时的编译调用的是zend_do_foreach_end();

我们重点看一下ZEND_FE_FETCH 这个OPCDE里的代码流程,并把跟array类型数据的FE_FETCH代码拉出来:

case ZEND_ITER_PLAIN_ARRAY: //zend_vm_execute.h 11719行php 5.4.18 
    		fe_ht = Z_ARRVAL_P(array);      // 
			zend_hash_set_pointer(fe_ht, &EX_T(opline->op1.var).fe.fe_pos);
			if (zend_hash_get_current_data(fe_ht, (void **) &value)==FAILURE) {
				/* reached end of iteration */
				ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.opline_num);
			}
			if (use_key) {
				key_type = zend_hash_get_current_key_ex(fe_ht, &str_key, &str_key_len, &int_key, 1, NULL);
			}
			zend_hash_move_forward(fe_ht);
			zend_hash_get_pointer(fe_ht, &EX_T(opline->op1.var).fe.fe_pos);
			break;

为了方便理解,我们把zend_hash_set_pointer和zend_hash_get_pointer,zend_hash_move_forward的代码也展开了:

ZEND_API int zend_hash_set_pointer(HashTable *ht, const HashPointer *ptr)
{
    if (ptr->pos == NULL) {
		ht->pInternalPointer = NULL;
	} else if (ht->pInternalPointer != ptr->pos) {
		Bucket *p;

		IS_CONSISTENT(ht);
		p = ht->arBuckets[ptr->h & ht->nTableMask];
		while (p != NULL) {
			if (p == ptr->pos) {
				ht->pInternalPointer = p;
				return 1;
			}
			p = p->pNext;
		}
		return 0;
	}
	return 1;
}
ZEND_API int zend_hash_get_pointer(const HashTable *ht, HashPointer *ptr)
{
    ptr->pos = ht->pInternalPointer;
	if (ht->pInternalPointer) {
		ptr->h = ht->pInternalPointer->h;
		return 1;
	} else {
		ptr->h = 0;
		return 0;
	}
}
ZEND_API int zend_hash_move_forward_ex(HashTable *ht, HashPosition *pos)
{
    HashPosition *current = pos ? pos : &ht->pInternalPointer;

	IS_CONSISTENT(ht);

	if (*current) {
		*current = (*current)->pListNext;
		return SUCCESS;
	} else
		return FAILURE;
}

zend_hash_set_pointer的代码可以简单理解为,其核心是,如果HashTable的内部指针位置 跟HashPosition的位置不一致,就把HashTable的内部指针指向HashPosition; zend_hash_get_pointer的代码可以简单理解为,HashPosition的指针指向HashTable的 内部指针的位置。(完整的逻辑是:如果当前HashTable的内部指针非空,则把HashPosition的指针指向HashTable的 内部指针的位置;如果HashTable的内部指针为空,则把opcode.fe.pos的指针置空).

hashTable的内部指针:HashTable这个结构体 定义了一个pInternalPointer成员变量,这个变量 指向当前的成员。只有有了个这个成员变量,才能做数组的next(),prev(),end(),reset()等操作。reset() 操作就是将当前数组的HashTable的pInternalPointer 指向链表头部.

现在我们简单整理了下FE_FETCH的逻辑:

  • 取当前的数组的HashTable;
  • 如果当前HashTable的内部指针跟opcode.fe.pos 记录的指针不一样,就把HashTable的指针指向这一个;
  • 取出当前位置的数据,如果失败跳出循环;
  • 如果数组的key也被用到,当前的key也存一下;
  • 调用zend_hash_move_forward,将HashTable的内部指针前移一位;
  • 将内部指针的位置复制给opcode.fe.pos。

现在我们清楚了,在foreach每次循环时,调用的是FE_FETCH 这个opcode,这个操作实际上将当前的 HashTable的指针是跟opcode.fe.pos绑定了,即便用户在foreach的代码体中间修改当前的内部指针,也是 不管用的,因为接下来再一次循环时,数组的内部指针又被修改到和opcode.fe.pos一致了。 来段PHP代码跑一下,让我们更明确一些:

<?php
$array=array("lucy"=>1983,"lilei"=>1982,"hanmeimei"=>1981,"lintao"=>1980);
$b = &$array;
foreach($array as $k=>$v){
    print "{\n";
    print "\tcurrent:".current($array)."\n";
    print "\tnext:".next($array)."\n";
    print "\tcurrent:".current($array)."\n";
    print "\tnext:".next($array)."\n";
    print "\tcurrent:".current($array)."\n";
    print "}\n\n";
}

我们在循环体里调用了两次next。现在运行他:

{
    current:1982
	next:1981
	current:1981
	next:1980
	current:1980
}

{
	current:1981
	next:1980
	current:1980
	next:
	current:
}

{
	current:1980
	next:
	current:
	next:
	current:
}

{
	current:
	next:
	current:
	next:
	current:
}

观察一下,第一次循环时,我们的数组已经指到了1980这一项了,到下一次循环时,却又回到了1981 了。

再看另一段代码:

/** for_3.php */
<?php
$arr = array(0,1,2,3,4,5);
reset($arr);
//$b = & $arr;
//while (list($key,$v) = each($arr))
foreach($arr as $v){
    if($v == 3){//改成v==2 v==4,结果又不一样;
        $arr[6] = 6;
    }
}
echo current($arr);

输出的结果4. 如果把中间那个表达式改为if($v==4) {$arr[6]=6;} ,结果又变成了5.

为什么呢?

这又涉及到另一个概念,写时复制.PHP内核为了节省内存,在涉及新变量时,默认不创建拷贝.只有 当这个变量的值发生改变时,才会真正的新建一个变量,将变量的值复制一份出来,修改新的变量值. 在刚才这段代码里,执行到$arr[6]=6时,php发现$arr这个变量产生了修改,因此将arr指向的变量 拷贝了一份,将$arr指向了这个新的变量.虽然就在前面我们刚刚提过,不论你在循环体里如何修改$arr 当前的内部指针了,在下一次循环时,PHP 执行for 循环时,都会将$arr的内部指针重置;但是, 由于写时复制,$arr 发生值修改时,$arr实际创建了另一个值并指向了新的值;因此,foreach虽然 执行了同步修内部指针的动作,但是却是修改了老的$arr变量的内部指针;新产生的$arr变量的内部指针一 直未被修改,因此最后调用current($arr),输出的就是$arr变量指向新变量时,内部指针的位置;

While死循环之迷

看看这段毁三观的代码:

<?php
/** while_1.php */
$mates = array("lucy","lilei","hanmeimei");
while($mate  = each($mates)){
    echo $mate["value"]."\n";
	$another = $mates;//这一行代码导致了死循环;
}

以绝大多数PHP程序员的常识,这段代码就是简单的输出lucy,lilei,hanmeimei三个元素,就完事了;但是, 实际情况上,这个小脚本导致了疯狂的死循环. 经过查阅资料,找到了主要原因:把数据赋值给另一个变量时,会导致一个副作用:数组的内部指针会被重置(就跟调用了reset一样); 原文在:http://php.net/manual/zh/function.each.php

因为将一个数组赋值给另一个数组时会重置原来的数组指针,因此在上边的例子中如果我们在循环内部将 $fruit 赋给了另一个变量的话将会导致无限循环。

但是刚才我们刚刚了解过foreach的知识,foreach开始会重置数组的内部指针,每次循环时会同步指针,所以,foreach不会出现死循环的情况.我们可以用这个代码测试一下:

$mates = array("lucy","lilei","hanmeimei");
foreach($mates as $mate){
    echo $mate."\n";
    $another = $mates;
}

那么,使用while(each)的语句组合呢? 我们看一下使用while语句的while_1.php 的opcode:

compiled vars:  !0 = $mates, !1 = $mate, !2 = $another
line     # *  op                           fetch          ext  return  operands
---------------------------------------------------------------------------------
   2     0  >   EXT_STMT
         1      INIT_ARRAY                                       ~0      'lucy'
         2      ADD_ARRAY_ELEMENT                                ~0      'lilei'
         3      ADD_ARRAY_ELEMENT                                ~0      'hanmeimei'
         4      ASSIGN                                                   !0, ~0
   3     5      EXT_STMT
         6  >   EXT_FCALL_BEGIN
         7      SEND_REF                                                 !0
         8      DO_FCALL                                      1  $2      'each'
         9      EXT_FCALL_END
        10      ASSIGN                                           $3      !1, $2
        11    > JMPZ                                                     $3, ->19
   4    12  >   EXT_STMT
        13      FETCH_DIM_R                                      $4      !1, 'value'
        14      CONCAT                                           ~5      $4, '%0A'
        15      ECHO                                                     ~5
   5    16      EXT_STMT
        17      ASSIGN                                                   !2, !0
   6    18    > JMP                                                      ->6
   7    19  > > RETURN                                                   1

branch: #  0; line:     2-    3; sop:     0; eop:     5; out1:   6
branch: #  6; line:     3-    3; sop:     6; eop:    11; out1:  12; out2:  19
branch: # 12; line:     4-    6; sop:    12; eop:    18; out1:   6
branch: # 19; line:     7-    7; sop:    19; eop:    19
path #1: 0, 6, 12, 6, 19,
path #2: 0, 6, 19,

我们会发现.....while没有引入特殊的OPCODE!和foreach语句的待遇大不相同啊;PHP引入了 FE_RESET,FE_FETCH来实现foreach,中间还悄悄地生成了一个临时变量;但是却只用了一个JMP,一个JUMPZ就搞定了while. 这就解释了,为什么foreach足够"聪明",不管怎么折腾,都不会多循环或是少循环一次,但是while(each)就不行了, 搞不好就死循环了,一切仅仅只是因为,foreach比while的待遇高,是专有实现.