引用

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 中。