面向对象的语义特征

我们知道,编程语言是用来对这个世界建模的,而面向对象则是建模方式中比较推崇的一种方法。我们来说一说面向对象的语义特征,进而让我们理解编程语言的本质。

类型角度

在汇编语言里面,是只支持简单类型的,例如整型。如果我们要实现稍微复杂一点的类型,例如字符串,那么,我们就需要一块内存,然后往这块内存里面挨个的放入字符,然后,我们把这块内存存储的内容,叫做字符串。同样的道理,类也是一种复杂的类型。

作用域角度

分为类的作用域和对象成员的作用域。

对于类来说,如果没有声明命名空间,那么,类的作用域就是根命名空间;如果有命名空间,那么类的作用域就是这个命名空间。

对于对象成员来说,成员的作用域则是这个对象。我们是通过这个对象来找到这个成员的(当然,如果你是静态成员,那么,也可以通过类来找到)。

所以,我们在实现面向对象的时候,必定会把这个对象放入栈帧里面,例如PHP里面的execute_data::This。而我们在方法里面使用的$this,实际上就是当前栈帧的execute_data::This。例如:

1
2
3
4
5
6
7
8
9
10
11
12
class Foo
{
public $a;

public $b;

public function __construct($a, $b)
{
$this->a = $a;
$this->b = $b;
}
}

我们在构造函数里面,通过$this来访问成员a。那么,我们就是在构造函数的这个栈帧里面,找到$this变量,然后,再查找这个对象的a属性。

使用Visitor模式来解析抽象语法树

我们在生成了AST之后,要做的事情就是去解析它。我们可以对它做很多的操作,比如修改AST的某些节点;对AST执行我们的语义操作,比如碰到+号,表示我们要做加法运算了,这样,我们就可以实现我们自己的语言了。

visitor模式的思想就是,当我们遍历AST上的每一个节点的时候,都去执行我们注册的所有visitor。这样,我们可以让代码更加的优雅,我们只需要专注于实现当前visitor的功能即可,让AST的结构和对AST的操作解耦。

我以PHP为例,大致介绍下如何通过visitor模式来用PHP实现PHP。我们给我们的这门语言命名为yaphp(实际上,这正是我现在开发的语言,还未开源)。

我们有如下yaphp的代码:

1
2
3
4
5
6
<?php

$a = 1 + 2 + 3 - 4;
$b = 2 * 3;
$c = $a + $b;
var_dump($c);

我们可以得到它的抽象语法树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
array(
0: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: a
)
expr: Expr_BinaryOp_Minus(
left: Expr_BinaryOp_Plus(
left: Expr_BinaryOp_Plus(
left: Scalar_LNumber(
value: 1
)
right: Scalar_LNumber(
value: 2
)
)
right: Scalar_LNumber(
value: 3
)
)
right: Scalar_LNumber(
value: 4
)
)
)
)
1: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: b
)
expr: Expr_BinaryOp_Mul(
left: Scalar_LNumber(
value: 2
)
right: Scalar_LNumber(
value: 3
)
)
)
)
2: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: c
)
expr: Expr_BinaryOp_Plus(
left: Expr_Variable(
name: a
)
right: Expr_Variable(
name: b
)
)
)
)
3: Stmt_Expression(
expr: Expr_FuncCall(
name: Name(
parts: array(
0: var_dump
)
)
args: array(
0: Arg(
name: null
value: Expr_Variable(
name: c
)
byRef: false
unpack: false
)
)
)
)
)

这个抽象语法树还是比较简单的。我们大致可以看到,有4条表达式语句。我们逐个来看。

第一条表达式语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
0: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: a
)
expr: Expr_BinaryOp_Minus(
left: Expr_BinaryOp_Plus(
left: Expr_BinaryOp_Plus(
left: Scalar_LNumber(
value: 1
)
right: Scalar_LNumber(
value: 2
)
)
right: Scalar_LNumber(
value: 3
)
)
right: Scalar_LNumber(
value: 4
)
)
)
)

这是一个赋值语句,我们通过这个结构得出,这个AST是右结合的。也就意味着,我们的赋值语句是右结合的。Expr_Assign它的左子树是一个变量的名字,Expr_Assign它的右子树是一个算数表达式。通过这个算数表达式的AST结构,我们可以看成,它是左结合的。

OK,我们可以根据这些节点的类型,写出对应的visitor

首先是AdditiveExpressionVisitor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

declare(strict_types=1);
/**
* This file is part of Yaphp.
*
* @contact codinghuang@qq.com
*/
namespace Yaphp\NodeVisitor;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp;
use PhpParser\Node\Expr\BinaryOp\Minus;
use PhpParser\Node\Expr\BinaryOp\Plus;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\NodeVisitorAbstract;
use Yaphp\HandWritten\CompilerGlobals;

class AdditiveExpressionVisitor extends NodeVisitorAbstract
{
public function enterNode(Node $node)
{
if (! ($node instanceof Plus) && ! ($node instanceof Minus)) {
return;
}
switch (get_class($node)) {
case Plus::class:
case Minus::class:
$result = $this->additiveExpression($node);
$resultNode = new Node\Scalar\LNumber($result);
break;
default:
break;
}

return $resultNode;
}

protected function additiveExpression(Expr $expr): int
{
if (isset($expr->left)) {
$leftValue = $this->additiveExpression($expr->left);
}
if (isset($expr->right)) {
$rightValue = $this->additiveExpression($expr->right);
}

switch (get_class($expr)) {
case Plus::class:
return $leftValue + $rightValue;
break;
case Minus::class:
return $leftValue - $rightValue;
break;
case Variable::class:
return CompilerGlobals::getSymbol($expr->name);
break;
case LNumber::class:
return $expr->value;
break;
default:
echo sprintf("Don't support expression %s", $expr->getType());
exit;
break;
}

return $leftValue + $rightValue;
}
}

