如何设计一门语言(五)——面向对象和消息发送

面向对象这个抽象的特例总是有说不完的话题,更糟糕的是很多语言都错误地实现了面向对象——class居然可以当一个变量类型什么的这只是让人们写代码写的更糟糕而已。当然这个话题第三篇文章已经说过了,现在来谈谈人们喜欢拿来装逼的另一个话题——消息发送。

按照惯例先来点题外话。说到消息发送,有些人喜欢跳出来说,objective-c的消息做得多优雅啊,代码都可以写成一句话[golang screw:you you:suck]之类的。其实这个还做得不够彻底。在几年前易语言曾经火了一阵,但是为什么大家这么讨厌他呢?其实显然不是因为每个token都是汉字,而是因为他做的一点都不像中文,谁会说话的时候带那么多符号呀。其实objective-c也一样,没人会因为想在一句英语里面用冒号来分割短语的。

当我还在读大三的时候,我由于受到了Apple Script(也是苹果做的)的启发,试图发明一门语言,让他可以尽量写起来像自然语言——当然他仍然是严格的编程语言。但是这门语言因为其奇特的语法结构,我只好自己想出了一个两遍parse地方法。第一遍parse出所有函数头,让后用这些函数头临时组成一个parser,用它来parse语句的部分。后面整个也实现出来了,然后我就去做了一下调查,发现大家不喜欢,原因是要输入的东西太多了。不过我这里还是贴一下当初是怎么设计的:

phrase print(content) is
    external function "writeln"
end phrase

phrase first (count) items of fibonacci sequence is
    if count equals to 1 then
        result is [1]
    else if count equals to 2 then
        result is [1,1]
    else
        let list be [1,1]
        repeat with i from 3 to count
            let list be list joins with item length of list - 1 of list + item length of list - 2 of list
        end
        result is list
    end
end phrase

phrase (number) is odd is
    result is number mod 2 is 0
end phrase alias odd number

phrase append (item) after (list) is
    let 0 element of list from length of list be [item]
end phrase

phrase ((item) is validated) in (list) is
    let filtered list be []
    repeat with item in list
        append item after filtered list if item is validated
    end
    result is filtered list
end phrase

phrase main is
    print odd number in first 10 items of fibonacci sequence
end phrase

倒数第二个函数声明甚至连函数指针的声明也如此的优雅(我自己认为的),整个程序组织起来,我们要输出斐波那契数列里面前10个数字中间的奇数,于是就写成了

print odd number in first 10 items of fibonacci sequence

看起来比objective-c要漂亮把。其实如果想把所有的东西换成中文,算法也不需要变化。现在用空格来分割一个一个的词,中文直接用字符就好了,剩下的都一样。要parse这个程序根本没办法用流行的那些方法来parse。当然我知道大家也不会关心这些特别复杂的问题,于是题外话就到这里结束了,这个语言的实现的代码你们大概也永远都不会看到的,啊哈哈哈哈。

为什么要提这件事情呢?我主要是想告诉大家,就算你在用面向对象语言,想在程序里面给一个对象发送一条消息,这个对象并不是非得写在最前面的。为什么呢?有的时候对象不止一个——这个东西叫multiple dispatching,著名的问题就是如何给一堆面向对象的几何体类做他们的求交函数——用面向对象的惯用做法做起来会特别的难受。不过现在我们先来看一下普通的消息发送是什么样子的。

对于一个我们知道他是什么类型的对象来说,发送一个消息就跟直接调用一个函数一样,因为你不需要去resolve一下这个函数到底是谁。譬如说下面的代码:

class Language
{
public:
    void YouSuck(){ ... }
};

Language golang;
golang.YouSuck();

最终翻译出来的结果会近似

struct Language
{
};

void Language_YouSuck(Language* const this)
{
    ...
}

Language golang;
Language_YouSuck(&golang);

很多人其实并不能在学习面向对象语言的时候就直接意识到这一点。其实我也是在高中的时候玩delphi突然就在网上看见了这么一篇文章,然后我才明白的。看起来这个过渡并不是特别的自然是不是。

当你要写一个独立的class,不继承自任何东西的时候,这个class的作用只有两个。第一个是封装,这个第三篇文章已经提到过了。第二个作用就是给里面的那些函数做一个匿名的namespace。这是什么意思呢?就像上面的代码一样,你写golang.YouSuck(),编译器会知道golang是一个Language,然后去调用Language::YouSuck()。如果你调用lisp.YouSuck()的时候,说不定lisp是另一个叫做BetterThanGolangLanguage的类型,然后他就去里面找了YouSuck。这里并不会因为两个YouSuck的名字一样,编译器就把它搞混了。这个东西这跟重载也差不多,我就曾经在Microsoft Research里面看见过一个人做了一个语言(主要是用来验证语言本身的正确性的),其中a.b(c, d)是b(a, c, d)的语法糖,这个“.”毫无特别之处。

