解读《小类、大对象》

sweet tip: 本文的一些背景知识来源于袁英杰《小类,大对象:C++》,建议先阅读《小类,大对象:C++》。

2015年,初次接触小类、大对象的时候,还不知道其背后的设计意图。但是直觉上给我一个很强的冲击:原来利用这样一种多重继承的手段,就可以使类的职责更加单一,符合了高内聚、低耦合的设计。之前写过一篇文章,叫做《浅析ROLE》,跟袁英杰的《小类,大对象:C++》谈到的很多内容很相似。但是对于其背后的设计哲学,以及存在的一些陷阱,却全然不知。后来,通过反复实践,也跳进过一些坑。曾经一度,甚至开始对它产生怀疑:虽然设计是好的,但是如果这个架构引入很多故障,那么是不是值得去用它呢?

其实,会用和用好之间还有很远的路要走。用好,需要了解其背后的设计过程。任何一个设计,都是存在其约束和上下文的,如果不想了解其上下文,而把它作为一个放之四海的准则,往往会产生很多让人困惑的问题。正如文章《小类,大对象:C++》中谈到,有些规则甚至要靠人为的约定保证的,这就要求人懂得这个架构背后的设计原理,以及清晰知道自己用这个架构的设计意图。

《小类,大对象:C++》核心的实现是多重继承,但是文章中没有用具体的代码实现来展示多重继承的优势和一些问题的规避,只是文字上的描述,比如菱形继承中数据重复的问题。本文将把这些以示例代码的形式展开,旨在让自己有更深入的认识,也期望能够帮助到有类似困惑的人。

1 多个父类存在同名的方法
struct Father
{
    void eat()
    {
        cout<<"Father::eat"<<endl;
    }
};

struct Son
{
    void eat()
    {
        cout<<"Son::eat"<<endl;
    }
};

struct Person : Father, Son
{
};

下面的调用是错误的,因为有歧义:

    Person person;
    person.eat(); //compile error

既然你对角色进行了划分,在某种场景下,你只可能是FatherSon中的一种,这是你的设计意图(而我们常常会忘记这个初心)。这种情况下,甚至连编译器都看不过去了,会通过报错来提示你,它搞不清楚你现在到底是父亲还是儿子。

也许更较真一点,你说,我跟我的妈妈和儿子同时在一起吃饭,那我在这顿饭上我既是父亲又是儿子。哈哈,那我也来较真一下,你可能在吃其中某一口饭的时候是像个父亲一样的吃,在吃另一口的时候,像个儿子再吃。在某一个时刻(就是你决定调用eat方法的时刻),你一定是处于某个角色,而不是两个兼有。

所以对eat的调用应该是这样的,它一定是某个角色在调用:

    Person person;
    Father& father = person; 
    father.eat();
2 菱形继承
  • 传统意义上的继承关系是这样的(它是单继承,向下生长):
  • 《小类,大对象:C++》中讲的继承关系是这样的(多重继承),称之为倒置树(它是向上生长的):

那么,是不是利用小类、大对象做设计,就完全摒弃了传统的继承方式呢?答案是否定的。传统的继承方式,对于消除重复等,仍然是一件利器,二者不冲突。正是由于二者的共存,导致了菱形继承无可避免。

2.1 产生菱形继承的几种情况
(1) 为了消除重复而引入菱形继承的情况

通过Man::eat()消除Father::eat()Son::eat()中的重复,像下面的代码:

struct Man
{
    void eat()
    {
        cout<<"Man::eat"<<endl;
    }
};

struct Father : Man
{
    void eat()
    {
        Man::eat();
        cout<<"Father::eat"<<endl;
    }
};

struct Son : Man
{
    void eat()
    {
        Man::eat();
        cout<<"Son::eat"<<endl;
    }
};

struct Person : Father, Son
{
};

如果你是这么调用eat方法,是行不通的:

    Person person;
    Man& man = person; //compile error
    man.eat();