因为,加法和减法我们认为是同一类操作,所以,我们可以把加法和减法写在一个visitor里面。

接着就是AssignVistor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

declare(strict_types=1);
/**
* This file is part of Yaphp.
*
* @contact codinghuang@qq.com
*/
namespace Yaphp\NodeVisitor;

use PhpParser\Node;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\BinaryOp;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\NodeVisitorAbstract;
use Yaphp\HandWritten\CompilerGlobals;

class AssignVistor extends NodeVisitorAbstract
{
public function leaveNode(Node $node)
{
$value = -INF;
if (! ($node instanceof Assign)) {
return;
}
if ($node->expr instanceof LNumber) {
$value = $node->expr->value;
} else if ($node->expr instanceof BinaryOp) {
$return = (new AdditiveExpressionVisitor)->enterNode($node->expr);
$value = $return->value;
}
CompilerGlobals::setSymbol($node->var->name, $value);
}
}

可以看到,实现变量就是一个字典,把变量的名字和对应的值存起来即可。目前我们这篇文章不考虑变量的作用域,所以我们把所有的变量通通存在了一个全局变量里面。

第二条表达式语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: b
)
expr: Expr_BinaryOp_Mul(
left: Scalar_LNumber(
value: 2
)
right: Scalar_LNumber(
value: 3
)
)
)
)

可以看到,这个结构和加法的算数表达式几乎是一样的。但是,我们认为加法和乘法还是有一定的区别的,所以我们单独给乘法写一个visitor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php

declare(strict_types=1);
/**
* This file is part of Yaphp.
*
* @contact codinghuang@qq.com
*/
namespace Yaphp\NodeVisitor;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp\Div;
use PhpParser\Node\Expr\BinaryOp\Mul;
use PhpParser\NodeVisitorAbstract;

class MultiplicativeExpressionVisitor extends NodeVisitorAbstract
{
public function enterNode(Node $node)
{
if (! ($node instanceof Mul) && ! ($node instanceof Div)) {
return;
}
switch (get_class($node)) {
case Mul::class:
case Div::class:
$result = $this->multiplicativeExpression($node);
$resultNode = new Node\Scalar\LNumber($result);
break;
default:
break;
}

return $resultNode;
}

protected function multiplicativeExpression(Expr $expr): int
{
if (isset($expr->left)) {
$leftValue = $this->multiplicativeExpression($expr->left);
}
if (isset($expr->right)) {
$rightValue = $this->multiplicativeExpression($expr->right);
}

switch (get_class($expr)) {
case Mul::class:
return $leftValue * $rightValue;
break;
case Div::class:
return $leftValue / $rightValue;
break;
default:
return $expr->value;
break;
}

return $leftValue * $rightValue;
}
}

第三条表达式语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2: Stmt_Expression(
expr: Expr_Assign(
var: Expr_Variable(
name: c
)
expr: Expr_BinaryOp_Plus(
left: Expr_Variable(
name: a
)
right: Expr_Variable(
name: b
)
)
)
)

这是两个变量相加,然后把表达式的结果赋值给一个新的变量。因为,我们把两个变量相加,也放在了AdditiveExpressionVisitor,所以,这里我们无须再实现一个新的visitor了。

我们来看最后一个表达式语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
3: Stmt_Expression(
expr: Expr_FuncCall(
name: Name(
parts: array(
0: var_dump
)
)
args: array(
0: Arg(
name: null
value: Expr_Variable(
name: c
)
byRef: false
unpack: false
)
)
)
)

这是一个函数调用了,所以,我们需要实现一个新的FuncCallExpressionVisitor。因为,函数也是可以求值的,所以我们把函数调用visitor归类为expression

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php

declare(strict_types=1);
/**
* This file is part of Yaphp.
*
* @contact codinghuang@qq.com
*/
namespace Yaphp\NodeVisitor;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\NodeVisitorAbstract;
use Yaphp\HandWritten\CompilerGlobals;

class FuncCallExpressionVisitor extends NodeVisitorAbstract
{
public function leaveNode(Node $node)
{
if (! ($node instanceof FuncCall)) {
return;
}

$functionName = $node->name->parts[0];
$symbol = $node->args[0]->value->name;

if (! CompilerGlobals::hasSymbol($symbol)) {
throw new \Exception(sprintf('not define symbol %s', $symbol), 1);
}

if ($functionName === 'var_dump') {
$this->varDumpHandler(CompilerGlobals::getSymbol($symbol));
}
}

protected function varDumpHandler($symbol)
{
var_dump($symbol);
}
}

这里,我们实现var_dump的方式就非常的简单了。当我们发现,这是一个var_dump函数的时候,我们直接调用var_dump函数即可。

至此,我们就算是实现了我们所有的visitor了。只要我们对AST使用上我们的visitor,我们就可以很愉快的去解析它了。

为什么一定要写注释和测试

最近在公司用PHP重写之前的Lua代码,这份Lua代码量不多,3000行左右。但是,我花了不少时间重写。感触非常大,所以想分享一下。

首先,我说一下这份需要重写的代码问题:

1
2
3
4
1. 没有一个测试。
2. 因为功能不断的在增加,加上对代码质量的不重视,复制粘贴的地方太多了。可能只是改了几个传参而已,但是,却复制了整个函数。
3. 多个地方,本来可以用几句话写完的逻辑,可能是因为当时没想到,逻辑写的非常复杂。并且这段复杂的代码没有注释。
4. 整个项目的注释占比太少了,可以说几乎没有。

我针对这几点来说一下。

第一点,没有一个测试。

这是我认为最严重的问题。我个人认为,一个没有测试的项目,经过无数迭代和多人接手之后,必定是屎山。你代码可以写的不好,但是,你必须要尽可能的写足够的测试,来保证你目前的功能都是正常的。