有一天,情况变了。专门开发蹩脚编译器的AMD公司看见golang很符合他们的口味,于是也写了一个golang的实现。那这个事情应该怎么建模呢?因为golang本身是一套标准,你可也可以称呼他为协议,然后下面有若干个实现。所以Language本身作为一个category也只好跟着golang变成interface了。为了程序简单我们只看其中的一个小片段:

class IGolang
{
public:
    virtual void YouSuck()=0;
};

class GoogleGolang : public IGolang
{
public:
    void YouSuck()override{ /*1*/ }
};

class AmdGolang : public IGolang
{
public:
    void YouSuck()override{ /*2*/ }
};

IGolang* golang = new GoogleGolang;
golang->YouSuck();

我很喜欢VC++的专有关键字override,他可以在我想override但是不小心写错了一点的时候提示我,避免了我大量的错误的发生。当然这个东西别的编译器不支持,所以我在我的代码的靠前的地方写了一个宏,发现不是VC++再编译,我就把override给#define成空的。反正我的程序里面不会用关键字来当变量名的。

看着这个程序,已经不能单纯的用GoogleGolang_YouSuck(golang)来代替这个消息发送了,因为类型是IGolang的话说不定下面是一个AmdGolang。所以在这里我们就要引入虚函数表了。一旦引入了虚函数表,代码就会瞬间变得复杂起来。我见过很多人问,虚函数表那么大,要是每一个类的实例都带一个表的话岂不是很浪费内存?这种人就应该先去看《Inside the C++ Object Model》,然后再反省一下自己的问题有多么的——呃——先看带有虚函数表的程序长什么样子好了:

struct vtable_IGolang
{
    void (*YouSuck)(IGolang* const this);
};

struct IGolang
{
    vtable_IGolang* vtable;
};

//---------------------------------------------------

vtable_IGolang vtable_GoogleGolang;
vtable_GoogleGolang.YouSuck = &vtable_GoogleGolang_YouSuck;

struct GoogleGolang
{
    IGolang parent;
};

void vtable_GoogleGolang_YouSuck(IGolang* const this)
{
    int offset=(int)(&((GoogleGolang*)0)->parent);
    GoogleGolang_YouSuck((GoogleGolang*)((char*)this-offset));
}

void GoogleGolang_YouSuck(GoogleGolang* const this)
{
    /*1*/
}

void GoogleGolang_ctor(GoogleGolang* const this)
{
    this->parent->vtable = &vtable_GoogleGolang;
}

//---------------------------------------------------
// AmdGolang略,长得都一样
//---------------------------------------------------

GoogleGolang* tmp = (GoogleGolang*)malloc(sizeof(GoogleGolang));
GoogleGolang_ctor(tmp);
IGolang* golang = &tmp->parent;
golang->vtable->YouSuck(golang);

基本上已经面目全非了。当然实际上C++生成的代码比这个要复杂得多。我这里只是不想把那些细节牵引进来,针对我们的那个例子写了个可能的实现。面向对象的语法糖多么的重要啊,尽管你也可以在需要的时候用C语言把这些东西写出来(就跟那个愚蠢的某著名linux GUI框架一样),但是可读性已经完全丧失了吧。明明那么几行就可以表达出来的东西,我们为了达到同样的性能,用C写要把代码写成屎。东西一多,名字用完了,都只好对着代码发呆了,决定把C扔了,完全用C++来写。万一哪天用到了virtual继承——在某些情况下其实是相当好用的,譬如说第三篇文章讲的,在C++里面用interface,而且也很常见——那用C就只能呵呵呵了,写出来的代码再也没法读了,没法再把OOP实践下去了。

好了,消息发送的简单的实现大概也就讲到这里了。只要不是C++,其他语言譬如说只有单根继承的Delphi,实现OOP大概也就是上面这个样子。于是我们围绕着消息发送的语法糖玩了很久,终于遇到了两大终极问题。这两个问题说白了都是开放和封闭的矛盾。我们用基类和一大堆子类的结构来写程序的时候,需要把逻辑都封装在虚函数里面,不然的话你就得cast了,cast是将程序最终导向失控的根源之一。这个时候我们对类型扩展是开放的,而对逻辑扩展是封闭的。这是什么意思呢?让我们来看下面这个例子:

