第七章:元編程 2

特別說明,為便于查閱,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS

代理黑入 [[Prototype]]

[[Get]]操作是[[Prototype]]機(jī)制被調(diào)用的主要渠道。當(dāng)一個(gè)屬性不能在直接對(duì)象上找到時(shí),[[Get]]會(huì)自動(dòng)將操作交給[[Prototype]]對(duì)象。

這意味著你可以使用一個(gè)代理的get(..)機(jī)關(guān)來模擬或擴(kuò)展這個(gè)[[Prototype]]機(jī)制的概念。

我們將考慮的第一種黑科技是創(chuàng)建兩個(gè)通過[[Prototype]]循環(huán)鏈接的對(duì)象(或者說,至少看起來是這樣?。?。你不能實(shí)際創(chuàng)建一個(gè)真正循環(huán)的[[Prototype]]鏈,因?yàn)橐鎸?huì)拋出一個(gè)錯(cuò)誤。但是代理可以假冒它!

考慮如下代碼:

var handlers = {
        get(target,key,context) {
            if (Reflect.has( target, key )) {
                return Reflect.get(
                    target, key, context
                );
            }
            // 假冒循環(huán)的 `[[Prototype]]`
            else {
                return Reflect.get(
                    target[
                        Symbol.for( "[[Prototype]]" )
                    ],
                    key,
                    context
                );
            }
        }
    },
    obj1 = new Proxy(
        {
            name: "obj-1",
            foo() {
                console.log( "foo:", this.name );
            }
        },
        handlers
    ),
    obj2 = Object.assign(
        Object.create( obj1 ),
        {
            name: "obj-2",
            bar() {
                console.log( "bar:", this.name );
                this.foo();
            }
        }
    );

// 假冒循環(huán)的 `[[Prototype]]` 鏈
obj1[ Symbol.for( "[[Prototype]]" ) ] = obj2;

obj1.bar();
// bar: obj-1 <-- 通過代理假冒 [[Prototype]]
// foo: obj-1 <-- `this` 上下文環(huán)境依然被保留

obj2.foo();
// foo: obj-2 <-- 通過 [[Prototype]]

注意: 為了讓事情簡單一些,在這個(gè)例子中我們沒有代理/轉(zhuǎn)送[[Set]]。要完整地模擬[[Prototype]]兼容,你會(huì)想要實(shí)現(xiàn)一個(gè)set(..)處理器,它在[[Prototype]]鏈上檢索一個(gè)匹配得屬性并遵循它的描述符的行為(例如,set,可寫性)。參見本系列的 this與對(duì)象原型。

在前面的代碼段中,obj2憑借Object.create(..)語句[[Prototype]]鏈接到obj1。但是要?jiǎng)?chuàng)建反向(循環(huán))的鏈接,我們?cè)?code>obj1的symbol位置Symbol.for("[[Prototype]]")(參見第二章的“Symbol”)上創(chuàng)建了一個(gè)屬性。這個(gè)symbol可能看起來有些特別/魔幻,但它不是的。它只是允許我使用一個(gè)被方便地命名的屬性,這個(gè)屬性在語義上看來是與我進(jìn)行的任務(wù)有關(guān)聯(lián)的。

然后,代理的get(..)處理器首先檢查一個(gè)被請(qǐng)求的key是否存在于代理上。如果每個(gè)有,操作就被手動(dòng)地交給存儲(chǔ)在targetSymbol.for("[[Prototype]]")位置中的對(duì)象引用。

這種模式的一個(gè)重要優(yōu)點(diǎn)是,在obj1obj2之間建立循環(huán)關(guān)系幾乎沒有入侵它們的定義。雖然前面的代碼段為了簡短而將所有的步驟交織在一起,但是如果你仔細(xì)觀察,代理處理器的邏輯完全是范用的(不具體地知道obj1obj2)。所以,這段邏輯可以抽出到一個(gè)簡單的將它們連在一起的幫助函數(shù)中,例如setCircularPrototypeOf(..)。我們將此作為一個(gè)練習(xí)留給讀者。

現(xiàn)在我們看到了如何使用get(..)來模擬一個(gè)[[Prototype]]鏈接,但讓我們將這種黑科技推動(dòng)的遠(yuǎn)一些。與其制造一個(gè)循環(huán)[[Prototype]],搞一個(gè)多重[[Prototype]]鏈接(也就是“多重繼承”)怎么樣?這看起來相當(dāng)直白:

var obj1 = {
        name: "obj-1",
        foo() {
            console.log( "obj1.foo:", this.name );
        },
    },
    obj2 = {
        name: "obj-2",
        foo() {
            console.log( "obj2.foo:", this.name );
        },
        bar() {
            console.log( "obj2.bar:", this.name );
        }
    },
    handlers = {
        get(target,key,context) {
            if (Reflect.has( target, key )) {
                return Reflect.get(
                    target, key, context
                );
            }
            // 假冒多重 `[[Prototype]]`
            else {
                for (var P of target[
                    Symbol.for( "[[Prototype]]" )
                ]) {
                    if (Reflect.has( P, key )) {
                        return Reflect.get(
                            P, key, context
                        );
                    }
                }
            }
        }
    },
    obj3 = new Proxy(
        {
            name: "obj-3",
            baz() {
                this.foo();
                this.bar();
            }
        },
        handlers
    );

