PHP 中的操作符過載

CismonX發表於2019-04-20

1. 操作符過載

操作符過載是一種語法糖,它在 C++、Python、Kotlin 等程式語言中被廣泛使用。這一特性有助於我們寫出更加整潔、表述力更強的程式碼,尤其是當我們對某些物件進行數學操作時。

例如,當我們在 PHP 中使用一個 Complex 類,我們往往更希望這樣寫:

$a = new Complex(1.1, 2.2);
$b = new Complex(1.2, 2.3);

$c = $a * $b / ($a + $b);

而不是這樣:

$c = $a->mul($b)->div($a->add($b));

儘管這個 RFC 提出了要在 PHP 中實現這一特性,然而截至目前,這一提議並未被實施。幸運的是,我們可以通過在 PHP 擴充套件中編寫一些簡單的邏輯來實現操作符過載,而無需修改 PHP 本身的原始碼。PECL operator 擴充套件做的就是這樣一件事情(注意,該擴充套件的釋出版本比較舊,想要 PHP7 支援需要看 git master 分支)。

本文中,我們將討論在一個 PHP 擴充套件中實現操作符過載的相關細節。我們假定讀者具備 C/C++ 的程式語言基礎,並且對 PHP 的 Zend 實現有初步的瞭解。

2. PHP 的操作碼

在一個 PHP 指令碼可以在 Zend VM 中執行之前,它首先會被編譯為一系列操作碼。與機器碼類似,一個 PHP 操作碼包含指令、運算元等,其儲存在結構體 zend_op 中。

struct _zend_op {
    const void *handler;      // 操作碼處理函式的指標
    znode_op op1;             // 第一個運算元
    znode_op op2;             // 第二個運算元
    znode_op result;          // 執行結果
    uint32_t extended_value;  // 與該操作碼相關的額外資訊
    uint32_t lineno;          // 操作碼所在行數
    zend_uchar opcode;        // 操作碼指令
    zend_uchar op1_type;      // 第一個運算元的型別
    zend_uchar op2_type;      // 第二個運算元的型別
    zend_uchar result_type;   // 執行結果的型別
};

2.1 運算元

運算元之於操作碼,如同引數之於函式。結構體 zend_op 的運算元成員儲存了其所指向的物件的偏移量或指標,在 znode_op 中被定義。由於運算元有多種不同型別(我們後面會討論),因此用一個聯合體定義。

typedef union _znode_op {
    uint32_t      constant;
    uint32_t      var;
    uint32_t      num;
    uint32_t      opline_num;
#if ZEND_USE_ABS_JMP_ADDR
    zend_op      *jmp_addr;
#else
    uint32_t      jmp_offset;
#endif
#if ZEND_USE_ABS_CONST_ADDR
    zval         *zv;
#endif
} znode_op;

正如 zend_compile.h 中所述:

On 64-bit systems, less optimal but more compact VM code leads to better performance. So on 32-bit systems we use absolute addresses for jump targets and constants, but on 64-bit systems relative 32-bit offsets.

在 64 位系統中,巨集 ZEND_USE_ABS_JMP_ADDRZEND_USE_ABS_CONST_ADDR 被定義為 0, 因此 znode_op 永遠是 32 位大小。

2.2 操作指令

指令碼用於指示 Zend VM 應該對運算元進行什麼樣的操作。在 zend_vm_opcodes.h 中可以看到所有的指令碼定義。

PHP 原始碼中的操作符會被編譯為對應的指令碼。藉助 phpdbg 或類似除錯工具,我們可以分析編譯後的操作碼。如,PHP 程式碼 $c = $a + $b 會被編譯為:

ADD    $a, $b, ~0  # "+" 操作符 
ASSIGN $c, ~0      # "=" 操作符

可以看到,+ 操作符對應指令 ZEND_ADD$a$b 是操作碼的兩個運算元。操作結果被儲存在臨時變數 ~0 中,並在下一行的賦值指令中被賦值給 $c