那么,没有测试的话,会导致什么问题呢?

很明显,你不敢去修改一个你不太熟悉的地方,你不知道这么改对不对,会不会影响其他的代码。所以,你可能就会自己去写一遍。甚至来说,你非常熟悉这份代码了,但是,你没有测试,你也不敢去改,因为你改完之后,你确定不了改完后代码后,整个系统正不正常。毕竟,你怕背锅。久而久之,这份代码极其丑陋。后面,你想改也没动力了。

并且,没有测试的话,你完全不敢去升级你使用的框架,除非你头铁。你知道这个框架有bug了,但是,你不知道升级之后,是否会有api不兼容的问题,导致你的项目出其他的bug

所以,没有测试,团队的代码会越来越丑陋。(如果是个人的项目,可能你还能撑得住)

你可能有千万个理由说自己没时间写测试。然而,写一个测试其实要不了多少时间,可能比你在postman等工具手动填充参数要的时间还少。我个人觉得,不写测试,是不热爱编程,没有享受敲击键盘的快感,你是一个喜欢手动完成一些任务的人。

第二点,代码质量问题。

我重写的时候,旧代码有太多的复制粘贴了。但是,我在完全重写之前,我是不敢对代码进行优化的。那么,我是怎么做的,我先按照Lua的代码,一字一句的完全翻译完,旧代码复制了几遍,我就重写几遍。然后,我给我重写后的代码编写足够的测试,然后我才敢进行优化。

那么,如果旧代码有测试会怎么样?我完全可以对旧代码进行重构,然后跑一遍测试,看一看测试是否通过。然后,我再按照优化后的代码进行重写。这样,重写的错误率会大大降低。

第三点,简单的逻辑落地在代码上,就变复杂了。

其实,把简单的逻辑写复杂了还能接收,但是,如果没有对这段复杂的代码进行解释,以后就没多少人能够读懂,写代码的人过久了估计也读不懂。所以,我建议,如果代码被你写复杂了,你一定要在旁边写上注释,解释一下这段代码做了什么。以后,就好按照注释进行代码优化。

第四点,没有注释。

很多人可能说,代码就可以表达意思,实际上我觉得并不可以。就算你有一个好的函数名字,你依然会去点开这个函数来看看,看它到底怎么写的。一旦你点进去 ,那么,很可能你就会被里面的代码给整懵了。你不懂这段代码为什么要这么做,即使你有了更好的点子,你也不敢去改。你怕你的想法和这段代码并不完全一致,一些细节,你可能没有考虑到。

还有一些特殊的if条件,我建议也最好注释一下。因为它可能只在某些特殊的场景才会出现,但是随着我们系统的变化,这个场景可能就不会出现了,那么,我们以后完全可以把这个特殊的代码分支给删了。

优化递归下降算法的尾递归

我们在文章递归下降算法左递归问题这篇文章里面,介绍了如何消除左递归。总结起来,就是把非终结符放到终结符的右边,使得我们在推导的过程中,可以消耗掉下一个token

但是,这种方法有一个问题。还是以1 + 2 + 3这个表达式为例来讲解一下优化手段。

首先,我们的文法规则如下:

1
additive -> intLiteral | intLiteral + additive

按照这个文法,我们将会构造出如下抽象语法树:

1
2
3
    +
1 +
2 3

我们发现,这棵树是向右倾斜的,那么,我们在解析这个抽象语法树的时候,就必然是先计算2 + 3,得到5之后 ,再计算1 + 5。所以,这是右结合的(注意,结合性和优先级的区别,优先级指的是,无论什么时候,都是先计算,而结合性指的是一种普遍的计算顺序,从哪边到哪边。顺带一提的是,优先级我们可以通过层级嵌套来实现,把优先级高的放在子级,那么,它必然就先计算了)。但是,一般来说,我们的加法表达式都是左结合的。(也有右结合的例子,例如$a = 1 + 2,先计算1 + 2,然后再计算$a = 3)。所以 ,我们需要调整一下这个抽象语法树的结构,我们打算让这棵树向左倾斜,这样的话,就会先计算左边的子树,然后再计算左边的子树。所以,我们期望的结构如下 :

1
2
3
        +
+ 3
1 2

这样,我们在遍历这棵树的时候,就会先计算1 + 2,然后再计算3 + 3。这是符合我们的预期的。

那么,为什么文法:

1
additive -> intLiteral | intLiteral + additive

生成的AST它是右结合的呢?因为我们的非终结符是在右边,终结符在左边,所以,在一棵子树里面,左节点必然是终结符,右子树必然是一棵递归的树,直到右子树碰到终结符,才停止右子树的生成。

所以,如果我们要让一棵树变成左结合的,我们可以调整一下文法,把非终结符放到左边,终结符放到右边。如下:

1
additive -> intLiteral | additive + intLiteral

当时,我们之前说过了,左递归会造成无限递归。但是,无限循环的前提是,我们使用的是递归下降算法来生成AST。如果我们不用递归下降算法来构造AST,那么我们是可以避免无限递归的。并且,不是所有的算法都不能处理左递归,例如LR算法是可以处理左递归的。

好了,我们现在使用ebnf来改造下这个文法:

1
2
3
additiveExpression -> intLiteral | additiveExpression + intLiteral
->
additiveExpression: intLiteral | intLiteral (+ intLiteral)*