// 假冒多重 `[[Prototype]]` 鏈接
obj3[ Symbol.for( "[[Prototype]]" ) ] = [
    obj1, obj2
];

obj3.baz();
// obj1.foo: obj-3
// obj2.bar: obj-3

注意: 正如在前面的循環(huán)[[Prototype]]例子后的注意中提到的,我們沒有實(shí)現(xiàn)set(..)處理器,但對(duì)于一個(gè)將[[Set]]模擬為普通[[Prototype]]行為的解決方案來說,它將是必要的。

obj3被設(shè)置為多重委托到obj1obj2。在obj2.baz()中,this.foo()調(diào)用最終成為從obj1中抽出foo()(先到先得,雖然還有一個(gè)在obj2上的foo())。如果我們將連接重新排列為obj2, obj1,那么obj2.foo()將被找到并使用。

同理,this.bar()調(diào)用沒有在obj1上找到bar(),所以它退而檢查obj2,這里找到了一個(gè)匹配。

obj1obj2代表obj3的兩個(gè)平行的[[Prototype]]鏈。obj1和/或obj2自身可以擁有委托至其他對(duì)象的普通[[Prototype]],或者自身也可以是多重委托的代理(就像obj3一樣)。

正如先前的循環(huán)[[Prototype]]的例子一樣,obj1obj2obj3的定義幾乎完全與處理多重委托的范用代理邏輯相分離。定義一個(gè)setPrototypesOf(..)(注意那個(gè)“s”?。┻@樣的工具將是小菜一碟,它接收一個(gè)主對(duì)象和一組模擬多重[[Prototype]]鏈接用的對(duì)象。同樣,我們將此作為練習(xí)留給讀者。

希望在這種種例子之后代理的力量現(xiàn)在變得明朗了。代理使得許多強(qiáng)大的元編程任務(wù)成為可能。

Reflect API

Reflect對(duì)象是一個(gè)普通對(duì)象(就像Math),不是其他內(nèi)建原生類型那樣的函數(shù)/構(gòu)造器。

它持有對(duì)應(yīng)于你可以控制的各種元編程任務(wù)的靜態(tài)函數(shù)。這些函數(shù)與代理可以定義的處理器方法(機(jī)關(guān))一一對(duì)應(yīng)。

這些函數(shù)中的一些看起來與在Object上的同名函數(shù)很相似:

  • Reflect.getOwnPropertyDescriptor(..)
  • Reflect.defineProperty(..)
  • Reflect.getPrototypeOf(..)
  • Reflect.setPrototypeOf(..)
  • Reflect.preventExtensions(..)
  • Reflect.isExtensible(..)

這些工具一般與它們的Object.*對(duì)等物的行為相同。但一個(gè)區(qū)別是,Object.*對(duì)等物在它們的第一個(gè)參數(shù)值(目標(biāo)對(duì)象)還不是對(duì)象的情況下,試圖將它強(qiáng)制轉(zhuǎn)換為一個(gè)對(duì)象。Reflect.*方法在同樣的情況下僅簡單地拋出一個(gè)錯(cuò)誤。

一個(gè)對(duì)象的鍵可以使用這些工具訪問/檢測:

  • Reflect.ownKeys(..):返回一個(gè)所有直屬(不是“繼承的”)鍵的列表,正如被 Object.getOwnPropertyNames(..)Object.getOwnPropertySymbols(..)返回的那樣。關(guān)于鍵的順序問題,參見“屬性枚舉順序”一節(jié)。
  • Reflect.enumerate(..):返回一個(gè)產(chǎn)生所有(直屬和“繼承的”)非symbol、可枚舉的鍵的迭代器(參見本系列的 this與對(duì)象原型)。 實(shí)質(zhì)上,這組鍵與在for..in循環(huán)中被處理的那一組鍵是相同的。關(guān)于鍵的順序問題,參見“屬性枚舉順序”一節(jié)。
  • Reflect.has(..):實(shí)質(zhì)上與用于檢查一個(gè)屬性是否存在于一個(gè)對(duì)象或它的[[Prototype]]鏈上的in操作符相同。例如,Reflect.has(o,"foo")實(shí)質(zhì)上實(shí)施"foo" in o。

函數(shù)調(diào)用和構(gòu)造器調(diào)用可以使用這些工具手動(dòng)地實(shí)施,與普通的語法(例如,(..)new)分開:

  • Reflect.apply(..):例如,Reflect.apply(foo,thisObj,[42,"bar"])使用thisObj作為foo(..)函數(shù)的this來調(diào)用它,并傳入?yún)?shù)值42"bar"。
  • Reflect.construct(..):例如,Reflect.construct(foo,[42,"bar"])實(shí)質(zhì)上調(diào)用new foo(42,"bar")。

