Madokakaroto (◉3◉) is writing cpp

从抽象谈面向对象与泛型编程 part.1 - 面相对象的抽象

1. 前言

抽象是我们常用的思维过程。一系列事物通过大脑的提炼,归纳和综合,让我们可以从无序中找出有序,从一个个具体的问题中找出通用的解决方法。编程中对抽象的语言支持,就是从面相对象的程序设计,后文称为OOP,开始变得比较完善的。如今软件复杂程度越来越高,OOP一直都是解决复杂软件设计的重要方法。使用OOP可以屏蔽各个任务具体的细节,抽象出一个简化而统一的流程,从而降低复杂软件开发的难度。这是一个建模的过程,也是OOP能够成功解决问题的原因。但是OOP的抽象能力是有限的,OOP的滥用反而会使复杂度无控制地上升。鄙文会浅讨OOP抽象适用的场景,并会阐述为什么作者觉得everyting is object是一个错误的设计。这个系列的文章后面还会探讨另外一种抽象能力更强大的范式,泛型编程GP。鄙文不是引战OOP与GP孰优孰劣,而是以抽象为线索,浅讨如何编写出能应付更加复杂程序的方法。

2. 传统OOP中的抽象

OOP有一个经典的案例。鱼会游泳,鸟会飞行,而它们都是动物。于是用OOP就很自然的有了下面的代码:

class animal {
public:
    virtual char const* species() = 0;
};

class bird : public animal {
public:
    char const* species() override {
        return "bird";
    }

    virtual void fly() {
        std::cout << "bird::fly" << std::endl;
    }
};

class fish : public animal {
public:
    char const* species() override {
        return "fish";
    }

    virtual void swim() {
        std::cout << "fish::swim" << std::endl;
    }
};

许多文章和书籍对OOP都是这样教的。从animal继承以扩展新的动物类,如哺乳类。从bird或fish继承,以扩展出具体的物种。当然还有许多文章也拿这个例子作为OOP的反面教材。因为当我们要实现飞鱼的时候,飞鱼既可以游泳,也可以飞行,这个继承结构无所适从。那么究竟是哪里出了问题?

熟悉OOP的人会说,这里违反了里氏里氏替换原则(LSP)。飞鱼属于鱼类,但是我们抽象的鱼类无法满足要求。那我们给鱼增加一个fly接口:

class fish : public animal {
public:
    char const* species() override {
        return "fish";
    }

    virtual void swim() {
        std::cout << "fish::swim" << std::endl;
    }

    virtual void fly() {
        std::cout << "I can`t fly" << std::endl;
    }
};

class flying_fish : public fish {
public:
    void fly() override {
        std::cout << "flying_fish::fly" << std::endl;
    }
};

这能解决飞鱼这一个情况,但现实中好像还有更多复杂的情况。例如天鹅属于鸟类,但是它既会走路,也会飞行,当然也会游泳。这个时候我们又不得不扩充鸟类的接口。问题似乎变复杂了,绝大部分的鱼并不会飞行,但是为了兼容飞鱼我们为鱼类添加了fly接口。其实,真正的问题在于,我们为了实现一个可以游泳的动物,根据有限的经验抽象出了鱼类,并把这个动物归为鱼类。这种抽象的方式的代价,就是给实现类与抽象类之间,增加了一个is_a的关系,这个关系是一个很强的依赖关系。

对于我们举的这个例子而言,这种错误很容易发现,这是得益于动物这个话题是人们所熟知的。但是现实的问题往往都是未充分了解的,所以我们的抽象会很容易地违反LSP。其次,仅仅使用接口的异同类对事物进行抽象和归属,似乎并不是一个很好的主意。于是OOP有了改良,对行为的抽象看起来要更合理一些。

3. 接口编程与行为抽象

来到了接口编程,我们不再为类别进行抽象了,而是抽象行为。例如,上面的例子,我们不再为动物区分哺乳类,鸟类和鱼类等,也不会为鱼类实现飞鱼或者鲈鱼等其他具体的物种。简单起见,假设动物的行为有飞行,行走和游泳,于是我们抽象出了这三个接口。

class flyable {
public:
    virtual void fly() = 0;
};

class walkable {
public:
    virtual void walk() = 0;
};

class swimmable {
public:
    virtual void swim() = 0;
};

飞鱼既可以飞行也可以游泳,我们只要实现这两个行为,飞鱼的问题就很好地解决了:

class flying_fish 
    : public flyable
    , public swimmable {
public:
    void fly() override {
        std::cout << "flying_fish::fly" << std::endl;
    }

    void swim() override {
        std::cout << "flying_fish::swim" << std::endl;
    }
};

如果语言不支持多重继承,可以使用组合的方式实现。目前的设计好像能应付所有的动物,甚至能推广得更远。例如,实现飞机也很容易:

class plane : public flyable {
public:
    void fly() override {
        std::cout << "plane::fly" << std::endl;
    }
};

现在我们要实现一个功能,让我们实现的飞鱼先飞行再游泳。在OOP中,对象的具体语义是不可知的,例如我们访问飞鱼的fly接口是通过flyable接口对象,对于swim同样是从swimmable对象访问,而我们并不知道flyable和swimmable是否是从飞鱼而来的。OOP在这里会带来运行期的额外开销。呃,这里好像碰到了点什么问题。我们如何保证flyable和swimmable两个接口对象,都来自于同一个飞鱼对象?

在我们继续往下解决这个问题之前,让我们在回想一下,为什么要抽象。抽象是为了屏蔽每个任务具体的细节,提炼出一个统一的处理流程。也就是说,统一的处理流程是我们抽象的目标。而现在的这个先飞行再游泳的功能,无法在我们现有抽象的模型中表达。问题已经很清楚了,是我们的抽象出了问题。这就是OOP在设计的时候一定要避免的误区,我们的抽象是为了得到统一的处理流程,而不是为了适应更广泛复杂的真实世界的模型而过度抽象,遵守KISS很重要。

