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_ADDR
和 ZEND_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_op
的 op1_type
,op2_type
,result_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
、$b
是 IS_CV
,字面量 1
是 IS_CONST
,表示式產生的臨時變數 ~1
、~2
是 TMP_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_op
的 extended_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 擴充套件的一部分。
- complex.hh,包含了和複數類相關的操作碼處理函式的具體實現。
- complex.cc,複數類的實現。
- operators.cc,包含操作符過載的實現。
- 002-complex-operators.phpt,有關操作符過載的測試樣例。
- 可自定義的操作碼處理函式是一個強大的功能,它的用途遠遠不限於操作符過載。因為我們可以 hook 幾乎所有在 Zend VM 中執行的指令,包括函式呼叫等。
- 假設我們想要實現一個 profiler,我們可能會考慮對
ZEND_INIT_FCALL
和ZEND_RETURN
註冊處理函式。
- 假設我們想要實現一個 profiler,我們可能會考慮對
- 事物均有兩面性。由於額外的函式呼叫開銷,使用自定義的操作碼處理函式會降低 PHP 程式整體的執行效能。
- 當一個處理函式中包含了大量分支判斷,最後還很可能返回一個
ZEND_USER_OPCODE_DISPATCH
時,你可能需要考慮一下,這個函式是否有實現的必要。
- 當一個處理函式中包含了大量分支判斷,最後還很可能返回一個
本作品採用《CC 協議》,轉載必須註明作者和本文連結