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
既然你对角色进行了划分,在某种场景下,你只可能是Father
和Son
中的一种,这是你的设计意图(而我们常常会忘记这个初心)。这种情况下,甚至连编译器都看不过去了,会通过报错来提示你,它搞不清楚你现在到底是父亲还是儿子。
也许更较真一点,你说,我跟我的妈妈和儿子同时在一起吃饭,那我在这顿饭上我既是父亲又是儿子。哈哈,那我也来较真一下,你可能在吃其中某一口饭的时候是像个父亲一样的吃,在吃另一口的时候,像个儿子再吃。在某一个时刻(就是你决定调用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
{
、、、
};
这样,企图通过Father
、Son
或Person
的对象去访问Man
,都将是非法的。这也更强烈地表明了我们的设计意图:在这个继承体系里,Man
仅仅用来消除重复,不作为角色使用。
因此,这样调用会失败:
Person person;
Man& man = person; //compile error
man.eat();
这样也会失败:
Person person;
Father& father = person;
Man& man = father; //compile error
man.eat();
(2) 为了抽象出新的角色而引入菱形继承的情况
例如,我们从Father
和Son
抽象出公民(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
,可以有Father
和Son
两个角色,但是绝对不应该有两个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
你可能会说,干嘛费这么大劲去理清楚这些问题,我们完全可以避免出现菱形继承。如果你觉得你完全可以避免这种菱形继承的问题,那你就错了,当系统足够复杂、继承关系足够复杂时,它们可能分布在遥远的地方,你很难全局把握;且不说这些类和模块由不同人维护,即便是同一个维护,天长日久,也足以让你难以理清已经存在的继承关系。而承认这些问题的存在并做到心中有数,然后按照我们的约束和原则去做设计,才是成功之道。