class Shape
{
public:
    virtual double GetArea()=0;
    virtual bool HitTest(Point p)=0;
};

class Circle : public Shape ...;
class Rectangle : public Shape ... ;

我们每当添加一个新形状的时候,只要实现GetArea和HitTest,那么事情就做完了。所以你可以无限的添加新形状——所以类型扩展是开放的。但是你却永远只能做GetArea和HitTest——对逻辑扩展是封闭的。你如果想做除了GetArea和HitTest以外的更多的事情的话,这个时候你就被迫做cast了。那么在类型相对稳定的情况下有没有别的方法呢?设计模式告诉我们,我们可以用Visitor来把情况扭转过来——做成对类型扩展封闭,而对逻辑扩展开放的:

class IShapeVisitor
{
public:
    virtual void Visit(Circle* shape)=0;
    virtual void Visit(Rectangle* shape)=0;
};

class Shape
{
public:
    virtual void Accept(IShapeVisitor* visitor)=0;
};

class Circle : public Shape
{
public:
    ...

    void Accept(IShapeVIsitor* visitor)override
    {
        visitor->Visit(this);  // 因为重载的关系,会调用到第一个Visit函数
    }
};

class Rectangle : public Shape
{
public:
    ...

    void Accept(IShapeVIsitor* visitor)override
    {
        visitor->Visit(this);  // 因为重载的关系,会调用到第二个Visit函数
    }
};

//------------------------------------------

class GetAreaVisitor : public IShapeVisitor
{
public:
    double result;

    void Visit(Circle* shape)
    {
        result = ...;
    }

    void Visit(Rectangle* shape)
    {
        result = ...;
    }
};

class HitTestVisitor : public IShapeVisitor ...;

这个时候GetArea可能调用起来就不是那么方便了,不过我们总是可以把它写成一个函数:

double GetArea(Shape* shape)
{
    GetAreaVisitor visitor;
    shape->Accept(&visitor);
    return visitor.result;
}

这个时候你可以随意的做新的事情了,但是一旦需要添加新类型的时候,你需要改动很多东西,首先是Visitor的接口,其实是让所有的逻辑都支持新类型,这样你就不能仅仅通过添加新代码来扩展新类型了。所以这就是对逻辑扩展开放,而对类型扩展封闭了。

所以第一个问题就是:能不能做成类型扩展也开放,逻辑扩展也开放呢?在回答这个问题之前,我们先来看下一个问题。我们要对两个Shape进行求交,看看他们是不是有重叠在一起的部分。但是每一个具体的Shape,譬如Circle啊Rectangle啊,定义都是不一样的,没办法有通用的处理办法,所以我们只能写3个函数了(RR, CC, CR)。如果有3各类型,那么我们就需要6个函数。如果有4个类型,那我们就需要有10个函数——才能处理所有情况。公式倒是可以一下子看出来,函数数量就等于1+2+ … +n,n等于类型的数量。

这看起来好像是一个类型扩展开放的问题是吧,但是实际上他只能用逻辑扩展的方法来做。为什么呢?你看我们的一个visitor其实很像是我们对一个一个的具体类型都试一下看看shape是不是这个类型,从而做出正确的处理。不过这跟我们直接用if地方法相比有两个优点:1、快;2、编译器替你查错有保证。

那实际上应该怎么做呢?想想,我们这里有两次“if type”。第一次针对第一个参数,第二次针对第二个参数。所以我们一共需要n+1=3个visitor。写的方法倒是不复杂,首先我们得准备好RR,CC,CR三个逻辑,然后用visitor去识别类型然后调用它们:

bool IntersectCC(Circle* s1, Circle* s2){ ... }
bool IntersectCR(Circle* s1, Rectangle* s2){ ... }
bool IntersectRR(Rectangle* s1, Rectangle* s2){ ... }
// RC和CR是一样的

class IntersectWithCircleVisitor : public IShapeVisitor
{
public:
    Circle* s1;
    bool result;

    void Visit(Circle* shape)
    {
        result=IntersectCC(s1, shape);
    }

    void Visit(Rectangle* shape)
    {
        result=IntersectCR(s1, shape);
    }
};

class IntersectWithRectangleVisitor : public IShapeVisitor
{
public:
    Rectangle* s1;
    bool result;

    void Visit(Circle* shape)
    {
        result=IntersectCR(shape, s1);
    }

    void Visit(Rectangle* shape)
    {
        result=IntersectRR(s1, shape);
    }
};

class IntersectVisitor : public IShapeVisitor
{
public:
    bool result;
    IShape* s2;

