閉包、對象,以及堆「族」
在上篇文章中我們提到了閉包、對象、以及棧外的其它東西。我們學習的大部分內容都是與特定編程語言無關的元素,但是,我主要還是專註於 JavaScript,以及一些 C。讓我們以一個簡單的 C 程序開始,它的功能是讀取一首歌曲和樂隊名字,然後將它們輸出給用戶:
#include <stdio.h>
#include <string.h>
char *read()
{
char data[64];
fgets(data, 64, stdin);
return data;
}
int main(int argc, char *argv[])
{
char *song, *band;
puts("Enter song, then band:");
song = read();
band = read();
printf("n%sby %s", song, band);
return 0;
}
stackFolly.c 下載
如果你運行這個程序,你會得到什麼?(=> 表示程序輸出):
./stackFolly
=> Enter song, then band:
The Past is a Grotesque Animal
of Montreal
=> ?ǿontreal
=> by ?ǿontreal
(曾經的 C 新手說)發生了錯誤?
事實證明,函數的棧變數的內容僅在棧幀活動期間才是可用的,也就是說,僅在函數返回之前。在上面的返回中,被棧幀使用過的內存 被認為是可用的,並且在下一個函數調用中可以被覆寫。
下面的圖展示了這種情況下究竟發生了什麼。這個圖現在有一個圖片映射(LCTT 譯註:譯文中無法包含此映射,上下兩個矩形區域分別鏈接至輸出的 #47 行和 #70 行),因此,你可以點擊一個數據片斷去看一下相關的 GDB 輸出(GDB 命令在 這裡)。只要 read()
讀取了歌曲的名字,棧將是這個樣子:
在這個時候,這個 song
變數立即指向到歌曲的名字。不幸的是,存儲字元串的內存位置準備被下次調用的任意函數的棧幀重用。在這種情況下,read()
再次被調用,而且使用的是同一個位置的棧幀,因此,結果變成下圖的樣子(LCTT 譯註:上下兩個矩形映射分別鏈接至 #76 行和 #79 行):
樂隊名字被讀入到相同的內存位置,並且覆蓋了前面存儲的歌曲名字。band
和 song
最終都準確指向到相同點。最後,我們甚至都不能得到 「of Montreal」(LCTT 譯註:一個歐美樂隊的名字) 的正確輸出。你能猜到是為什麼嗎?
因此,即使棧很有用,但也有很重要的限制。它不能被一個函數用於去存儲比該函數的運行周期還要長的數據。你必須將它交給 堆,然後與熱點緩存、明確的瞬時操作、以及頻繁計算的偏移等內容道別。有利的一面是,它是工作 的:
這個代價是你必須記得去 free()
內存,或者由一個垃圾回收機制花費一些性能來隨機回收,垃圾回收將去找到未使用的堆對象,然後去回收它們。那就是棧和堆之間在本質上的權衡:性能 vs. 靈活性。
大多數編程語言的虛擬機都有一個中間層用來做一個 C 程序員該做的一些事情。棧被用於值類型,比如,整數、浮點數、以及布爾型。這些都按特定值(像上面的 argc
)的位元組順序被直接保存在本地變數和對象欄位中。相比之下,堆被用於引用類型,比如,字元串和 對象。 變數和欄位包含一個引用到這個對象的內存地址,像上面的 song
和 band
。
參考這個 JavaScript 函數:
function fn()
{
var a = 10;
var b = { name: 'foo', n: 10 };
}
它可能的結果如下(LCTT 譯註:圖片內「object」、「string」和「a」的映射分別鏈接至 #1671 行、 #8656 行和 #1264 行):
我之所以說「可能」的原因是,特定的行為高度依賴於實現。這篇文章使用的許多流程圖形是以一個 V8 為中心的方法,這些圖形都鏈接到相關的源代碼。在 V8 中,僅 小整數 是 以值的方式保存。因此,從現在開始,我將在對象中直接以字元串去展示,以避免引起混亂,但是,請記住,正如上圖所示的那樣,它們在堆中是分開保存的。
現在,我們來看一下閉包,它其實很簡單,但是由於我們將它宣傳的過於誇張,以致於有點神化了。先看一個簡單的 JS 函數:
function add(a, b)
{
var c = a + b;
return c;
}
這個函數定義了一個 詞法域 ,它是一個快樂的小王國,在這裡它的名字 a
、b
、c
是有明確意義的。它有兩個參數和由函數聲明的一個本地變數。程序也可以在別的地方使用相同的名字,但是在 add
內部它們所引用的內容是明確的。儘管詞法域是一個很好的術語,它符合我們直觀上的理解:畢竟,我們從字面意義上看,我們可以像詞法分析器一樣,把它看作在源代碼中的一個文本塊。
在看到棧幀的操作之後,很容易想像出這個名稱的具體實現。在 add
內部,這些名字引用到函數的每個運行實例中私有的棧的位置。這種情況在一個虛擬機中經常發生。
現在,我們來嵌套兩個詞法域:
function makeGreeter()
{
return function hi(name){
console.log('hi, ' + name);
}
}
var hi = makeGreeter();
hi('dear reader'); // prints "hi, dear reader"
那樣更有趣。函數 hi
在函數 makeGreeter
運行的時候被構建在它內部。它有它自己的詞法域,name
在這個地方是一個棧上的參數,但是,它似乎也可以訪問父級的詞法域,它可以那樣做。我們來看一下那樣做的好處:
function makeGreeter(greeting)
{
return function greet(name){
console.log(greeting + ', ' + name);
}
}
var heya = makeGreeter('HEYA');
heya('dear reader'); // prints "HEYA, dear reader"
雖然有點不習慣,但是很酷。即便這樣違背了我們的直覺:greeting
確實看起來像一個棧變數,這種類型應該在 makeGreeter()
返回後消失。可是因為 greet()
一直保持工作,出現了一些奇怪的事情。進入閉包(LCTT 譯註:「Context」 和 「JSFunction」 映射分別鏈接至 #188 行和 #7245 行):
虛擬機分配一個對象去保存被裡面的 greet()
使用的父級變數。它就好像是 makeGreeter
的詞法作用域在那個時刻被 關閉 了,一旦需要時被具體化到一個堆對象(在這個案例中,是指返回的函數的生命周期)。因此叫做 閉包 ,當你這樣去想它的時候,它的名字就有意義了。如果使用(或者捕獲)了更多的父級變數,對象內容將有更多的屬性,每個捕獲的變數有一個。當然,發送到 greet()
的代碼知道從對象內容中去讀取問候語,而不是從棧上。
這是完整的示例:
function makeGreeter(greetings)
{
var count = 0;
var greeter = {};
for (var i = 0; i < greetings.length; i++) {
var greeting = greetings[i];
greeter[greeting] = function(name){
count++;
console.log(greeting + ', ' + name);
}
}
greeter.count = function(){return count;}
return greeter;
}
var greeter = makeGreeter(["hi", "hello","howdy"])
greeter.hi('poppet');//prints "howdy, poppet"
greeter.hello('darling');// prints "howdy, darling"
greeter.count(); // returns 2
是的,count()
在工作,但是我們的 greeter
是在 howdy
中的棧上。你能告訴我為什麼嗎?我們使用 count
是一條線索:儘管詞法域進入一個堆對象中被關閉,但是變數(或者對象屬性)帶的值仍然可能被改變。下圖是我們擁有的內容(LCTT 譯註:映射從左到右「Object」、「JSFunction」和「Context」分別鏈接至 #1671 行、#7245 行和 #188 行):
這是一個被所有函數共享的公共內容。那就是為什麼 count
工作的原因。但是,greeting
也是被共享的,並且它被設置為迭代結束後的最後一個值,在這個案例中是 「howdy」。這是一個很常見的一般錯誤,避免它的簡單方法是,引用一個函數調用,以閉包變數作為一個參數。在 CoffeeScript 中, do 命令提供了一個實現這種目的的簡單方式。下面是對我們的 greeter
的一個簡單的解決方案:
function makeGreeter(greetings)
{
var count = 0;
var greeter = {};
greetings.forEach(function(greeting){
greeter[greeting] = function(name){
count++;
console.log(greeting + ', ' + name);
}
});
greeter.count = function(){return count;}
return greeter;
}
var greeter = makeGreeter(["hi", "hello", "howdy"])
greeter.hi('poppet'); // prints "hi, poppet"
greeter.hello('darling'); // prints "hello, darling"
greeter.count(); // returns 2
它現在是工作的,並且結果將變成下圖所示(LCTT 譯註:映射從左到右「Object」、「JSFunction」和「Context」分別鏈接至 #1671 行、#7245 行和 #188 行):
這裡有許多箭頭!在這裡我們感興趣的特性是:在我們的代碼中,我們閉包了兩個嵌套的詞法內容,並且完全可以確保我們得到了兩個鏈接到堆上的對象內容。你可以嵌套並且閉包任何詞法內容,像「俄羅斯套娃」似的,並且最終從本質上說你使用的是所有那些 Context 對象的一個鏈表。
當然,就像受信鴿攜帶信息啟發實現了 TCP 一樣,去實現這些編程語言的特性也有很多種方法。例如,ES6 規範定義了 詞法環境 作為 環境記錄( 大致相當於在一個塊內的本地標識)的組成部分,加上一個鏈接到外部環境的記錄,這樣就允許我們看到的嵌套。邏輯規則是由規範(一個希望)所確定的,但是其實現取決於將它們變成比特和位元組的轉換。
你也可以檢查具體案例中由 V8 產生的彙編代碼。Vyacheslav Egorov 有一篇很好的文章,它使用 V8 的 閉包內部構件 詳細解釋了這一過程。我剛開始學習 V8,因此,歡迎指教。如果你熟悉 C#,檢查閉包產生的中間代碼將會很受啟發 —— 你將看到顯式定義的 V8 內容和實例化的模擬。
閉包是個強大的「傢伙」。它在被一組函數共享期間,提供了一個簡單的方式去隱藏來自調用者的信息。我喜歡它們真正地隱藏你的數據:不像對象欄位,調用者並不能訪問或者甚至是看到閉包變數。保持介面清晰而安全。
但是,它們並不是「銀彈」(LCTT 譯註:意指極為有效的解決方案,或者寄予厚望的新技術)。有時候一個對象的擁護者和一個閉包的狂熱者會無休止地爭論它們的優點。就像大多數的技術討論一樣,他們通常更關注的是自尊而不是真正的權衡。不管怎樣,Anton van Straaten 的這篇 史詩級的公案 解決了這個問題:
德高望重的老師 Qc Na 和它的學生 Anton 一起散步。Anton 希望將老師引入到一個討論中,Anton 說:「老師,我聽說對象是一個非常好的東西,是這樣的嗎?Qc Na 同情地看了一眼,責備它的學生說:「可憐的孩子 —— 對象不過是窮人的閉包而已。」 Anton 待它的老師走了之後,回到他的房間,專心學習閉包。他認真地閱讀了完整的 「Lambda:The Ultimate…" 系列文章和它的相關資料,並使用一個基於閉包的對象系統實現了一個小的架構解釋器。他學到了很多的東西,並期待告訴老師他的進步。在又一次和 Qc Na 散步時,Anton 嘗試給老師留下一個好的印象,說「老師,我仔細研究了這個問題,並且,現在理解了對象真的是窮人的閉包。」Qc Na 用它的手杖打了一下 Anton 說:「你什麼時候才能明白?閉包是窮人的對象。」在那個時候,Anton 頓悟了。Anton van Straaten 說:「原來架構這麼酷啊?」
探秘「棧」系列文章到此結束了。後面我將計划去寫一些其它的編程語言實現的主題,像對象綁定和虛表。但是,內核調用是很強大的,因此,明天將發布一篇操作系統的文章。我邀請你 訂閱 並 關注我。
via:https://manybutfinite.com/post/closures-objects-heap/
作者:Gustavo Duarte 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive