3. PHP 引用解惑

1. 前言

引用是PHP中最令人迷惑、最容易被误解的概念之一。因此,它有幸成为了初/中级PHP岗位经久不衰的面试题,很多基础不牢者也常常栽在这里。

我们知道,在C/C++中一个引用类型的变量,它实际存储的值其实是被引用的变量的地址,因此当被引用的值改变时,引用者所表现出来的值也会随之改变。这当然非常容易理解,PHP的引用也似乎与之没什么不同。

我们上一章讲过,每个PHP变量都是一个zval,那么一个引用型变量,它自然存储了指向另一个zval的指针了?我们看一下这一段PHP脚本:

$foo = 10;
$bar = &$foo;
unset($foo);
var_dump($foo); // NULL
var_dump($bar); // int(10)

如果这个假设是正确的,$bar存储了指向$foo的指针,那么输出的结果不会是10,而是NULL。那引用究竟是何方神圣呢?且容我慢慢道来。

2. 基本概念

2.1 引用与引用计数

引用与引用计数的概念常被混淆。我们在上一章讲过“引用计数”的概念。大家知道,数组、字符串、对象、资源属于GC类型,一个GC类型的实例中存储了它的引用数量。当引用数量降为0,即不再有zval指向它时,它就会在特定的时机被销毁。

我们这次讲的“引用”,指的不是zval对GC类型的引用,而是一种名为“引用”的GC类型,与上面说的4种类型属于并列关系。

2.2 zend_reference

引用类型的数据存储在结构体zend_reference中,与其他GC类型同理:

struct _zend_reference {
    zend_refcounted_h gc;
    zval              val;
};

可见,zend_reference是对zval的封装。这里要注意,封装的是zval本身而不是它的指针,这一点非常关键。

2.3 创建引用

对于任意GC类型我们都可以直接使用emalloc()为其分配内存,实际开发中,可以借助zend_type.h中提供了一些常用的宏进行操作。

#define ZVAL_NEW_REF(z, r) do {                               \
        zend_reference *_ref =                                \
        (zend_reference *) emalloc(sizeof(zend_reference));   \
        GC_REFCOUNT(_ref) = 1;                                \
        GC_TYPE_INFO(_ref) = IS_REFERENCE;                    \
        ZVAL_COPY_VALUE(&_ref->val, r);                       \
        Z_REF_P(z) = _ref;                                    \
        Z_TYPE_INFO_P(z) = IS_REFERENCE_EX;                   \
    } while (0)

ZVAL_NEW_REF()接受两个zval的指针作为参数,它的行为有三:

  • 使用emalloc()在堆上为一个zend_reference分配内存并将其初始化。
  • 将第二个zval的值拷贝给引用的val成员。
  • 使第一个zval指向这个引用。

同理,还有一些功能相似的宏:

  • ZVAL_NEW_EMPTY_REF()只接受一个参数,它不会给zend_referenceval成员赋值。
  • ZVAL_NEW_PERSISTENT_REF()使用malloc()而非emalloc()分配内存,这意味着该引用不会被ZendMM回收。
#define ZVAL_MAKE_REF(zv) do {                        \
        zval *__zv = (zv);                            \
        if (!Z_ISREF_P(__zv)) {                       \
            ZVAL_NEW_REF(__zv, __zv);                 \
        }                                             \
    } while (0)

ZVAL_MAKE_REF()接受一个zval作为参数,如果类型不是引用,则用其值初始化一个新引用,并指向它。

2.4 解引用

当我们使用一个引用时,我们往往只关心它所指向的zval。将一个引用类型的zval转化为它所引用的zval的操作,被称为解引用。

#define ZVAL_DEREF(z) do {                         \
        if (UNEXPECTED(Z_ISREF_P(z))) {            \
            (z) = Z_REFVAL_P(z);                   \
        }                                          \
    } while (0)

ZVAL_DEREF()接受一个zval的指针作为参数。先判断其的类型是否为引用,如果是,使这个指针指向其所引用的zval(即zend_referenceval成员)的地址。