對(duì)象屬性訪問,設(shè)置,和刪除可以使用這些工具手動(dòng)實(shí)施:

  • Reflect.get(..):例如,Reflect.get(o,"foo")會(huì)取得o.foo
  • Reflect.set(..):例如,Reflect.set(o,"foo",42)實(shí)質(zhì)上實(shí)施o.foo = 42
  • Reflect.deleteProperty(..):例如,Reflect.deleteProperty(o,"foo")實(shí)質(zhì)上實(shí)施delete o.foo。

Reflect的元編程能力給了你可以模擬各種語法特性的程序化等價(jià)物,暴露以前隱藏著的抽象操作。例如,你可以使用這些能力來擴(kuò)展 領(lǐng)域特定語言(DSL)的特性和API。

屬性順序

在ES6之前,羅列一個(gè)對(duì)象的鍵/屬性的順序沒有在語言規(guī)范中定義,而是依賴于具體實(shí)現(xiàn)的。一般來說,大多數(shù)引擎會(huì)以創(chuàng)建的順序來羅列它們,雖然開發(fā)者們已經(jīng)被強(qiáng)烈建議永遠(yuǎn)不要依仗這種順序。

在ES6中,羅列直屬屬性的屬性是由[[OwnPropertyKeys]]算法定義的(ES6語言規(guī)范,9.1.12部分),它產(chǎn)生所有直屬屬性(字符串或symbol),不論其可枚舉性。這種順序僅對(duì)Reflect.ownKeys(..)有保證()。

這個(gè)順序是:

  1. 首先,以數(shù)字上升的順序,枚舉所有數(shù)字索引的直屬屬性。
  2. 然后,以創(chuàng)建順序枚舉剩下的直屬字符串屬性名。
  3. 最后,以創(chuàng)建順序枚舉直屬symbol屬性。

考慮如下代碼:

var o = {};

o[Symbol("c")] = "yay";
o[2] = true;
o[1] = true;
o.b = "awesome";
o.a = "cool";

Reflect.ownKeys( o );               // [1,2,"b","a",Symbol(c)]
Object.getOwnPropertyNames( o );    // [1,2,"b","a"]
Object.getOwnPropertySymbols( o );  // [Symbol(c)]

另一方面,[[Enumeration]]算法(ES6語言規(guī)范,9.1.11部分)從目標(biāo)對(duì)象和它的[[Prototype]]鏈中僅產(chǎn)生可枚舉屬性。它被用于Reflect.enumerate(..)for..in。可觀察到的順序是依賴于具體實(shí)現(xiàn)的,語言規(guī)范沒有控制它。

相比之下,Object.keys(..)調(diào)用[[OwnPropertyKeys]]算法來得到一個(gè)所有直屬屬性的列表。但是,它過濾掉了不可枚舉屬性,然后特別為了JSON.stringify(..)for..in而將這個(gè)列表重排,以匹配遺留的、依賴于具體實(shí)現(xiàn)的行為。所以通過擴(kuò)展,這個(gè)順序 Reflect.enumerate(..)的順序像吻合。

換言之,所有四種機(jī)制(Reflect.enumerate(..),Object.keys(..)for..in,和JSON.stringify(..))都同樣將與依賴于具體實(shí)現(xiàn)的順序像吻合,雖然技術(shù)上它們是以不同的方式達(dá)到的同樣的效果。

具體實(shí)現(xiàn)可以將這四種機(jī)制與[[OwnPropertyKeys]]的順序相吻合,但不是必須的。無論如何,你將很可能從它們的行為中觀察到以下的排序:

var o = { a: 1, b: 2 };
var p = Object.create( o );
p.c = 3;
p.d = 4;

for (var prop of Reflect.enumerate( p )) {
    console.log( prop );
}
// c d a b

for (var prop in p) {
    console.log( prop );
}
// c d a b

JSON.stringify( p );
// {"c":3,"d":4}

Object.keys( p );
// ["c","d"]

這一切可以歸納為:在ES6中,根據(jù)語言規(guī)范Reflect.ownKeys(..),Object.getOwnPropertyNames(..),和Object.getOwnPropertySymbols(..)保證都有可預(yù)見和可靠的順序。所以依賴于這種順序來建造代碼是安全的。

Reflect.enumerate(..),Object.keys(..),和for..in (擴(kuò)展一下的話還有JSON.stringify(..))繼續(xù)互相共享一個(gè)可觀察的順序,就像它們往常一樣。但這個(gè)順序不一定與Reflect.ownKeys(..)的相同。在使用它們依賴于具體實(shí)現(xiàn)的順序時(shí)依然應(yīng)當(dāng)小心。