然而,並非所有操作符都有對應的指令碼。如程式碼 $c = $a > -$b 會被編譯為:

MUL        $b, -1, ~0  # 轉換為乘法操作,乘以 -1
IS_SMALLER ~0, $a, ~1  # 調換操作符位置,並轉換為小於比較
ASSIGN     $c, ~1

在之後的章節,我們會對這種情況進行進一步說明。

2.3 運算元型別

結構體 zend_opop1_typeop2_typeresult_type 成員分別儲存了第一個運算元、第二個運算元和執行結果的運算元型別。其可能的值如下:

#define IS_UNUSED   0
#define IS_CONST    (1<<0)
#define IS_TMP_VAR  (1<<1)
#define IS_VAR      (1<<2)
#define IS_CV       (1<<3) // Compiled variable
  • 如果運算元不被使用,則其型別為 IS_UNUSED.
  • 如果運算元是一個字面量, 則其型別為 IS_CONST.
  • 如果運算元是一個由表示式返回臨時變數, 則其型別為 IS_TMP_VAR.
  • 如果運算元是一個在編譯期被確定的變數, 則其型別為 IS_CV.
  • 如果運算元是一個由表示式返回的在編譯期被確定的變數, 則其型別為 IS_VAR.

通過使用除錯工具,可以有助於我們理解運算元的型別。如以下 PHP 程式碼:

$a = 1;
$a + 1;
$b = $a + 1;
$a += 1;
$c = $b = $a += 1;

會被編譯為:

                       # (op1     op2     result) type
ASSIGN     $a, 1       #  CV      CONST   UNUSED
ADD        $a, 1,  ~1  #  CV      CONST   TMP_VAR
FREE       ~1          #  TMP_VAR UNUSED  UNUSED
ADD        $a, 1,  ~2  #  CV      CONST   TMP_VAR
ASSIGN     $b, ~2      #  CV      TMP_VAR UNUSED
ASSIGN_ADD $a, 1       #  CV      CONST   UNUSED
ASSIGN_ADD $a, 1,  @5  #  CV      CONST   VAR
ASSIGN     $b, @5, @6  #  CV      VAR     VAR
ASSIGN     $c, @6      #  CV      VAR     UNUSED

可以看出,編譯期確定的變數 $a$bIS_CV,字面量 1IS_CONST,表示式產生的臨時變數 ~1~2TMP_VAR@5@6 雖然對應 $a$b,但它們是由表示式返回的,因此是 IS_VAR

同時,我們也發現,對於賦值指令,若其執行結果未被使用,則不會返回結果,而非賦值指令永遠會返回結果,即使其未被使用。這是因為賦值指令的運算結果會被賦值給第一個運算元,當其未被使用時,不需要額外的指令去釋放記憶體。在後面的章節我們會進一步討論這一細節。

3. 操作碼處理函式

操作碼處理函式的職能是根據給定的指令和運算元執行對應的操作,就像 CPU 執行機器碼一樣。通過呼叫如下的 Zend API,我們可以用自定義的函式來替代 Zend VM 內建的操作碼處理函式:

ZEND_API int zend_set_user_opcode_handler(
    zend_uchar            opcode,
    user_opcode_handler_t handler);

其中 handler 引數是自定義的操作碼處理函式的指標,opcode 引數是我們想要替代的指令。想要取消設定自定義操作碼處理函式,向 handler 引數傳遞 nullptr 即可。每當操作碼被執行時,Zend VM 會呼叫與其指令碼相對應的自定義函式(如果它存在)。

函式指標 user_opcode_handler_t 定義如下:

typedef int (*user_opcode_handler_t) (zend_execute_data *execute_data);

操作碼處理函式接受 execute_data 指標作為引數,並返回一個整型,其值為下述之一,代表該函式執行完成後進行的下一步操作。

#define ZEND_USER_OPCODE_CONTINUE   0
#define ZEND_USER_OPCODE_RETURN     1
#define ZEND_USER_OPCODE_DISPATCH   2
#define ZEND_USER_OPCODE_ENTER      3
#define ZEND_USER_OPCODE_LEAVE      4