一个容易与其混淆的宏ZVAL_UNREF()需要谨慎使用。

#define ZVAL_UNREF(z) do {                             \
        zval *_z = (z);                                \
        zend_reference *ref;                           \
        ZEND_ASSERT(Z_ISREF_P(_z));                    \
        ref = Z_REF_P(_z);                             \
        ZVAL_COPY_VALUE(_z, &ref->val);                \
        efree_size(ref, sizeof(zend_reference));       \
    } while (0)

它将一个zval所引用的zval的值拷贝给它,然后直接销毁对应的zend_reference。当且仅当这个zend_reference的引用数量为1的时候你才可以这样做,否则其他指向这个引用的zval将指向无效的内存。

3. 引用的使用

3.1 引用赋值

了解了引用的基本概念后,我们可以开始分析,在PHP中引用是如何被使用的。下面我们分析前言中给出的一段简单脚本:

$bar = &$foo;

我们刚才知道,引用的值是存储在一个zend_referenceval成员中的,那么这一过程在底层的简化表示,应该是这样的:

ZVAL_MAKE_REF(foo);
ZVAL_REF(bar, Z_REF_P(foo));
GC_ADDREF(Z_REFVAL_P(foo));  // 注意:refcount应+1

也就是说,现在$foo已经不是一个整型的zval了,而是和$bar一样,同时指向一个zend_reference,其val成员为曾经的$foo,即值为10的整型。

unset($foo)则是类似如下所示的过程:

zval_ptr_dtor(foo);
ZVAL_NULL(foo);

我们在上一章讲过zval_ptr_dtor()这个宏,它会判断一个zval是否为GC类型,如果是,对其引用数量减1。此时若引用数量为0,则执行销毁操作。

unset($foo)以后,仍有$bar指向存储了整型变量10的zend_reference,因此在var_dump($bar)时我们得到了“int(10)”的输出。

3.2 引用传参和引用返回

一般情况下,函数传参的过程中,作为参数的变量会被复制,若它指向除了引用之外的GC类型,则其引用数量也会递增。若作为参数的变量本身是引用类型,则先会对其进行解引用。返回值也是同理。

然而,当我们指定引用传参时,情况变得有些不同。如下所示:

function func(&$foo) {
    ++$foo;
}
$bar = 10;
$baz = &bar;
func($bar);
func($baz);

上例中,当$bar被当作参数传递给func()时,为了能够使函数内对该参数变量的修改对外部有效,一个zend_reference会被创建,其val成员的值为$bar的值,随后,函数外的$bar和函数内的$foo都会同时指向这个引用。类似地,$baz被作为参数传递时本身已经是引用类型,则解引用不会发生。

引用返回的原理与引用传参类似,这里不再赘述。

当我们在进行PHP扩展开发时,如果希望实现的函数支持引用传参和引用返回,我们需要在其对应的zend_internal_arg_info中显式地指定。相关的宏中包含了这样的参数,例如:

