如何設計一門語言(七)——閉包、lambda和interface

人們都很喜歡討論閉包這個概念。其實這個概念對於寫代碼來說一點用都沒有,寫代碼只須要掌握好lambda表達式和class+interface的語義就好了。基本上只有在寫編譯器和虛擬機的時候才須要管什麼是閉包。不過由於系列文章主題的緣故,在這裏我就跟你們講一下閉包是什麼東西。在理解閉包以前,咱們得先理解一些常見的argument passing和symbol resolving的規則。javascript

首先第一個就是call by value了。這個規則咱們你們都很熟悉,由於流行的語言都是這麼作的。你們還記得剛開始學編程的時候,書上老是有一道題目,說的是:java

void Swap(int a, int b)
{
    int t = a;
    a = b;
    b = t;
}

int main()
{
    int a=0;
    int b=1;
    Swap(a, b);
    printf("%d, %d", a, b);
}

而後問程序會輸出什麼。固然咱們如今都知道,a和b仍然是0和1,沒有受到變化。這就是call by value。若是咱們修改一下規則,讓參數老是經過引用傳遞進來,所以Swap會致使main函數最後會輸出1和0的話,那這個就是call by reference了。python

除此以外,一個不太常見的例子就是call by need了。call by need這個東西在某些著名的實用的函數式語言(譬如Haskell)是一個重要的規則,說的就是若是一個參數沒被用上,那傳進去的時候就不會執行。聽起來好像有點玄,我仍然用C語言來舉個例子。程序員

int Add(int a, int b)
{
    return a + b;
}

int Choose(bool first, int a, int b)
{
    return first ? a : b;
}

int main()
{
    int r = Choose(false, Add(1, 2), Add(3, 4));
    printf("%d", r);
}

這個程序Add會被調用多少次呢?你們都知道是兩次。可是在Haskell裏面這麼寫的話,就只會被調用一次。爲何呢?由於Choose的第一個參數是false,因此函數的返回值只依賴與b,而不依賴與a。因此在main函數裏面它感受到了這一點,因而只算Add(3, 4),不算Add(1, 2)。不過你們別覺得這是由於編譯器優化的時候內聯了這個函數才這麼幹的,Haskell的這個機制是在運行時起做用的。因此若是咱們寫了個快速排序的算法,而後把一個數組排序後只輸出第一個數字,那麼整個程序是O(n)時間複雜度的。由於快速排序的average case在把第一個元素肯定下來的時候,只花了O(n)的時間。再加上整個程序只輸出第一個數字,因此後面的他就不算了,因而整個程序也是O(n)。算法

因而你們知道call by name、call by reference和call by need了。如今來給你們講一個call by name的神奇的規則。這個規則神奇到,我以爲根本沒辦法駕馭它來寫出一個正確的程序。我來舉個例子:編程

int Set(int a, int b, int c, int d)
{
    a += b;
    a += c;
    a += d;
}

int main()
{
    int i = 0;
    int x[3] = {1, 2, 3};
    Set(x[i++], 10, 100, 1000);
    printf("%d, %d, %d, %d", x[0], x[1], x[2], i);
}

學過C語言的都知道這個程序其實什麼都沒作。若是把C語言的call by value改爲了call by reference的話,那麼x和i的值分別是{1111, 2, 3}和1。可是咱們知道,人類的想象力是很豐富的,因而發明了一種叫作call by name的規則。call by name也是call by reference的,可是區別在於你每一次使用一個參數的時候,程序都會把計算這個參數的表達式執行一遍。所以,若是把C語言的call by value換成call by name,那麼上面的程序作的事情實際上就是:數組

x[i++] += 10;
x[i++] += 100;
x[i++] += 1000;

程序執行完以後x和i的值就是{11, 102, 1003}和3了。閉包

很神奇對吧,稍微不注意就會中招,是個大坑,基本無法用對吧。那大家還成天用C語言的宏來代替函數幹什麼呢。我依稀記得Ada有網友指出這是Algol 60)仍是什麼語言就是用這個規則的,印象比較模糊。異步

講完了argument passing的事情,在理解lambda表達式以前,咱們還須要知道兩個流行的symbol resolving的規則。所謂的symbol resolving講的就是解決程序在看到一個名字的時候,如何知道這個名字到底指向的是誰的問題。因而我又能夠舉一個簡單粗暴的例子了:函數