在多數情況下,我們只會用到如下所描述的其中兩個返回值:

  • ZEND_USER_OPCODE_CONTINUE 表示該操作碼已經執行完成,應該繼續執行下一行指令。
  • ZEND_USER_OPCODE_DISPATCH 表示該操作碼並沒有被執行,應先轉為使用內建操作碼處理函式去執行,再執行下一行指令。

3.1 實現操作碼處理函式

我們用 C++ 定義一個普適性的操作碼處理函式模版,如下所示。其中,handler 引數包含處理操作碼的具體業務邏輯,它可以為一個函式指標、lambda 表示式或仿函式,接受三個 zval 指標作為引數,分別為兩個運算元和執行結果。

template <typename F>
int op_handler(zend_execute_data *execute_data, F handler)
{
    // 在這裡做一些初始化操作
    if (!handler(op1, op2, result)) {
        return ZEND_USER_OPCODE_DISPATCH;
    }
    // 在這裡做一些後續操作
    return ZEND_USER_OPCODE_CONTINUE;
}

在函式的開始,我們先進行一些初始化操作。首先,從 execute_data 中獲取到當前執行的操作碼,並從操作碼中獲取到各個運算元所對應的 zval

const zend_op *opline = EX(opline);
zend_free_op free_op1, free_op2;
zval *op1 = zend_get_zval_ptr(opline, opline->op1_type, &opline->op1,
                              execute_data, &free_op1, 0);
zval *op2 = zend_get_zval_ptr(opline, opline->op2_type, &opline->op2,
                              execute_data, &free_op2, 0);
zval *result = opline->result_type ? EX_VAR(opline->result.var) : nullptr;

運算元可能是指向其他 zval 的引用,即 zend_reference。我們往往需要先對其解引用。

if (EXPECTED(op1)) {
    ZVAL_DEREF(op1);
}
if (op2) {
    ZVAL_DEREF(op2);
}

現在,我們可以像之前所描述的那樣呼叫 handler

若運算元是臨時變數,當操作碼處理函式執行完成後,我們需要先釋放它們。最後,將 execute_data->opline 指向下一行操作碼。

if (free_op2) {
    zval_ptr_dtor_nogc(free_op2);
}
if (free_op1) {
    zval_ptr_dtor_nogc(free_op1);
}
EX(opline) = opline + 1;

現在,我們就可以根據需要,註冊自定義的操作碼處理函式。

int add_handler(zend_execute_data *execute_data)
{
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (/* 是否要在這裡過載 "+" 操作符? */) {
            // 過載的具體實現
            return true;
        }
        return false;
    });
}

PHP_MINIT_FUNCTION(my_extension)
{
    // 一般情況下,我們在擴充套件被載入時註冊自定義操作碼處理函式
    zend_set_user_opcode_handler(ZEND_ADD, add_handler);
}

4. 操作符過載的實現細節

我們現已知道,通過自定義的操作碼處理函式,可以實現操作符過載。下面我們將討論一些實現細節,從而幫助大家減少在開發過程中的踩坑。

4.1 二元操作符

語法 指令碼
$a + $b ZEND_ADD
$a - $b ZEND_SUB
$a * $b ZEND_MUL
$a / $b ZEND_DIV
$a % $b ZEND_MOD
$a ** $b ZEND_POW
$a << $b ZEND_SL
$a >> $b ZEND_SR
$a . $b ZEND_CONCAT
$a | $b ZEND_BW_OR
$a & $b ZEND_BW_AND
$a ^ $b ZEND_BW_XOR
$a === $b ZEND_IS_IDENTICAL
$a !== $b ZEND_IS_NOT_IDENTICAL
$a == $b ZEND_IS_EQUAL
$a != $b ZEND_IS_NOT_EQUAL
$a < $b ZEND_IS_SMALLER
$a <= $b ZEND_IS_SMALLER_OR_EQUAL
$a xor $b ZEND_BOOL_XOR
$a <=> $b ZEND_SPACESHIP