特性測試

什么是特性測試?它是一種由你運(yùn)行來判定一個(gè)特性是否可用的測試。有些時(shí)候,這種測試不僅是為了判定存在性,還是為判定對(duì)特定行為的適應(yīng)性 —— 特性可能存在但有bug。

這是一種元編程技術(shù) —— 測試你程序?qū)⒁\(yùn)行的環(huán)境然后判定你的程序應(yīng)當(dāng)如何動(dòng)作。

在JS中特性測試最常見的用法是檢測一個(gè)API的存在性,而且如果它不存在,就定義一個(gè)填補(bǔ)(見第一章)。例如:

if (!Number.isNaN) {
    Number.isNaN = function(x) {
        return x !== x;
    };
}

在這個(gè)代碼段中的if語句就是一個(gè)元編程:我們探測我們的程序和它的運(yùn)行時(shí)環(huán)境,來判定我們是否和如何進(jìn)行后續(xù)處理。

但是如何測試一個(gè)涉及新語法的特性呢?

你可能會(huì)嘗試這樣的東西:

try {
    a = () => {};
    ARROW_FUNCS_ENABLED = true;
}
catch (err) {
    ARROW_FUNCS_ENABLED = false;
}

不幸的是,這不能工作,因?yàn)槲覀兊腏S程序是要被編譯的。因此,如果引擎還沒有支持ES6箭頭函數(shù)的話,它就會(huì)在() => {}語法的地方熄火。你程序中的語法錯(cuò)誤會(huì)阻止它的運(yùn)行,進(jìn)而阻止你程序根據(jù)特性是否被支持而進(jìn)行后續(xù)的不同相應(yīng)。

為了圍繞語法相關(guān)的特性進(jìn)行特性測試的元編程,我們需要一個(gè)方法將測試與我們程序?qū)⒁ㄟ^的初始編譯步驟隔離開。舉例來說,如果我們能夠?qū)⑦M(jìn)行測試的代碼存儲(chǔ)在一個(gè)字符串中,之后JS引擎默認(rèn)地將不會(huì)嘗試編譯這個(gè)字符串中的內(nèi)容,直到我們要求它這么做。

你的思路是不是跳到了使用eval(..)

別這么著急??纯幢鞠盗械?作用域與閉包 來了解一下為什么eval(..)是一個(gè)壞主意。但是有另外一個(gè)缺陷較少的選項(xiàng):Function(..)構(gòu)造器。

考慮如下代碼:

try {
    new Function( "( () => {} )" );
    ARROW_FUNCS_ENABLED = true;
}
catch (err) {
    ARROW_FUNCS_ENABLED = false;
}

好了,現(xiàn)在我們判定一個(gè)像箭頭函數(shù)這樣的特性是否 被當(dāng)前的引擎所編譯來進(jìn)行元編程。你可能會(huì)想知道,我們要用這種信息做什么?

檢查API的存在性,并定義后備的API填補(bǔ),對(duì)于特性檢測成功或失敗來說都是一條明確的道路。但是對(duì)于從ARROW_FUNCS_ENABLEDtrue還是false中得到的信息來說,我們能對(duì)它做什么呢?

因?yàn)槿绻娌恢С忠环N特性,它的語法就不能出現(xiàn)在一個(gè)文件中,所以你不能在這個(gè)文件中定義使用這種語法的函數(shù)。

你所能做的是,使用測試來判定你應(yīng)當(dāng)加載哪一組JS文件。例如,如果在你的JS應(yīng)用程序中的啟動(dòng)裝置中有一組這樣的特性測試,那么它就可以測試環(huán)境來判定你的ES6代碼是否可以直接加載運(yùn)行,或者你是否需要加載一個(gè)代碼的轉(zhuǎn)譯版本(參見第一章)。

這種技術(shù)稱為 分割投遞。

事實(shí)表明,你使用ES6編寫的JS程序有時(shí)可以在ES6+瀏覽器中完全“原生地”運(yùn)行,但是另一些時(shí)候需要在前ES6瀏覽器中運(yùn)行轉(zhuǎn)譯版本。如果你總是加載并使用轉(zhuǎn)譯代碼,即便是在新的ES6兼容環(huán)境中,至少是有些情況下你運(yùn)行的也是次優(yōu)的代碼。這并不理想。

分割投遞更加復(fù)雜和精巧,但對(duì)于你編寫的代碼和你的程序所必須在其中運(yùn)行的瀏覽器支持的特性之間,它代表一種更加成熟和健壯的橋接方式。

FeatureTests.io

為所有的ES6+語法以及語義行為定義特性測試,是一項(xiàng)你可能不想自己解決的艱巨任務(wù)。因?yàn)檫@些測試要求動(dòng)態(tài)編譯(new Function(..)),這會(huì)產(chǎn)生不幸的性能損耗。