Action<int> SetX()
{
    int x = 0;
    return (int n)=>
    {
        x = n;
    };
}

void Main()
{
    int x = 10;
    var setX = SetX();
    setX(20);
    Console.WriteLine(x);
}

弱智都知道這個程序其實什麼都沒作,就輸出10。這是由於C#用的symbol resolving地方法是lexical scoping。對於SetX裏面那個lambda表達式來說,那個x是SetX的x而不是Main的x,由於lexical scoping的含義就是,在定義的地方向上查找名字。那爲何不能在運行的時候向上查找名字從而讓SetX裏面的lambda表達式實際上訪問的是Main函數裏面的x呢?實際上是有人這麼幹的。這種作法叫dynamic scoping。咱們知道,著名的javascript語言的eval函數,字符串參數裏面的全部名字就是在運行的時候查找的。

=======================我是背景知識的分割線=======================

想必你們都以爲,若是一個語言的lambda表達式在定義和執行的時候採用的是lexical scoping和call by value那該有多好呀。流行的語言都是這麼作的。就算規定到這麼細,那仍是有一個分歧。到底一個lambda表達式抓下來的外面的符號是隻讀的仍是可讀寫的呢?python告訴咱們,這是隻讀的。C#和javascript告訴咱們,這是可讀寫的。C++告訴咱們,大家本身來決定每個符號的規則。做爲一個對語言瞭解得很深入,知道本身每一行代碼到底在作什麼,並且還頗有自制力的程序員來講,我仍是比較喜歡C#那種作法。由於其實C++就算你把一個值抓了下來,大部分狀況下仍是不能優化的,那何苦每一個變量都要我本身說明我究竟是想只讀呢,仍是要讀寫均可以呢?函數體我怎麼用這個變量不是已經很清楚的表達出來了嘛。

那說到底閉包是什麼呢?閉包其實就是那個被lambda表達式抓下來的「上下文」加上函數自己了。像上面的SetX函數裏面的lambda表達式的閉包,就是x變量。一個語言有了帶閉包的lambda表達式,意味着什麼呢?我下面給你們展現一小段代碼。如今要從動態類型的的lambda表達式開始講,就湊合着用那個無聊的javascript吧:

function pair(a, b) {
    return function(c) {
        return c(a, b);
    };
}

function first(a, b) {
    return a;
}

function second(a, b) {
    return b;
}

var p = pair(1, pair(2, 3));
var a = p(first);
var b = p(second)(first);
var c = p(second)(second);
print(a, b, c);

這個程序的a、b和c究竟是什麼值呢?固然就算看不懂這個程序的人也能夠很快猜出來他們是一、2和3了,由於變量名實在是定義的太清楚了。那麼程序的運行過程究竟是怎麼樣的呢?你們能夠看到這個程序的任何一個值在建立以後都沒有被第二次賦值過,因而這種程序就是沒有反作用的,那就表明其實在這裏call by value和call by need是沒有區別的。call by need意味着函數的參數的求值順序也是無所謂的。在這種狀況下,程序就變得跟數學公式同樣,能夠推導了。那咱們如今就來推導一下:

var p = pair(1, pair(2, 3));
var a = p(first);

// ↓↓↓↓↓

var p = function(c) {
    return c(1, pair(2, 3));
};
var a = p(first);

// ↓↓↓↓↓

var a = first(1, pair(2, 3));

// ↓↓↓↓↓

var a = 1;

這也算是個老掉牙的例子了啊。閉包在這裏體現了他強大的做用,把參數保留了起來,咱們能夠在這以後進行訪問。彷彿咱們寫的就是下面這樣的代碼:

var p = {
    first : 1,
    second : {
        first : 1,
        second : 2,
    }
};

var a = p.first;
var b = p.second.first;
var c = p.second.second;

因而咱們獲得了一個結論,(帶閉包的)lambda表達式能夠代替一個成員爲只讀的struct了。那麼,成員能夠讀寫的struct要怎麼作呢?作法固然跟上面的不同。究其緣由,就是由於javascript使用了call by value的規則,使得pair裏面的return c(a, b);沒辦法將a和b的引用傳遞給c,這樣就沒有人能夠修改a和b的值了。雖然a和b在那些c裏面是改不了的,可是pair函數內部是能夠修改的。若是咱們要堅持只是用lambda表達式的話,就得要求c把修改後的全部「這個struct的成員變量」都拿出來。因而就有了下面的代碼:

// 在這裏咱們繼續使用上面的pair、first和second函數

function mutable_pair(a, b) {
    return function(c) {
        var x = c(a, b);
        // 這裏咱們把pair當鏈表用,一個(1, 2, 3)的鏈表會被儲存爲pair(1, pair(2, pair(3, null)))
        a = x(second)(first);
        b = x(second)(second)(first);
        return x(first);
    };
}

function get_first(a, b) {
    return pair(a, pair(a, pair(b, null)));
}

function get_second(a, b) {
    return pair(b, pair(a, pair(b, null)));
}

function set_first(value) {
    return function(a, b) {
        return pair(undefined, pair(value, pair(b, null)));
    };
}

function set_second(value) {
    return function(a, b) {
        return pair(undefined, pair(a, pair(value, null)));
    };
}

var p = mutable_pair(1, 2);
var a = p(get_first);
var b = p(get_second);
print(a, b);
p(set_first(3));
p(set_second(4));
var c = p(get_first);
var d = p(get_second);
print(c, d);

咱們能夠看到,由於get_first和get_second作了一個只讀的事情,因此返回的鏈表的第二個值(表明新的a)和第三個值(表明新的b)都是舊的a和b。可是set_first和set_second就不同了。所以在執行到第二個print的時候,咱們能夠看到p的兩個值已經被更改爲了3和4。

雖然這裏已經涉及到了「綁定過的變量從新賦值」的事情,不過咱們仍是能夠嘗試推導一下,究竟p(set_first(3));的時候究竟幹了什麼事情:

var p = mutable_pair(1, 2);
p(set_first(3));

// ↓↓↓↓↓

p = return function(c) {
    var x = c(1, 2);
    a = x(second)(first);
    b = x(second)(second)(first);
    return x(first);
};
p(set_first(3));

// ↓↓↓↓↓

var x = set_first(3)(1, 2);
p.a = x(second)(first); // 這裏的a和b是p的閉包內包含的上下文的變量了,因此這麼寫會清楚一點
p.b = x(second)(second)(first);
// return x(first);出來的值沒人要,因此省略掉。
// ↓↓↓↓↓

var x = (function(a, b) {
    return pair(undefined, pair(3, pair(b, null)));
})(1, 2);
p.a = x(second)(first);
p.b = x(second)(second)(first);// ↓↓↓↓↓

x = pair(undefined, pair(3, pair(2, null)));
p.a = x(second)(first);
p.b = x(second)(second)(first);// ↓↓↓↓↓

p.a = 3;
p.b = 2;

因爲涉及到了上下文的修改,這個推導嚴格上來講已經不能叫推導了,只能叫解說了。不過咱們能夠發現,僅僅使用能夠捕捉可讀寫的上下文的lambda表達式,已經能夠實現可讀寫的struct的效果了。並且這個struct的讀寫是經過getter和setter來實現的,因而只要咱們寫的複雜一點,咱們就獲得了一個interface。因而那個mutable_pair,就能夠當作是一個構造函數了。

大括號不能換行的代碼真他媽的難讀啊,遠遠望去就像一坨屎!go語言還把javascript自動補全分號的算法給抄去了,真是沒品位。

因此,interface其實跟lambda表達是同樣,也能夠當作是一個閉包。只是interface的入口比較多,lambda表達式的入口只有一個(相似於C++的operator())。你們可能會問,class是什麼呢?class固然是interface內部不可告人的實現細節的。咱們知道,依賴實現細節來編程是不對的,因此咱們要依賴接口編程

固然,即便是倉促設計出javascript的那我的,大概也是知道構造函數也是一個函數的,並且類的成員跟函數的上下文鏈表的節點對象其實沒什麼區別。因而咱們會看到,javascript裏面是這麼作面向對象的事情的:

function rectangle(a, b) {
    this.width = a;
    this.height = height;
}

rectangle.prototype.get_area = function() {
    return this.width * this.height;
};

var r = new rectangle(3, 4);
print(r.get_area());

而後咱們就拿到了一個3×4的長方形的面積12了。不過javascript給咱們帶來的一點點小困惑是,函數的this參數實際上是dynamic scoping的,也就是說,這個this究竟是什麼,要看你在哪如何調用這個函數。因而其實

obj.method(args)