二元操作符接受兩個運算元,永遠有返回值,而且允許修改運算元(當然如果嘗試修改字面量或臨時變數,是毫無意義的)。

注意,正如我們在 2.2 中所述,>>= 操作符是沒有對應的指令碼的。儘管在絕大多數情況下 $a > $b$b < $a 是完全等價的,但也有例外,如 PECL operator 擴充套件,需要區分這兩個操作符,並呼叫 __is_smaller()__is_greater() 這兩個魔術方法之一。

PECL operator 擴充套件提出了一種方法,即利用 zend_opextended_value 成員區分 ><。但這個 hack 是在解析語法樹時做的,沒有提供 API 可供我們用自定義方法去替換,需要修改 PHP 的原始碼並重新編譯 PHP。此外,這個做法很可能會影響其在未來 PHP 版本中的相容性。

這種情況下,建議採用類似如下所示的解決方案:

int is_smaller_handler(zend_execute_data *execute_data) {
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (Z_TYPE_P(zv1) == IS_OBJECT) {
            if (__zobj_has_method(Z_OBJ_P(zv1), "__is_smaller")) {
                // 在這裡呼叫 `$zv1->__is_smaller($zv2)`.
                return true;
            }
        } else if (Z_TYPE_P(zv2) == IS_OBJECT) {
            if (__zobj_has_method(Z_OBJ_P(zv2), "__is_greater")) {
                // 在這裡呼叫 `$zv2->__is_greater($zv1)`.
                return true;
            }
        }
        return false;
    });
}

4.2 二元賦值操作符

語法 指令碼
$a += $b ZEND_ASSIGN_ADD
$a -= $b ZEND_ASSIGN_SUB
$a *= $b ZEND_ASSIGN_MUL
$a /= $b ZEND_ASSIGN_DIV
$a %= $b ZEND_ASSIGN_MOD
$a **= $b ZEND_ASSIGN_POW
$a <<= $b ZEND_ASSIGN_SL
$a >>= $b ZEND_ASSIGN_SR
$a .= $b ZEND_ASSIGN_CONCAT
$a |= $b ZEND_ASSIGN_BW_OR
$a &= $b ZEND_ASSIGN_BW_AND
$a ^= $b ZEND_ASSIGN_BW_XOR
$a = $b ZEND_ASSIGN
$a =& $b ZEND_ASSIGN_REF

二元賦值操作符與一般的二元操作符類似,區別在於當返回值不被使用(opline->result_type == IS_UNUSED)的時候,不要在操作碼處理函式中對其賦值,否則可能會引起錯誤。

一般來說,二元賦值操作符對應的操作碼執行完成後,要將執行結果賦值給第一個運算元。但這並不是必須的,而且 Zend VM 不會幫我們做這件事。

程式碼示例:

int assign_add_handler(zend_execute_data *execute_data) {
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (Z_TYPE_P(zv1) == IS_OBJECT) {
            // 在這裡處理 "+" 操作符
            __update_value(zv1, add_result);
            if (rv != nullptr) {
                ZVAL_COPY(rv, zv1);
            }
            return true;
        }
        return false;
    });
}

4.3 一元操作符

語法 指令碼
~$a ZEND_BW_NOT
!$a ZEND_BOOL_NOT

一元操作符僅接受一個運算元(opline->op1),永遠有返回值,而且允許修改運算元。

正如我們在 2.2 所述,一元操作符 -$a+$a 沒有對應的指令碼,因為它們被編譯為運算元與 -1 and 1 的乘法。如果在我們想要實現的邏輯中,-$a$a * (-1) 不等價,則需要在 ZEND_MUL 的處理函式中加入一些額外的邏輯。

注意,在 PHP 7.3 和低於 7.3 的版本之間,存在如下的相容性問題,即 $a * (-1)(-1) * $a 的區別:

