在思考怎么写这一篇文章的时候,我又想到了之前讨论正交概念的事情。若是一个系统被设计成正交的,他的功能扩展起来也能够很容易的保持质量这是没错的,可是对于每个单独给他扩展功能的个体来讲,这个系统一点都很差用。因此我以为如今的语言被设计成这样也是有那么点道理的。就算是设计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到本地代码了,可是这让咱们从语义上的耦合中解放了出来,对于写须要稳定执行的大型程序来讲,有着莫大的助。