如何设计一门语言(十二)——设计可扩展的类型

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

posted on 2013-11-10 17:07  陈梓瀚(vczh)  阅读(3985)  评论(13编辑  收藏  举报