另外,在每次你的應(yīng)用運(yùn)行時(shí)都執(zhí)行這些測試可能是一種浪費(fèi),因?yàn)槠骄鶃碚f一個(gè)用戶的瀏覽器在幾周之內(nèi)至多只會(huì)更新一次,而即使是這樣,新特性也不一定會(huì)在每次更新中都出現(xiàn)。

最終,管理一個(gè)對(duì)你特定代碼庫進(jìn)行的特性測試列表 —— 你的程序?qū)⒑苌儆玫紼S6的全部 —— 是很容易失控而且易錯(cuò)的。

https://featuretests.io”的“特性測試服務(wù)”為這種挫折提供了解決方案。

你可以將這個(gè)服務(wù)的庫加載到你的頁面中,而它會(huì)加載最新的測試定義并運(yùn)行所有的特性測試。在可能的情況下,它將使用Web Worker的后臺(tái)處理中這樣做,以降低性能上的開銷。它還會(huì)使用LocalStorage持久化來緩存測試的結(jié)果 —— 以一種可以被所有你訪問的使用這個(gè)服務(wù)的站點(diǎn)所共享的方式,這將及大地降低測試需要在每個(gè)瀏覽器實(shí)例上運(yùn)行的頻度。

你可以在每一個(gè)用戶的瀏覽器上進(jìn)行運(yùn)行時(shí)特性測試,而且你可以使用這些測試結(jié)果動(dòng)態(tài)地向用戶傳遞最適合他們環(huán)境的代碼(不多也不少)。

另外,這個(gè)服務(wù)還提供工具和API來掃描你的文件以判定你需要什么特性,這樣你就能夠完全自動(dòng)化你的分割投遞構(gòu)建過程。

對(duì)ES6的所有以及未來的部分進(jìn)行特性測試,以確保對(duì)于任何給定的環(huán)境都只有最佳的代碼會(huì)被加載和運(yùn)行 —— FeatureTests.io使這成為可能。

尾部調(diào)用優(yōu)化(TCO)

通常來說,當(dāng)從一個(gè)函數(shù)內(nèi)部發(fā)起對(duì)另一個(gè)函數(shù)的調(diào)用時(shí),就會(huì)分配一個(gè) 棧幀 來分離地管理這另一個(gè)函數(shù)調(diào)用的變量/狀態(tài)。這種分配不僅花費(fèi)一些處理時(shí)間,還會(huì)消耗一些額外的內(nèi)存。

一個(gè)調(diào)用棧鏈從一個(gè)函數(shù)到另一個(gè)再到另一個(gè),通常至多擁有10-15跳。在這些場景下,內(nèi)存使用不太可能是某種實(shí)際問題。

然而,當(dāng)你考慮遞歸編程(一個(gè)函數(shù)頻繁地調(diào)用自己) —— 或者使用兩個(gè)或更多的函數(shù)相互調(diào)用而構(gòu)成相互遞歸 —— 調(diào)用棧就可能輕易地到達(dá)上百,上千,或更多層的深度。如果內(nèi)存的使用無限制地增長下去,你可能看到了它將導(dǎo)致的問題。

JavaScript引擎不得不設(shè)置一個(gè)隨意的限度來防止這樣的編程技術(shù)耗盡瀏覽器或設(shè)備的內(nèi)存。這就是為什么我們會(huì)在到達(dá)這個(gè)限度時(shí)得到令人沮喪的“RangeError: Maximum call stack size exceeded”。

警告: 調(diào)用棧深度的限制是不由語言規(guī)范控制的。它是依賴于具體實(shí)現(xiàn)的,而且將會(huì)根據(jù)瀏覽器和設(shè)備不同而不同。你絕不應(yīng)該帶著可精確觀察到的限度的強(qiáng)烈臆想進(jìn)行編碼,因?yàn)樗鼈冞€很可能在每個(gè)版本中變化。

一種稱為 尾部調(diào)用 的特定函數(shù)調(diào)用模式,可以以一種避免額外的棧幀分配的方法進(jìn)行優(yōu)化。如果額外的分配可以被避免,那么就沒有理由隨意地限制調(diào)用棧的深度,這樣引擎就可以讓它們沒有邊界地運(yùn)行下去。

一個(gè)尾部調(diào)用是一個(gè)帶有函數(shù)調(diào)用的return語句,除了返回它的值,函數(shù)調(diào)用之后沒有任何事情需要發(fā)生。

這種優(yōu)化只能在strict模式下進(jìn)行。又一個(gè)你總是應(yīng)該用strict編寫所有代碼的理由!

這個(gè)函數(shù)調(diào)用 不是 在尾部:

"use strict";

function foo(x) {
    return x * 2;
}

function bar(x) {
    // 不是一個(gè)尾部調(diào)用
    return 1 + foo( x );
}

bar( 10 );              // 21

