在思考怎麼寫這一篇文章的時候,我又想到了之前討論正交概念的事情。若是一個系統被設計成正交的,他的功能擴展起來也能夠很容易的保持質量這是沒錯的,可是對於每個單獨給他擴展功能的個體來講,這個系統一點都很差用。因此我以爲如今的語言被設計成這樣也是有那麼點道理的。就算是設計Java的那誰,他也不是傻逼,那爲何Java會被設計成這樣?我以爲這跟他剛開始想讓金字塔的底層程序員也能夠順利使用Java是有關係的。 html
難道好用的語言就活該很差擴展碼?實際上不是這樣的,可是這仍然是上面那個正交概念的問題。一個容易擴展的語言要讓你以爲好用,首先你要投入時間來學習他。若是你想簡單的借鑑那些很差擴展的語言的經驗(如Java)來在短期內學會如何使用一個容易擴展的語言(如C++/C#)——你的出發點就已經投機了。因此這裏有一個前提值得我再強調一次——首先你須要投入時間去學習他。 程序員
正如我一直在羣裏說的:"C++須要不斷的練習——vczh"。要如何練習才能讓本身藉助語言作出一個可擴展的架構呢?先決條件就是,當你在練習的時候,你必須是在練習如何實現一個從功能上就要求你必須保證他的可擴展性的系統,舉個例子,GUI庫就是其中的一類。我至今認爲,學會實現一個GUI庫,比經過練習別的什麼東西來提升本身的能力來說,簡直就算一個捷徑了。 web
那麼什麼是擴展呢?簡單的來說,擴展就是在不修改原有代碼的狀況下,僅僅經過添加新的代碼,就可讓原有的功能適應更多的狀況。通常來說,擴展的主要目的並非要增長新的功能,而是要只增長新代碼的前提下修改原有的功能。譬如說原來你的系統只支持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的方法組織起來的。若是在語言的類型系統的幫助下,咱們能夠輕鬆作出這樣子的架構,那這個語言就算有可擴展的類型了。 數據庫
這個其實已經被人在C++和各類靜態類型的函數式語言裏面作爛了。簡單的來說,C++處理這種問題的方法就是提供偏特化。惋惜C++的偏特化只讓作在class上面,結果由於你們對class的誤解很深,順便連偏特化這種比OO簡單一萬倍的東西也誤解了。偏特化不容許用在函數上,由於函數已經有了重載,可是C++的各類標準在使用函數來擴展類型的時候,實際上仍是當他是偏特化那麼用的。我舉個例子。 windows
C++11多了一個foreach循環,寫成for(auto x : xs) { … }。STL的類型都支持這種新的for循環。C++11的for循環是爲了STL的容器設計的嗎?顯然不是。你也能夠給你本身寫的容器加上for循環。方法有兩種,分別是:一、給你的類型T加上T::begin和T::end兩個成員函數;二、給你的類型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); asp.net
若是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#不同才設計成這個樣子的,惋惜精華沒有抄去,卻抄了糟粕。
自從Java吧靜態類型和麪向對象捆綁在一塊兒以後,業界對"運行期對類型的擴展"這個主題思考了不少年,甚至還出了一本著做叫《設計模式》,讓不少人捧爲經典。你們爭先恐後的學習,而效果卻不怎麼樣。這是由於《設計模式》很差嗎?不是。這是由於靜態類型和麪向對象捆綁在一塊兒以後,設計一個可擴展的架構就很難嗎?也不是。真正的緣由是,Java設計(好像也是抄的Simular?我記不太清楚了)的虛函數把這個問題的難題提高了一個等級。
用正確的概念來理解問題可讓咱們更容易的掌握問題的本質。語言是有魔力的,習慣說中文的人,思考方式都跟中國人差很少。習慣說英語的人,思考方式都跟美國人差很少。所以習慣了使用C++/C#/Java的人,他們對於面向對象的想法其實也是差很少的。這是人類的天性。儘管你們鼓吹說語言只是工具,咱們應該掌握方法論什麼的,可是這就跟要求男人面對一個萌妹紙不勃起同樣,違背了人類的本性,難度簡直過高了。因而我今天從虛函數和Visitor模式講起,告訴你們爲何虛函數的這種形式會讓"擴展的時候不修改原有的代碼"變難。
絕大多數的系統的擴展,均可以最後化簡(這並不要求你非得這麼作)爲"當它的類型是這個的時候你就幹那個"的這麼件事。對於在編譯的時候就已經知道的,咱們能夠用偏特化的方法讓編譯器在生成代碼的時候就先搞好。對於運行的時候,你拿到一個基類(其實爲何必定要有基類?應該有的是interface!參見上一篇文章——刪減語言的功能),那如何O(1)時間複雜度(這裏的n指的是全部跟此次跳轉有關係的類型的數量)就跳轉到你想要的那個分支上去呢?因而咱們有了虛函數。
靜態的擴展用的是靜態的分派,因而編譯器幫咱們把函數名都hardcode到生成的代碼裏面。動態的類型用的是動態的分派,因而咱們獲得的固然是一個至關於函數指針的東西。因而咱們會把這個函數指針保存在從基類對象能夠O(1)訪問到的地方。虛函數就是這麼實現的,並且這種類型的分派必需要這麼實現的。可是,寫成代碼就必定要寫程序函數嗎?
其實原本沒什麼理由讓一個語言(或者library)長的樣子必須有提示你他是怎麼實現的功能。關心太多容易得病,執着太多心生痛苦啊。因此好好的解決問題就行了。至於原理是什麼,下了班再去關心。估計還有一些人不明白爲何很差,我就舉一個通俗的例子。咱們都知道dynamic_cast的性能不怎麼樣,虛函數用來作if的性能要遠遠比dynamic_cast用來作if的性能好得多。所以下面全部的答案都基於這個前提——要快,不要dynamic_cast!
好了,如今咱們的任務是,拿到一個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); // 輸出img的tag內容
};
// 其它實現略
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){} // 輸出img的tag內容
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。並且還有另外一個問題,就是虛函數的默認實現寫起來比較鳥了。
因此這兩種作法都各有各的耦合。
看了上面對於虛函數和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)。
爲何運行期的類型擴展就那麼多翔,而靜態類型的擴展就不會呢?緣由是靜態類型的擴展是寫在類型的外部的。假設一下,咱們的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到本地代碼了,可是這讓咱們從語義上的耦合中解放了出來,對於寫須要穩定執行的大型程序來講,有着莫大的助。