// pass_by_ref为1则为引用传参
#define ZEND_ARG_INFO(pass_by_ref, name)    { #name, 0, pass_by_ref, 0},
// return_reference为1则为引用返回
#define ZEND_BEGIN_ARG_INFO_EX(name, _unused, return_reference, required_num_args)  \
    static const zend_internal_arg_info name[] = { \
        { (const char*)(zend_uintptr_t)(required_num_args), 0, return_reference, 0 },

假设我们实现的函数foo()接受一个参数,按引用传参,且返回引用,则它的参数类型表如下所示:

ZEND_BEGIN_ARG_INFO_EX(foo_arginfo, 0, 1, 1)
    ZEND_ARG_INFO(1, bar)
ZEND_END_ARG_INFO()

4. 间接变量

讲到这里,引用的神秘面纱已经被揭开。一些读者不免会好奇,那么究竟有没有一个zval直接指向另一个zval的情况呢?答案是有的。

回顾上一章对zval的讲解,联合体zend_value的一个成员zv的类型就是一个指向zval的指针。一个存储了另一个zval的指针的zval被称为间接变量,类型为IS_INDIRECT,通过宏Z_INDERECT()Z_INDIRECT_P()可用来方便地访问它所指向的zval

虽然间接变量无法用来实现PHP中的引用,但它本身就是一种更加原始、直接的引用,没有额外分配内存所产生的时间和空间开销。Zend引擎内部有多处使用了间接变量。

4.1 符号表

符号表是间接变量的最常见的使用场合。符号表是一个zend_array,它包含了一系列键值对,对于一个函数的符号表来说,键为局部变量名,值为指向对应局部变量的间接变量。

PHP的局部变量连续地存储在当前调用的函数的栈帧内,有两种方式可以对其进行访问,偏移量或者变量名。

function foo() {
    $foo = 'bar';
    $bar = 'baz';
    echo $foo;    // 通过偏移量访问foo
    echo $$foo;   // 通过变量名访问bar
}

当我们直接使用一个变量时,即是通过偏移量访问,因为在编译期,每个局部变量在栈帧中的偏移量已经确定,且表达式使用的是哪一个变量也是确定的。因此,无需使用变量名就可以进行访问,编译生成的字节码也不包含其使用的变量名。

然而,当我们使用可变变量时,就无法直接通过偏移量快速访问,因为函数名存储在一个可变的字符串中,在编译期无法确定。这时就要对符号表进行一次散列查找,进而得到需要的局部变量。

4.2 类成员的预设值

间接变量也被用于类成员中。我们可以直接在类成员的定义中为其预设值,例如:

class foo {
    private $bar = 'hello';
    // ...
}

$bar成员的预设值在脚本即将被执行的初始化时期被创建,且只会被创建一次,直到脚本执行结束后才会被销毁。每当foo的实例被创建,它的$bar成员都会被初始化为指向该预设值的间接变量。显然,这里不需要引用计数,使用间接变量而不是引用是一种高效的做法。

5. 下期预告

下次我会为大家带来PHP字符串相关的讲解,敬请期待。

Living on the bleeding edge

《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 5

根据我目前的水平还看不懂,起码知道了引用不像windows里面的快捷方式,谢谢作者了。

1年前 评论

整形和浮点直接存入值,其他类型存指针

1年前 评论

本来这一章要讲在扩展中定义函数和传参,但是后来想一想,还是先把几个GC类型都讲完再讲那些吧,于是就先从引用讲起。而且其实定义函数和传参这块并不需要展开讲,简单看一看一些扩展的源码就能轻松掌握了,而原理性的东西还是值得多说一说的。

1年前 评论

记得 PHP 7 之前的与 7 及以上的版本在计数上的操作是有变化的(如果没记错的情况下)。建议把 PHP 版本也说明下。 个人理解引用是这样的,还请指导下:

 // $a 变量地址指向 10 
$a = 10;

//  $b 变量地址指向 10,与 变量 $a 的地址相同(指向同一个值)
$b = &$a; 

// 这里只是把 $a 变量的地址与值 10 断开,而非把值 10 也 unset 掉
unset($a); 

 // 结果:10 , $a 变量的地址与值 10 断开,$b 变量的地址依然指向值 10 ,所以 unset($a) 变量 $b  
 // 的值不变
var_dump($b);

// gc 回收,也是把未有地址指向的值(无家可归的值)销毁,怎么知道未有地址指向某个值就是通过计
// 数来标识,当计数为 0 时,gc 回收时就会把这个空间释放。
4个月前 评论

请教一个问题?

数组、字段串、对象、资源是属于 GC 类型,当引用计数为 0 时,会被 GC 销毁。

当 new class 一个对象时,这个对象并没有赋值给任何变量,那这个 new class 会创建堆吗?创建堆了再销毁还是直接跳过创建堆销毁?

4个月前 评论

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!