引用
PHP 引用(在 & 符号的意义上)对用户空间代码大多是透明的,但在实现中需要一致的特殊处理。本章讨论如何表示引用,以及内部代码应如何处理它们。
引用语义
在讨论 PHP 引用的内部表示之前,澄清一些关于 PHP 中引用语义的常见误解可能会有所帮助。请看这个基本示例:
$a = 0;
$b =& $a;
$a++;
$b++;
var_dump($a); // int(2)
var_dump($b); // int(2)
人们通常会说 “$b 是对 $a 的引用”。然而,这并不完全正确,因为 PHP 中的引用没有方向性的概念。在 $b =& $a 之后, $a 和 $b 都引用一个公共值,并且这两个变量都没有任何特权。
当我们考虑引用和数组副本的交互时,这变得特别成问题:
$array = [0];
$ref =& $array[0];
$array2 = $array;
$array2[] = 42; // Triggering copy-on-write makes no difference here.
$ref++;
var_dump($array[0]); // int(1)
var_dump($array2[0]); // int(1)
$ref =& $array[0] 行在 $ref 和 $array[0] 之间创建引用。当随后复制该数组时,它会成为 $ref、$array[0] 和 $array2[0] 之间的引用,因为该引用也被复制。
直觉上这种行为是错误的。造成这种情况的原因有两个:第一个是前面提到的缺乏方向性。如果我们编写了 $array[0] =& $ref ,这种行为就会有意义。在这种情况下,预计 $array2[0] 的副本也指向 $ref 。然而,我们实际上无法区分这两种情况。
第二个也是更重要的原因是一个更技术性的原因: $array2 = $array 仅执行引用计数增量,这意味着即使我们想删除引用,我们也没有机会删除引用。
表示
引用使用指向 zend_reference 结构的 IS_REFERENCE zval 表示:
struct _zend_reference {
zend_refcounted_h gc;
zval val;
zend_property_info_source_list sources;
};
Zval 本身没有引用计数,并且不能共享。zend_reference 结构本质上表示一个可以共享的引用计数 zval。多个 zval 可以指向同一个 zend_reference ,并且它包含的 val 的任何更改都可以从所有源观察到。
类型来源
通常,PHP 不会跟踪谁或什么使用了给定的引用。存储的唯一信息是有多少用户(通过引用计数),以便引用可以及时销毁。
然而,由于 PHP 7.4 中引入了类型化属性,我们确实需要跟踪哪些类型化属性使用了某个引用,以便通过引用强制属性类型进行间接修改:
class Test {
public int $prop = 42;
}
$test = new Test;
$ref =& $test->prop;
$ref = "string"; // TypeError
zend_reference 的 sources 成员存储 zend_property_info 指针列表,以跟踪使用引用的类型化属性。ZEND_REF_HAS_TYPE_SOURCES()、ZEND_REF_ADD_TYPE_SOURCE() 和 ZEND_REF_DEL_TYPE_SOURCE() 等宏用于管理此源列表,但通常只有引擎代码需要处理此问题。
初始化引用
就像其他 zval 一样,引用是通过一组宏初始化的。最基本的接受已创建的 zend_reference 指针:
zval ref;
ZVAL_REF(ref, zend_reference_ptr);
要从头开始创建引用,可以使用 ZVAL_NEW_REF() :
zval ref;
zval initial_val;
ZVAL_STRING(initial_val, "test");
ZVAL_NEW_REF(&ref, &initial_val);
该宏接受一个初始值作为引用。请注意,它是使用 ZVAL_COPY_VALUE 移入引用的,引用计数不会增加。或者,ZVAL_NEW_EMPTY_REF() 使值保持未初始化状态:
zval ref;
ZVAL_NEW_EMPTY_REF(&ref);
ZVAL_STRING(Z_REFVAL(ref), "test");
这里我们创建一个空引用,然后直接初始化引用值 Z_REFVAL(ref) 。最后, ZVAL_MAKE_REF() 可用于将现有 zval 提升为引用:
zval *zv = /* ... */;
ZVAL_MAKE_REF(zv);
如果 zv 已经是一个引用,则这不会执行任何操作。如果还不是引用,这会将 zv 更改为引用,并将其初始值设置为 zv 的旧值。
解引用和展开
大多数代码不想以任何特殊方式处理引用,而只是想查看底层值:
zval *zv = /* ... */;
if (Z_ISREF_P(zv)) {
zv = Z_REFVAL_P(zv);
}
如果该值是引用( Z_ISREF ),我们将转而查看它包含的值。此操作称为 “取消引用”,更简洁地写为 ZVAL_DEREF(zv) 。它非常常见,基本上应该应用于可能出现引用 zval 的任何点。例如,数组上的典型循环可能如下所示:
zval *val;
ZEND_HASH_FOREACH_VAL(ht, val) {
ZVAL_DEREF(val);
/* Do something with val, now a guaranteed non-reference. */
} ZEND_HASH_FOREACH_END();
ZVAL_COPY_DEREF(target, source) 宏是 ZVAL_COPY 和 ZVAL_DEREF 的组合形式。它将 source 的取消引用值复制到 target 中。
取消引用只是将指针从外部 zval 移动到内部 zval,而不改变任何一个。还可以通过执行解包来实际删除引用包装器。通过查看其实现可能最容易理解此操作:
static zend_always_inline void zend_unwrap_reference(zval *op) {
if (Z_REFCOUNT_P(op) == 1) {
ZVAL_UNREF(op);
} else {
Z_DELREF_P(op);
ZVAL_COPY(op, Z_REFVAL_P(op));
}
}
如果引用计数为 1,则内部值将移至 op 中,并且引用包装器将被销毁。这就是 ZVAL_UNREF() 的作用。如果引用计数大于 1,则我们减少引用包装器的引用计数,并将内部值复制(随着引用计数增加)到 op 中。这意味着展开操作不一定会破坏引用(如果它有其他用户),但会删除一种特定用途。
间接 zval
除了引用之外,PHP 还有一种更直接的机制来共享 zval。 IS_INDIRECT 类型存储指向另一个 zval 的直接指针:
zval val1;
ZVAL_LONG(&val1, 42);
zval val2;
ZVAL_INDIRECT(&val2, &val1);
ZEND_ASSERT(Z_INDIRECT(val2) == &val1);
虽然与引用有一些表面相似性,但这种机制通常不可用,因为没有什么可以确保指向的 zval 不会被释放。因此,间接 zval 只能在受控情况下使用,例如从属性哈希表指向属性槽表。这是可能的,因为我们知道属性槽表在对象的生命周期内不会被重新分配,并且属性哈希表和属性槽表同时被释放,因此不会留下悬空指针。
因此,间接 zval 只能在特定情况下出现,并且不能存储在通用用户态公开的 zval 中。