条款39: 避免 "向下转换" 继承层次
在当今喧嚣的经济时代,关注一下我们的金融机构是个不错的主意。所以,看看下面这 个有关银行帐户的协议类(Protocol class )(参见条款34):
class Person { ... };
class BankAccount { public: BankAccount(const Person *primaryOwner, const Person *jointOwner); virtual ~BankAccount();
virtual void makeDeposit(double amount) = 0; virtual void makeWithdrawal(double amount) = 0;
virtual double balance() const = 0;
...
};
很多银行现在提供了多种令人眼花缭乱的帐户类型,但为简化起见,我们假设只有一种 银行帐户,称为存款帐户:
class SavingsAccount: public BankAccount { public: SavingsAccount(const Person *primaryOwner, const Person *jointOwner); ~SavingsAccount();
void creditInterest(); // 给帐户增加利息
...
};
这远远称不上是一个真正的存款帐户,但还是那句话,现在什么年代?至少,它满足我 们现在的需要。
银行想为它所有的帐户维持一个列表,这可能是通过标准库(参见条款49)中的list类 模板实现的。假设列表被叫做allAccounts:
list<BankAccount*> allAccounts; // 银行中所有帐户
和所有的标准容器一样,list存储的是对象的拷贝,所以,为避免每个BankAccount存 储多个拷贝,银行决定让allAccounts保存BankAccount的指针,而不是BankAccount本 身。
假设现在准备写一段代码来遍历所有的帐户,为每个帐户计算利息。你会这么写:
// 不能通过编译的循环(如果你以前从没 // 见过使用 "迭代子" 的代码,参见下文) for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
(*p)->creditInterest(); // 错误!
}
但是,编译器很快就会让你认识到:allAccounts包含的指针指向的是BankAccount对象 ,而非SavingsAccount对象,所以每次循环,p指向的是一个BankAccount。这使得对credi tInterest 的调用无效,因为creditInterest只是为SavingsAccount对象声明的,而不是BankAccount 。
如果"list<BankAccount*>::iterator p = allAccounts.begin()" 在你看来更象电话 线中的噪音,而不是C++,那很显然,你以前无缘见识过C++标准库中的容器类模板。标 准库中的这一部分通常被称为标准模板库(STL),你可以在条款49和M35初窥其概貌。 但现在你只用知道,变量p工作起来就象一个指针,它将allAccounts中的元素从头到尾 循环一遍。也就是说,p工作起来就好象它的类型是BankAccount**而列表中的元素都存 储在一个数组中。
上面的循环不能通过编译很令人泄气。的确,allAccounts是被定义为保存BankAccount *,但要知道,上面的循环中它事实上保存的是SavingsAccount*,因为SavingsAccount 是仅有的可以被实例话的类。愚蠢的编译器!对我们来说这么显然的事情它竟然笨得一 无所知。所以你决定告诉它:allAccounts真的包含的是SavingsAccount*:
// 可以通过编译的循环,但很糟糕 for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
static_cast<SavingsAccount*>(*p)->creditInterest();
}
一切问题迎刃而解!解决得很清晰,很漂亮,很简明,所做的仅仅是一个简单的转换而 已。你知道allAccounts指针保存的是什么类型的指针,迟钝的编译器不知道,所以你 通过一个转换来告诉它,还有比这更合理的事吗?
在此,我要拿圣经的故事做比喻。转换之于C++程序员,就象苹果之于夏娃。
这种类型的转换 ---- 从一个基类指针到一个派生类指针 ---- 被称为 "向下转换", 因为它向下转换了继承的层次结构。在刚看到的例子中,向下转换碰巧可以工作;但正 如下面即将看到的,它将给今后的维护人员带来恶梦。
还是回到银行的话题上来。受到存款帐户业务大获成功的激励,银行决定再推出支票帐 户业务。另外,假设支票帐户和存款帐户一样,也要负担利息:
class CheckingAccount: public BankAccount { public: void creditInterest(); // 给帐户增加利息
...
};
不用说,allAccounts现在是一个包含存款和支票两种帐户指针的列表。于是,上面所 写的计算利息的循环转瞬间有了大麻烦。
第一个问题是,虽然新增了一个CheckingAccount,但即使不去修改循环代码,编译还 是可以继续通过。因为编译器只是简单地听信于你所告诉它们(通过static_cast)的 一切:*p指向的是SavingsAccount*。谁叫你是它的主人呢?这会给今后维护带来第一 个恶梦。维护期第二个恶梦在于,你一定想去解决这个问题,所以你会写出这样的代码 :
for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
if (*p 指向一个 SavingsAccount) static_cast<SavingsAccount*>(*p)->creditInterest(); else static_cast<CheckingAccount*>(*p)->creditInterest();
}
任何时候发现自己写出 "如果对象属于类型T1,做某事;但如果属于类型T2,做另外某 事" 之类的代码,就要扇自己一个耳光。这不是C++的做法。是的,在C,Pascal,甚至 Smalltalk中,它是很合理的做法,但在C++中不是。在C++中,要使用虚函数。
记得吗?对于一个虚函数,编译器可以根据所使用对象的类型来保证正确的函数调用。 所以不要在代码中随处乱扔条件语句或开关语句;让编译器来为你效劳。如下所示:
class BankAccount { ... }; // 同上
// 一个新类,表示要支付利息的帐户 class InterestBearingAccount: public BankAccount { public: virtual void creditInterest() = 0;
...
};
class SavingsAccount: public InterestBearingAccount {
... // 同上
};
class CheckingAccount: public InterestBearingAccount {
... // as above
};
用图形表示如下:
BankAccount ^ | InterestBearingAccount /\ / \ / \ CheckingAccount SavingsAccount
因为存款和支票账户都要支付利息,所以很自然地想到把这一共同行为转移到一个公共 的基类中。但是,如果假设不是所有的银行帐户都需要支付利息(以我的经验,这当然 是个合理的假设),就不能把它转移到BankAccount类中。所以,要为BankAccount引入 一个新的子类InterestBearingAccount,并使SavingsAccoun和CheckingAccount从它继 承。
存款和支票账户都要支付利息的事实是通过InterestBearingAccount的纯虚函数creditInt erest 来体现的,它要在子类SavingsAccount和CheckingAccount中重新定义。
有了新的类层次结构,就可以这样来重写循环代码:
// 好一些,但还不完美 for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
static_cast<InterestBearingAccount*>(*p)->creditInterest();
}
尽管这个循环还是包含一个讨厌的转换,但代码已经比过去健壮多了,因为即使又增加 InterestBearingAccount新的子类到程序中,它还是可以继续工作。
为了完全消除转换,就必须对设计做一些改变。一种方法是限制帐户列表的类型。如果 能得到一列InterestBearingAccount对象而不是BankAccount对象,那就太好了:
// 银行中所有要支付利息的帐户 list<InterestBearingAccount*> allIBAccounts;
// 可以通过编译且现在将来都可以工作的循环 for (list<InterestBearingAccount*>::iterator p = allIBAccounts.begin(); p != allIBAccounts.end(); ++p) {
(*p)->creditInterest();
}
如果不想用上面这种 "采用更特定的列表" 的方法,那就让creditInterest操作使用于 所有的银行帐户,但对于不用支付利息的帐户来说,它只是一个空操作。这个方法可以 这样来表示:
class BankAccount { public: virtual void creditInterest() {}
...
};
class SavingsAccount: public BankAccount { ... }; class CheckingAccount: public BankAccount { ... }; list<BankAccount*> allAccounts; // 看啊,没有转换! for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
(*p)->creditInterest();
}
要注意的是,虚函数BankAccount::creditInterest提供一个了空的缺省实现。这可以 很方便地表示,它的行为在缺省情况下是一个空操作;但这也会给它本身带来难以预见 的问题。想知道内幕,以及如何消除这一危险,请参考条款36。还要注意的是,creditInt erest 是一个(隐式的)内联函数,这本身没什么问题;但因为它同时又是一个虚函数,内联 指令就有可能被忽略。条款33解释了为什么。
正如上面已经看到的,"向下转换" 可以通过几种方法来消除。最好的方法是将这种转 换用虚函数调用来代替,同时,它可能对有些类不适用,所以要使这些类的每个虚函数 成为一个空操作。第二个方法是加强类型约束,使得指针的声明类型和你所知道的真的 指针类型之间没有出入。为了消除向下转换,无论费多大工夫都是值得的,因为向下转 换难看、容易导致错误,而且使得代码难于理解、升级和维护(参见条款M32)。
至此,我所说的都是事实;但,不是全部事实。有些情况下,真的不得不执行向下转换 。
例如,假设还是面临本条款开始的那种情况,即,allAccounts保存BankAccount指针, creditInterest只是为SavingsAccount对象定义,要写一个循环来为每个帐户计算利息 。进一步假设,你不能改动这些类;你不能改变BankAccount,SavingsAccount或allAccou nts 的定义。(如果它们在某个只读的库中定义,就会出现这种情况)如果是这样的话,你 就只有使用向下转换了,无论你认为这个办法有多丑陋。
尽管如此,还是有比上面那种原始转换更好的办法。这种方法称为 "安全的向下转换" ,它通过C++的dynamic_cast运算符(参见条款M2)来实现。当对一个指针使用dynamic_ca st 时,先尝试转换,如果成功(即,指针的动态类型(见条款38)和正被转换的类型一致) ,就返回新类型的合法指针;如果dynamic_cast失败,返回空指针。
下面就是加上了 "安全向下转换" 的例子:
class BankAccount { ... }; // 和本条款开始时一样
class SavingsAccount: // 同上 public BankAccount { ... };
class CheckingAccount: // 同上 public BankAccount { ... };
list<BankAccount*> allAccounts; // 看起来应该熟悉些了吧...
void error(const string& msg); // 出错处理函数; // 见下文
// 嗯,至少转换很安全 for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
// 尝试将*p安全转换为SavingsAccount*; // psa的定义信息见下文 if (SavingsAccount *psa = dynamic_cast<SavingsAccount*>(*p)) { psa->creditInterest(); }
// 尝试将它安全转换为CheckingAccount else if (CheckingAccount *pca = dynamic_cast<CheckingAccount*>(*p)) { pca->creditInterest(); }
// 未知的帐户类型 else { error("Unknown account type!"); } }
这种方法远不够理想,但至少可以检测到转换失败,而用dynamic_cast是无法做到的。 但要注意,对所有转换都失败的情况也要检查。这正是上面代码中最后一个else语句的 用意所在。采用虚函数,就不必进行这样的检查,因为每个虚函数调用必然都会被解析 为某个函数。然而,一旦打算进行转换,这一切好处都化为乌有。例如,如果某个人在 类层次结构中增加了一种新类型的帐户,但又忘了更新上面的代码,所有对它的转换就 会失败。所以,处理这种可能发生的情况十分重要。大部分情况下,并非所有的转换都 会失败;但是,一旦允许转换,再好的程序员也会碰上麻烦。
上面if语句的条件部分,有些看上去象变量定义的东西,看到它你是不是慌张地擦了擦 眼镜?如果真这样,别担心,你没看错。这种定义变量的方法是和dynamic_cast同时增 加到C++语言中的。这一特性使得写出的代码更简洁,因为对psa或pca来说,它们只有 在被dynamic_cast成功初始化的情况下,才会真正被用到;使用新的语法,就不必在( 包含转换的)条件语句外定义这些变量。(条款32解释了为什么通常要避免多余的变量 定义)如果编译器尚不支持这种定义变量的新方法,可以按老方法来做:
for (list<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) {
SavingsAccount *psa; // 传统定义 CheckingAccount *pca; // 传统定义
if (psa = dynamic_cast<SavingsAccount*>(*p)) { psa->creditInterest(); }
else if (pca = dynamic_cast<CheckingAccount*>(*p)) { pca->creditInterest(); }
else { error("Unknown account type!"); } }
当然,从处理事情的重要性来说,把psa和pca这样的变量放在哪儿定义并不十分重要。 重要之处在于:用if-then-else风格的编程来进行向下转换比用虚函数要逊色得多,应 该将这种方法保留到万不得已的情况下使用。运气好的话,你的程序世界里将永远看不 到这样悲惨荒凉的景象。
--------------------------------------------------------------------------- -----
条款40: 通过分层来体现 "有一个" 或 "用...来实现"
使某个类的对象成为另一个类的数据成员,从而实现将一个类构筑在另一个类之上,这 一过程称为 "分层"(Layering)。例如:
class Address { ... }; // 某人居住之处
class PhoneNumber { ... };
class Person { public: ...
private: string name; // 下层对象 Address address; // 同上 PhoneNumber voiceNumber; // 同上 PhoneNumber faxNumber; // 同上 };
本例中,Person类被认为是置于string,Address和PhoneNumber类的上层,因为它包含 那些类型的数据成员。"分层" 这一术语有很多同义词,它也常被称为:构成(composition ),包含(containment)或嵌入(embedding)。
条款35解释了公有继承的含义是 "是一个"。对应地,分层的含义是 "有一个" 或 "用 ...来实现"。
上面的Person类展示了 "有一个" 的关系。一个Person对象 "有一个" 名字,地址,电 话号码和传真号码。你不能说,一个人 "是一个" 名字或一个人 "是一个" 地址;你得 说,一个人 "有一个" 名字, "有一个" 地址,等等。大多数人对区分这些没什么困难 ,所以混淆 "是一个" 和 "有一个" 的情况相对来说比较少见。
稍微有点麻烦的是区分 "是一个" 和 "用...来实现"。例如,假设需要一个类模板,用 来表示任意对象的集合,并且集合中没有重复元素。程序设计中,重用(Reuse)是再 好不过的一件事了,而且你也许已经读过条款49中关于C++标准库的总体介绍,那么, 你的第一反应一定是想采用标准库中的set模板。是啊,既然可以使用别人所写的东西 ,为什么还要再去写一个新的模板呢?
但是,深入研究set的帮助文档后,你会发现,set的下述限制将不能满足你的程序要求 :set要求包含在它内部的元素必须是完全有序的,即,对set中的任两个元素a和b来说 ,一定可以确定:要么a<b,要么b<a。对许多类型来说,这个要求很容易满足,而且, 对象间完全有序使得set可以在性能方面提供某些保证,这一点很吸引人。(参见条款 49了解标准库在性能上更多的保证)然而,你所需要的是更广泛的东西:一个类似set 的类,但对象不必完全有序;用C++标准所包装的术语来说,它们只需要所谓的 "相等 可比较性":对于同种类型的a和b对象来说,要能确定是否a==b。这种要求更简单,它 更适合于那些表示颜色这类东西的类型。总不能说红色比绿色更少或绿色比红色更少吧 ?看来,对你的程序来说,还是得需要自己来写个模板。
当然,重用还是件好事。作为数据结构专家,你知道,在实现集合的众多选择中,一个 最简单的办法是采用链表。你一定猜到了什么。对,标准库中正有这么一个list模板( 用来产生链表类)!所以可以重用它。
具体来说,你决定让自己的Set模板从list继承。即,Set<T>将从list<T>继承。因为, 在你的实现中,Set对象实际上将是list对象。于是你这样声明Set模板:
// Set中错误地使用了list template<class T> class Set: public list<T> { ... };
至此,一切好象都很正确,但实际上错误不小。正如条款35所说明的,如果D "是一个 " B,对B成立的所有事实对D也成立。但是,list对象可以包含重复元素,所以如果3051 这个值被增加到list<int>中两次,list中将包含3051的两个拷贝。相反,Set不可以包 含重复元素,所以如果3051被增加到Set<int>中两次,Set中将只包含这个值的一个拷 贝。于是,说一个Set "是一个" list就是弥天大谎,因为如上所述,有一些在list对 象中成立的事实在Set对象中不成立。
因为这两个类的关系并非 "是一个",所以用公有继承来表示它们的关系就是一个错误 。正确的方法是让Set对象 "用list对象来实现":
// Set中使用list的正确方法 template<class T> class Set { public: bool member(const T& item) const;
void insert(const T& item); void remove(const T& item);
int cardinality() const;
private: list<T> rep; // 表示一个Set };
Set的成员函数可以利用list以及标准库其它部分所提供的大量功能,所以,实现代码 既不难写也很易读:
template<class T> bool Set<T>::member(const T& item) const { return find(rep.begin(), rep.end(), item) != rep.end(); }
template<class T> void Set<T>::insert(const T& item) { if (!member(item)) rep.push_back(item); }
template<class T> void Set<T>::remove(const T& item) { list<T>::iterator it = find(rep.begin(), rep.end(), item);
if (it != rep.end()) rep.erase(it); }
template<class T> int Set<T>::cardinality() const { return rep.size(); }
这些函数很简单,所以很自然地想到将它们作为内联函数;但在做最后决定前,还是回 顾一下条款33所做的讨论。(上面的代码中,find, begin, end, push_back等函数是 标准库基本框架的一部分,它们可用来对list这样的容器模板进行操作。标准库框架的 总体介绍参见条款49和M35。)
值得指出的是,Set类的接口没有做到完整并且最小(参见条款18)。从完整性上来说 ,它最大的遗漏在于不能对Set中的内容进行循环,而这一功能对很多程序来说是必需 的(标准库中的所有成员都提供了这一功能,包括set)。Set的另一个缺陷是没有遵循 标准库所采用的容器类常规(见条款49和M35),从而造成使用Set时更难以利用库中其 它的部分。
Set的接口尽管有这些瑕疵,但下面这一点不能被掩盖:Set在理解它和list的关系上, 具有无可辩驳的正确性。这种关系并非 "是一个"(虽然初看会以为是),而是 "用.. .来实现",通过分层来实现这种关系是类的设计者应该感到自豪的。
顺便说一句,当通过分层使两个类产生联系时,实际上在两个类之间建立了编译时的依 赖关系。关于为什么要考虑到这一点以及如何减少这方面的麻烦,参见条款34。
--------------------------------------------------------------------------- -----
条款41: 区分继承和模板
考虑下面两个设计问题:
· 作为一位立志献身计算机科学的学生,你想设计一个类来表示对象的堆栈。这将需 要多个不同的类,因为每个堆栈中的元素必须是同类的,即,它里面包含的必须只是同 种类型的对象。例如,会有一个类来表示int的堆栈,第二个类来表示string的堆栈, 第三个类来表示string的堆栈的堆栈,等等。你也许对设计一个最小的类接口(参见条 款18)很感兴趣,所以会将对堆栈的操作限制在:创建堆栈,销毁堆栈,将对象压入堆 栈,将对象弹出堆栈,以及检查堆栈是否为空。设计中,你不会借助标准库中的类(包 括stack ---- 参见条款49),因为你渴望亲手写这些代码。重用(Reuse)是一件美事 ,但当你的目标是探究事情的工作原理时,那就只有挖地三尺了。
· 作为一位爱猫的宠物迷,你想设计一个类来表示猫。这也将需要多个不同的类,因 为每个品种的猫都会有点不同。和所有对象一样,猫可以被创建和销毁,但,正如所有 猫迷所知道的,猫所做的其它事不外乎吃和睡。然而,每一种猫吃和睡都有各自惹人喜 爱的方式。
这两个问题的说明听起来很相似,但却导致完全不同的两种设计。为什么?
答案涉及到"类的行为" 和 "类所操作的对象的类型"之间的关系。对于堆栈和猫来说, 要处理的都是各种不同的类型(堆栈包含类型为T的对象,猫则为品种T),但你必须问 自己这样一个问题:类型T影响类的行为吗?如果T不影响行为,你可以使用模板。如果 T影响行为,你就需要虚函数,从而要使用继承。
下面的代码通过定义一个链表来实现Stack类,假设堆栈的对象类型为T:
class Stack { public: Stack(); ~Stack();
void push(const T& object); T pop();
bool empty() const; // 堆栈为空?
private: struct StackNode { // 链表节点 T data; // 此节点数据 StackNode *next; // 链表中下一节点
// StackNode构造函数,初始化两个域 StackNode(const T& newData, StackNode *nextNode) : data(newData), next(nextNode) {} };
StackNode *top; // 堆栈顶部
Stack(const Stack& rhs); // 防止拷贝和 Stack& operator=(const Stack& rhs); // 赋值(见条款27) };
于是,Stack对象将构造如下所示的数据结构:
Stack对象 top--> data+next--> data+next--> data+next--> data+next --------------------------------------------------- --------------------------------- StackNode对象
链表本身是由StackNode对象构成的,但那只是Stack类的一个实现细节,所以StackNode 被声明为Stack的私有类型。注意StackNode有一个构造函数,用来确保它所有的域都被 正确初始化。即使你闭着眼睛都可以写出一个链表,但也不要忽视了C++的一些新特性 ,如struct中的构造函数。
下面看看你对Stack成员函数的实现。和许多原型(prototype)的实现(离制作成软件 产品相差太远)一样,这里没有错误检查,因为在原型世界里,没有东西会出错。
Stack::Stack(): top(0) {} // 顶部初始化为null
void Stack::push(const T& object) { top = new StackNode(object, top); // 新节点放在 } // 链表头部
T Stack::pop() { StackNode *topOfStack = top; // 记住头节点 top = top->next;
T data = topOfStack->data; // 记住节点数据 delete topOfStack;
return data; }
Stack::~Stack() // 删除堆栈中所有对象 { while (top) { StackNode *toDie = top; // 得到头节点指针 top = top->next; // 移向下一节点 delete toDie; // 删除前面的头节点 } }
bool Stack::empty() const { return top == 0; }
这些代码毫无吸引人之处。实际上,唯一有趣的一点在于:即使对T一无所知,你还是 能够写出每个成员函数。(上面的代码中实际上有个假设,即,假设可以调用T的拷贝 构造函数;但正如条款45所说明的,这是一个绝对合理的假设)不管T是什么,对构造 ,销毁,压栈,出栈,确定栈是否为空等操作所写的代码不会变。除了 "可以调用T的 拷贝构造函数" 这一假设外,stack的行为在任何地方都不依赖于T。这就是模板类的特 点:行为不依赖于类型。
将stack类转化成一个模板就很简单了,即使是Dilbert的老板都会写:
template<class T> class Stack {
... // 完全和上面相同
};
但是,猫呢?为什么猫不适合模板?
重读上面的说明,注意这一条:"每一种猫吃和睡都有各自惹人喜爱的方式"。这意味着 必须为每种不同的猫实现不同的行为。不可能写一个函数来处理所有的猫,所能做的只 能是制定一个函数接口,所有种类的猫都必须实现它。啊哈!衍生一个函数接口的方法 只能是去声明一个纯虚函数(参见条款36):
class Cat { public: virtual ~Cat(); // 参见条款14
virtual void eat() = 0; // 所有的猫吃食 virtual void sleep() = 0; // 所有的猫睡觉 };
Cat的子类 ---- 比如,Siamese和BritishShortHairedTabby ---- 当然得重新定义继 承而来的eat和sleep函数接口:
class Siamese: public Cat { public: void eat(); void sleep();
...
};
class BritishShortHairedTabby: public Cat { public: void eat(); void sleep();
...
};
好了,现在知道了为什么模板适合Stack类而不适合Cat类,也知道了为什么继承适合Cat 类。唯一剩下的问题是,为什么继承不适合Stack类。想知道为什么,不妨试着去声明 一个Stack层次结构的根类 ---- 所有其它的堆栈类都从这个唯一的类继承:
class Stack { // a stack of anything public: virtual void push(const ??? object) = 0; virtual ??? pop() = 0;
...
};
现在问题很明显了。该为纯虚函数push和pop声明什么类型呢?记住,每一个子类必须 重新声明继承而来的虚函数,而且参数类型和返回类型都要和基类的声明完全相同。不 幸的是,一个int堆栈只能压入和弹出int对象,而一个Cat堆栈只能压入和弹出Cat对象 。Stack类要怎样声明它的纯虚函数才能使用户既可以创建出int堆栈又可以创建出Cat 堆栈呢?冷酷而严峻的事实是,做不到。这就是为什么说继承不适合创建堆栈。
但也许你做事喜欢偷偷摸摸。或许你认为自己可以通过使用通用(void*)指针来骗过编 译器。但事实证明,现在这种情况下,通用指针也帮不上忙。因为你无法避开这一条件 :派生类虚函数的声明永远不能和它在基类中的声明相抵触。但是,通用指针可以帮助 解决另外一个不同的问题,它和模板所生成的类的效率有关。详细介绍参见条款42。
讲完了堆栈和猫,下面将本条款得到的结论总结如下:
· 当对象的类型不影响类中函数的行为时,就要使用模板来生成这样一组类。 · 当对象的类型影响类中函数的行为时,就要使用继承来得到这样一组类。
真正消化了以上两点的含义,你就可以在设计中游刃于继承或模板之间。
--------------------------------------------------------------------------- -----
|