如何設計一門語言(十二)——設計可擴充套件的型別

陳梓瀚(vczh)發表於2013-11-10

在思考怎麼寫這一篇文章的時候,我又想到了以前討論正交概念的事情。如果一個系統被設計成正交的,他的功能擴充套件起來也可以很容易的保持質量這是沒錯的,但是對於每一個單獨給他擴充套件功能的個體來說,這個系統一點都不好用。所以我覺得現在的語言被設計成這樣也是有那麼點道理的。就算是設計Java的那誰,他也不是傻逼,那為什麼Java會被設計成這樣?我覺得這跟他剛開始想讓金字塔的底層程式設計師也可以順利使用Java是有關係的。

 

難道好用的語言就活該不好擴充套件碼?實際上不是這樣的,但是這仍然是上面那個正交概念的問題。一個容易擴充套件的語言要讓你覺得好用,首先你要投入時間來學習他。如果你想簡單的借鑑那些不好擴充套件的語言的經驗(如Java)來在短時間內學會如何使用一個容易擴充套件的語言(如C++/C#)——你的出發點就已經投機了。所以這裡有一個前提值得我再強調一次——首先你需要投入時間去學習他。

 

正如我一直在群裡說的:"C++需要不斷的練習——vczh"。要如何練習才能讓自己藉助語言做出一個可擴充套件的架構呢?先決條件就是,當你在練習的時候,你必須是在練習如何實現一個從功能上就要求你必須保證他的可擴充套件性的系統,舉個例子,GUI庫就是其中的一類。我至今認為,學會實現一個GUI庫,比通過練習別的什麼東西來提高自己的能力來講,簡直就算一個捷徑了。

 

那麼什麼是擴充套件呢?簡單的來講,擴充套件就是在不修改原有程式碼的情況下,僅僅通過新增新的程式碼,就可以讓原有的功能適應更多的情況。一般來講,擴充套件的主要目的並不是要增加新的功能,而是要只增加新程式碼的前提下修改原有的功能。譬如說原來你的系統只支援SQLServer,結果有一天你遇到了一個喜歡Oracle的新客戶,你要把東西賣給他,那就得支援Oracle了吧。但是我們知道,SQLServer和Oracle在各種協議(asp.net、odbc什麼的)上面是有偏好的,用DB不喜歡的協議來連線他的時候bug特別多,這就造成了你又可能沒辦法使用單一的協議來正確的使用各種資料庫,因此擴充套件的這個擔子就落在你的身上了。當然這種系統並不是人人都要寫,我也可以換一個例子,假如你在設計一個GPU叢集上的程式,那麼這個叢集的基礎架構得支援NVidia和AMD的顯示卡,還得支援DirectCompute、Cuda和OpenCL。然而我們知道,OpenCL在不同的平臺上,有互不相容的不同的bug,導致你實際上並不可能僅僅通過一份不變的程式碼,就充分發揮OpenCL在每一個平臺上的最佳狀態……現實世界的需求真是orz(OpenCL在windows上用AMD卡定義一個struct都很容易導致崩潰什麼的,我覺得這根本不能用)……

 

在語言裡面談擴充套件,始終都離不開兩個方面:編譯期和執行期。這些東西都是用看起來很像pattern matching的方法組織起來的。如果在語言的型別系統的幫助下,我們可以輕鬆做出這樣子的架構,那這個語言就算有可擴充套件的型別了。

 

  1. 編譯期對型別的擴充套件

 

這個其實已經被人在C++和各種靜態型別的函式式語言裡面做爛了。簡單的來講,C++處理這種問題的方法就是提供偏特化。可惜C++的偏特化只讓做在class上面,結果因為大家對class的誤解很深,順便連偏特化這種比OO簡單一萬倍的東西也誤解了。偏特化不允許用在函式上,因為函式已經有了過載,但是C++的各種標準在使用函式來擴充套件型別的時候,實際上還是當他是偏特化那麼用的。我舉個例子。

 

C++11多了一個foreach迴圈,寫成for(auto x : xs) { … }。STL的型別都支援這種新的for迴圈。C++11的for迴圈是為了STL的容器設計的嗎?顯然不是。你也可以給你自己寫的容器加上for迴圈。方法有兩種,分別是:1、給你的型別T加上T::begin和T::end兩個成員函式;2、給你的型別T實現begin(T)和end(T)兩個全域性函式。我還沒有去詳細考證,但是我認為預設的begin(T)和end(T)全域性函式就是去呼叫T::begin和T::end的,因此for迴圈只需要認begin和end兩個全域性函式就可以了。

 

那自己的型別怎麼辦呢?當然也要去過載begin和end了。現在全域性函式沒有過載,因此寫出來大概是:
template<typename T> auto begin(const T& t)->decltype(t.begin()) { return t.begin(); }

template<typename T> my_iterator<T> begin(const my_container<T>& t);

template<typename T> my_range_iterator<T> begin(pair<T, T> range);

 

如果C++的函式支援偏特化的話,那麼上面這段程式碼就會被改成這樣,而且for迴圈也就不去找各種各樣的begin函式了,而只認定那一個std::begin就可以了:
template<typename T> auto begin(const T& t)->decltype(t.begin()) { return t.begin(); }

template<typename T> my_iterator<T> begin< my_container<T>>(const my_container<T>& t);

template<typename T> my_range_iterator<T> begin< pair<T, T>>( const pair<T, T>& range);

 

為什麼要偏特化呢?因為這至少保證你寫出來的begin函式跟for函式想要的begin函式的begin函式的簽名是相容的(譬如說不能有兩個引數之類的)。事實上C++11的for迴圈剛開始是要求大家通過偏特化一個叫做std::range的型別來支援的,這個range型別裡面有兩個static函式,分別叫begin和end。後來之所以改成這樣,我猜大概是因為C++的每一個函式過載也可以是模板函式,因此就不需要引入一個新的型別了,就讓大家去過載好了。而且for做出來的時候,C++標準裡面還沒有concept,因此也沒辦法表達"對於所有可以迴圈的型別T,我們都有std::range<T>必須滿足這個叫做range_loopable<T>的concept"這樣的前置條件。

 

過載用起來很容易讓人走火入門,很多人到最後都會把一些僅僅看起來像而實際上語義完全不同的東西用過載來表達,函式的引數連相似性都沒有。其實這是不對的,這種時候就應該把函式改成兩個不同的名字。假如當初設計C++的是我,那我一定會把函式過載幹掉,然後允許人們對函式進行偏特化,並且加上concept。既然std::begin已經被定義為迴圈的輔助函式了,那麼你過載一個std::begin,他卻不能用來迴圈(譬如說有兩個引數什麼的),那有意義嗎?完全沒有。

 

這種例子還有很多,譬如如何讓自己的型別可以被<<到wcout的做法啦,boost的那個serialization框架,還有各種各樣的庫,其實都利用了相同的思想——對型別做編譯期的擴充套件,使用一些手段使得在不需要修改原來的程式碼的前提下,就可以讓編譯器找到你新加進去的函式,從而使得呼叫的寫法不用發生變化就可以對原有的功能支援更多的情況。至少我們讓我們自己的型別支援for迴圈就不需要翻開std::begin的程式碼把我們的型別寫進去,只需要在隨便什麼空白的地方過載一個std::begin就可以了。這就是一個很好地體現。C++的標準庫一直在引導大家正確設計一個可擴充套件的架構,可惜很多人都意識不到這一點,為了自己那一點連正確性都談不上的強迫症,放棄了很多東西。

 

很多靜態型別的函式式語言使用concept來完成上述的工作。當一個concept定義好了之後,我們就可以通過對concept的實現進行偏特化來讓我們的型別T滿足concept的要求,來讓那些呼叫這個concept的泛型程式碼,可以在處理的物件是T的時候,轉而呼叫我們提供的實現。Haskell就是一個典型的例子,一個sort函式必然要求元素是可比較的,一個可以比較的型別定義為實現了Ord這個type class的型別。所以你只要給你自己的型別T實現Ord這個type class,那sort函式就可以對T的列表進行排序了。

 

對於C++和C#這種沒有concept或者concept不是主要概念的語言裡面,對型別做靜態的擴充套件只需要你的型別滿足"我可以這麼這麼幹"就可以了。譬如說你過載一個begin和end,那你的型別就可以被foreach;你給你的型別實現了operator<等函式,那麼一個包含你的型別的容器就可以被sort;或者C#的只要你的型別T<U>有一大堆長得跟System.Linq.Enumerable裡面定義的擴充套件函式一樣的擴充套件函式,那麼Linq的神奇的語法就可以用在你的型別上等等。這跟動態型別的"只要它長的像鴨子,那麼它就是鴨子"的做法有異曲同工之效。如果你的begin函式的簽名沒寫對,編譯器也不會屌你,直到你對他for的時候編譯器才會告訴你說你做錯了。這跟很多動態型別的語言的很多錯誤必須在執行的時候才發現的性質也是類似的。

 

Concept對於可靜態擴充套件的型別的約束,就如同型別對於邏輯的約束一樣。沒有concept的C++模板,就跟用動態型別語言寫邏輯一樣,只有到用到的那一刻你才知道你到底寫對了沒有,而且錯誤也會爆發在你使用它的地方,而不是你定義它的地方。因此本著編譯器幫你找到儘可能多的錯誤的原則,C++也開始有concept了。

 

C#的擴充套件方法用在Linq上面,其實編譯器也要求你滿足一個內在的concept,只是這個概念無法用C#的語法表達出來。所以我們在寫Linq Provider的時候也會有同樣的感覺。Java的interface都可以寫預設實現了,但是卻沒有靜態方法。這就造成了我們實際上無法跟C++和C#一樣,在不修改原有程式碼的前提下,讓原有的功能滿足更多的情況。因為C#的新增擴充套件方法的情況,到了Java裡面就變成讓一個類多繼承自一個interface,必須修改程式碼了。Java的這個功能特別的雞肋,不知道是不是他故意想跟C#不一樣才設計成這個樣子的,可惜精華沒有抄去,卻抄了糟粕。

 

  1. 執行期對型別的擴充套件

 

自從Java吧靜態型別和麵向物件捆綁在一起之後,業界對"執行期對型別的擴充套件"這個主題思考了很多年,甚至還出了一本著作叫《設計模式》,讓很多人捧為經典。大家爭先恐後的學習,而效果卻不怎麼樣。這是因為《設計模式》不好嗎?不是。這是因為靜態型別和麵向物件捆綁在一起之後,設計一個可擴充套件的架構就很難嗎?也不是。真正的原因是,Java設計(好像也是抄的Simular?我記不太清楚了)的虛擬函式把這個問題的難題提升了一個等級。

 

用正確的概念來理解問題可以讓我們更容易的掌握問題的本質。語言是有魔力的,習慣說中文的人,思考方式都跟中國人差不多。習慣說英語的人,思考方式都跟美國人差不多。因此習慣了使用C++/C#/Java的人,他們對於物件導向的想法其實也是差不多的。這是人類的天性。儘管大家鼓吹說語言只是工具,我們應該掌握方法論什麼的,但是這就跟要求男人面對一個萌妹紙不勃起一樣,違背了人類的本性,難度簡直太高了。於是我今天從虛擬函式和Visitor模式講起,告訴大家為什麼虛擬函式的這種形式會讓"擴充套件的時候不修改原有的程式碼"變難。

 

絕大多數的系統的擴充套件,都可以最後化簡(這並不要求你非得這麼做)為"當它的型別是這個的時候你就幹那個"的這麼件事。對於在編譯的時候就已經知道的,我們可以用偏特化的方法讓編譯器在生成程式碼的時候就先搞好。對於執行的時候,你拿到一個基類(其實為什麼一定要有基類?應該有的是interface!參見上一篇文章——刪減語言的功能),那如何O(1)時間複雜度(這裡的n指的是所有跟這次跳轉有關係的型別的數量)就跳轉到你想要的那個分支上去呢?於是我們有了虛擬函式。

 

靜態的擴充套件用的是靜態的分派,於是編譯器幫我們把函式名都hardcode到生成的程式碼裡面。動態的型別用的是動態的分派,於是我們得到的當然是一個相當於函式指標的東西。於是我們會把這個函式指標儲存在從基類物件可以O(1)訪問到的地方。虛擬函式就是這麼實現的,而且這種型別的分派必須要這麼實現的。但是,寫成程式碼就一定要寫程式函式嗎

 

其實本來沒什麼理由讓一個語言(或者library)長的樣子必須有提示你他是怎麼實現的功能。關心太多容易得病,執著太多心生痛苦啊。所以好好的解決問題就好了。至於原理是什麼,下了班再去關心。估計還有一些人不明白為什麼不好,我就舉一個通俗的例子。我們都知道dynamic_cast的效能不怎麼樣,虛擬函式用來做if的效能要遠遠比dynamic_cast用來做if的效能好得多。因此下面所有的答案都基於這個前提——要快,不要dynamic_cast!

 

  1. 處理HTML

 

好了,現在我們的任務是,拿到一個HTML,然後要對他做一些功能,譬如說把它格式化成文字啦,看一下他是否包含超連結啦等等。假設我們已經解決HTML的語法分析問題,那麼我們會得到一顆靜態型別的語法樹。這棵語法樹如無意外一定是長下面這個樣子的。另外一種選擇是存成動態型別的,但是這跟物件導向無關,所以就不提了。

 

class DomBase

{

public:

    virtual ~DomBase();

 

    static shared_ptr<DomBase> Parse(const wstring& htmlText);

};

 

class DomText : public DomBase{};

class DomImg : public DomBase{};

class DomA : public DomBase{};

class DomDiv : public DomBase{};

......

 

HTML的tag種類繁多,大概有那麼上百個吧。那現在我們要給他加上一個格式化成字串的功能,這顯然是一個遞迴的演算法,先把sub tree一個一個格式化,最後組合起來就好了。可能對於不同的非文字標籤會有不同的格式化方法。程式碼寫出來就是這樣——基本上是唯一的作法:

 

class DomBase

{

public:

    virtual ~DomBase();

    static shared_ptr<DomBase> Parse(const wstring& htmlText);

 

    virtual void FormatToText(ostream& o); // 預設實現,把所有subtree的結果合併

};

 

class DomText : public DomBase

{

public:

    void FormatToText(ostream& o); // 直接輸出文字

};

class DomImg : public DomBase

{

public:

    void FormatToText(ostream& o); // 輸出imgtag內容

};

// 其它實現略

class DomA : public DomBase{};

class DomDiv : public DomBase{};

 

這已經構成一個基本的HTML的Dom Tree了。現在我提一個要求如下,要求在不修改原有程式碼只新增新程式碼的情況下,避免dynamic_cast,實現一個考察一顆Dom Tree是否包含超連結的功能。能做嗎?

 

無論大家如何苦思冥想,答案都是做不到。儘管這麼一看可能覺得這不是什麼大事,但實際上這意味著:你無法通過新增模組的方式來給一個已知的Dom Tree新增"判斷它是否包含超連結"的這個功能。有的人可能會說,那把它建模成動態型別的樹不就可以了?這是沒錯,但這實際上有兩個問題。第一個是著顯著的增加了你的測試成本,不過對於充滿了廉價勞動力的web行業來說這好像也不是什麼大問題。第二個更加本質——HTML可以這麼做,並不代表所有的東西都可以裝怎麼做事吧。

 

那在靜態型別的前提下,要如何解決這個問題呢?很久以前我們的《設計模式》就給我們提供了visitor模式,用來解決這樣的問題。如果把這個Dom Tree修改成visitor模式的程式碼的話,那原來FormatToText就會變成這個樣子:

 

class DomText;

class DomImg;

class DomA;

class DomDiv;

 

class DomBase

{

public:

    virtual ~DomBase();

    static shared_ptr<DomBase> Parse(const wstring& htmlText);

 

    class IVisitor

    {

    public:

        virtual ~IVisitor();

 

        virtual void Visit(DomText* dom) = 0;

        virtual void Visit(DomImg* dom) = 0;

        virtual void Visit(DomA* dom) = 0;

        virtual void Visit(DomDiv* dom) = 0;

    };

 

    virtual void Accept(IVisitor* visitor) = 0;

};

 

class DomText : public DomBase

{

public:

    void Accept(IVisitor* visitor)override

    {

        visitor->Visit(this);

    }

};

class DomImg : public DomBase

{

public:

    void Accept(IVisitor* visitor)override

    {

        visitor->Visit(this);

    }

};

class DomA : public DomBase

{

public:

    void Accept(IVisitor* visitor)override

    {

        visitor->Visit(this);

    }

};

class DomDiv : public DomBase

{

public:

    void Accept(IVisitor* visitor)override

    {

        visitor->Visit(this);

    }

};

 

class FormatToTextVisitor : public DomBase::IVisitor

{

private:

    ostream& o;

public:

    FormatToTextVisitor(ostream& _o)

        :o(_o)

    {

 

    }

 

    void Visit(DomText* dom){} // 直接輸出文字

    void Visit(DomImg* dom){} // 輸出imgtag內容

    void Visit(DomA* dom){} // 預設實現,把所有subtree的結果合併

    void Visit(DomDiv* dom){} // 預設實現,把所有subtree的結果合併

 

    static void Evaluate(DomBase* dom, ostream& o)

    {

        FormatToTextVisitor visitor(o);

        dom->Accept(&visitor);

    }

};

 

看起來長了不少,但是我們驚奇地發現,這下子我們可以通過提供一個Visitor,來在不修改原有程式碼的前提下,避免dynamic_cast,實現判斷一顆Dom Tree是否包含超連結的功能了!不過別高興得太早。這兩種做法都是有缺陷的。

 

虛擬函式的好處是你可以在不修改原有程式碼的前提下新增新的Dom型別,但是所有針對Dom Tree的操作緊密的耦合在了一起,並且邏輯還分散在了每一個具體的Dom型別裡面。你新增一個新功能就要修改所有的DomBase的子類,因為你要給他們都新增你需要的虛擬函式。

 

Visitor的好處是你可以在不修改原有程式碼的前提下新增新的Dom操作,但是所有的Dom型別卻緊密的耦合在了一起,因為IVisitor型別要包含所有DomBase的子類。你每天加一個新的Dom型別就得修改所有的操作——即IVisitor的介面和所有的具體的Visitor。而且還有另一個問題,就是虛擬函式的預設實現寫起來比較鳥了

 

所以這兩種做法都各有各的耦合。

 

  1. 碰撞系統

 

看了上面對於虛擬函式和Visitor的描述,大家大概知道了虛擬函式和Visitor其實都是同一個東西,只是各有各的犧牲。因此他們是可以互相轉換的——大家通過不斷地練習就可以知道如何把一個解法表達成虛擬函式的同時也可以表達成Visitor了。但是Visitor的程式碼又臭又長,所以下面我只用虛擬函式來寫,懶得敲太多程式碼了。

 

虛擬函式只有一個this引數,所以他是single dynamic dispatch。對於碰撞系統來說,不同種類的物體之間的碰撞程式碼都是不一樣的,所以他有兩個"this引數",所以他是multiple dynamic dispatch。在接下來的描述會發現,只要遇上了multiple dynamic dispatch,在現有的架構下避免dynamic_cast,無論你用虛擬函式還是visitor模式,做出來的solution全都是不管操作有沒有偶合在一起,反正型別是肯定會偶合在一起的。

 

現在我們面對的問題是這樣的。在物理引擎裡面,我們經常需要判斷兩個物體是否碰撞。但是物體又不只是三角形組成的多面體,還有可能是標準的球形啊、立方體什麼的。因此這顯然還是一個繼承的結構,而且還有一個虛擬函式用來判斷一個物件跟另一個物件是否碰撞:

 

class Geometry

{

public:

    virtual ~Geometry();

 

    virtual bool IsCollided(Geometry* second) = 0;

};

 

class Sphere : public Geometry

{

public:

    bool IsCollided(Geometry* second)override

    {

        // then ???

    }

};

 

class Cube : public Geometry

{

public:

    bool IsCollided(Geometry* second)override

    {

        // then ???

    }

};

 

class Triangles : public Geometry

{

public:

    bool IsCollided(Geometry* second)override

    {

        // then ???

    }

};

 

大家猛然發現,在這個函式體裡面也不知道second到底是什麼東西。這意味著,我們還要對second做一次single dynamic dispatch,這也就意味著我們需要新增新的虛擬函式。而且這不是一個,而是很多。他們分別是什麼呢?由於我們已經對first(也就是那個this指標)dispatch過一次了,所以我們要把dispatch的結果告訴second,要讓它在dispatch一次。所以當first分別是Sphere、Cube和Triangles的時候,對second的dispatch應該有不同的邏輯。因此很遺憾的,程式碼會變成這樣:

 

class Sphere;

class Cube;

class Triangles;

 

class Geometry

{

public:

    virtual ~Geometry();

 

    virtual bool IsCollided(Geometry* second) = 0;

    virtual bool IsCollided_Sphere(Sphere* first) = 0;

    virtual bool IsCollided_Cube(Cube* first) = 0;

    virtual bool IsCollided_Triangles(Triangles* first) = 0;

};

 

class Sphere : public Geometry

{

public:

    bool IsCollided(Geometry* second)override

    {

        return second->IsCollided_Sphere(this);

    }

 

    bool IsCollided_Sphere(Sphere* first)override

    {

        // Sphere * Sphere

    }

 

    bool IsCollided_Cube(Cube* first)override

    {

        // Cube * Sphere

    }

 

    bool IsCollided_Triangles(Triangles* first)override

    {

        // Triangles * Sphere

    }

};

 

class Cube : public Geometry

{

public:

    bool IsCollided(Geometry* second)override

    {

        return second->IsCollided_Cube(this);

    }

 

    bool IsCollided_Sphere(Sphere* first)override

    {

        // Sphere * Cube

    }

 

    bool IsCollided_Cube(Cube* first)override

    {

        // Cube * Cube

    }

 

    bool IsCollided_Triangles(Triangles* first)override

    {

        // Triangles * Cube

    }

};

 

class Triangles : public Geometry

{

public:

    bool IsCollided(Geometry* second)override

    {

        return second->IsCollided_Triangles(this);

    }

 

    bool IsCollided_Sphere(Sphere* first)override

    {

        // Sphere * Triangles

    }

 

    bool IsCollided_Cube(Cube* first)override

    {

        // Cube * Triangles

    }

 

    bool IsCollided_Triangles(Triangles* first)override

    {

        // Triangles * Triangles

    }

};

 

大家可以想象,如果還有第三個Geometry引數,那還得給Geometry加上9個新的虛擬函式,三個子類分別實現他們,加起來我們一共要寫13個虛擬函式(3^0 + 3^1 + 3^2)39個函式體(3^1 + 3^2 + 3^3)。

 

  1. 結尾

 

為什麼執行期的型別擴充套件就那麼多翔,而靜態型別的擴充套件就不會呢?原因是靜態型別的擴充套件是寫在型別的外部的。假設一下,我們的C++支援下面的寫法:

 

bool IsCollided(switch Geometry* first, switch Geometry* second);

bool IsCollided(case Sphere* first, case Sphere* second);

bool IsCollided(case Sphere* first, case Cube* second);

bool IsCollided(case Sphere* first, case Triangles* second);

bool IsCollided(case Cube* first, case Sphere* second);

bool IsCollided(case Cube* first, case Cube* second);

bool IsCollided(case Cube* first, case Triangles* second);

bool IsCollided(case Triangles* first, case Sphere* second);

bool IsCollided(case Triangles* first, case Cube* second);

bool IsCollided(case Triangles* first, case Triangles* second);

 

最後編譯器在編譯的時候,把所有的"動態偏特化"收集起來——就像做模板偏特化的時候一樣——然後替我們生成上面一大片翔一樣的虛擬函式的程式碼,那該多好啊!

 

Dynamic dispatch和解耦這從一開始以來就是一對矛盾,要徹底解決他們其實是很難的。雖然上面的作法看起來型別和操作都解耦了,可實際上這就讓我們失去了原生程式碼的dll的功能了。因為編譯器不可能收集到以後才動態連結進來的dll程式碼裡面的"動態偏特化"的程式碼對吧。不過這個問題對於像CLR一樣基於一個VM一樣的支援JIT的runtime來講,這其實並不是個大問題。而且Java的J2EE也好,Microsoft的Enterprise Library也好,他們的IoC(Inverse of Control)其實也是在模擬這個寫法。我認為以後靜態型別語言的方向,肯定是朝著這個路線走的。儘管這些概念再也不能被直接map到原生程式碼了,但是這讓我們從語義上的耦合中解放了出來,對於寫需要穩定執行的大型程式來說,有著莫大的助。

相關文章