foo(x)調(diào)用完成后必須進(jìn)行1 + ..,所以那個(gè)bar(..)調(diào)用的狀態(tài)需要被保留。

但是下面的代碼段中展示的foo(..)bar(..)都是位于尾部,因?yàn)樗鼈兌际窃谧陨泶a路徑上(除了return以外)發(fā)生的最后一件事:

"use strict";

function foo(x) {
    return x * 2;
}

function bar(x) {
    x = x + 1;
    if (x > 10) {
        return foo( x );
    }
    else {
        return bar( x + 1 );
    }
}

bar( 5 );               // 24
bar( 15 );              // 32

在這個(gè)程序中,bar(..)明顯是遞歸,但foo(..)只是一個(gè)普通的函數(shù)調(diào)用。這兩個(gè)函數(shù)調(diào)用都位于 恰當(dāng)?shù)奈膊课恢?/em>。x + 1bar(..)調(diào)用之前被求值,而且不論這個(gè)調(diào)用何時(shí)完成,所有將要放生的只有return。

這些形式的恰當(dāng)尾部調(diào)用(Proper Tail Calls —— PTC)是可以被優(yōu)化的 —— 稱為尾部調(diào)用優(yōu)化(TCO)—— 于是額外的棧幀分配是不必要的。與為下一個(gè)函數(shù)調(diào)用創(chuàng)建新的棧幀不同,引擎會(huì)重用既存的棧幀。這能夠工作是因?yàn)橐粋€(gè)函數(shù)不需要保留任何當(dāng)前狀態(tài) —— 在PTC之后的狀態(tài)下不會(huì)發(fā)生任何事情。

TCO意味著調(diào)用??梢杂卸嗌顚?shí)際上是沒有限度的。這種技巧稍稍改進(jìn)了一般程序中的普通函數(shù)調(diào)用,但更重要的是它打開了一扇大門:可以使用遞歸表達(dá)程序,即使它的調(diào)用棧深度有成千上萬層。

我們不再局限于單純地在理論上考慮用遞歸解決問題了,而是可以在真實(shí)的JavaScript程序中使用它!

作為ES6,所有的PTC都應(yīng)該是可以以這種方式優(yōu)化的,不論遞歸與否。

重寫尾部調(diào)用

然而,障礙是只有PTC是可以被優(yōu)化的;非PTC理所當(dāng)然地依然可以工作,但是將造成往常那樣的棧幀分配。如果你希望優(yōu)化機(jī)制啟動(dòng),就必須小心地使用PTC構(gòu)造你的函數(shù)。

如果你有一個(gè)沒有用PTC編寫的函數(shù),你可能會(huì)發(fā)現(xiàn)你需要手動(dòng)地重新安排你的代碼,使它成為合法的TCO。

考慮如下代碼:

"use strict";

function foo(x) {
    if (x <= 1) return 1;
    return (x / 2) + foo( x - 1 );
}

foo( 123456 );          // RangeError

對(duì)foo(x-1)的調(diào)用不是一個(gè)PTC,因?yàn)樵?code>return之前它的結(jié)果必須被加上(x / 2)。

但是,要使這段代碼在一個(gè)ES6引擎中是合法的TCO,我們可以像下面這樣重寫它:

"use strict";

var foo = (function(){
    function _foo(acc,x) {
        if (x <= 1) return acc;
        return _foo( (x / 2) + acc, x - 1 );
    }

    return function(x) {
        return _foo( 1, x );
    };
})();

foo( 123456 );          // 3810376848.5

如果你在一個(gè)實(shí)現(xiàn)了TCO的ES6引擎中運(yùn)行前面這個(gè)代碼段,你將會(huì)如展示的那樣得到答案3810376848.5。然而,它仍然會(huì)在非TCO引擎中因?yàn)?code>RangeError而失敗。

非TCO優(yōu)化

有另一種技術(shù)可以重寫代碼,讓調(diào)用棧不隨每次調(diào)用增長。

一個(gè)這樣的技術(shù)稱為 蹦床,它相當(dāng)于讓每一部分結(jié)果表示為一個(gè)函數(shù),這個(gè)函數(shù)要么返回另一個(gè)部分結(jié)果函數(shù),要么返回最終結(jié)果。然后你就可以簡單地循環(huán)直到你不再收到一個(gè)函數(shù),這時(shí)你就得到了結(jié)果??紤]如下代碼:

"use strict";

function trampoline( res ) {
    while (typeof res == "function") {
        res = res();
    }
    return res;
}

var foo = (function(){
    function _foo(acc,x) {
        if (x <= 1) return acc;
        return function partial(){
            return _foo( (x / 2) + acc, x - 1 );
        };
    }

    return function(x) {
        return trampoline( _foo( 1, x ) );
    };
})();

foo( 123456 );          // 3810376848.5

