原文地址:https://github.com/vorshen/blog/blob/master/callable-object/index.md
今天我們來聊一聊可調用對象,從底層來說,調用是指新建了棧幀,寄存器指向發生了變化。
從直觀上看可以加 () 執行的就是可調用對象!比如我們熟悉的 javascript 中函數。
javascript 中的 callable
1 2 3 4 5 |
function drink() { console.log('利利不流淚,喝酒喝到醉'); } drink(); |
但是有沒有想過,為什么這段代碼可以按順序執行?如果了解 C 或者 Java,程序的入口一定是一個 main 函數,為什么 js 中無需 main 函數了呢?
從 v8 源碼一探究竟,這是因為 v8 會將整個 js 代碼,包裝成一個函數,源碼位置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// v8/src/execution/execution.cc // ... // ?code 非常重要 Handle<Code> code = JSEntry(isolate, params.execution_target, params.is_construct); { // ... if (params.execution_target == Execution::Target::kCallable) { // clang-format off // {new_target}, {target}, {receiver}, return value: tagged pointers // {argv}: pointer to array of tagged pointers using JSEntryFunction = GeneratedCode<Address( Address root_register_value, Address new_target, Address target, Address receiver, intptr_t argc, Address** argv)>; // clang-format on JSEntryFunction stub_entry = JSEntryFunction::FromAddress(isolate, code->InstructionStart()); Address orig_func = params.new_target->ptr(); Address func = params.target->ptr(); Address recv = params.receiver->ptr(); Address** argv = reinterpret_cast<Address**>(params.argv); RuntimeCallTimerScope timer(isolate, RuntimeCallCounterId::kJS_Execution); // ?下面是真正的執行 value = Object(stub_entry.Call(isolate->isolate_data()->isolate_root(), orig_func, func, recv, params.argc, argv)); // ... |
Code 對象非常的重要,這個就是 v8 中函數執行的關鍵,v8 相關原話有:
Code describes objects with on-the-fly generated machine code.
JSFunctions are pairs (context, function code), sometimes also called closures.
JSFunction(v8 內數據類型) 相比較 JSObject 重大的差異也就是多了 code 屬性,這也就是 Function 可以執行,而 Object 無法執行的原因。
其實我們將上面列子中的 js 代碼,編譯成字節碼,也可以看出來整個文本可以執行的原因。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
[generated bytecode for function: (0x06c008212561 <SharedFunctionInfo>)] // 注意點1 Parameter count 1 Register count 3 Frame size 24 0x6c008212626 @ 0 : 12 00 LdaConstant [0] 0x6c008212628 @ 2 : 26 f9 Star r1 0x6c00821262a @ 4 : 27 fe f8 Mov <closure>, r2 0x6c00821262d @ 7 : 62 3f 01 f9 02 CallRuntime [DeclareGlobals], r1-r2 0x6c008212632 @ 12 : 13 01 00 LdaGlobal [1], [0] 0x6c008212635 @ 15 : 26 f9 Star r1 0x6c008212637 @ 17 : 5d f9 02 CallUndefinedReceiver0 r1, [2] // 注意點3 0x6c00821263a @ 20 : 26 fa Star r0 0x6c00821263c @ 22 : ab Return Constant pool (size = 2) Handler Table (size = 0) Source Position Table (size = 0) [generated bytecode for function: drink (0x06c0082125b9 <SharedFunctionInfo drink>)] // 注意點2 Parameter count 1 Register count 3 Frame size 24 0x6c00821278a @ 0 : 13 00 00 LdaGlobal [0], [0] 0x6c00821278d @ 3 : 26 f9 Star r1 0x6c00821278f @ 5 : 28 f9 01 02 LdaNamedProperty r1, [1], [2] 0x6c008212793 @ 9 : 26 fa Star r0 0x6c008212795 @ 11 : 12 02 LdaConstant [2] 0x6c008212797 @ 13 : 26 f8 Star r2 0x6c008212799 @ 15 : 5a fa f9 f8 04 CallProperty1 r0, r1, r2, [4] 0x6c00821279e @ 20 : 0d LdaUndefined 0x6c00821279f @ 21 : ab Return Constant pool (size = 3) Handler Table (size = 0) Source Position Table (size = 0) |
沒接觸過字節碼也沒關系,從上面至少能看到 generated bytecode for function 出現了兩次,意味著有兩個函數。
注意點 2 那里有一個 drink 關鍵字,代表是我們顯示聲明的函數;注意點 1 那里就是整段 js 代碼,被作為了一個匿名函數執行。
注意點 3 就是調用 drink 的地方。
不過 js 本身是一個函數式編程語言,函數式是如何表現的我們不用多說,重點說一說「閉包」,閉包一詞不可能有前端開發不知道 (哪怕沒用過,面試也遇到過),那我們思考一下,為什么閉包可以跨越棧幀的限制?
以下面這個函數為例:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const drink = (function() { let flag = 0; return function() { if (++flag > 3) { console.log('利利喝不動了'); return; } console.log('利利噸噸噸'); }; })(); drink(); |
如果使用 d8 輸出字節碼,可以看到總共有三個 generated bytecode for function。整段執行的過程,我們先按常理猜測一下,函數執行作用域變化應該如下:
這里總共有三個階段,重點看后面兩個。
- 第二階段是執行了匿名的自執行函數,此時聲明了一個 flag 變量在對應的作用域。
- 第三階段是執行 drink 函數,這里用到了兩個變量。
- console,來自于上層的作用域,可以理解。
- flag,這個就比較詭異了,因為理論上 flag 應該隨著匿名函數的執行結束銷毀了才對。
這里 v8 做了處理,當解析腳本的時候,發現這樣的情況,會在匿名函數執行階段將 flag 拷貝到堆中,并且給 drink 函數增加一個 scope 引用。
所以真實的圖應該是這樣:
從字節碼上我們可以看到當 return 的函數使沒使用閉包,字節碼是截然不同的,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 使用閉包 const drink = (function() { let i = 0; return function() { if (++i > 3) { console.log('利利喝不動了'); return; } console.log('利利噸噸噸'); }; })(); drink(); ///////////////////////////////// // 匿名函數字節碼如下 [generated bytecode for function: (0x3e97082125e9 <SharedFunctionInfo>)] Parameter count 1 Register count 1 Frame size 8 0x3e97082126d6 @ 0 : 85 00 01 CreateFunctionContext [0], [1] 0x3e97082126d9 @ 3 : 16 fa PushContext r0 0x3e97082126db @ 5 : 0f LdaTheHole 0x3e97082126dc @ 6 : 1d 02 StaCurrentContextSlot [2] 0x3e97082126de @ 8 : 0b LdaZero 0x3e97082126df @ 9 : 1d 02 StaCurrentContextSlot [2] 0x3e97082126e1 @ 11 : 82 01 00 02 CreateClosure [1], [0], #2 0x3e97082126e5 @ 15 : ab Return Constant pool (size = 2) Handler Table (size = 0) Source Position Table (size = 0) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 未使用閉包 let i = 0; const drink = (function() { return function() { if (++i > 3) { console.log('利利喝不動了'); return; } console.log('利利噸噸噸'); }; })(); drink(); ///////////////////////////////// // 匿名函數字節碼如下 [generated bytecode for function: (0x11f5082125e9 <SharedFunctionInfo>)] Parameter count 1 Register count 0 Frame size 0 0x11f5082126d6 @ 0 : 82 00 00 02 CreateClosure [0], [0], #2 0x11f5082126da @ 4 : ab Return Constant pool (size = 1) Handler Table (size = 0) Source Position Table (size = 0) |
作用域查找的代碼在 https://github.com/v8/v8/blob/master/src/ast/scopes.cc#L1975,感興趣的同學可以自行查閱。
C++ 中的 callable
如果查看 v8 源碼的同學,深入到執行 Code 具體執行,發現最后是通過 Adress 類型,而 Adress 就是表示了一個地址,下面是 v8 的 Adress 源碼:
1 |
typedef uintptr_t Address; |
那么地址可以執行么?當然可以,看如下 C++ 代碼:
1 2 3 4 5 6 7 8 9 10 |
void drink() { printf("利利噸噸噸 \n"); } typedef unsigned long int uintptr_t; int main(int argc, char* argv[]) { uintptr_t t = (uintptr_t)drink; ((void(*)(void))t)(); } |
我們沒有采用顯式調用的方式,而是采取了通過函數入口地址來調用,我們來看一下這種方式和直接調用匯編上的差異。
左邊是通過地址調用,右邊是直接調用,可以看到匯編層面都是 call 命令,只是函數指針是手動獲取地址再賦到了寄存器中執行而已。
雖然 C++ 不是函數式編程語言,無法顯性的傳遞函數作為參數,但是我們知道了函數其實就是一個地址,所以可以使用函數指針解決。示例代碼很簡單就不貼了。
對于 C++ 層面的 callable,那可就廣泛了,只要是重載了 operator() 的對象,都可以成為 callable,如下:
1 2 3 4 5 6 7 8 9 10 11 |
class Yori { public: void operator()() const { printf("利利噸噸噸 \n"); } }; int main() { Yori lili; lili(); } |
我們一般稱為這種對象為函數對象,這也是 lambda 表達式的原理,比如下面兩個執行方式,原理是一樣的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
#define FUNC_BODY \ if (curr++ >= limit) { \ printf("利利喝不動了 \n"); \ } else { \ printf("利利[%s]噸噸噸 \n", type.c_str()); \ } \ class Yori { public: Yori() = delete; Yori(int& curr, int limit): curr(curr), limit(limit) {} void operator()(const string& type) { FUNC_BODY } private: int& curr; int limit; }; int main() { int curr = 0; int limit = 2; string type("一杯"); // 通過函數對象的方式進行 call Yori lili_class(curr, limit); lili_class(type); lili_class(type); lili_class(type); // 通過 lambda 的方式進行 call auto lili_lambda = [&curr, limit](const string type)->void { FUNC_BODY }; lili_lambda(type); lili_lambda(type); lili_lambda(type); } |
不過還是 lambda 在寫法上方便了很多,而且 lambda 在沒有捕獲場景下,是可以作為函數指針進行調用的。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
typedef void (*callback) (); void drink(callback func) { // 函數指針作為形參 printf("利利噸噸噸 \n"); func(); // 執行函數指針 } int main() { drink([]() {}); // lambda 表達式作為實參 int i = 0; drink([&i]() {}); // 當有捕獲時,報錯! return 0; } |
第一個 drink 可以正常指定,第二個就不行了,因為擁有捕獲的 lambda 表達式是無法轉換為函數指針的。
不存在從 "lambda []void ()->void" 到 "callback" 的適當轉換函數
對于上面這種情況,可以采用函數包裝器模版,我們只需要將上面的代碼改成這樣就行.
1 2 3 4 5 6 7 8 9 10 |
void drink(function<void()> func) { printf("利利噸噸噸 \n"); func(); } int main() { int i = 0; drink([&i]() {}); // 捕獲也沒事了,??? return 0; } |
之所以可以這也,是因為 function 只關心你是不是 callable 的,并不在乎你本身是如何 call 的。
總結
簡單分析了一下程序中的 callable 對象,如果有什么問題,可以留言討論,奧力給。