当一个类型A的对象中包含另一个类型B的对象时,我们说A与B之间的关系是“组合”。请看示例:
class Address { ... }; // 住址
class PhoneNumber { ... };
class Person {
public:
...
private:
std::string name; // 组合对象
Address address; // 同上
PhoneNumber voiceNumber; // 同上
PhoneNumber faxNumber; // 同上
};
上述示例中,Person对象由string、Address和PhoneNumber三种对象组合而成。在程序员之间,“组合”一词拥有众多的同义词。诸如:分层、包含、聚合和嵌入。
条目32中解释了公共继承意味着“A是一个B”。同时组合也有其内涵,事实上它拥有两个内涵,组合既可以表示“A拥有一个B”,也可以表示“A以B的形式实现”。这是由于在你的软件中你针对的是两个不同的领域。你的程序中的一些对象与你正在建模的世界相关,比如人、车、视频帧等等。这些对象则存在于应用领域。而另一些对象单纯是为了程序的具体实现人为创造的,诸如缓冲区、互斥锁、搜索树,等等。这些类型的对象则针对软件中的实现领域。当组合出现在应用域内的对象之间时,它表达的是“A拥有一个B”的关系;而组合出现在实现域中,则意味着“A以B的形式实现”。
上文中的Person类演示了“A包含B”的关系。一个人——Person对象“拥有”一个姓名(name)、一个住址(address)、一个电话号码(voiceNumber)、一个传真号码(faxNumber)、你不能说“人是姓名”、“人是地址”这样的话。应该说“人有姓名”、“人有住址”。大多数人还是能够轻易区分“是”和“有”之间的区别的。因此“A是B”和“A拥有B”两者并不易混淆。
某种意义上讲,“A是B”与“A以B的形式实现”二者之间的区别更让人难以分辨。举例说,假设你需要一个表示集合的模板,其中容纳的对象的数目非常有限。即该集合中不允许存在重复的对象。由于复用在OOP的世界里是十分美妙的事情,你会本能的想到使用标准库中的set模板。有现成的工具为什么不加以利用呢。
不幸的是,set模板的具体实现中一般每个元素都会有三个指针的开销。这是因为set通常被实现为平衡搜索树,这种数据结构确保了查找、插入、删除操作的时间复杂度均为O(lgn)。在效率至上的环境中,这种设计方案合情合理,然而在你的程序中,空间比速度更加重要,这时标准库中的set模板就变得水土不服了。看上去你需要另起炉灶。
固然,复用的确是一件美妙的事情。作为数据结构专家的你,对于实现集合有各式各样的手段,其中之一便是使用链表。你当然也了解标准C++库中有一个list模板,因此你可以去(复)用它。
于是,你决定开辟一个全新的模板Set,由list模板继承而得。也就是说Set<T>将由list<T>继承而得。在你的实现中,Set对象实际上将会是一个list对象。于是你这样声明Set模板:
template<typename T> // 创建Set:此处是复用list的错误做法
class Set: public std::list<T> { ... };
这一方按乍看上去十分完美,实际上却存有隐患。如条目32所讲,如果D是一个B,那么对于B成立的一切对于D也成立。然而,list对象中可以存在重复的元素,因此如果我们先后两次将3051这个值插入list<int>中,这个表中将存在两个3051的副本。相反,Set不应含有重复的元素,如果两次插入3051,那么Set<int>中应仅存在一个该值的副本。于是“Set是一个list”这一说法便不成立了——对于list对象成立的一些结论不适用于Set对象。
由于这两个类之间的关系不是“A是B”,因此使用公共继承的方式来构造两者之间的关系便是错误的。我们可以想到Set对象可以“以list的形式实现”,以下是正确的做法:
template<class T> // 创建Set:此处是复用list的正确做法
class Set {
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
std::size_t size() const;
private:
std::list<T> rep; // 代表Set中的数据
};
Set的成员函数可以全方位的依赖list乃至标准库中其他部分提供的各项功能,因此实现方法是简单直接的,只要你掌握STL的基本使用方法即可:
template<typename T>
bool Set<T>::member(const T& item) const
{
return std::find(rep.begin(), rep.end(), item) != rep.end();
}
template<typename T>
void Set<T>::insert(const T& item)
{
if (!member(item)) rep.push_back(item);
}
template<typename T>
void Set<T>::remove(const T& item)
{
typename std::list<T>::iterator it =
std::find(rep.begin(), rep.end(), item);
// 此处为何使用typename请参见条目42
if (it != rep.end()) rep.erase(it);
}
template<typename T>
std::size_t Set<T>::size() const
{
return rep.size();
}
这些函数足够简单,我们有理由将它们声明为内联函数,然而在你做出明确决定之前,我还是建议你去条目30复习一下内联的相关知识。
一些人可能会说:Set的接口应该更加遵守条目18中讨论的主题:“设计接口要易于使用而不易误用”,是否应该让Set遵守STL容器的标准,但是这里遵守这些标准需要为Set添加一大批内容,这样做会淹没它与list之间的关系。由于本章节讨论的中心是这一关系问题,因此这里我牺牲了STL的兼容性,而更多考虑了讲述的清晰程度。另外,Set接口的不完善并不会掩盖此处关于它的无须争辩的事实:其与list之间的关系并不是“A是B”(尽管乍看上去很像),而是“A以B的形式实现”。
时刻牢记
l 组合与公共继承之间存在着本质区别。
l 组合在应用领域意味着“A是B”,在实现领域意味着“A以B的形式实现”。