這種返工需要一些最低限度的改變來將遞歸抽出到trampoline(..)中的循環(huán)中:

  1. 首先,我們將return _foo ..這一行包裝進(jìn)函數(shù)表達(dá)式return partial() {..。
  2. 然后我們將_foo(1,x)包裝進(jìn)trampoline(..)調(diào)用。

這種技術(shù)之所以不受調(diào)用棧限制的影響,是因?yàn)槊總€(gè)內(nèi)部的partial(..)函數(shù)都只是返回到trampoline(..)while循環(huán)中,這個(gè)循環(huán)運(yùn)行它然后再一次循環(huán)迭代。換言之,partial(..)并不遞歸地調(diào)用它自己,它只是返回另一個(gè)函數(shù)。棧的深度維持不變,所以它需要運(yùn)行多久就可以運(yùn)行多久。

蹦床表達(dá)的是,內(nèi)部的partial()函數(shù)使用在變量xacc上的閉包來保持迭代與迭代之間的狀態(tài)。它的優(yōu)勢是循環(huán)的邏輯可以被抽出到一個(gè)可重用的trampoline(..)工具函數(shù)中,許多庫都提供這個(gè)工具的各種版本。你可以使用不同的蹦床算法在你的程序中重用trampoline(..)多次。

當(dāng)然,如果你真的想要深度優(yōu)化(于是可復(fù)用性不予考慮),你可以摒棄閉包狀態(tài),并將對(duì)acc的狀態(tài)追蹤,與一個(gè)循環(huán)一起內(nèi)聯(lián)到一個(gè)函數(shù)的作用域內(nèi)。這種技術(shù)通常稱為 遞歸展開

"use strict";

function foo(x) {
    var acc = 1;
    while (x > 1) {
        acc = (x / 2) + acc;
        x = x - 1;
    }
    return acc;
}

foo( 123456 );          // 3810376848.5

算法的這種表達(dá)形式很容易閱讀,而且很可能是在我們探索過的各種形式中性能最好的(嚴(yán)格地說)一個(gè)。很明顯它看起來是一個(gè)勝利者,而且你可能會(huì)想知道為什么你曾嘗試其他的方式。

這些是為什么你可能不想總是手動(dòng)地展開遞歸的原因:

  • 與為了復(fù)用而將彈簧(循環(huán))邏輯抽出去相比,我們內(nèi)聯(lián)了它。這在僅有一個(gè)這樣的例子需要考慮時(shí)工作的很好,但只要你在程序中有五六個(gè)或更多這樣的東西時(shí),你將很可能想要一些可復(fù)用性來將讓事情更簡短、更易管理一些。

  • 這里的例子為了展示不同的形式而被故意地搞得很簡單。在現(xiàn)實(shí)中,遞歸算法有著更多的復(fù)雜性,比如相互遞歸(有多于一個(gè)的函數(shù)調(diào)用它自己)。

    你在這條路上走得越遠(yuǎn),展開 優(yōu)化就變得越復(fù)雜和越依靠手動(dòng)。你很快就會(huì)失去所有可讀性的認(rèn)知價(jià)值。遞歸,甚至是PTC形式的遞歸的主要優(yōu)點(diǎn)是,它保留了算法的可讀性,并將性能優(yōu)化的任務(wù)交給引擎。

如果你使用PTC編寫你的算法,ES6引擎將會(huì)實(shí)施TCO來使你的代碼運(yùn)行在一個(gè)定長深度的棧中(通過重用棧幀)。你將在得到遞歸的可讀性的同時(shí),也得到性能上的大部分好處與無限的運(yùn)行長度。

元?

TCO與元編程有什么關(guān)系?

正如我們?cè)谠缦鹊摹疤匦詼y試”一節(jié)中講過的,你可以在運(yùn)行時(shí)判定一個(gè)引擎支持什么特性。這也包括TCO,雖然判定的過程相當(dāng)粗暴??紤]如下代碼:

"use strict";

try {
    (function foo(x){
        if (x < 5E5) return foo( x + 1 );
    })( 1 );

    TCO_ENABLED = true;
}
catch (err) {
    TCO_ENABLED = false;
}

在一個(gè)非TCO引擎中,遞歸循環(huán)最終將會(huì)失敗,拋出一個(gè)被try..catch捕獲的異常。否則循環(huán)將由TCO輕易地完成。

討厭,對(duì)吧?

但是圍繞著TCO特性進(jìn)行的元編程(或者,沒有它)如何給我們的代碼帶來好處?簡單的答案是你可以使用這樣的特性測試來決定加載一個(gè)你的應(yīng)用程序的使用遞歸的版本,還是一個(gè)被轉(zhuǎn)換/轉(zhuǎn)譯為不需要遞歸的版本。

自我調(diào)整的代碼

但這里有另外一種看待這個(gè)問題的方式:

"use strict";

function foo(x) {
    function _foo() {
        if (x > 1) {
            acc = acc + (x / 2);
            x = x - 1;
            return _foo();
        }
    }

    var acc = 1;

    while (x > 1) {
        try {
            _foo();
        }
        catch (err) { }
    }

    return acc;
}