对于这个例子,如果我们的需求只是希望在程序中,统一控制实体先飞行再游泳,应该这样抽象

class fly_then_swim_behavior {
public:
    virtual void fly_then_swim() = 0;
};

class flying_fish : public fly_then_swim_behavior {
public:
    // ... 
    void fly_then_swim() override {
        std::cout << "flying_fish::fly, interval..., swim." << std::endl;
    }
};

class swam : public fly_then_swim_behavior {
public:
    // ... 
    void fly_then_swim() override {
        std::cout << "swam::fly, landing..., swim." << std::endl;
    }
};

如果我们的需求是想统一控制实体的fly和swin行为,并能够灵活地组合这些行为,可以这样抽象:

class flyable_and_swimmable {
public:
    virtual void fly() = 0;
    virtual void swim() = 0;
};

class flying_fish : public flyable_and_swimmable {
public:
    void fly() override { // .... }
    void swim() override { // ... }
};

OOP的抽象的程度应该到此为止,如果问题更复杂可以适当使用设计模式解决,当然设计模式不是鄙文探讨的范围。坚守KISS原则是一件很难的事情。Everything is object,这个极具诱惑和理想主义的咒语,一直在不停地将我们拉入深渊。是的,一直有一条路通往那里。

4. 深渊,从这里开始

下面的设计,不应该归于OOP的范畴了。因为它们为了试图用统一的方法解决一切问题,而且都违背了KISS最基本的教义。首先,如果我们想要继续坚持之前的设计,又希望问题能够得到解决,本着接口统一的原则,我们可以像COM一样,提供一个接口查询的机制:

using interface_base = void*;

class object_base {
public:
    virtual void query_interface(uuid_t id, interface_base*& i) = 0;
};

class flyable {
public:
    uuid_t id;
    virtual void fly() = 0;
};

class swimmable {
    uuid_t id;
    virtual void swim() = 0;
};

class flying_fish 
    : public object_base
    , public flyable
    , public swimmable {
public:
    // ... omitted codes here
    void query_interface(uuid_t id, object_base*& i) override {
        if(id == flyable::id){
            i = static_cast<flyable*>(this);
        } else if(id == swimmable::id) {
            i = static_cast<swimmable*>(this);
        } else {
            i = nullptr;
        }
    }
};

void fly_then_swim(object_base* obj) {
    interface_base fly, swim;
    obj->query_interface(flyable::id, fly);
    obj->query_interface(swimmable::id, swim);
    if(fly && swim) {
        reinterpret_cast<flyable*>(fly)->fly();
        reinterpret_cast<swimmable*>(swim)->swim();
    }
}

还有一个解决方案,就是使用发送消息:

class object_base {
public:
    virtual void send_message(uuid_t msg, void* data) = 0;
    virtual bool message_processible(uuid_t msg) = 0;
};

class flying_fish : public object_base {
public:
    // omitted codes
    void send_message(uuid_t msg, void*) override {
        if(msg == fly_msg)
            std::cout << "flying_fish::fly" << std::endl;
        else if(msg == swim_msg)
            std::cout << "flying_fish::swim" << std::endl;
    }

    bool message_processible(uuid_t msg) override {
        return msg == fly_msg || msg == swim_msg;
    }
};

void fly_then_swim(object_base* obj) {
    if(obj->message_processible(fly_msg) && 
        obj->message_processible(swim_msg)) {
        obj->send_message(fly_msg, nullptr);
        obj->send_message(swim_msg, nullptr);
    }
}

以上两个方法很类似,就放在一起讨论。两个方法都引入了一个共同的公共基类,object_base. 对!Everything is object就从这里开始了。第一种方法提供了一个统一的接口查询机制,而后者提供了一个统一的发送消息机制。两者都需要全局唯一的ID来区分不同的接口或者消息。接口查询的方式会带来接口对象不安全强转,而发送消息会招致不安全的数据转换。就目前而言所引入的公共基类和全局ID的负载,还有不安全的代码都不是很严重的问题。例如后者,可以在对象语义完整的时候,创建代理来绑定操作,从而抵消不安全的代码。最严重的问题是,OOP在这里已经荡然无存了。没有了接口的约束,之前抽象的模型也毫无意义了,这于过程式编程毫无差别。如此抽象的结果,却是写出了等同之前毫无抽象的代码,这种设计难道不是一个悖论吗?

深渊也是有底部的,我们继续把查询接口与查询消息推广,其实就是现代语言都提供的反射。反射是一种内省机制,可以让我们查询对象的成员方法和成员数据。当然,与其这样使用反射,何不去使用一门动态语言?

5. 小结

OOP使用的接口抽象的方法,本质上是围绕接口进行的归类方法。这种方法可以做到统一调度流程,屏蔽具体的细节,但同时也许多代价:

  1. 围绕接口进行抽象,只利用了语法约束,但丢弃了语义;
  2. 归类引入了很强的is_a的依赖关系,而程序中的归类结果通常与现实中经验相差甚远;
  3. 对于静态类型,有很明确的编译期的语言,OOP把负担都扔给了运行期;

OOP有着适中的抽象能力,用来对现实问题的简化模型进行抽象,而everything is object是OOP的滥用。

下一篇,我们将讨论静态强类型语言所擅长的泛型编程。GP具有比OOP强大许多的抽象能力,它比OOP更适合于抽象复杂的模型。我们会以最大公约数算法为线索,讨论GP在C++编程语言中的实践方法。