(需要注意的一点事,这里的+不是正则的元符号+的含义,它仅仅是字符串+

可能这个过程大家会看不懂,我多讲一点推导过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
additiveExpression: intLiteral | additiveExpression + intLiteral + intLiteral + intLiteral ....
->
因为,“+ intLiteral + intLiteral + intLiteral ....” 这部分必须终止,所以,additiveExpression最后一次推导必然是得到intLiteral,因此,additiveExpression -> intLiteral additiveExpression':

additiveExpression: intLiteral | intLiteral additiveExpression'

所以,我们现在需要解决的问题就是如何用additiveExpression'去推导“+ intLiteral + intLiteral + intLiteral ....”。通过归纳法,我们可以很轻易的得到如下结果:

additiveExpression': + intLiteral additiveExpression' | ε

其中,ε表示空集。这在递归的时候,作为结束条件。我们发现,这是一个尾递归了,那么,我们就可以想到尾递归的优化,我们可以写成一个循环:
->
additiveExpression: intLiteral | intLiteral (+ intLiteral)*

因此,最终,我们把一个左递归的文法转化成了一个没有左递归的文法。

然后,我们可以轻易的写出这个文法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
protected function additiveUnRecursiveMode()
{
/** @var AstNode */
$child1 = $this->scanner->primary();
$node = $child1;

while (true) {
$token = $this->scanner->peekToken();
if ($token == null) {
// 没有token了,所以我们需要终止循环
break;
}
if ($token != Token::ADD) {
throw new TokenException(sprintf('Token [%s] that are not expected, need a [%s].', $token, Token::ADD), Errno::UN_EXPECTED_TOKEN);
}
$this->scanner->readToken();
$child2 = $this->scanner->primary();
$node = new AstNode(AstNodeType::ADD_NODE, '+');
$node->addChildNode($child1);
$node->addChildNode($child2);
/**
* 因为我们希望这棵AST是左结合的,所以,我们把生成的子树作为父节点的左子树
*/
$child1 = $node;
}

// 此处的node是AST的根结点
return $node;
}

递归下降算法左递归问题

我们在学习编译原理的过程中,一定会学习到递归下降的算法来进行语法分析。

首先,我们需要去理解“下降”的含义。我们可以这么去理解:

上级文法嵌套下级文法,上级的算法调用下级的算法。表现在生成 AST 中,上级算法生成上级节点,下级算法生成下级节点

好的,现在,我们来通过一个计算器的程序来学习一下递归下降算法。

首先,我们有一个问题。我们能否用正则表达式来表达算数表达式?答案是不能。

假设我们想要用正则表达式去表达所有的算数表达式,那么这一定是一个体力活,并且计算能力是有限的。例如,我们可以有如下的算数表达式:

1
2
3
4
5
1 + 2
1 + 2 + 3
1 * 2 + 3
1 + 2 * 3
...... 等等

那么,我们是没有办法找到一个或者有限个正则表达式来表达所有的算数表达式。

好的,现在,我们尝试着用递归下降算法来解决算数表达式的问题。为了简单讨论,这里,我们只有加法,并且只包含整数。所以,我们有如下的语法:

1
2
3
4
additive
: int
| additive int
;

意思是,我们的加法表达式可以只是一个整数,也可以是加法表达式加上一个整数。而加法表达式加上一个整数,这个就是我们递归下降算法中“递归”的含义了。但是,上面的程序,是会造成左递归的。

比如说我们要计算这个算数表达式:1 + 2

我们可以来模拟计算过程:

1
2
3
首先匹配是不是整型字面量,发现是,但是后面还有token,所以匹配失败;
然后匹配是不是加法表达式,这里是递归调用;
会重复上面两步,无穷无尽。

所以,左递归是递归下降算法无法处理的(因为左递归的情况下,我们是无法消耗token的,因此造成了无限递归)。但是,我们有如下的解决办法,我们把递归的加法表达式移到右边,那么就有了如下的语法:

1
2
3
4
additive
: int
| int additive
;

我们可以来模拟计算过程:

1
2
3
4
首先匹配是不是整型字面量,发现是,但是后面还有token,所以匹配失败;
然后匹配是不是整型字面量,发现是,然后消耗一个加号,然后递归的再次匹配加法表达式;
然后匹配是不是整形字面量,发现是。
匹配完成!

我们发现,这个语法可以解决左递归问题。因为这个方法可以消耗掉一个int token和一个加号 token之后,再递归的执行加法表达式。

我们可以编写如下代码来描述这个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?php

/**
* additive
* : int
* | additive int
* ;
*/
define('UNKNOW', 0);
define('INT_NODE', 1);
define('ADD_NODE', 2);

class Node
{
/**
* @var array[Node]
*/
public $children;

/**
* @var int
*/
public $nodeType;

/**
* @var int
*/
public $value;

public function __construct(int $nodeType = UNKNOW, ?int $value = null)
{
$this->nodeType = $nodeType;
$this->value = $value;
}

public function addChildNode(Node $node)
{
$this->children[] = $node;
}
}

function primary(SplQueue $queue): Node {
/** @var int */
$token = $queue->dequeue();

return new Node(INT_NODE, $token);
}

function peekToken(SplQueue $queue) {
if ($queue->isEmpty()) {
return null;
}
return $queue->bottom();
}

function readToken(SplQueue $queue) {
return $queue->dequeue();
}

function additive(SplQueue $queue): Node {
/** @var Node */
$child1 = primary($queue);
$node = $child1;
$token = peekToken($queue);

if ($child1->nodeType === INT_NODE && $token != null) {
if ($token == '+') {
readToken($queue);
$child2 = additive($queue);
$node = new Node(ADD_NODE);
$node->addChildNode($child1);
$node->addChildNode($child2);
}
}
return $node;
}

$queue = new SplQueue;

$queue->enqueue(1);
$queue->enqueue('+');
$queue->enqueue(2);

$node = additive($queue);

最后,我们将会得到一个ADD_NODE类型的根结点。其中第一个子节点是值为1Node,第二个节点是值为2Node。然后,我们对这个AST进行遍历,就可以得到算数表达式的结果了。

通过责任链模式设计多级缓存

背景:最近业务上面有一个多级缓存的需求,也很简单,内存 -> 文件 -> MySQL。开始的时候,我只做了 文件 -> MySQL 的缓存,但是,后面我又加了一个内存作为缓存,并且,我在测试的时候,发现我忘了在读取到下一级的文件缓存数据之后,更新到上一级的内存缓存。于是我就发现了这很容易造成缓存没更新的问题,所以调研了一下,发现责任链设计模式可以很好的解决这个问题。

我们可以先来看一看不用责任链模式,我的代码是如何写的。首先是只有 文件 -> MySQL 的缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function get(string $configKey): array
{
$filePath = $configKey . DIRECTORY_SEPARATOR . 'cache.json';

// 先查看文件里面有没有缓存配置信息
$result = $this->readFromCachedFile($filePath);
if (! empty($result)) {
$this->logger->debug('read config from cached file', ['path' => $filePath]);

return $result;
}

/** @var Config */
$config = Config::query()->where('id', $configKey)->first();

$this->logger->debug('query config from mysql');

$payload = json_decode($config, true);

$result['gslb'] = $payload['gslb'];
$result['sdkconfig'] = $payload['sdkconfig'][$role] ?? [];

$this->writeToCachedFile($filePath, json_encode($result));
$this->logger->debug('write config to cached file', ['filePath' => $filePath]);

return $result;
}

可以看到,代码可读性还是不错的。先查找文件,然后再查找数据库,然后更新文件。一路下来,没有任何难题。

但是,当我变成了 内存 -> 文件 -> MySQL 缓存之后,问题开始凸显出来了。我的第一版代码是这样的:s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public function get(string $configKey): array
{
// 先查看内存里面有没有缓存配置信息
$result = $this->readFromCachedMemory($configKey);
if (! empty($result)) {
$this->logger->debug('read config from cached memory', ['path' => $configKey]);
return $result;
}

$filePath = $configKey . DIRECTORY_SEPARATOR . 'cache.json';

// 先查看文件里面有没有缓存配置信息
$result = $this->readFromCachedFile($filePath);
if (! empty($result)) {
$this->logger->debug('read config from cached file', ['path' => $filePath]);

return $result;
}

/** @var Config */
$config = Config::query()->where('id', $configKey)->first();

$this->logger->debug('query config from mysql');

$payload = json_decode($config, true);

$result['gslb'] = $payload['gslb'];
$result['sdkconfig'] = $payload['sdkconfig'][$role] ?? [];

$this->writeToCachedMemory($configKey, json_encode($result));
$this->logger->debug('write config to cached memory', ['path' => $configKey]);
$this->writeToCachedFile($filePath, json_encode($result));
$this->logger->debug('write config to cached file', ['filePath' => $filePath]);

return $result;
}

咋眼一看,可能还真看不出啥问题。但是编写完足够的单元测试之后,问题就凸显出来了。我发现,这段代码有问题:

1
2
3
4
5
6
7
// 先查看文件里面有没有缓存配置信息
$result = $this->readFromCachedFile($filePath);
if (! empty($result)) {
$this->logger->debug('read config from cached file', ['path' => $filePath]);

return $result;
}

这里在文件里面找到了数据之后,我忘记去更新数据到内存里面了。当我发现这个问题之后,我意识到了问题的严重性,这简直就是一个维护成本极高的代码。因为我想到,我仅仅是加了一个内存缓存,就出现了忘记保存缓存数据的问题,那以后要是又加了几个缓存,那代码写起来几乎就是灾难了吧。每一次下一级缓存找到之后,我们都要更新所有的上一级缓存,这代码大概会变成这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 查看一级缓存
$result = $this->readFromFirstCache($configKey);
if (! empty($result)) {
return $result;
}

// 查看二级缓存
$result = $this->readFromSecondCache($configKey);
if (! empty($result)) {
$this->writeToFirstCache($configKey, $result);
return $result;
}

// 查看三级缓存
$result = $this->readFromThreeCache($configKey);
if (! empty($result)) {
$this->writeToSecondCache($configKey);
$this->writeToFirstCache($configKey, $result);
return $result;
}

// 查看数据库
$result = $this->readFromMySQL($configKey);
if (! empty($result)) {
$this->writeToThreeCache($configKey);
$this->writeToSecondCache($configKey);
$this->writeToFirstCache($configKey, $result);
return $result;
}

我是觉得这个代码维护起来极其困难了。

然后,责任链设计模式就可以用上了。其实说白了,责任链设计模式就是一个考察你的递归基本功的设计模式。原理很简单,当上一级执行某种操作失败之后,就找下一级,一直递归的执行这个操作,直到找了数据之后,我们开始回溯,并且更新上一级的数据。

我们可以用这份代码进行表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<?php

abstract class CachedChain
{
public const MEMORY = 1;

public const FILE = 2;

public const MYSQL = 3;

/**
* @var int
*/
protected $level;

/**
* @var CachedChain
*/
protected $nextCache;

/**
* @var string
*/
public $data = '';

abstract protected function read(array $param): string;

public function setNextCache(CachedChain $nextCache)
{
$this->nextCache = $nextCache;
}

public function readContent(int $level, array $param): string
{
$content = "";

if ($this->level >= $level) {
$content = $this->read($param);
}

if (!empty($content)) {
return $content;
}

$content = $this->nextCache->readContent($level, $param);
$this->data = $content;
return $content;
}
}

class CachedMemory extends CachedChain
{
protected $level = self::MEMORY;

protected function read(array $param): string
{
return $this->data;
}
}

class CachedFile extends CachedChain
{
protected $level = self::FILE;

protected function read(array $param): string
{
return $this->data;
}
}

class CachedDB extends CachedChain
{
protected $level = self::MYSQL;

protected function read(array $param): string
{
return $this->data;
}
}

$memory = new CachedMemory;
$file = new CachedFile;
$db = new CachedDB;
$db->data = 'hello world';

$memory->setNextCache($file);
$file->setNextCache($db);

$data = $memory->readContent(CachedChain::MEMORY, []);
var_dump($data);
$data = $memory->readContent(CachedChain::MEMORY, []);
var_dump($data);

这个代码运行结果:

1
2
string(11) "hello world"
string(11) "hello world"

感兴趣的小伙伴可以调试一下。然后稍加修改,就可以做成一个通用的组件了。

尽可能不要在PHP的C扩展里面把重要的字段存成对象的属性

我们有如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

use Swoole\Server;

$serv = new Server('127.0.0.1', 9580);

$serv->on('Receive', function ($serv, $fd, $reactorId, $data) {
array_walk($serv, function (&$property) {
if (isset($property[0]) && $property[0] instanceof Swoole\Server\Port) {
$primaryPort = $property[0];
array_walk($primaryPort, function (&$callback) {
$callback = null;
});
}
});
});

$serv->start();

这个代码看起来会非常的绕,但是,为了解释我们的问题,这个写法还是很具有代表性的。

因为PHP的设计问题,我们可以在类的外面通过array_walk来访问一个对象的私有属性,并且修改它。我们来看一下Swoole\Server底层是如何存成port的:

1
2
3
4
5
6
7
8
9
10
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onConnect"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onReceive"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onClose"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onPacket"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onBufferFull"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onBufferEmpty"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onRequest"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onHandShake"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onOpen"), ZEND_ACC_PRIVATE);
zend_declare_property_null(swoole_server_port_ce, ZEND_STRL("onMessage"), ZEND_ACC_PRIVATE);

我们发现,这里把回调函数设置成了private属性,但是终究是无法避免被修改的下场。

只要我们跑这个服务器,并且给这个服务器发送数据。那么,我们就可以让这个Server coredump。这是我的测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PHP Fatal error:  Uncaught Error: Cannot use object of type Swoole\Server as array in /Users/hantaohuang/codeDir/phpCode/library/test.php:10
Stack trace:
#0 {main}
thrown in /Users/hantaohuang/codeDir/phpCode/library/test.php on line 10

Fatal error: Uncaught Error: Cannot use object of type Swoole\Server as array in /Users/hantaohuang/codeDir/phpCode/library/test.php:10
Stack trace:
#0 {main}
thrown in /Users/hantaohuang/codeDir/phpCode/library/test.php on line 10
[2020-08-18 21:16:56 *13285.3] ERROR php_swoole_server_rshutdown (ERRNO 503): Fatal error: Uncaught Error: Cannot use object of type Swoole\Server as array in /Users/hantaohuang/codeDir/phpCode/library/test.php:10
Stack trace:
#0 {main}
thrown in /Users/hantaohuang/codeDir/phpCode/library/test.php on line 10
[2020-08-18 21:16:56 $12938.0] WARNING check_worker_exit_status: worker#3[pid=13285] abnormal exit, status=255, signal=0

我们可以看到,worker进程挂了。

Swoole的多个线程如何处理信号

Swoole内核里面,有多种线程。比如说心跳线程(心跳线程我们会在未来的版本进行移除),reactor线程,中断检查线程等等。

那么,在信号的管理方面,Swoole又是怎么做的呢?我们又如下规则:

1
2
3
1. 如果是异常产生的信号(比如程序错误,像SIGPIPE、SIGEGV这些),则只有产生异常的线程收到并处理。
2. 如果是用pthread_kill产生的内部信号,则只有pthread_kill参数中指定的目标线程收到并处理。
3. 如果是外部使用kill命令产生的信号,通常是SIGINT、SIGHUP等job control信号,则会遍历所有线程,直到找到一个不阻塞该信号的线程,然后调用它来处理。注意只有一个线程能收到。

那么Swoole是如何实现阻塞信号的呢?它提供了一个叫做swSignal_none的函数:

1
2
3
4
5
6
7
8
void swSignal_none(void) {
sigset_t mask;
sigfillset(&mask);
int ret = pthread_sigmask(SIG_BLOCK, &mask, nullptr);
if (ret < 0) {
swSysWarn("pthread_sigmask() failed");
}
}

其中,

1
sigfillset(&mask);

表示设置所有的信号。

1
int ret = pthread_sigmask(SIG_BLOCK, &mask, nullptr);

表示对所有的信号进行阻塞。

我们发现,Swoole对心跳线程、中断检查线程等线程调用了swSignal_none。因为Swoole不希望这些线程去处理信号以及被这些信号打扰。具体哪些地方调用了swSignal_none,感兴趣的小伙伴可以看一看源码。

PHP数组系列函数源码分析--end

本文基于PHP的commit: d92229d8c78aac25925284e23aa7903dca9ed005

如果我们要获取数组的最后一个元素,我们很可能会这么写:

1
2
3
4
5
6
7
8
9
10
11
<?php

declare(strict_types=1);

$arr = [
'one' => 1,
'two' => 2,
'three' => 3,
];

var_dump(end($arr));

输出结果如下:

1
int(3)

我们来看一下end对应的PHP层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PHP_FUNCTION(end)
{
HashTable *array;
zval *entry;

ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_ARRAY_OR_OBJECT_HT_EX(array, 0, 1)
ZEND_PARSE_PARAMETERS_END();

zend_hash_internal_pointer_end(array);

if (USED_RET()) {
if ((entry = zend_hash_get_current_data(array)) == NULL) {
RETURN_FALSE;
}

if (Z_TYPE_P(entry) == IS_INDIRECT) {
entry = Z_INDIRECT_P(entry);
}

ZVAL_COPY_DEREF(return_value, entry);
}
}

可以看到,核心代码就是zend_hash_internal_pointer_end,它负责找到数组的最后一个元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define zend_hash_internal_pointer_end(ht) \
zend_hash_internal_pointer_end_ex(ht, &(ht)->nInternalPointer)

/* This function will be extremely optimized by remembering
* the end of the list
*/
ZEND_API void ZEND_FASTCALL zend_hash_internal_pointer_end_ex(HashTable *ht, HashPosition *pos)
{
uint32_t idx;

IS_CONSISTENT(ht);
HT_ASSERT(ht, &ht->nInternalPointer != pos || GC_REFCOUNT(ht) == 1);

idx = ht->nNumUsed;
while (idx > 0) {
idx--;
if (Z_TYPE(ht->arData[idx].val) != IS_UNDEF) {
*pos = idx;
return;
}
}
*pos = ht->nNumUsed;
}

通过这个函数的注释,我们可以明白。如果我们能够大概记住数组的末尾的元素,那么,这个函数的性能是非常高的。

这个代码也是很简单的,首先,通过:

1
2
idx = ht->nNumUsed;
idx--;

来找到最后一个bucket的位置。然后,判断bucket里面的变量是否是IS_UNDEF。如果不是,那么就找到了数组的最后一个元素;否则,一直往前找。

所以,如果这个数组的末尾都是IS_UNDEF,那么这个函数的性能会非常的差劲。极端情况下,只有数组的第一个元素不是IS_UNDEF,其他的都是IS_UNDEF,那么这个函数的时间复杂度就是O(n)了。

这里,我们有一个需要非常注意的点,这个end函数会去设置nInternalPointer指针。如果我们调用end函数后,紧接着调用current函数,那么我们就会得到数组的最后一个元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

declare(strict_types=1);

$arr = [
'one' => 1,
'two' => 2,
'three' => 3,
];

var_dump(current($arr));
var_dump(end($arr));
var_dump(current($arr));

输出结果如下:

1
2
3
int(1)
int(3)
int(3)

但是,并不是说nInternalPointer就代表最后一个元素的位置。nInternalPointer表示数组里面有这么一个指针,它指向了PHP数组里面的一个元素,仅此而已。

PHP数组系列函数源码分析(一)--count

本文基于的PHP commit为: d92229d8c78aac25925284e23aa7903dca9ed005

首先,我们来看一下count函数的PHP层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/* {{{ Count the number of elements in a variable (usually an array) */
PHP_FUNCTION(count)
{
zval *array;
zend_long mode = COUNT_NORMAL;
zend_long cnt;

ZEND_PARSE_PARAMETERS_START(1, 2)
Z_PARAM_ZVAL(array)
Z_PARAM_OPTIONAL
Z_PARAM_LONG(mode)
ZEND_PARSE_PARAMETERS_END();

if (mode != COUNT_NORMAL && mode != COUNT_RECURSIVE) {
zend_argument_value_error(2, "must be either COUNT_NORMAL or COUNT_RECURSIVE");
RETURN_THROWS();
}

switch (Z_TYPE_P(array)) {
case IS_NULL:
/* Intentionally not converted to an exception */
php_error_docref(NULL, E_WARNING, "Parameter must be an array or an object that implements Countable");
RETURN_LONG(0);
break;
case IS_ARRAY:
if (mode != COUNT_RECURSIVE) {
cnt = zend_array_count(Z_ARRVAL_P(array));
} else {
cnt = php_count_recursive(Z_ARRVAL_P(array));
}
RETURN_LONG(cnt);
break;
case IS_OBJECT: {
zval retval;
/* first, we check if the handler is defined */
if (Z_OBJ_HT_P(array)->count_elements) {
RETVAL_LONG(1);
if (SUCCESS == Z_OBJ_HT(*array)->count_elements(Z_OBJ_P(array), &Z_LVAL_P(return_value))) {
return;
}
if (EG(exception)) {
RETURN_THROWS();
}
}
/* if not and the object implements Countable we call its count() method */
if (instanceof_function(Z_OBJCE_P(array), zend_ce_countable)) {
zend_call_method_with_0_params(Z_OBJ_P(array), NULL, NULL, "count", &retval);
if (Z_TYPE(retval) != IS_UNDEF) {
RETVAL_LONG(zval_get_long(&retval));
zval_ptr_dtor(&retval);
}
return;
}

/* If There's no handler and it doesn't implement Countable then add a warning */
/* Intentionally not converted to an exception */
php_error_docref(NULL, E_WARNING, "Parameter must be an array or an object that implements Countable");
RETURN_LONG(1);
break;
}
default:
/* Intentionally not converted to an exception */
php_error_docref(NULL, E_WARNING, "Parameter must be an array or an object that implements Countable");
RETURN_LONG(1);
break;
}
}
/* }}} */

可以看出,这个函数可以计算数组和对象。我们先来看一下是如何计算数组元素的个数的:

1
2
3
4
5
6
if (mode != COUNT_RECURSIVE) {
cnt = zend_array_count(Z_ARRVAL_P(array));
} else {
cnt = php_count_recursive(Z_ARRVAL_P(array));
}
RETURN_LONG(cnt);

COUNT_RECURSIVE代表是否需要递归的去计算数组的元素个数(比如说,数组里面又套了一个数组)。如果不需要递归的去计算,那么调用zend_array_count;如果需要递归的去计算,那么调用php_count_recursive

注意,count这个函数要被调用的话,我们得设置countmodeCOUNT_RECURSIVE。例如:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

declare(strict_types=1);

$arr = [
'one' => 1,
'two' => 2,
'three' => 3,
];

$num = count($arr, COUNT_RECURSIVE);
var_dump($num);

否则,count会直接走zend_count对应的opcode handler,然后调用zend_array_count

我们来看一看zend_array_count

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ZEND_API uint32_t zend_array_count(HashTable *ht)
{
uint32_t num;
if (UNEXPECTED(HT_FLAGS(ht) & HASH_FLAG_HAS_EMPTY_IND)) {
num = zend_array_recalc_elements(ht);
if (UNEXPECTED(ht->nNumOfElements == num)) {
HT_FLAGS(ht) &= ~HASH_FLAG_HAS_EMPTY_IND;
}
} else if (UNEXPECTED(ht == &EG(symbol_table))) {
num = zend_array_recalc_elements(ht);
} else {
num = zend_hash_num_elements(ht);
}
return num;
}

可以看到,一个看似简单的PHP函数,有非常多的细节需要考虑。(我之前是觉得这个函数要实现起来非常简单啊,直接调用zend_hash_num_elements就好了)

我们先来看这部分代码:

1
2
3
4
5
6
if (UNEXPECTED(HT_FLAGS(ht) & HASH_FLAG_HAS_EMPTY_IND)) {
num = zend_array_recalc_elements(ht);
if (UNEXPECTED(ht->nNumOfElements == num)) {
HT_FLAGS(ht) &= ~HASH_FLAG_HAS_EMPTY_IND;
}
}

首先是判断是否是HASH_FLAG_HAS_EMPTY_IND标志(IND应该是indirect的意思,而不是index)。这个标志表示是否存在空的间接zval。搜索整个PHP源码,我们发现,这个标志在两个地方会被设置:

1
2
ZEND_API int ZEND_FASTCALL zend_hash_del_ind(HashTable *ht, zend_string *key);
ZEND_API int ZEND_FASTCALL zend_hash_str_del_ind(HashTable *ht, const char *str, size_t len);

并且,我们会发现,这两个函数似乎只使用在了符号表EG(symbol_table)上面。而EG(symbol_table)对应的PHP变量是$GLOBALS

所以,我们可以很轻易的写一个测试例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

declare(strict_types=1);

var_dump(count($GLOBALS));

$one = 1;
$two = 2;
$three = 3;

var_dump(count($GLOBALS));

unset($GLOBALS['two']);

var_dump(count($GLOBALS));

var_dump($two);

执行结果如下:

1
2
3
4
5
6
int(8)
int(11)
int(10)

Warning: Undefined variable $two in /Users/hantaohuang/codeDir/cCode/php-src/test.php on line 17
NULL

可以看到,最初$GLOBALS数组里面的元素个数是8个。

注意,这里指的是$GLOBALS数组里面非UNDEF的元素个数是8个,实际上,因为最初的$GLOBALS有一些元素它是UNDEF,所以,nNumOfElements它的值会大于8,就这个脚本而言,初始的nNumOfElements的值是11,因为PHP在编译阶段,就会往$GLOBALS数组里面插入我们在全局作用域使用到的变量(即abc),但是因为这些变量是在后面使用的,所以,最开始的时候,这3个数组元素是UNDEF的。

当我们在全局作用域里面为这3个数组元素赋值之后,$GLOBALS数组里面的元素个数变成了11。并且,当我们unset$GLOBALS数组里面的一个元素之后,数组里面的元素少了一个。

这里,我们需要注意的一个点是,我们得unset($GLOBALS['two']),而不能unset($two)。否则是不会设置HASH_FLAG_HAS_EMPTY_IND标志的。(因为这个标志是在UNSET_DIM这个opcode里面设置的)

然后就是zend_array_recalc_elements这个函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static uint32_t zend_array_recalc_elements(HashTable *ht)
{
zval *val;
uint32_t num = ht->nNumOfElements;

ZEND_HASH_FOREACH_VAL(ht, val) {
if (Z_TYPE_P(val) == IS_INDIRECT) {
if (UNEXPECTED(Z_TYPE_P(Z_INDIRECT_P(val)) == IS_UNDEF)) {
num--;
}
}
} ZEND_HASH_FOREACH_END();
return num;
}

顾名思义,这个函数就是用来重新计算数组里面元素的个数的。那上面的那个例子来说,unset($GLOBALS['two'])是不会减少数组的nNumOfElements的值的。所以,我们需要这么一个函数来计算真正的元素个数。

我们接着来看后面的代码:

1
2
3
else if (UNEXPECTED(ht == &EG(symbol_table))) {
num = zend_array_recalc_elements(ht);
}

我们也可以很轻易的写出对应的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

declare(strict_types=1);

$one = 1;
$two = 2;
$three = 3;

unset($two);

var_dump(count($GLOBALS));

var_dump($two);

输出如下:

1
2
3
4
int(10)

Warning: Undefined variable $two in /Users/hantaohuang/codeDir/cCode/php-src/test.php on line 13
NULL

这个代码和上面的代码的区别是,这里我们是直接unset($two)。那么,此时就不会执行UNSET_DIM handler了,因此也不会设置数组的HASH_FLAG_HAS_EMPTY_IND标志。但是,$GLOBALS['two']它依然是UNDEF的,因为$GLOBALS['two']它是变量$two的一个间接zval。所以,在unset之后,$GLOBALS的元素个数也是10

我们接着来看后面的代码:

1
2
3
else {
num = zend_hash_num_elements(ht);
}

这段代码就简单了,直接是取数组的nNumOfElements值。我们可以非常轻易的写出测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

declare(strict_types=1);

$arr = [
'one' => 1,
'two' => 2,
'three' => 3,
];

var_dump(count($arr));

unset($arr['two']);

var_dump(count($arr));

我们稍微解释一下。

当最开始定义数组的时候,数组的nNumUsednNumOfElements都是3unset($arr['two'])之后,nNumUsednNumOfElements分别是32。所以,count($arr)得到的元素个数是2

可以看出,一个简单的count函数,实际上还是有非常多的细节需要考虑的。而这一切的一切,都来自于$GLOBALS这个变量。顺便一提的是,最近PHP内核的诸多bug都是由$GLOBALS这个变量引起的。