譯者注: 文中的操作都是基於 PHP5.6 進行的修改,翻譯這篇文章的時候 PHP7 都已經出了,有很多方法已經被遺棄,希望各位注意不要踩坑。
正文
最近有好多人問我怎麼給 PHP 新增新語法特性。我仔細想了想,確實沒有這方面的教程,接下來我會闡述整個流程。同時這篇文章也是對 Zend 引擎的一個簡介。
我提前為這篇過長的文章道歉。
這篇文章假設你已經掌握了一些 C 的基本知識,並且瞭解 PHP 的一些基本概念(像 zvals 結構體)。如果你不具備這些條件,建議先去了解一下。
我將使用你可能從其他語言獲知的 in
運算子作為一個例子。它表現如下:
$words = ['hello', 'world', 'foo', 'bar'];
var_dump('hello' in $words); // true
var_dump('foo' in $words); // true
var_dump('blub' in $words); // false
$string = 'PHP is fun!';
var_dump('PHP' in $string); // true
var_dump('Python' in $string); // false
基本上來說,in
運算子和 in_array
函式在陣列中的使用一樣(但是沒有 needle/haystack 問題),和字元函式 false != strpos($str2, $str1)
也類似。
準備工作
在開始之前,你必須檢出並編譯 PHP。所以接下來我們需要安裝一些工具。大部分可能都預先在系統上安裝好了,但是你必須使用自己選擇的包管理工具安裝 "re2c" 和 “bison”。如果你用的是 Ubuntu:
$ sudo apt-get install re2c
$ sudo apt-get install bison
接下來,從 git 上克隆 php-src 並進行編譯:
// 獲取原始碼
$ git clone http://git.php.net/repository/php-src.git
$ cd php-src
// 建立新分支
$ git checkout -b addInOperator
// 構建 ./configure (預編譯)指令碼
$ ./buildconf
// 使用 debug 模式和 執行緒安全模式 預編譯
$ ./configure --disable-all --enable-debug --enable-maintainer-zts
// 編譯 (4 是你擁有的核心數)
$ make -j4
PHP 二進位制包應該在 sapi/cli/php
。你可以嘗試以下操作:
$ sapi/cli/php -v
$ sapi/cli/php -r 'echo "Hallo World!";'
現在你可能已經有了一個編譯過的 PHP,接下來我們看下 PHP 在執行一個指令碼的時候都做了哪些事。
PHP 指令碼的生命週期
執行一個 PHP 指令碼有三個主要階段:
- Tokenization(符號化)
- Parsing & Compilation(解析和編譯)
- Execute(執行)
接下來我會詳細解釋每個階段都在做什麼,如何實現以及我們需要修改什麼地方才能讓 in
運算子執行。
符號化
第一階段 PHP 讀取原始碼,把原始碼切分成更小的 “token” 單元。舉個例子 <?php echo "Hello World!";
會被拆解成下面的 token:
T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ("Hello World!")
';'
(譯者注: 這裡是官方的 token表)
如你所見原始程式碼被切分成具有語義的 token。處理過程被稱為符號化,掃描和詞法解析的實現在 Zend
目錄下的 zend_language_scanner.l
檔案。
如果你開啟檔案向下滾動到差不多 1000 行(譯者注: php 8.0.0 在 1261 行),你會發現大量的 token 定義語句像下面這樣:
<ST_IN_SCRIPTING>"exit" {
return T_EXIT;
}
上述程式碼的意思很明顯是: 如果在原始碼中遇到了 exit
,lexer 應該標記它為 T_EXIT
。<
和 >
中間的內容是文字應該被匹配的狀態。
ST_IN_SCRIPTING
是對 PHP 原始碼來說是正常狀態。還有一些其他的狀態像 ST_DOUBLE_QUOTE
(在雙引號中間),ST_HEREDOC
(在 heredoc 字串中間),等等。
另一個可以在掃描期間做的是指定一個“語義”值(也可以稱為"lower case" 或者簡稱"lval")。下面是例子:
<ST_IN_SCRIPTING,ST_VAR_OFFSET>{LABEL} {
zend_copy_value(zendlval, yytext, yyleng);
zendlval->type = IS_STRING;
{LABEL}
匹配一個 PHP 標識(可以被定義為[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*
),程式碼返回 token T_STRING
。另外它複製 token 的文字到 zendlval
。所以如果 lexer 遇到一個標識像 FooBarClass
,它將設定 FooBarClass
作為lval。字串,數字和變數名稱也一樣。
幸運的是 in
運算子並不需要深層次的 lexer 知識。我們只需要新增以下程式碼段到檔案中(與上面的 exit
類似):
<ST_IN_SCRIPTING>"in" {
return T_IN;
}
(譯者注: 新版已經不是上面的寫法了)
除此之外我們需要讓引擎知道我們新增了一個新的 token。開啟 zend_language_parser.y
加入下面的行在它的類似程式碼中(在定義運算子的程式碼段中):
%token T_IN "in (T_IN)"
現在你應該用 make -j4
重新編譯下 PHP (必須在頂級目錄 php-src
中執行,不是 Zend/
)。這會產生一個新由 re2c 生成的 lexer 並編譯它。為了測試我們的修改是否生效。需要執行以下命令:
$ sapi/cli/php -r 'in'
這將會給出一個解析錯誤:
Parse error: syntax error, unexpected 'in' (T_IN) in Command line code on line 1
我們需要做的最後一件事就是使用 Tokenizer 擴充套件 重新生成資料,你需要使用 cd
進入 ext/tokenizer
目錄並且執行 ./tokenizer_data_gen.sh
。
如果你執行 git diff --stat,你會看見下面的資訊:
Zend/zend_language_parser.y | 1 +
Zend/zend_language_scanner.c | 1765 +++++++++++++++++++------------------
Zend/zend_language_scanner.l | 4 +
Zend/zend_language_scanner_defs.h | 2 +-
ext/tokenizer/tokenizer_data.c | 4 +-
5 files changed, 904 insertions(+), 872 deletions(-)
zend_language_scanner.c
內容的變更是 re2C 重新生成的 lexer。因為它包含了行號資訊,每個對 lexer 的改變都會產生巨大的不同。所以不用擔心;)
解析和編譯
目前為止原始碼已經被分解成有含義的 token,PHP已經可以識別更大的結構像"this is an if
block"或者"you are defining function here"。這個過程被稱為解析,規則被定義在 zend_language_parser.y
檔案中。這只是一個定義檔案,真正的解析器還是由 bison 生成的。
為了瞭解解析器的定義是如何執行的,我們來看個例子:
class_statement:
variable_modifiers { CG(access_type) = Z_LVAL($1.u.constant); } class_variable_declaration ';'
| class_constant_declaration ';'
| trait_use_statement
| method_modifiers function is_reference T_STRING { zend_do_begin_function_declaration(&$2, &$4, 1, $3.op_type, &$1 TSRMLS_CC); } '('
parameter_list ')' method_body { zend_do_abstract_method(&$4, &$1, &$9 TSRMLS_CC); zend_do_end_function_declaration(&$2 TSRMLS_CC); }
;
我們把花括號中的內容去掉,剩下的內容如下:
class_statement:
variable_modifiers class_variable_declaration ';'
| class_constant_declaration ';'
| trait_use_statement
| method_modifiers function is_reference T_STRING '(' parameter_list ')' method_body
;
你可以這樣解讀:
A class statement is
a variable declaration (with access modifier)
or a class constant declaration
or a trait use statement
or a method (with method modifier, optional return-by-ref, method name, parameter list and method body)
想知道什麼是“methid modifer”,你需要去看 method_modifier
的定義。這就相當直白了。
為了讓解析器支援 in
,我們需要把 expr T_IN expr
規則加到 expr_without_variable
裡面:
expr_without_variable:
...
| expr T_IN expr
...
;
如果你執行 make -j4
,bison 會嘗試重新構建解析器,但是會報以下的錯誤:
conflicts: 87 shift/reduce
/some/path/php-src/Zend/zend_language_parser.y: expected 3 shift/reduce conflicts
make: *** [/some/path/php-src/Zend/zend_language_parser.c] Error 1
shift/reduce 意思是解析器在某些情況下不知道怎麼去做。PHP 語法有 3 個 shift/reduce 自相矛盾的衝突(意料之中,因為類似 elseif/else 的歧義)。其餘的 84 個衝突是因為新規則造成的。
原因是我們沒有規定 in
如何在其他運算子之間執行。舉個例子:
// if you write
$foo in $bar && $someOtherCond
// should PHP interpret this as
($foo in $bar) && $someOtherCond
// or as
$foo in ($bar && $someOtherCond)
上述被成為”運算子的優先順序“。還有一個相關的概念是”運算子的關聯性“,它決定了你寫$foo in $bar in $baz
時會發生什麼。
為了解決 shift/reduce 的衝突,你需要在解析器的開始處找到下面的行並把 T_IN
追加在這行後面
%nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL
這意味著 in
和 <
比較運算子有相同的優先順序,而且沒有關聯性。下面是 in
如何執行的一些示例:
$foo in $bar && $someOtherCond
// 被解釋為
($foo in $bar) && $someOtherCond
// because `&&` has lower precedence than `in`
$foo in ['abc', 'def'] + ['ghi', 'jkl']
// 被解釋為
$foo in (['abc', 'def'] + ['ghi', 'jkl'])
// 因為 `+` 的優先順序比 `in` 高
$foo in $bar in $baz
// 會丟擲解析異常,因為 `in` 是無關聯性的
如果執行 make -j4
,會發現報錯沒了。然後你可以嘗試執行 sapi/cli/php -r '"foo" in "bar";'
。這什麼也不會做,除了列印除一個記憶體洩漏資訊:
[Thu Jul 26 22:33:14 2012] Script: '-'
Zend/zend_language_scanner.l(876) : Freeing 0xB777E7AC (4 bytes), script=-
=== Total 1 memory leaks detected ===
預料之中,因為到目前為止我們還沒有告訴解析器匹配到 in
的時候該怎麼做。這就是花括號裡的內容的作用(譯者注: 還記得上面講解析器定義的時候簡化的花括號嗎),接下來我們用下面的內容替換掉 expr T_IN expr
:
expr T_IN expr { zend_do_binary_op(ZEND_IN, &$$, &$1, &$3 TSRMLS_CC); }
花括號裡的內容被成為語義動作,在解析器匹配到固定規則的時候執行。$$
,$1
和 $3
這些看起來奇奇怪怪的東西是節點。$1
關聯第一個 expr
,$3
關聯第二個 expr
($3
是規則裡的第三個元素),$$
是儲存結果的節點。
zend_do_binary_op
是一個編譯器指令。它告訴編譯器發行 ZEND_IN
操作指令,指令將會把 $1
和 $3
作為運算元,將計算結果存入 $$
中。
編譯指令在 zend_compole.c
中定義(裡面帶有 zend_compile.h 標頭檔案)。 zend_do_binary_op
定義如下:
void zend_do_binary_op(zend_uchar op, znode *result, const znode *op1, const znode *op2 TSRMLS_DC)
{
zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC);
opline->opcode = op;
opline->result_type = IS_TMP_VAR;
opline->result.var = get_temporary_variable(CG(active_op_array));
SET_NODE(opline->op1, op1);
SET_NODE(opline->op2, op2);
GET_NODE(result, opline->result);
}
程式碼應該比較好理解,下節我們會把它放到一個有上下文的環境中。最後提醒一件事,在大多數情況下當你想要新增自己的語法的時候,你必須新增自己的 _do_*
方法。新增一個二進位制運算子是為數不多的情況中的一個。如果你必須要加一個新的 _do_*
函式,先看看現存的函式能不能滿足你的需求。它們中的大部分都挺簡單的。
執行
在上節我提到了編譯器在發行操作碼。接下來我們近距離看下這些操作碼(看 zend_compile.h):
struct _zend_op {
opcode_handler_t handler;
znode_op op1;
znode_op op2;
znode_op result;
ulong extended_value;
uint lineno;
zend_uchar opcode;
zend_uchar op1_type;
zend_uchar op2_type;
zend_uchar result_type;
};
對上述結構一個簡短的介紹:
-
opcode
: 這是一個真正被執行的操作。可以用ZEND_ADD
或者ZEND_SUB
當例子。 -
op1
,op2
,result
: 每個操作最多可以擁有兩個運算元(它可以只選擇其中用一個或者一會也不用)和一個結果節點。op1_type
,op2_type
和result_type
決定了節點的型別。稍後我們會去了解節點和節點的型別。 -
extended_value
: 擴充套件值用來儲存標記和一些別的整型值。比如說變數獲取指定用它儲存變數的型別(像ZEND_FETCH_LOCAL
或者ZEND_FETCH_GLOBAL
) -
handler
: 用來最佳化操作碼的執行,它儲存處理函式與操作碼和運算元型別相關。
這是自動確定的,因此不必在編譯程式碼中設定。 -
lineno
: 這就不多說了..
這裡有五種基本的型別可以詳細解釋 *_type
屬性:
-
IS_TMP_VAR
: 臨時變數,通常用在一些表示式的結果像$foor + $bar
上。臨時變數不能共享,所以不能使用引用計數。它們的生命週期很短,所以在使用完成後馬上被銷燬。臨時變數通常被寫成~n
,~0
表示第一個臨時變數,~1
表示第二個,以此類推。 -
IS_CV
: 編譯變數。用來儲存雜湊表查詢結果,PHP 快取簡單變數的位置像$foo
在陣列中的地址(C 陣列)。此外,編譯變數允許 PHP 完全最佳化雜湊表。編譯變數使用!n
表示(n
表示編譯變數陣列的偏移量) -
IS_VAR
: 只是一些簡單的變數可以被轉換為編譯變數。
所有其他型別的變數訪問,如$foo['bar']
或$foo->bar
返回一個IS_VAR
變數。它基本上就是一個正常的 zval (有引用計數和其他的所有屬性)。Vars 這樣$n
表示。 -
IS_CONST
: 常量在程式碼中的表示比較隨意。舉個例子,"foo"
或者3.141
都是IS_CONST
型別。常量允許更近一步的最佳化,像複用 zvals,預先計算雜湊值。 -
id_UNUSED
: 運算元沒有被使用。
與此相關的 znode_op
的結構:
typedef union _znode_op {
zend_uint constant;
zend_uint var;
zend_uint num;
zend_ulong hash;
zend_uint opline_num;
zend_op *jmp_addr;
zval *zv;
zend_literal *literal;
void *ptr;
} znode_op;
我們可以看到節點就是一個聯合體。它可以包含上述元素中的一個(只有一個),具體哪個取決於上下文。比如 zv
用來儲存 IS_CONST
zvals,var
用來儲存 IS_CV
,IS_VAR
和 IS_TMP_VAR
變數。剩下的使用在不同的特殊環境下。例如 jmp_addr
和 JMP*
指令結合使用(在迴圈和條件判斷中使用)。其餘都只在編譯期間使用,不是在執行期間(像 constant
)。
現在我們瞭解了單個操作碼的結構,唯一剩下的問題就是這些操作碼存在什麼地方: PHP 為每個函式(和檔案)建立一個 zend_op_array
,裡面儲存了操作碼和很多其他的資訊。我不想深入去講每個部分都是幹什麼的,你只需要瞭解這個結構體存在就行了。
接下來我們回到 in
運算子的實現!我們已經指示編譯器去發行一個 ZEND_IN
操作碼。現在我們需要定義這個操作碼可以幹什麼。
這部分在 zend_vm_def.h
中實現。如果你看過這個檔案,你會發現裡面全是下面這樣的定義:
ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
USE_OPLINE
zend_free_op free_op1, free_op2;
SAVE_OPLINE();
fast_add_function(&EX_T(opline->result.var).tmp_var,
GET_OP1_ZVAL_PTR(BP_VAR_R),
GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
FREE_OP1();
FREE_OP2();
CHECK_EXCEPTION();
ZEND_VM_NEXT_OPCODE();
}
ZEND_IN
操作碼的定義和這個基本一樣,所以我們來了解下這個定在在幹什麼。我會逐行解釋:
// 頭部定義個四個事情:
// 1. 這是一個 ID 為 1 的操作碼
// 2. 這個操作碼叫 ZEND_ADD
// 3. 這個操作碼接受 CONST, TMP, VAR 和 CV 作為第一個運算元
// 4. 這個操作碼接受 CONST, TMP, VAR 和 CV 作為第二個運算元
ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
// USE_OPLINE 意味著我們想像 `opline` 一樣操作 zend_op.
// 這個對所有存取運算元或者設定返回值的操作碼都是必須的
USE_OPLINE
// For every operand that is accessed a free_op* variable has to be defined.
// 這個用來判斷運算元是否需要釋放.
zend_free_op free_op1, free_op2;
// SAVE_OPLINE() 載入 zend_op 到 `opline`。
// USE_OPLINE 只是宣告。
SAVE_OPLINE();
// 呼叫 fast add 函式
fast_add_function(
// 告訴函式把結果放在 tmp_var 裡
// EX_T 使用 ID opline->result.var 來操作臨時變數
&EX_T(opline->result.var).tmp_var,
// 以讀取模式獲取第一個運算元 ( R 在 BP_VAR_R 的含義是讀取,read 的縮寫)
GET_OP1_ZVAL_PTR(BP_VAR_R),
// 以讀取模式獲取第二個運算元
GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
// 釋放兩個運算元 (必須的情況下)
FREE_OP1();
FREE_OP2();
// 檢查異常。異常可能發生在任何地方,所以必須在所有操作碼中檢查異常。
// 如果有疑問,加上異常檢測。
CHECK_EXCEPTION();
// 處理下一個操作碼
ZEND_VM_NEXT_OPCODE();
}
你可能會注意到這個檔案裡的東西大部分都是 大寫
的。因為 zend_vm_def.h
只是一個定義檔案。真正的 ZEND VM 根據它生成,最終儲存在 zend_vm_execute.h
(巨...大的一個檔案)。PHP 有三個不同的虛擬機器型別,CALL
(預設) GOTO
SWITCH
。因為他們有不同的實現細節,定義檔案使用了大量的偽宏(像 USE_OPLINE
),它們最終會被具體實現替代掉。
此外,生成的 VM 為所有可能的運算元型別的組合建立專門的實現。所以最後不會只有一個 ZEND_ADD 函式,會有不同的函式實現,像 ZEND_ADD_CONST_CONST
,ZEND_ADD_CONST_TMP
,ZEND_ADD_CONST_VAR
…
現在為了實現 ZEND_IN
操作碼,你應該在 zend_vm_def.h
檔案結尾處新增一個操作碼定義框架:
// 159 是我這裡下個沒有被使用的操作碼編號。 或許你需要選擇一個更大的數字。
ZEND_VM_HANDLER(159, ZEND_IN, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
USE_OPLINE
zend_free_op free_op1, free_op2;
zval *op1, *op2;
SAVE_OPLINE();
op1 = GET_OP1_ZVAL_PTR(BP_VAR_R);
op2 = GET_OP2_ZVAL_PTR(BP_VAR_R);
/* TODO */
FREE_OP1();
FREE_OP2();
CHECK_EXCEPTION();
ZEND_VM_NEXT_OPCODE();
}
上面的程式碼只會獲取運算元然後丟棄。
為了生成一個新的 VM ,你需要在 Zend/
目錄內執行 php_zend_vm_gen.php
。(如果它給了你一堆 /e modifier being deprecated 警告,忽略掉就行了)。執行完以後,去頂級目錄執行 make -j4
重新編譯。
終於,我們能實現真正的邏輯了。我們開始寫字串型別的情況吧:
if (Z_TYPE_P(op2) == IS_STRING) {
zval op1_copy;
int use_copy;
// 把要 needle(要找的資料) 轉換為 string
zend_make_printable_zval(op1, &op1_copy, &use_copy);
if (Z_STRLEN_P(op1) == 0) {
/* 空的 needle 直接返回 true */
ZVAL_TRUE(&EX_T(opline->result.var).tmp_var);
} else {
char *found = zend_memnstr(
Z_STRVAL_P(op2), /* haystack */
Z_STRVAL_P(op1), /* needle */
Z_STRLEN_P(op1), /* needle length */
Z_STRVAL_P(op2) + Z_STRLEN_P(op2) /* haystack end ptr */
);
ZVAL_BOOL(&EX_T(opline->result.var).tmp_var, found != NULL);
}
/* Free copy */
if (use_copy) {
zval_dtor(&op1_copy);
}
}
最難的部分是把 needle 轉換成字串,這裡使用了 zend_make_printable_zval
。這個函式也許會建立一個新的 zval。這就是我們傳 op1_copy
和 use_copy
的原因。
如果函式複製了值,我們只需將它放入op1變數中(所以我們不必到處處理兩個不同的變數)。此外,必須在最後釋放複製的值(最後三行的內容)。
如果你新增了上面的程式碼到/* TODO */
所在的位置,再執行 zend_vm_gen.php
然後重新編譯 make -j4
,你已經完成了 in
運算子一半的工作:
$ sapi/cli/php -r 'var_dump("foo" in "bar");'
bool(false)
$ sapi/cli/php -r 'var_dump("foo" in "foobar");'
bool(true)
$ sapi/cli/php -r 'var_dump("foo" in "hallo foo world");'
bool(true)
$ sapi/cli/php -r 'var_dump(2 in "123");'
bool(true)
$ sapi/cli/php -r 'var_dump(5 in "123");'
bool(false)
$ sapi/cli/php -r 'var_dump("" in "test");'
bool(true)
接下來我們進行實現陣列的部分:
else if (Z_TYPE_P(op2) == IS_ARRAY) {
HashPosition pos;
zval **value;
/* Start under the assumption that the value isn't contained */
ZVAL_FALSE(&EX_T(opline->result.var).tmp_var);
/* Iterate through the array */
zend_hash_internal_pointer_reset_ex(Z_ARRVAL_P(op2), &pos);
while (zend_hash_get_current_data_ex(Z_ARRVAL_P(op2), (void **) &value, &pos) == SUCCESS) {
zval result;
/* Compare values using == */
if (is_equal_function(&result, op1, *value TSRMLS_CC) == SUCCESS && Z_LVAL(result)) {
ZVAL_TRUE(&EX_T(opline->result.var).tmp_var);
break;
}
zend_hash_move_forward_ex(Z_ARRVAL_P(op2), &pos);
}
}
這裡我們簡單的遍歷了 haystack 中的每個值,並檢查是否和 needle 相等。我們在這裡使用 ==
對比,要使用 ==
對比的話,必須使用 is_identical_function
代替 is_equal_function
。
再次執行完 zend_vm_gen.php
和 make -j4
後,in
運算子號就支援陣列型別的操作了:
$ sapi/cli/php -r 'var_dump("test" in []);'
bool(false)
$ sapi/cli/php -r 'var_dump("test" in ["foo", "bar"]);'
bool(false)
$ sapi/cli/php -r 'var_dump("test" in ["foo", "test", "bar"]);'
bool(true)
$ sapi/cli/php -r 'var_dump(0 in ["foo"]);'
bool(true) // because we're comparing using ==
最後一件需要考慮的事情是,如果第二個引數既不是陣列又不是字串我們該如何處理。這裡我選擇最簡單的辦法: 丟擲一個警告並返回 false:
else {
zend_error(E_WARNING, "Right operand of in has to be either string or array");
ZVAL_FALSE(&EX_T(opline->result.var).tmp_var);
}
重新生成 VM,再編譯後:
$ sapi/cli/php -r 'var_dump("foo" in new stdClass);'
Warning: Right operand of in has to be either string or array in Command line code on line 1
bool(false)
終篇想法
我希望這篇文章可以幫你理解如何給 PHP 新增新特性,理解 Zend 引擎 如何執行 php 指令碼。儘管這篇文章很長,但是我只覆蓋到了整個系統的一小部分。當你想對 ZE 做出一些修改的時候,工作量最大的部分就是閱讀已經存在的程式碼。交叉引用工具在閱讀程式碼的時候會提供很大幫助。除此以外,也可以在 efnet 的 #php.pecl 房間問問題。
當你新增完你想加的特性後,下一步就是把它放到內部郵件列表。人們會檢視你加的特性並決定是否應該把它加進專案中。
對了,還有最後一件事: in
運算子只是一個示例。我並不打算提議包含這個特性 ;)
如果你有任何問題或意見,請在下方留言。
本作品採用《CC 協議》,轉載必須註明作者和本文連結