5.6 类的继承与多态
面向对象程序设计概述
面向对象程序设计(object-oriented programming)的核心是数据抽象、继承和动态绑定。
- 数据抽象:分离类的接口和实现
- 继承:定义相似的类型并对其相似关系建模
- 动态绑定:在一定程序上忽略相似类型的区别,以统一的方式使用它们的对象
1. 数据抽象
Tips:封装(encapsulation)指的向用户隐藏类的实现细节,即类的用户只能使用接口而无法访问实现部分。
数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程技术。
- 接口:用户所能执行的操作
- 实现:类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数
2. 继承
编码规范:不要过度使用继承,组合常常更合适一些。尽量做到只在“是一个”(“is-a”)的情况下使用继承,在“has-a”的情况请使用组合。
通过继承(inheritance)联系在一起的类构成一种层次关系。层次关系的根部一般被称为基类(base class),其他类直接或间接从基类继承而来,这些继承得到的类被称为派生类(derived class)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类各自定义各自独有的成员。
2.1 虚函数
Tips:任何构造函数之外的非静态函数都可以是虚函数,关键字
virtual
只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。
2.2 类派生列表
派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(或哪些)基类继承而来的,每个基类前面还可以有访问说明符。
3. 动态绑定
通过使用动态绑定(dynamic binding),我们可以用相同的代码处理基类和派生类对象。这意味着当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。
Tips:在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针中。
3.1 静态类型与动态类型
Tips:如果表达式既不是引用也不是指针,那么它的动态类型永远与静态类型一致。
当我们在使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型(static type)与该表达式表示对象的动态类型(dynamic type)区分开来。
- 静态类型:编译时已知,是变量声明时的类型或表达式生成的类型
- 动态类型:运行时可知,是变量或表达式表示的内存中的对象的类型
3.2 动态类型的本质
之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。
Tips:如果我们已知某个基类向派生类的转换是安全的,那么我们可以使用
static_cast
来强制覆盖掉编译器的检查工作。
虚函数
在C++语言中,当我们使用基类的引用或者指针调用一个虚成员函数时会执行动态绑定。通常情况下如果我们不使用某个函数则无须为该函数提供定义,但是直到运行时我们才能知道到底调用了哪个版本的虚函数,因此所有的虚函数都必须有定义。
1. 虚函数参数与返回值
一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。一个派生类中的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。同派生类中虚函数的返回类型也必须与基类虚函数匹配,除非:
Tips:当类的虚函数返回类型是类本身的指针或引用时,返回类型可以不同。比如
D
由B
派生得到,则基类的虚函数可以返回B*
而派生类的对应函数可以返回D*
,只不过这样的返回类型要求从D
到B
的类型转换是·可访问的。
2. final和override说明符
在传统C++中,经常容易发现意外重载虚函数的事情:
struct Base {
virtual void foo();
};
struct SubClass: Base {
void foo();
};
有下列三种非预期的场景:
SubClass::foo
可能是程序员加入的一个和基类虚函数恰好同名的成员函数,却被编译器当作重载虚函数SubClass::foo
可能是程序员想重载虚函数,但是因为形参列表不同导致编译器认为这是一个新定义的成员函数- 当基类的虚函数
Base::foo
被删除后,SubClass::foo
就不再重载该虚函数而摇身一变成为一个普通的成员函数
2.1 override
编码规范:对于重载的虚函数或虚析构函数,使用
override
关键字显式地进行标记,标记为override
的函数如果不是对基类虚函数重载的话,编译会报错。
一旦类中的某个函数被声明为虚函数,那么在所有的派生类中它都是虚函数。一个派生类的函数如果覆盖了某个继承而来的虚函数,那么它的形参类型必须与基类函数完全一致。
派生类中如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,编译器会认为新定义的函数与基类中原有的函数是相互独立的。这会带来一个问题:如果我们本来希望派生类可以覆盖掉基类中的虚函数,但是一不小心把形参列表写错了,这可能与我们的本意不符。
C++11新标准提供了override
关键字来显式地告知虚拟器进行重载,编译器将检查基类是否存在这样的虚函数,否则将无法通过编译。这样的好处是使得程序员的意图更加清晰(覆盖基类中的虚函数),如果我们使用override
关键字标记了某个函数但是该函数没有覆盖已有的虚函数,此时编译器会报错。
struct Base {
virtual void foo(int);
};
struct SubClass: Base {
virtual void foo(int) override; // 合法
virtual void foo(float) override; // 非法, 父类无此虚函数
};
2.2 final
我们可以把类中的某个函数指定为final
,之后任何尝试覆盖该函数的操作都会引发错误,用于防止类被继续继承或者终止虚函数继续重载。
struct Base {
virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法
struct SubClass2: SubClass1 {
}; // 非法, SubClass1已final
struct SubClass3: Base {
void foo(); // 非法, foo已final
};
3. 回避动态绑定
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以达到这个目的:
#include <string>
#include <iostream>
// 基类
struct Base {
virtual std::string foo() { return "Base"; }
};
struct SubClass : Base {
std::string foo() override { return "SubClass"; }
};
int main() {
Base base;
SubClass sub_class;
// pb静态类型Base*, 动态类型SubClass*
Base *pb = &sub_class;
// 调用SubClass::foo(动态绑定)
std::cout << pb->foo() << std::endl;
// 调用Base::foo(避开动态绑定)
std::cout << pb->Base::foo() << std::endl;
}
// 输出:
SubClass
Base
Tips:通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
4. 编码规范:绝不在构造和析构过程中调用virtual函数
Effective C++:Never call virtual functions during construction or destruction.
- 在构造和析构期间不要调用virtual函数,因为这类调用不下降至derived class(比起当前执行构造函数和析构函数那层)。
在构造函数或者析构函数中调用virtual函数可能带来非预期的结果:
#include <iostream>
class Base {
public:
Base() {
foo(); // 在基类的构造函数中调用virtual函数
}
virtual void foo() const {
std::cout << "Base::foo()" << std::endl;
}
};
class Derived : public Base {
public:
void foo() const override {
std::cout << "Derived::foo()" << std::endl;
}
};
int main() {
Derived d;
return 0;
}
// 编译:
$g++ -g test.cpp -std=c++11 -o test
// 输出:
$./test
Base::foo()
注意我们构造的是派生类对象Derived
,但是调用的virtual函数却是基类版本Base::foo()
。这意味在基类构造期间virtual函数绝不会下降到派生类层次。非正式的说法可能更加传神:在基类构造期间,virtual函数不是virtual函数。
这是因为基类构造函数执行要早于派生类构造函数,当基类构造函数执行时派生类的成员变量尚未初始化。在此期间调用的virtual函数如果下降至派生类层次就有可能访问到尚未初始化的派生类成员变量,这是一个危险的操作,所以C++不允许你走这条路。
相同道理也适用于析构函数。一旦派生类的析构函数开始执行,对象内的派生类成员变量便呈现出未定义值,所以C++把它们当作不存在。进入基类析构函数后对象就被C++当作一个基类对象。
抽象基类
1. 纯虚函数
纯虚(pure virtual)函数用于告诉用户这个虚函数是没有实际意义的,和普通的虚函数不同,我们无须定义一个纯虚函数。纯虚函数的定义方式是在函数体的位置加上=0
:
struct Base {
virtual std::string foo() = 0; // 纯虚函数
};
struct SubClass : Base {
std::string foo() override { return "SubClass"; }
};
2. 抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。
Tips:我们不能(直接)创建一个抽象基类的对象。
访问控制
每个类分别控制着其成员对于派生类来说是否可访问。
1. 受保护的成员
Tips:派生类的成员和友元函数只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的成员不具有特殊的访问权限。
一个类使用protected
关键字来声明它希望与派生类分享但是不想被其他公共访问使用的成员,它的含义如下:
- 受保护成员对于类的用户是不可访问的
- 受保护成员对于派生类的成员和友元是可访问的
对于第二条规则,需要注意派生类的成员或友元只能通过派生类对象来访问基类的受保护成员,派生类对于基类对象中的受保护成员没有任何访问特权:
struct Base {
protected:
int prot_mem; // protected成员
};
class SubClass : Base {
// 正确: 能访问SubClass::prot_mem成员
friend void clobber(SubClass& s) {
s.j = s.prot_mem;
}
// 错误: 不能访问Base::prot_mem成员
friend void clobber(Base& b) {
b.prot_mem = 0;
}
// j默认是private成员
int j;
};
2. 公有、私有和受保护继承
编码规范:所有继承必须是
public
的,如果你想使用私有继承,你应该替换成把基类的实例作为成员对象的方式。
某个类对其继承而来的成员的访问权限受到两个因素影响:
- 在基类中该成员的访问说明符
- 在派生类的派生列表中的访问说明符
举个例子:
class Base {
public:
void pub_mem(); // public成员
protected:
int prot_mem; // protected成员
private:
char priv_mem; // private成员
};
// public继承
struct PubDerv: public Base {
// 正确: 派生类可以访问受保护成员
int f() { return prot_mem; }
// 错误: 派生类不可访问private成员
char g() { return priv_mem; }
};
// 私有继承
struct PrivDerv: private Base {
// 正确: private继承不影响派生类的访问权限
int f() const { return prot_mem; }
};
PubDerv pub_derv;
PrivDerv priv_derv;
pub_derv.pub_mem(); // 正确: pub_mem在派生类中是public的
priv_derv.pub_mem(); // 错误: pub_mem在派生类中是private的
Tips:派生访问说明符对于派生类的成员及友元能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。即
PubDerv
和PrivDerv
都能访问受保护的成员prot_mem
,同时它们都不能访问私有成员priv_mem
。派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
3. 派生类向基类转换的可访问性
派生类的派生访问说明符会影响派生类向基类转换的可访问性。假定D继承自B:
- 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护地或者私有地,则用户代码不能使用该转换
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换
- 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换
Tips:一个类包含三种不同的用户,即普通用户、类的实现者和派生类。基类应该将其接口部分声明为公有的,同时将实现部分分为两组:一组声明为受保护的可供派生类访问,另一组声明为私有的只能由基类及基类的友元访问。
4. 改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用using
声明可以实现这一目的:
class Base {
public:
std::size_t size() const { return n; }
protected:
std::size_t n;
};
// private继承: 继承而来的成员是私有成员
class SubClass : private Base {
public:
using Base::size;
protected:
using Base::n;
};
因为SubClass使用了私有继承,所以继承而来的成员size
和n
默认是派生类SubClass
的私有成员,然而我们使用using
声明语句改变了这些成员的可访问性。改变之后SubClass
的用户将可以使用size
成员,而SubClass
的派生类可以使用n
。
Tips:派生类只能为那些它可以访问(非私有成员)的名字提供
using
声明。
5. 默认的继承保护级别
默认情况下,使用class
关键字定义的派生类是私有继承的;而使用struct
关键字定义的派生类是公有继承的:
// 基类
class Base {};
// 默认public继承
struct D1 : Base {};
// 默认private继承
class D2 : Base {};
继承中的类作用域
1. 简介
每个类定义自己的作用域,在这个作用域中我们定义类的成员。当存在继承关系时,派生类的作用域嵌套在其基类的作用域之中。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
2. 在编译时根据静态类型进行名字查找
一个对象、引用或指针的静态类型决定了对象的哪些成员是可见的:
class Base {};
class SubClass : public Base {
public:
void foo() {
printf("foo\n");
}
};
SubClass sub_class;
// p1: 静态类型是Base*, 动态类型是SubClass*
Base *p1 = &sub_class;
// p2: 静态类型和动态类型都是SubClass*
SubClass *p2 = &sub_class;
// 错误: 静态类型Base没有foo成员
p1->foo();
// 正确: 静态类型有foo成员
p2->foo();
尽管在sub_class
中确实有一个foo
成员,但是该成员对于p1
却是不可见的。p1
是Base
的指针,这意味着对foo
的搜索将从Base
开始,因此无法通过Base
的对象、引用或者指针调用foo
成员。
3. 名字冲突与继承
Tips:派生类的成员将隐藏同名的基类成员,除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏在外层作用域(即基类)的名字:
#include <iostream>
class Base {
public:
Base(): mem(0) { }
protected:
int mem;
};
class SubClass : public Base {
public:
explicit SubClass(int i) : mem(i) { } // 用i初始化SubClass::mem, 基类Base执行默认初始化
int get_mem() { return mem; }
protected:
int mem; // 隐藏基类中的同名成员mem
};
int main() {
SubClass sub_class = SubClass(10);
std::cout << sub_class.get_mem() << std::endl; // 输出10
}
我们可以通过作用域运算符来使用隐藏的成员:
#include <iostream>
class Base {
public:
Base(): mem(0) { }
protected:
int mem;
};
class SubClass : public Base {
public:
explicit SubClass(int i) : mem(i) { } // 用i初始化SubClass::mem, 基类Base执行默认初始化
int get_mem() { return mem; }
int get_base_mem() { return Base::mem; } // 使用作用域运算符访问隐藏的成员
protected:
int mem; // 隐藏基类中的同名成员mem
};
int main() {
SubClass sub_class = SubClass(10);
std::cout << sub_class.get_mem() << std::endl; // 输出10
std::cout << sub_class.get_base_mem() << std::endl; // 输出0
}
4. 继承中的名字查找规则
理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用p->mem()
或者obj.mem()
,依次执行如下4个步骤:
- 首先确定
p
(或者obj
)的静态类型,因为我们调用的是一个成员,所以该类型必然是类类型 - 在
p
(或obj
)的静态类型对应的类中查找mem
,如果找不到则依次在直接基类中不断查找直至到达继承链的顶端,如果找遍了该类及其基类仍然找不到,则编译器将报错 - 一旦找到了
mem
,就进行常规的类型检查以确认对于当前找到的mem
,本次调用是否合法 - 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
- 如果
mem
是虚函数且我们是通过引用或者指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型 - 反之,如果
mem
不是虚函数或者我们是通过对象(而非引用或者指针)进行的调用,则编译器将产生一个常规函数调用
- 如果
5. 虚函数与作用域
根据前面我们讲到的继承中的名字查找规则,名字查找会优先于类型检查。类似于声明在内层作用域的函数并不会重载声明在外层作用域的函数,定义在派生类中的函数也不会重载其基类的成员。因此基类和派生类中的虚函数必须有相同的形参列表,否则相当于定义了一个接受不同实参的同名函数并隐藏了基类中的虚函数成员:
#include <iostream>
class Base {
public:
virtual void foo() { printf("Base\n"); }
};
class SubClass : public Base {
public:
// 隐藏基类中的foo虚函数而不是覆盖
virtual void foo(int) { printf("SubClass\n"); }
};
int main() {
Base base;
SubClass sub_class;
Base *p1 = &base;
Base *p2 = &sub_class;
// 正确: 调用Base::foo
p1->foo();
// 正确: 调用Base::foo
p2->foo();
SubClass *p3 = &sub_class;
p3->foo(10);
}
继承与虚析构函数
Tips:继承关系对基类拷贝构造控制最直接的影响就是基类通常应该定义一个虚析构函数,这样我们就可以动态分配继承体系中的对象了。一句话总结就是我们希望析构时执行的是指针动态类型的析构函数。
当我们delete
一个动态分配的对象的指针时会执行析构函数,如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。
需要注意的是:
- 一个基类总是需要虚析构函数,此时该析构函数为了成为虚函数而令内容为空,我们显然无法推断该基类是否还需要赋值运算符或拷贝构造函数(“三/五”法则特例)
- 即使基类通过
=default
使用了合成版本的析构函数,编译器也不会为这个类型合成移动操作
继承与合成拷贝控制
基类或派生类的合成拷贝控制成员与其他合成的构造函数、赋值运算符或析构函数类型:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁操作。
class Base {
public:
// 合成的默认构造函数
Base() = default;
// 合成的拷贝构造函数: 对成员依次拷贝
Base(const Base&) = default;
// 合成的移动构造函数: 对成员依次移动
Base(Base&&) = default;
// 合成的拷贝赋值运算符
Base& operator=(const Base&) = default;
// 合成的移动赋值运算符
Base& operator=(Base&&) = default;
};
派生类的拷贝控制成员
Tips:
- 派生类构造函数:初始化派生类自己的成员 + 初始化基类部分
- 派生类拷贝和移动构造函数:拷贝和移动派生类自有成员 + 拷贝和移动基类部分的成员
- 派生类赋值运算符:为派生类自有成员赋值 + 为其基类部分的成员赋值
- 派生类的析构函数:只负责销毁派生类自己分配的资源(基类部分是自动销毁的)
1. 派生类的拷贝和移动构造函数
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数来初始化对象的基类部分:
class Base {};
class SubClass : public Base {
public:
// 拷贝构造函数:
SubClass(const SubClass& sub_class) : Base(sub_class) /* SubClass成员的初始值列表 */ {
// 拷贝构造函数体
}
// 移动构造函数
SubClass(SubClass&& sub_class) : Base(sub_class) /* SubClass成员的初始值列表 */ {
// 移动构造函数体
}
};
如果我们在初始值列表中漏掉了基类的初始值时,基类部分会被默认初始值而非拷贝:
// 这个拷贝构造函数可能是不正确的定义: 基类部分被默认初始化而非拷贝
SubClass(const SubClass& sub_class) : /* SubClass成员的初始值列表, 但没有提供基类初始值 */ {
// 拷贝构造函数体
}
2. 派生类的赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值:
SubClass &SubClass::operator=(const SubClass &rhs) {
// 为基类部分赋值
Base::operator=(rhs);
// 按照过去的方式为派生类的成员赋值
// 酌情处理自赋值及释放已有资源等情况
return *this;
}
3. 派生类的析构函数
Tips:对象销毁的顺序正好与其创建的顺序相反,即派生类析构函数首先执行,然后是基类的析构函数,以此类推沿着继承体系的反方向直到最后。
和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源:
class SubClass : public Base {
public:
// Base::~Base被自动调用执行
~SubClass() { /* 用户定义清除派生类成员的操作 */ }
};
继承的构造函数
在C++11新标准中,派生类能够通过using
声明语句重用其直接基类定义的构造函数。
#include <iostream>
class Base {
public:
Base() = default;
explicit Base(int i) : i_(i) {}
int i_;
};
class SubClass : public Base {
public:
// 继承了接受单个int参数的构造函数
using Base::Base;
};
int main() {
// 继承的构造函数
SubClass sub_class = SubClass(10);
// 输出10
std::cout << sub_class.i_ << std::endl;
// 继承的构造函数不算用户定义的构造函数, 因此SubClass也会拥有一个合成的默认构造函数
SubClass sub_class2 = SubClass();
// 输出0
std::cout << sub_class2.i_ << std::endl;
}
通常情况下,using
声明语句只是零某个名字在当前作用域可见。而当作用于构造函数时,using
声明语句将零编译器产生代码:对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。需要注意的是:
- 类不能继承默认、拷贝和移动构造函数,这些构造函数按照正常规则被合成
- 如果派生类含有自己的数据成员,则这些成员将被默认初始化
- 继承的构造函数不会被当做用户定义的构造函数来使用,这意味着如果一个类只有继承的构造函数,则它也将拥有一个合成的默认构造函数
- 继承的构造函数不会改变该构造函数的访问级别(不管该
using
声明出现在哪里,基类的私有/受保护/公有构造函数在派生类中仍然是私有/受保护/公有的) - 基类构造函数含有默认实参时并不会被继承,派生类会获得多个继承的构造函数,每个构造函数分别省略掉一个含有默认实参的形参
继承与容器
由于不允许在容器中保存不同类型的元素,因此我们不能把具有继承关系的多种类型直接存放在对象中:
#include <iostream>
#include <vector>
class Base {};
class SubClass : public Base {};
int main() {
// 派生类容器: 只能存储派生类对象
std::vector<SubClass> sub_class_vec;
sub_class_vec.push_back(SubClass()); // 正确: 存储派生类对象
sub_class_vec.push_back(Base()); // 错误: 无法存储基类对象
// 基类容器: 可存储派生类对象和基类对象, 但派生类对象的“派生类”部分会被切掉
std::vector<Base> sub_base;
sub_base.push_back(SubClass()); // 正确: 但派生类部分会被丢弃掉
sub_base.push_back(Base()); // 正确: 存储基类对象
}
通常情况下我们可以在容器中存放基类的指针(更好的选择是智能指针),这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型:
Tips:类似于普通指针,我们也能把一个派生类的智能指针转换成基类的智能指针。
#include <iostream>
#include <vector>
#include <memory>
class Base {};
class SubClass : public Base {};
int main() {
std::vector<std::shared_ptr<Base>> basket;
basket.push_back(std::make_shared<Base>());
basket.push_back(std::make_shared<SubClass>());
}
编码规范:确定你的public继承模拟出is-a关系
Effective C++:Make sure public inheritance models “is-a”.
- “public继承”意味着“is-a”,适用于base class身上的每一件事情一定也适用于derived class身上,因为每一个derived class对象也都是一个base class对象。
is-a并非是唯一存在于classes之间的关系。另两个常见的关系是has-a(有一个)和is-implemented-in-terms-of(根据某物实现出)。将上述这些重要的相互关系中的任何一个误塑为is-a而造成的错误设计,在C++中并不罕见。所以你应该确定你确实了解这些个“class相互关系”之间的差异,并直到如何在C++中更好地塑造它们。