这是语言机制的限制,典型的多重继承带来的二义性,编译器会报错。

但是,仍然需要回到设计去讨论这个问题,因为仅仅是为了消除重复,我们应该用private继承,防止外部直接把Man当做角色使用。

代码像这样:

struct Father: private Man
{
    、、、
};

struct Son : private Man
{
    、、、
};

这样,企图通过FatherSonPerson的对象去访问Man,都将是非法的。这也更强烈地表明了我们的设计意图:在这个继承体系里,Man仅仅用来消除重复,不作为角色使用。

因此,这样调用会失败:

    Person person;
    Man& man = person; //compile error
    man.eat();

这样也会失败:

    Person person;
    Father& father = person;
    Man& man = father; //compile error
    man.eat();
(2) 为了抽象出新的角色而引入菱形继承的情况

例如,我们从FatherSon抽象出公民(Citizen)这个角色,Citizen有选举权(vote)。

struct Citizen
{
    void vote()
    {
    }
};

struct Father : Citizen
{
};

struct Son : Citizen
{
};

struct Person : Father, Son
{
};

这样使用是错误的:

    Person person;
    Citizen& citizen = person; //compile error
    citizen.vote();

从语言机制上看,这个编译错误是由于存在歧义。

其实,从设计意图上看,Citizen作为新的角色诞生,应该作为它的直接子类的角色存在,这就是类的层次设计的问题。编译器的错误,就像在告诉你,不是所有的Person都是Citizen

所以,我们应该这样使用Citizen:

    Person person;
    Father& father = person;
    Citizen& citizen = father;
    citizen.vote();

或者用ROLE来表示的话,是这样:

    Person person;
    person.ROLE(Father).ROLE(Citizen).vote();

而对于ROLE(Citizen)的实现,放在Father这一层,不要让Person看到这个ROLE的存在:

struct Father: Citizen
{
    、、、

    IMPL_ROLE(Citizen);
};

如果真的必须要通过Person操作Citizen,你需要重新考虑一下,角色的抽取是不是合理。如果你真的觉得每一个Person都应该是Citizen, 那么Citizen应该是属于Person的一个角色。像下面这样:

struct Person : Father, Son, Citizen
{
};
(3) 为了抽象出新的接口而引入菱形继承的情况

例如,像下面这样:

struct Man
{
    virtual void eat() = 0;
};

struct Father : Man
{
    void eat()
    {
        cout<<"Father::eat"<<endl;
    }
};

struct Son : Man
{
    void eat()
    {
        cout<<"Son::eat"<<endl;
    }
};

struct Person : Father, Son
{
};

这种情况,跟新的角色的提取很类似,但是意图不同。我们可以用相同的手段来解决这种菱形继承的问题,那就是类的分层设计和使用。

有些方式可以保证用户使用正确的类层次:

namespace
{
    void g(Man& man)
    {
        man.eat();
    }
}

void f(Father& father)
{
    g(father);
}

使用的时候可能是这样的:

    Person person;
    f(person);

这样,我们可以通过namespace或者private的方式,隐藏g(Man& man),防止被外部用户直接调用,只给外部提供入参为Father的接口f(Father& father)

2.2 菱形继承中的数据重复
  • 基类数据的重复正是每个角色实现的需要。对于每个角色,它确实需要有自己的一份数据拷贝,即便这些数据和另外一个角色是重复的。这些“重复数据”在每个角色那里都有自己的不同状态。另外,由于外部访问是基于某个具体角色的,所以不会造成二义性问题。(摘自:《小类,大对象:C++》)

例如下面的代码场景:

struct Man
{
    Man(bool isOldEnough) : isOldEnough(isOldEnough)
    {}

private:
    bool isOldEnough;
};

struct Father : Man
{
    Father() : Man(true)
    {}
};

struct Son : Man
{
    Son() : Man(false)
    {}
};

