1. 项目概述:为什么“继承”是C++面向对象设计的基石?
在C++的江湖里,面向对象编程(OOP)有三大支柱:封装、继承和多态。如果说封装是让你把数据和操作打包成一个“黑盒子”,保护内部细节,那么继承就是让你能站在巨人的肩膀上,复用和扩展已有的“黑盒子”。今天咱们要深挖的,就是继承这个核心机制的上半部分。很多朋友学C++,对继承的理解可能停留在“子类拥有父类成员”的层面,但一到实际写代码,面对赋值兼容转换、子类构造函数调用顺序、析构函数虚不虚这些问题,就开始犯迷糊,甚至写出有内存泄漏风险或者行为诡异的代码。
我自己带团队做C++项目十几年,见过太多因为继承关系没理清而导致的Bug。比如,一个本该调用子类重写函数的地方,却调用了父类的版本,导致逻辑错误;又或者,用父类指针管理子类对象数组,最后delete时只调用了父类的析构函数,子类独有的资源没释放干净。这些问题,根源都在于对继承的底层机制理解不透彻。所以,这篇内容我们不搞花架子,就从最基础的概念定义开始,一步步拆解到赋值兼容规则,最后重点攻坚子类那些默认成员函数(构造、拷贝构造、赋值、析构)的生成与调用逻辑。我会结合大量代码示例和我在调试中踩过的坑,让你不仅知道怎么写,更明白为什么这么写,以及怎么写才安全高效。
2. 继承的概念与定义深度解析
2.1 继承的本质:代码复用与层次抽象
继承的核心思想是“是一个(is-a)”的关系。比如,“学生”是一种“人”,“轿车”是一种“汽车”。在代码层面,这意味着我们可以创建一个新的类(派生类或子类),它自动获得另一个已有类(基类或父类)的成员(数据和方法),并且可以添加自己特有的成员,或者修改继承来的行为。
为什么需要继承?最直接的好处就是代码复用。想象一下,你要写一个图形库,有圆形、矩形、三角形。它们都有位置、颜色、绘制、移动等共性。如果没有继承,你需要在每个类里重复定义x, y, color这些成员变量和move()、getArea()等函数声明。这不仅是体力活,更是维护的噩梦——一旦共性逻辑需要修改,你得改N个地方。用继承,我们把这些共性抽取到一个Shape基类中,然后让Circle、Rectangle、Triangle去继承它。共性代码只写一次,清晰又安全。
更深层次地,继承建立了类的层次结构,这是进行多态编程的基础。编译器看到一个Shape*指针时,它知道后面可能跟着Circle或Rectangle,这为运行时动态绑定(多态)提供了可能。
2.2 三种继承方式:public, protected, private 的权限魔术
定义继承的语法很简单:class Derived : [access-specifier] Base。这个[access-specifier]就是继承方式,它决定了基类成员在子类中的“可见性”或“访问权限”。这是理解继承的第一个关键点,很多人在这里会混淆。
注意:继承方式影响的是从基类继承而来的成员在子类中的访问权限,以及这些成员对于“子类的使用者”的访问权限。它不改变基类成员在基类内部的原有访问属性。
我们用一个表格来清晰对比三种继承方式的影响:
| 基类成员原有访问权限 | public继承后 | protected继承后 | private继承后 | 对子类外部(对象)的可见性 |
|---|---|---|---|---|
public | public | protected | private | public继承可见,其余不可见 |
protected | protected | protected | private | 均不可见 |
private | 不可访问 | 不可访问 | 不可访问 | 均不可见 |
核心解读:
public继承:这是最常用、最能体现“is-a”关系的继承方式。基类的public成员在子类中仍是public,protected仍是protected。这意味着子类对象可以像使用基类对象一样使用这些public成员,同时子类内部可以访问基类的protected成员。这是实现接口继承和子类型化的标准方式。protected继承:基类的public和protected成员在子类中都变成protected。这实际上是一种“实现继承”,它切断了“is-a”的对外接口。子类外部无法直接访问这些从基类来的成员(因为它们都成了protected或更私密),但子类内部及其后续派生类可以使用。这种用法较少,通常用于你只想复用基类的实现,而不想暴露其接口。private继承:基类的所有可访问成员(public和protected)在子类中都变成private。这是一种更强的“实现继承”,甚至对子类的子类都隐藏了基类的实现。从对象组合的角度看,private继承在功能上通常可以用“在一个类中包含另一个类的对象作为成员”来替代(即组合/聚合)。C++之父Bjarne Stroustrup也建议优先使用组合而非private继承,除非你需要重写基类的虚函数或访问其protected成员。
实操心得:
- 99%的情况,请使用
public继承。当你犹豫该用哪种时,选public。它语义清晰,符合直觉。 - 如果发现自己在考虑
protected或private继承,先停下来想想:“我是不是其实只需要这个类的功能,而不是‘是一种’的关系?”如果是,那么使用组合(将那个类的对象作为成员变量)通常是更清晰、耦合度更低的选择。 - 基类的
private成员,无论以何种方式继承,在子类中都是不可直接访问的。这是封装性的体现。如果子类需要访问或修改这些数据,基类应该提供protected的getter/setter函数。
2.3 继承下的名字查找与作用域遮蔽
当子类中定义了与基类同名的成员(变量或函数)时,会发生名字遮蔽。这意味着在子类的作用域内,子类的同名成员会“挡住”基类的成员。
class Base { public: int value = 10; void func() { std::cout << "Base::func()" << std::endl; } }; class Derived : public Base { public: int value = 20; // 遮蔽了 Base::value void func() { std::cout << "Derived::func()" << std::endl; } // 遮蔽了 Base::func() }; int main() { Derived d; std::cout << d.value << std::endl; // 输出 20,访问的是 Derived::value d.func(); // 输出 "Derived::func()",调用的是 Derived::func() // 如何访问被遮蔽的基类成员? std::cout << d.Base::value << std::endl; // 输出 10,使用作用域解析运算符 :: d.Base::func(); // 输出 "Base::func()" }关键点:
- 名字遮蔽发生在编译期,是基于类的作用域进行的,与函数是否虚函数无关(虚函数涉及的是动态绑定,是另一个话题)。
- 即使子类函数的参数列表与基类不同,只要名字相同,也会发生遮蔽。这有时会导致令人困惑的编译错误,你以为的函数重载并没有发生。
- 可以使用作用域解析运算符
::显式指定访问哪个类的成员。
踩坑记录:我曾遇到一个Bug,在子类中添加了一个void init()函数,结果发现基类中一个带参数的bool init(int mode)函数在子类中无法被调用了。就是因为名字被遮蔽了。解决方法要么是重命名子类函数,要么在子类中使用using Base::init;声明将基类的同名函数引入到子类作用域,形成重载集合。
3. 赋值兼容转换:理解类型关系的安全通道
赋值兼容规则是C++多态性的基石之一。它定义了在什么情况下,派生类对象可以“当作”基类对象来使用。这条规则是单向的、安全的。
3.1 规则详解:四种安全的使用场景
派生类对象可以赋值给基类对象。
class Base { /* ... */ }; class Derived : public Base { /* ... */ }; Derived d; Base b = d; // OK: 调用 Base 的拷贝构造函数(或编译器生成的)这里发生的是对象切片。
b获得的是d中属于Base部分的一个副本,Derived独有的部分被“切掉”了。b是一个纯粹的Base对象。派生类对象的地址可以赋值给基类指针。
Derived d; Base* pb = &d; // OK: pb 指向 d 对象中的 Base 子对象部分这是实现多态最常见的方式。指针
pb的静态类型是Base*,但它实际指向的是一个Derived对象。通过pb只能访问Base类中定义的成员(除非通过虚函数机制)。派生类对象可以初始化基类引用。
Derived d; Base& rb = d; // OK: rb 引用 d 对象中的 Base 子对象部分与指针类似,引用
rb绑定到了Derived对象d的Base子对象上。如果函数接受基类对象(或指针、引用)作为参数,可以传递派生类对象(或指针、引用)。
void process(Base& obj) { /* ... */ } Derived d; process(d); // OK: 隐式转换,obj 引用了 d 的 Base 部分
3.2 为什么是安全的?内存布局视角
从对象内存布局来看,一个派生类对象包含了一个完整的基类子对象,通常位于派生类对象内存布局的起始部分。因此,一个指向派生类对象的指针,其值同样也是一个指向该对象内基类子对象的合法指针。编译器知道这个偏移量(在单继承且不是虚基类的情况下,偏移量通常是0),所以转换是安全的、低成本的(通常不需要运行时计算)。
// 假设的内存布局(简化) // Derived 对象: // [ Base部分的数据成员 | Derived独有的数据成员 ] // ^ ^ // | | // Base* 指向这里 Derived* 指向这里(或更准确地说,指向整个对象的起始,但可被解释为指向Base部分)3.3 逆向转换与 dynamic_cast
赋值兼容是单向的。你不能把基类对象(或指向基类的指针/引用)直接赋值给派生类对象(或指针/引用),因为基类对象可能不包含派生类独有的成员,这是不安全的。
Base b; Derived* pd = &b; // 错误!编译不通过 Derived& rd = b; // 错误!编译不通过但是,如果你确定一个指向基类的指针实际上指向的是一个派生类对象,你可以使用dynamic_cast进行安全的向下转换。
Base* pb = new Derived(); // ... 经过一些逻辑,我们可能不确定 pb 到底指向什么 Derived* pd = dynamic_cast<Derived*>(pb); if (pd != nullptr) { // 转换成功,pb 确实指向一个 Derived 对象(或它的派生类) // 可以安全使用 pd 访问 Derived 的成员 } else { // 转换失败,pb 指向的不是 Derived 类型(或不是公有继承链上的类型) }注意事项:
dynamic_cast需要运行时类型信息(RTTI)支持,并且只能用于包含虚函数的类(多态类型)。- 如果转换失败,对指针类型的
dynamic_cast会返回nullptr,对引用类型的dynamic_cast会抛出std::bad_cast异常。 - 频繁使用
dynamic_cast可能意味着设计上有问题,应该考虑是否可以通过虚函数来替代。
4. 子类默认成员函数的生成与调用详解
这是继承中最容易出错的部分。当子类没有显式定义构造、拷贝、赋值、析构函数时,编译器会为我们生成。但这些生成函数的行为,与继承关系紧密相关。
4.1 构造函数:调用链与初始化顺序
子类对象的构造过程是从基类子对象到派生类成员,再到派生类自身的。
1. 调用顺序:
- 基类的构造函数。
- 派生类中成员对象的构造函数(按声明顺序)。
- 派生类自身的构造函数体。
2. 编译器生成的默认构造函数:如果派生类没有定义任何构造函数,编译器会生成一个合成的默认构造函数。这个合成函数会:
- 调用基类的默认构造函数。
- 按声明顺序调用每个成员对象的默认构造函数。
- 对于内置类型或复合类型的成员,不进行初始化(值是未定义的,除非在类内提供了初始值)。
3. 如何显式调用基类构造函数?在派生类构造函数的成员初始化列表中,可以(并且通常应该)显式调用基类的构造函数。如果不显式调用,编译器会尝试调用基类的默认构造函数。如果基类没有默认构造函数,则编译错误。
class Base { public: Base(int x) : m_x(x) { std::cout << "Base(int)" << std::endl; } private: int m_x; }; class Derived : public Base { public: // 错误:编译器尝试调用 Base(),但 Base 没有默认构造函数 // Derived() { } // 正确:在初始化列表中显式调用 Base 的构造函数 Derived(int x, int y) : Base(x), m_y(y) { std::cout << "Derived(int, int)" << std::endl; } private: int m_y; };4. 继承链的构造:对于多级继承,构造顺序是从最顶层的基类开始,沿着继承链依次向下。
class A { public: A() { std::cout << "A"; } }; class B : public A { public: B() { std::cout << "B"; } }; class C : public B { public: C() { std::cout << "C"; } }; C c; // 输出 "ABC"4.2 拷贝构造函数与赋值运算符:深拷贝与切片问题
1. 编译器生成的拷贝操作:如果派生类没有定义自己的拷贝构造函数和拷贝赋值运算符,编译器会生成合成的版本。这些合成函数会:
- 调用基类对应的拷贝操作(拷贝构造或赋值),来处理基类子对象部分。
- 对派生类自己的每个成员,执行成员类型的拷贝操作(即成员本身的拷贝构造或赋值)。
这通常意味着逐成员拷贝。如果基类或成员对象实现了正确的深拷贝,那么合成版本可能是够用的。但如果涉及动态内存等资源,就需要自己定义。
2. 派生类拷贝构造函数的正确写法:必须在初始化列表中显式调用基类的拷贝构造函数,否则编译器会调用基类的默认构造函数,导致基类部分数据丢失。
class Base { public: Base(int sz) : m_size(sz), m_data(new int[sz]) {} // 自定义拷贝构造函数(深拷贝) Base(const Base& other) : m_size(other.m_size), m_data(new int[other.m_size]) { std::copy(other.m_data, other.m_data + m_size, m_data); } // ... 析构函数需要 delete[] m_data private: int m_size; int* m_data; }; class Derived : public Base { public: Derived(int sz, int v) : Base(sz), m_extra(v) {} // 派生类拷贝构造函数 Derived(const Derived& other) : Base(other), // 关键!显式调用基类拷贝构造函数,传递派生类对象引用 m_extra(other.m_extra) { // 拷贝派生类独有成员 std::cout << "Derived copy ctor" << std::endl; } private: int m_extra; };注意Base(other)这一行。other虽然是Derived&类型,但由于赋值兼容规则,它可以被传递给期望const Base&的基类拷贝构造函数。这确保了基类子对象的正确拷贝。
3. 派生类拷贝赋值运算符的注意事项:同样,需要先调用基类的赋值运算符来处理基类部分。
class Derived : public Base { public: // ... 其他成员 Derived& operator=(const Derived& rhs) { if (this != &rhs) { Base::operator=(rhs); // 关键!显式调用基类赋值运算符 m_extra = rhs.m_extra; } return *this; } };重要陷阱:自赋值检查 (if (this != &rhs)) 必须在调用基类operator=之前吗?实际上,基类的operator=应该能正确处理自赋值。但为了安全起见,先做检查是个好习惯。更稳健的做法是采用“拷贝并交换” idiom,但这涉及更多细节。
4. “切片”问题在拷贝赋值中的体现:
Derived d1(5, 100); Derived d2(10, 200); Base& b_ref = d1; // b_ref 引用 d1 b_ref = d2; // 调用 Base::operator=(const Base&),发生切片!b_ref的静态类型是Base&,所以b_ref = d2调用的是Base类的赋值运算符。这只会拷贝d2的Base部分到d1的Base部分,d1的Derived部分(m_extra)保持不变。这很可能破坏了d1的对象完整性。这是赋值兼容规则带来的一个潜在陷阱。
4.3 析构函数:调用顺序与虚析构函数的重要性
1. 调用顺序:与构造顺序严格相反。
- 执行派生类自身的析构函数体。
- 调用派生类成员对象的析构函数(按声明逆序)。
- 调用基类的析构函数。
2. 编译器生成的析构函数:合成析构函数体为空,但会隐式地调用成员对象和基类的析构函数。对于管理资源的类,这通常不够,需要自定义。
3. 为什么基类析构函数必须是虚函数?这是C++面试的经典问题,也是实际项目中内存泄漏的常见根源。
class Base { public: ~Base() { std::cout << "~Base()" << std::endl; } // 非虚析构函数 }; class Derived : public Base { public: Derived() { m_buffer = new char[1024]; } ~Derived() { delete[] m_buffer; std::cout << "~Derived()" << std::endl; } private: char* m_buffer; }; int main() { Base* p = new Derived(); delete p; // 未定义行为!只调用了 ~Base(),没有调用 ~Derived() // 结果:Derived 的 m_buffer 内存泄漏! return 0; }当通过基类指针删除派生类对象时,如果基类析构函数不是虚函数,那么静态绑定发生,编译器根据指针的静态类型(Base*)决定调用~Base()。这导致派生类的析构函数不会被调用,派生类独有的资源(如m_buffer)无法释放。
修正:
class Base { public: virtual ~Base() { std::cout << "~Base()" << std::endl; } // 虚析构函数 };现在,delete p;会进行动态绑定,通过虚函数表找到Derived的析构函数并调用它。Derived的析构函数执行完后,会自动调用其基类(Base)的析构函数。资源得到正确释放。
黄金法则:
- 如果一个类有可能被继承(即作为基类),就应该将其析构函数声明为虚函数。
- 即使这个类看起来没有资源需要释放,声明虚析构函数也是一个好习惯,它几乎无成本(引入一个虚函数表指针),但能防止未来可能出现的严重错误。
- 只有明确设计为不被继承的类(例如某些工具类、不变量类),或者性能极其敏感的场合,才考虑使用非虚析构函数。在C++11以后,可以用
final关键字来禁止类被继承。
4.4 移动构造与移动赋值(C++11及以后)
对于支持移动语义的类,规则类似:
- 如果派生类没有定义移动操作,且没有声明拷贝操作、析构函数或赋值操作,编译器会合成移动构造函数和移动赋值运算符。
- 合成的移动操作会对基类部分和派生类成员部分执行移动操作(使用
std::move)。 - 如果派生类定义了拷贝操作或析构函数,编译器不会自动生成移动操作(遵循“三五法则”的扩展)。
- 在自定义派生类移动操作时,必须显式移动基类部分:
Base(std::move(other))和Base::operator=(std::move(other))。
5. 实战避坑指南与高级技巧
5.1 继承中的资源管理:三五法则的继承版
“三五法则”指出,如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能需要全部三个。在继承体系中,这个法则需要扩展:
“继承三五法则”实践:
- 如果基类需要管理资源(遵循三五法则),定义了拷贝控制成员。
- 那么派生类在管理自己额外资源时,通常也需要定义自己的拷贝控制成员(拷贝构造、拷贝赋值、析构)。
- 在定义这些成员时,必须记得显式调用基类对应的操作,如前面章节所示。
- 考虑移动语义(C++11后),则演变为“五之法则”(析构、拷贝构造、拷贝赋值、移动构造、移动赋值)。
一个常见的错误模式:
class Base { int* m_ptr; public: Base(int v) : m_ptr(new int(v)) {} ~Base() { delete m_ptr; } // 自定义析构函数 // 注意:没有定义拷贝构造和拷贝赋值!违反了三五法则。 }; class Derived : public Base { char* m_name; public: Derived(int v, const char* n) : Base(v), m_name(new char[strlen(n)+1]) { strcpy(m_name, n); } ~Derived() { delete[] m_name; } // 只释放了自己的资源 // 同样没有定义拷贝操作 }; int main() { Derived d1(42, "hello"); Derived d2 = d1; // 灾难!默认的拷贝构造函数被调用。 // 它调用了 Base 的默认拷贝构造函数(不存在,但编译器生成的是浅拷贝)。 // 于是 d2.m_ptr 和 d1.m_ptr 指向同一块内存。 // 析构时,这块内存会被 delete 两次,导致未定义行为。 }解决方案就是为Base和Derived都完整实现三五法则。
5.2 构造函数与析构函数中调用虚函数
在构造函数和析构函数中调用虚函数,不会发生多态(动态绑定),调用的是当前正在构造或析构的类所定义的版本。
class Base { public: Base() { init(); } // 在构造函数中调用虚函数 virtual void init() { std::cout << "Base::init" << std::endl; } virtual ~Base() { cleanup(); } // 在析构函数中调用虚函数 virtual void cleanup() { std::cout << "Base::cleanup" << std::endl; } }; class Derived : public Base { public: Derived() { } virtual void init() override { std::cout << "Derived::init" << std::endl; } virtual void cleanup() override { std::cout << "Derived::cleanup" << std::endl; } }; int main() { Derived d; // 输出: // Base::init // Derived::cleanup (析构时,对象已经是 Derived 类型,所以调用 Derived::cleanup) // Base::cleanup return 0; }原因:在Base构造函数执行时,Derived对象还没有完全构造好(Derived的成员和它自己的构造函数体还没执行)。此时对象的动态类型被认为是Base,因此虚函数机制不会下探到Derived的版本。析构函数同理,在~Derived()执行完后,对象中的Derived部分已经被认为“不存在”了,在调用基类析构函数时,对象的动态类型变回Base。
最佳实践:避免在构造函数和析构函数中调用虚函数。如果需要在对象构造时进行定制化初始化,可以考虑使用“初始化函数”模式,并在构造完成后由使用者显式调用,或者使用工厂方法。
5.3 多重继承与虚继承的初始化顺序
多重继承下,基类的构造顺序严格按照派生类声明中基类出现的顺序,与初始化列表中的顺序无关。
class A { public: A() { std::cout << "A"; } }; class B { public: B() { std::cout << "B"; } }; class C { public: C() { std::cout << "C"; } }; class D : public A, public B, public C { public: D() : C(), B(), A() { // 初始化列表顺序被忽略 std::cout << "D"; } }; D d; // 输出 "ABCD"虚继承(virtualinheritance)用于解决菱形继承问题,它确保了在继承体系中,虚基类子对象只存在一个实例。虚基类的初始化由最底层的派生类在其初始化列表中直接完成,中间层的派生类对虚基类构造函数的调用会被忽略。
class Base { public: Base(int) { std::cout << "Base"; } }; class A : virtual public Base { public: A(int x) : Base(x) { std::cout << "A"; } // 对 Base 的初始化可能被忽略 }; class B : virtual public Base { public: B(int x) : Base(x) { std::cout << "B"; } // 对 Base 的初始化可能被忽略 }; class Derived : public A, public B { public: // 必须直接初始化虚基类 Base Derived() : Base(42), A(1), B(2) { // Base 的初始化由 Derived 负责 std::cout << "Derived"; } }; Derived d; // 输出 "BaseABDerived" (Base 只初始化一次)虚继承增加了复杂性,除非必要(如接口类),否则应谨慎使用。
5.4 使用final和override提升代码安全
C++11引入了两个关键标识符,能显著提高继承相关代码的健壮性。
final:用于类或虚函数。- 用于类:表示该类不能被继承。
class Utility final { /* ... */ }; - 用于虚函数:表示该虚函数在派生类中不能被重写。
virtual void func() final;使用final可以防止意外的继承或重写,有时也能给编译器更多的优化机会。
- 用于类:表示该类不能被继承。
override:用于派生类中虚函数的声明之后,明确指示这个函数是重写基类的虚函数。class Base { public: virtual void foo(int); virtual void bar() const; }; class Derived : public Base { public: virtual void foo(int) override; // 正确 virtual void foo(double) override; // 错误!参数不匹配,不是重写,编译时报错 virtual void bar() override; // 错误!常量性不匹配,编译时报错 };使用
override可以让编译器帮你检查重写是否正确(函数签名是否完全匹配),避免因为手误(如参数类型、常量性不同)导致创建了一个新的虚函数而非重写,这是一种非常有效的防御性编程手段。
6. 总结与个人经验谈
继承是C++中构建复杂对象关系的强大工具,但“能力越大,责任越大”。通过上面的拆解,我们可以看到,从简单的概念定义到复杂的默认成员函数行为,每一步都需要清晰的理解。
回顾一下最重要的几点:
- 多用
public继承来表达“是一个”的关系,慎用protected/private继承,优先考虑组合。 - 深刻理解赋值兼容规则,它是多态的桥梁,但也要警惕“对象切片”问题。
- 严格把握构造和析构的顺序:构造由基类到派生,析构由派生到基类。
- 基类析构函数务必为虚函数,这是防止资源泄漏的生命线。
- 在派生类拷贝控制成员中,别忘了显式调用基类的对应操作,这是保证对象完整拷贝的关键。
- 避免在构造/析构函数中调用虚函数,因为那时多态机制可能不按你期望的方式工作。
- 积极使用
override和final,让编译器成为你代码安全的盟友。
在我自己的项目经历中,最深刻的教训都来自对第3、4、5点的忽视。曾经有一个底层通信模块的基类,因为觉得它很简单没有动态资源,就没写虚析构函数。后来同事继承它实现了一个带缓冲池的优化版本,结果在通过基类指针批量删除对象时,发生了微妙的内存泄漏,缓冲池的回收逻辑没执行。这个问题在压力测试下运行了好几天才因为内存耗尽暴露出来,调试过程非常痛苦。从那以后,只要一个类有被继承的可能,我的第一反应就是给它加上虚析构函数,这已经成了肌肉记忆。
另一个常见的坑是关于拷贝赋值运算符的自赋值检查。我曾写过类似if (this != &rhs) { Base::operator=(rhs); m_data = rhs.m_data; }的代码。但在一次复杂的多线程数据交换中,出现了rhs是*this的某个基类子对象引用的情况(通过复杂的类型转换),导致自赋值检查失效,进而引发了问题。更安全的做法是,确保每个类的operator=都能正确处理自赋值,或者采用“拷贝并交换”技术,创建一个临时副本再交换。
最后,关于设计,我想说,继承是一种紧耦合的关系。在当今更强调灵活性和可测试性的软件设计中,组合优于继承的原则越来越被重视。不要为了复用一点点代码就轻易引入继承链。如果可以用成员对象(组合)或通过接口(抽象基类)来实现,往往能得到更清晰、更易维护的代码结构。继承应该用于建立真正的类型层次关系,而不仅仅是代码复用的工具。