PHP 版本 語法 指令碼 運算元 1 運算元 2
7.3 -$a or +$a ZEND_MUL $a -1 or 1
7.1, 7.2 -$a or +$a ZEND_MUL -1 or 1 $a

如下是在 ZEND_MUL 處理函式中同時實現過載 -$a$a * $b 兩個操作符的例子:

int mul_handler(zend_execute_data *execute_data) {
    return op_handler(execute_data, [] (auto zv1, auto zv2, auto rv) {
        if (Z_TYPE_P(zv1) == IS_OBJECT) {
#if PHP_VERISON_ID >= 70300
            if (Z_TYPE_P(zv2) == IS_LONG && Z_LVAL_P(zv2) == -1) {
                // 在這裡處理 `-$zv1`
                return true;
            }
#endif
            // 在這裡處理 `$zv1 * $zv2`
            return true;
        } else if (Z_TYPE_P(zv2) == IS_OBJECT) {
#if PHP_VERISON_ID < 70300
            if (Z_TYPE_P(zv1) == IS_LONG && Z_LVAL_P(zv1) == -1) {
                // 在這裡處理 `-$zv2`
                return true;
            }
#endif
            // 在這裡處理 `$zv1 * $zv2`
            return true;
        }
        return false;
    });
}

4.4 一元賦值操作符

語法 指令碼
++$a ZEND_PRE_INC
$a++ ZEND_POST_INC
--$a ZEND_PRE_DEC
$a-- ZEND_POST_DEC

一元賦值操作符有兩種。第一種是字尾自增/自減操作符,其行為與非賦值的一元操作符相同。第二種是字首自增/自減操作符,它與二元賦值操作符的行為相同。

這不難理解,因為在常規的使用場景下,字尾自增/自減操作符需要將自己的初始值儲存在一個臨時變數中返回,而字首自增/自減操作符先執行自增/自減操作再返回,無需釋放臨時變數。

例如,以下 PHP 程式碼:

$a = 0;
$a++;
++$a;
$b = ++$a;

會被編譯為:

ASSIGN   $a, 0
POST_INC $a,   , ~1
FREE     ~1
PRE_INC  $a
PRE_INC  $a,   , @3
ASSIGN   $b, @3

4.5 無法過載操作符的情況

嘗試編譯以下程式碼:

$a = 2 + 3 * (7 + 9);
$b = 'foo' . 'bar';

我們會得到:

ASSIGN $a, 50
ASSIGN $b, "foobar"

可以看出,變數 $a$b 的值在編譯期已被確定,執行時沒有數學運算和字串拼接操作。對於任何一個只包含字面量和操作符的表示式,這種情況都是成立的。編譯器會識別出它,並呼叫 zend_compile.h 中定義的函式 zend_const_expr_to_zval() 對其進行求值。在這個函式中,操作碼處理函式是通過 get_binary_op()get_unary_op() 等函式獲取的。內建操作碼處理函式的指標被硬編碼在其中,因此,即使我們實現了自定義處理函式,它們也不會在這裡被呼叫。

5. 補充

  • 如果讀者需要一個完整可執行的例子,可以參考下面這個複數類的實現。它是我正在開發的一個 PHP 擴充套件的一部分。
  • 可自定義的操作碼處理函式是一個強大的功能,它的用途遠遠不限於操作符過載。因為我們可以 hook 幾乎所有在 Zend VM 中執行的指令,包括函式呼叫等。
    • 假設我們想要實現一個 profiler,我們可能會考慮對 ZEND_INIT_FCALLZEND_RETURN 註冊處理函式。
  • 事物均有兩面性。由於額外的函式呼叫開銷,使用自定義的操作碼處理函式會降低 PHP 程式整體的執行效能。
    • 當一個處理函式中包含了大量分支判斷,最後還很可能返回一個 ZEND_USER_OPCODE_DISPATCH 時,你可能需要考慮一下,這個函式是否有實現的必要。
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章