    void Visit(Circle* shape)
    {
        IntersectWithCircleVisitor visitor;
        visitor.s1=shape;
        s2->Accept(&visitor);
        result=visitor.result;
    }

    void Visit(Rectangle* shape)
    {
        IntersectWithRectangleVisitor visitor;
        visitor.s1=shape;
        s2->Accept(&visitor);
        result=visitor.result;
    }
};

bool Intersect(Shape* s1, Shape* s2)
{
    IntersectVisitor visitor;
    visitor.s2=s2;
    s1->Accept(&visitor);
    return visitor.result;
}

我觉得你们现在心里的想法肯定是:“我屮艸芔茻。”嗯,这种事情在物理引擎里面是经常要碰到的。然后当你需要添加一个新的形状的时候,呵呵呵呵呵呵呵呵。不过这也是没办法的,谁让现在的要求运行时性能的面向对象语言都这么做呢?

当然,如果在不要求性能的情况下,我们可以用ruby和它的mixin来做。至于说怎么办,其实你们应该发现了,添加一个Visitor和添加一个虚函数的感觉是差不多的。所以只要把Visitor当成虚函数的样子,让Ruby给mixin一堆新的函数进各种类型就好了。不过只有支持运行时mixin的语言才能做到这一点。强类型语言我觉得是别想了。

Mixin地方法倒是很直接,我们只要把每一个Visitor里面的Visit函数都给加进去就好了,大概感觉上就类似于:

class Shape
{
public:
    // Mixin的时候等价于给每一个具体的Shape类都添加下面三个虚函数的重写
    virtual bool Intersect(Shape* s2)=0;
    virtual bool IntersectWithCircle(Circle* s1)=0;
    virtual bool IntersectWithRectangle(Rectangle* s1)=0;
};

//--------------------------------------------

bool Circle::Intersect(Shape* s2)
{
    return s2->IntersectWithCircle(this);
}

bool Rectangle::Intersect(Shape* s2)
{
    return s2->IntersectWithRectangle(this);
}

//--------------------------------------------

bool Circle::IntersectWithCircle(Circle* s1)
{
    return IntersectCC(s1, this);
}

bool Rectangle::IntersectWithCircle(Circle* s1)
{
    return IntersectCR(s1, this);
}

//--------------------------------------------

bool Circle::IntersectWithRectangle(Rectangle* s1)
{
    return IntersectCR(this, s1);
}

bool Rectangle::IntersectWithRectangle(Rectangle* s1)
{
    return IntersectRR(s1, this);
}

这下子应该看出来为什么我说这种方法只能用Visitor了吧,否则就要把所有类型都写进Shape,就会很奇怪了。如果这样的逻辑一多,类型也有四五个的话,那每加一个逻辑就得添加一批虚函数,Shape类很快就会被玩坏了。而代表逻辑的Visitor是可以放在不同的地方的,互相之间是隔离的,维护起来就会比较容易。

那现在我们就要有第二个问题了:在拥有两个“this”的情况下,我们要如何做才能把逻辑做成类型扩展也开放,逻辑扩展也开放呢?然后参考我们的第一个问题:能不能做成类型扩展也开放,逻辑扩展也开放呢?你应该心里有数了吧,答案当然是——不能做。

这就是语言的极限了。面向对象才用的single dispatch的方法,能做到的东西是很有限的。情况稍微复杂那么一点点——就像上面对两个形状求交这种正常的问题——写起来都这么难受。

那呼应一下标题,如果我们要设计一门语言,来支持上面这种multiple dispatch,那可以怎么修改语法呢?这里面分为两种,第一种是像C++这样运行时load dll不增加符号的,第二种是像C#这样运行时load dll会增加符号的。对于前一种,其实我们可以简单的修改一下语法:

bool Intersect(switch Shape* s1, switch Shape* s2);

bool Intersect(case Circle* s1, case Circle* s2){ ... }
bool Intersect(case Circle* s1, case Rectangle* s2){ ... }
bool Intersect(case Rectangle* s1, case Circle* s2){ ... }
bool Intersect(case Rectangle* s1, case Rectangle* s2){ ... }

然后修改一下编译器,把这些东西翻译成虚函数塞回原来的Shape类里面就行了。对于第二种嘛,其实就相当于Intersect的根节点、Circle和CC写在dll1,Rectangle和CR、RC、RR写在dll2,然后dll1运行时把dll2给动态地load了进来,再之后调用Intersect的时候就好像“虚函数已经进去了”一样。至于要怎么做,这个大家回去慢慢思考一下吧,啊哈哈哈。

posted on 2013-05-25 11:08  陈梓瀚(vczh)  阅读(4200)  评论(4编辑  收藏  举报