foo( 123456 );          // 3810376848.5

這個(gè)算法試圖盡可能多地使用遞歸來工作,但是通過作用域中的變量xacc來跟蹤這個(gè)進(jìn)程。如果整個(gè)問題可以通過遞歸沒有錯(cuò)誤地解決,很好。如果引擎在某一點(diǎn)終止了遞歸,我們簡單地使用try..catch捕捉它,然后從我們離開的地方再試一次。

我認(rèn)為這是一種形式的元編程,因?yàn)槟阍谶\(yùn)行時(shí)期間探測著引擎是否能(遞歸地)完成任務(wù)的能力,并繞過了任何可能制約你的(非TCO的)引擎的限制。

一眼(或者是兩眼?。┛瓷先ィ掖蛸€這段代碼要比以前的版本難看許多。它運(yùn)行起來還相當(dāng)?shù)芈恍ㄔ谝粋€(gè)非TCO環(huán)境中長時(shí)間運(yùn)行的情況下)。

它主要的優(yōu)勢是,除了在非TCO引擎中也能完成任意棧大小的任務(wù)外,這種對(duì)遞歸棧限制的“解法”要比前面展示的蹦床和手動(dòng)展開技術(shù)靈活得多。

實(shí)質(zhì)上,這種情況下的_foo()實(shí)際上是任意遞歸任務(wù),甚至是相互遞歸的某種替身。剩下的內(nèi)容是應(yīng)當(dāng)對(duì)任何算法都可以工作的模板代碼。

唯一的“技巧”是為了能夠在達(dá)到遞歸限制的事件發(fā)生時(shí)繼續(xù)運(yùn)行,遞歸的狀態(tài)必須保存在遞歸函數(shù)外部的作用域變量中。我們是通過將xacc留在_foo()函數(shù)外面這樣做的,而不是像早先那樣將它們作為參數(shù)值傳遞給_foo()。

幾乎所有的遞歸算法都可以采用這種方法工作。這意味著它是在你的程序中,進(jìn)行最小的重寫就能利用TCO遞歸的最廣泛的可行方法。

這種方式仍然使用一個(gè)PTC,意味著這段代碼將會(huì) 漸進(jìn)增強(qiáng):從在一個(gè)老版瀏覽器中使用許多次循環(huán)(遞歸批處理)來運(yùn)行,到在一個(gè)ES6+環(huán)境中完全利用TCO遞歸。我覺得這相當(dāng)酷!

復(fù)習(xí)

元編程是當(dāng)你將程序的邏輯轉(zhuǎn)向關(guān)注它自身(或者它的運(yùn)行時(shí)環(huán)境)時(shí)進(jìn)行的編程,要么為了調(diào)查它自己的結(jié)構(gòu),要么為了修改它。元編程的主要價(jià)值是擴(kuò)展語言的普通機(jī)制來提供額外的能力。

在ES6以前,JavaScript已經(jīng)有了相當(dāng)?shù)脑幊棠芰?,但是ES6使用了幾個(gè)新特性及大地提高了它的地位。

從對(duì)匿名函數(shù)的函數(shù)名推斷,到告訴你一個(gè)構(gòu)造器是如何被調(diào)用的元屬性,你可以前所未有地在程序運(yùn)行期間來調(diào)查它的結(jié)構(gòu)。通用Symbols允許你覆蓋固有的行為,比如將一個(gè)對(duì)象轉(zhuǎn)換為一個(gè)基本類型值的強(qiáng)制轉(zhuǎn)換。代理可以攔截并自定義各種在對(duì)象上的底層操作,而且Reflect提供了模擬它們的工具。

特性測試,即便是對(duì)尾部調(diào)用優(yōu)化這樣微妙的語法行為,將元編程的焦點(diǎn)從你的程序提升到JS引擎的能力本身。通過更多地了解環(huán)境可以做什么,你的程序可以在運(yùn)行時(shí)將它們自己調(diào)整到最佳狀態(tài)。

你應(yīng)該進(jìn)行元編程嗎?我的建議是:先集中學(xué)習(xí)這門語言的核心機(jī)制是如何工作的。一旦你完全懂得了JS本身可以做什么,就是開始利用這些強(qiáng)大的元編程能力將這門語言向前推進(jìn)的時(shí)候了!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 6,689評(píng)論 3 22
  • 特別說明,為便于查閱,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS...
    殺破狼real閱讀 177評(píng)論 0 0
  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 3,720評(píng)論 2 27
  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 3,077評(píng)論 4 14
  • 現(xiàn)在是星期五晚上,不知道從什么時(shí)候開始,我對(duì)星期五晚上有了一種特別的情愫。盡管我不用朝九晚五的工作,但是每天我在思...
    水伊兒閱讀 598評(píng)論 0 0

友情鏈接更多精彩內(nèi)容