struct Person : Father, Son
{
};
  • 如果基类数据是共享的,那也不应该使用virtual继承,而是通过委托关系来共享数据。这样,就可以更加合理的避免数据重复。(摘自:《小类,大对象:C++》)

例如下面的例子,就是不必要的数据重复。

struct Age
{
    Age(int age) : age(age)
    {}

    int getAge() const
    {
        return Age;
    }

private:
    int age;
};

struct Father : Age
{
};

struct Son : Age
{
};

struct Person : Father, Son
{
};

对于同一个Person,可以有FatherSon两个角色,但是绝对不应该有两个age。所以这类数据重复是要避免的。

通过"委托"(私有继承)来处理这类数据重复是可以的:

struct Age
{
    Age(int age) : age(age)
    {}

    int getAge() const
    {
        return Age;
    }

private:
    int age;
};

struct Father
{
    int getAge() const
    {
        return ROLE(Age).getAge();
    }

private:
    USE_ROLE(Age);
};

struct Son
{
    int getAge() const
    {
        return ROLE(Age).getAge();
    }

private:
    USE_ROLE(Age);
};

struct Person : Father, Son, private : Age
{
private:
    IMPL_ROLE(Age);
};
2.3 为什么不使用虚继承?

你仍然可以通过虚继承来规避上面的所有问题(指编译问题):

struct Father: virtual Man
{
    、、、
};

struct Son : virtual Man
{
    、、、
};

但是,这正如不能工作的软件一样,包罗万象的软件同样糟糕。它没有任何设计意图可言,仅仅是骗过编译器。这种不明意图的设计,会给后续的维护和扩展带来无尽的隐患。

3 防止过度使用ROLE
struct Citizen
{
    void vote()
    {
    }
};

struct Father : Citizen
{
};

struct Person : Father, Son, Worker
{
};

例如下面的ROLE(Citizen)是完全没有必要的。

struct Father : Citizen
{
    void doVote()
    {
        ROLE(Citizen).vote();
    } 
};

因为一旦在void doVote()中使用了ROLE(Citizen),需要做额外的两个工作,即在Father中声明USE_ROLE(Citizen)和在Person中定义IMPL_ROLE(Citizen)

struct Father : Citizen
{
    void doVote()
    {
        ROLE(Citizen).vote();
    } 

private:
    USE_ROLE(Citizen);
};

struct Person : Father, Son, Worker
{
private:
    IMPL_ROLE(Citizen);
};

而这些工作完全没有必要,子类调用父类的方法,直接用::就行。

struct Father : Citizen
{
    void doVote()
    {
        Citizen::vote();
    } 

所以,一切从简,不要过度使用ROLE。ROLE用于没有直接继承关系但是有共同根的类之间方法的调用。

4 End

你可能会说,干嘛费这么大劲去理清楚这些问题,我们完全可以避免出现菱形继承。如果你觉得你完全可以避免这种菱形继承的问题,那你就错了,当系统足够复杂、继承关系足够复杂时,它们可能分布在遥远的地方,你很难全局把握;且不说这些类和模块由不同人维护,即便是同一个维护,天长日久,也足以让你难以理清已经存在的继承关系。而承认这些问题的存在并做到心中有数,然后按照我们的约束和原则去做设计,才是成功之道。

5 Refrence
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • 背景 时至今日,C++的核心战场在于:对于性能,空间和实时性有高要求的系统。 而在这类系统上,也有其特定的约束和挑...
    _袁英杰_阅读 10,926评论 19 41
  • DCI[https://en.wikipedia.org/wiki/Data,_context_and_inter...
    MagicBowen阅读 8,292评论 5 31
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,293评论 18 399
  • “Design is there to enable you to keep changing the softw...
    _张晓龙_阅读 12,149评论 3 49
  • 我们在一路成长中,去一个又一个的地方,认识一群又一群的人儿。每个地方都会带给我们不一样的感觉,我们边吐槽边怀恋,在...
    眉心没有美人痣阅读 160评论 0 1