3. PHP 引用解惑

CismonX發表於2018-05-01

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字串相關的講解,敬請期待。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章