整個東西是一個語法,它表明method的this參數是obj,剩下的參數是args。惋惜的是,這個語法並非由「obj.member」和「func(args)」組成的。那麼在上面的例子中,若是咱們把代碼改成:

var x = r.get_area;
print(x());

結果是什麼呢?反正不是12。若是你在C#裏面作這個事情,效果就跟javascript不同了。若是咱們有下面的代碼:

class Rectangle
{
    public int width;
    public int height;

    public int GetArea()
    {
        return width * height;
    }
};

那麼下面兩段代碼的意思是同樣的:

var r = new Rectangle
{
    width = 3;
    height = 4;
};

// 第一段代碼
Console.WriteLine(r.GetArea());

// 第二段代碼
Func<int> x = r.GetArea;
Console.WriteLine(x());

究其緣由,是由於javascript把obj.method(a, b)解釋成了GetMember(obj, 「method」).Invoke(a, b, this = r);了。因此你作r.get_area的時候,你拿到的實際上是定義在rectangle.prototype裏面的那個東西。可是C#作的事情不同,C#的第二段代碼其實至關於:

Func<int> x = ()=>
{
    return r.GetArea();
};
Console.WriteLine(x());

因此說C#這個作法比較符合直覺啊,爲何dynamic scoping(譬如javascript的this參數)和call by name(譬如C語言的宏)看起來都那麼屌絲,老是讓人掉坑裏,就是由於違反了直覺。不過javascript那麼作仍是情有可原的。估計第一次設計這個東西的時候,收到了靜態類型語言太多的影響,因而把obj.method(args)整個當成了一個總體來看。由於在C++裏面,this的確就是一個參數,只是她不能讓你obj.method,得寫&TObj::method,而後還有一個專門填this參數的語法——沒錯,就是.*和->*操做符了。

假如說,javascript的this參數要作成lexical scoping,而不是dynamic scoping,那麼能不能用lambda表達式來模擬interface呢?這固然是能夠,只是若是不用prototype的話,那咱們就會喪失javascript愛好者們想方設法絞盡腦汁用盡奇技淫巧鎖模擬出來的「繼承」效果了:

function mutable_pair(a, b) {
    _this = {
        get_first = function() { return a; },
        get_second = function() { return b; },
        set_first = function(value) { a = value; },
        set_second = function(value) { b = value; }
    };
return _this; } var p = new mutable_pair(1, 2); var a = p.get_first(); var b = p.get_second(); print(a, b); var c = p.set_first(3); var d = p.set_second(4); print(c, d);

這個時候,即便你寫

var x = p.set_first;
var y = p.set_second;
x(3);
y(4);

代碼也會跟咱們所指望的同樣正常工做了。並且創造出來的r,全部的成員變量都屏蔽掉了,只留下了幾個函數給你。與此同時,函數裏面訪問_this也會獲得建立出來的那個interface了。

你們到這裏大概已經明白閉包、lambda表達式和interface之間的關係了吧。我看了一下以前寫過的六篇文章,加上今天這篇,內容已經覆蓋了有:

  1. 閱讀C語言的複雜的聲明語法
  2. 什麼是語法噪音
  3. 什麼是語法的一致性
  4. C++的const的意思
  5. C#的struct和property的問題
  6. C++的多重繼承
  7. 封裝到底意味着什麼
  8. 爲何exception要比error code寫起來乾淨、容易維護並且不須要太多的溝通
  9. 爲何C#的有些interface應該表達爲concept
  10. 模板和模板元編程
  11. 協變和逆變
  12. type rich programming
  13. OO的消息發送的含義
  14. 虛函數表是如何實現的
  15. 什麼是OO裏面的類型擴展開放/封閉與邏輯擴展開放/封閉
  16. visitor模式如何逆轉類型和邏輯的擴展和封閉
  17. CPS(continuation passing style)變換與異步調用的異常處理的關係
  18. CPS如何讓exception變成error code
  19. argument passing和symbol resolving
  20. 如何用lambda實現mutable struct和immutable struct
  21. 如何用lambda實現interface

想了想,大概通俗易懂的能夠自學成才的那些東西大概都講完了。固然,系列是不會在這裏就結束的,只是後面的東西,大概就須要你們多一點思考了。

寫程序講究行雲流水。只有本身勤于思考,勤於作實驗,勤於造輪子,才能讓編程的學習事半功倍。

相關文章
相關標籤/搜索