一、初识继承
继承是面向对象编程中实现代码复用的重要手段。它允许我们在保持原有类特性的基础上进行扩展,从而产生新的类,即派生类。这种设计体现了类层次上的复用。
在术语上,原有类被称为基类(Base)、父类或超类;而在其基础上扩展而来的类则称为派生类(Derived)、子类。
语法格式
C++ 中定义继承关系非常简单,只需在类名后加上冒号和继承方式即可:
class Derived : public Base {
// 新增成员
};
这里的 public 表示公有继承,还有 protected 和 private 两种形式。
继承方式与访问权限
继承方式决定了基类成员在派生类中的可见性。
- public 继承:基类的 public 和 protected 成员在派生类中保持原有访问权限。
- protected 继承:基类的 public 和 protected 成员在派生类中变为 protected。
- private 继承:基类的所有成员在派生类中均变为 private。
注意:
- 基类的
private成员无论以何种方式继承,在派生类中都是不可见的。它们虽然被继承到了派生类对象的内存布局中,但语法上限制了访问。 - 如果基类成员不想在类外直接访问,但需要在派生类中使用,应定义为
protected。这是因继承而特有的访问限定符。 - 使用关键字
class时默认继承方式是private,使用struct时默认为public。建议显式写出继承方式以提高可读性。 - 实际开发中通常使用
public继承,很少使用protected或private继承,因为后者会限制成员的可用性,降低扩展维护性。
二、基类和派生类之间的赋值转换
理解对象间的赋值转换对于避免逻辑错误至关重要。
基本特性
- 派生类对象可以赋值给基类的对象、指针或引用。这被称为'对象切片'(Slicing),寓意将派生类中属于父类的部分切下来赋值过去。
- 基类对象不能赋值给派生类对象。
- 基类的指针或引用可以通过强制类型转换赋值给派生类的指针或引用,但这要求基类指针实际指向的是派生类对象,否则不安全。
切片现象演示
子类虽然继承了父类成员,但父类和子类中的同名成员(如 _name, _age)在内存中是独立的。子类包含两部分成员:继承来的父类成员和自身新增的成员。
代码示例:
class Base {
public:
int age;
Base(int a) : age(a) {}
};
class Derived : public Base {
public:
int no;
Derived(int a, int n) : Base(a), no(n) {}
};
int main() {
Derived d(18, 1001);
// 1. 派生类对象赋值给基类对象(切片)
Base b = d;
// 此时 b.age 为 18,但 b 中没有 no 成员
// 2. 基类指针指向派生类对象(安全)
Base* pb = &d;
Derived* pd = dynamic_cast<Derived*>(pb);
// 若 pb 确实指向 Derived 对象,pd 可安全访问 no
// 3. 危险操作:基类指针指向基类对象
Base* p_obj = new Base(20);
Derived* pd2 = dynamic_cast<Derived*>(p_obj);
// 此时 pd2 为空,若强行访问会导致未定义行为
}
三、重定义(隐藏)
在继承体系中,基类和派生类拥有独立的作用域。如果子类和父类中存在同名成员,子类成员将屏蔽对父类同名成员的直接访问,这种情况称为'隐藏'或'重定义'。
注:
- 成员函数只需要函数名相同就构成隐藏,参数列表不同也不影响隐藏判定(除非使用
using声明)。 - 在实际项目中,尽量避免在继承体系里定义同名的成员,以免混淆。
示例①:成员变量同名
class Base {
public:
int num;
};
class Derived : public Base {
public:
int num;
};
int main() {
Derived d;
d.num = 10; // 访问的是 Derived::num
d.Base::num = 20; // 显式访问 Base::num
}
如果不指定作用域,编译器优先查找局部域,然后是子类域,最后是父类域。找到一处即停止查询。
示例②:成员函数同名
class Base {
public:
void fun() { cout << "Base" << endl; }
void fun(int x) { cout << "Base int" << endl; }
};
class Derived : public Base {
public:
void fun() { cout << "Derived" << endl; }
};
int main() {
Derived d;
d.fun(); // 调用 Derived::fun,Base::fun 被隐藏
d.fun(1); // 报错!Base::fun(int) 也被隐藏了
}
通俗理解,当父子类出现同名成员时,默认先访问子类的同名成员,父类的同名成员仿佛被'藏'起来了。
四、派生类的默认成员函数
派生类的生命周期管理依赖于基类的配合。
基本特性
派生类的构造函数必须调用基类的构造函数来初始化基类部分。如果基类没有默认构造函数,必须在派生类构造函数的初始化列表中显式调用。拷贝构造函数和赋值运算符同理。析构函数则是先清理派生类成员,再自动调用基类析构函数。
总结:
- 派生类继承自基类的那部分,其构造、拷贝构造、赋值重载以及析构都需要通过派生类调用基类的来完成。
- 构造顺序:先基类,后派生类。
- 析构顺序:先派生类,后基类。
显式调用说明
- 父类的普通构造函数:若父类有默认构造函数,子类可不显式调用;若无,则必须显式调用。
- 父类的拷贝构造函数:子类的拷贝构造函数不会自动调用父类的拷贝构造函数,而是默认调用父类的默认构造函数。因此,若需正确拷贝父类部分,必须在初始化列表中显式调用父类的拷贝构造函数。
- 父类的赋值运算符重载:子类的赋值运算符重载不会自动调用父类的赋值运算符,需在函数体内部显式调用
Parent::operator=(other)。
五、继承与友元
友元关系不能继承。父类的友元不能访问子类的私有和保护成员,即使这些成员是从父类继承来的。因为父类的友元并不是子类的友元。
六、继承与静态成员
1. 基类静态成员变量
基类的静态成员变量在内存中仅存在一份实例,所有派生类(及基类自身的对象)共享这一份数据,不会因继承而产生新的副本。
- 内存唯一:全局区分配一次。
- 共享修改:任何对象修改都会反映在所有访问者眼中。
- 访问方式:推荐用
类名::静态成员访问。
2. 基类静态成员函数
派生类会继承该函数的实现,且所有派生类共享同一函数实例。
- 无多态性:静态成员函数无法被声明为
virtual,不支持多态。即使派生类重写了同名静态函数,也只是'隐藏',而非覆盖。
3. 派生类对基类静态成员的'隐藏'
若派生类定义了与基类同名的静态成员,会隐藏基类的静态成员,但不会覆盖。基类成员仍独立存在。
七、多继承
1. 单继承
前面例子多为单继承,此处略过。
2. 多继承
一般不建议设计出多继承,因为它增加了复杂度。
class Base1 {};
class Base2 {};
class Derived : public Base1, public Base2 {};
Derived 同时拥有 Base1 和 Base2 的成员。
3. 菱形继承
警告:一定不要设计出菱形继承,否则在复杂度和性能上可能出问题。
菱形继承会导致派生类中出现数据冗余和访问歧义。例如 FinalDerived 继承 Derived1 和 Derived2,两者都继承 Base,导致 FinalDerived 中有两份 Base 的成员。
4. 菱形继承的解决方法:虚继承
虚继承确保最终派生类中只保留共同基类的一份实例。
class Base { public: int x; };
class Derived1 : virtual public Base {};
class Derived2 : virtual public Base {};
class FinalDerived : public Derived1, public Derived2 {};
底层原理: 虚基类实例被移到内存末尾,成为共享实例。派生类增加虚基类指针(vbptr)和虚基类表(vbtable)来记录偏移量。最终派生类负责初始化虚基类。
注意:
- 仅在菱形继承场景下使用虚继承。
- 非菱形结构无需使用,徒增开销。
八、对象组合
通过在一个类中声明另一个类的对象作为成员,实现功能组合。这体现了 "has-a" 关系。
对象组合的形式
- 严格组合:成员对象完全依赖所属对象存在(如汽车和发动机)。
- 聚合:成员对象可独立存在,被所属对象引用(如公司和员工)。
- 关联:两个类通过指针/引用建立临时联系(如客户和订单)。
对象组合的优势
- 低耦合:修改成员对象内部实现不影响组合类。
- 高灵活性:可替换不同的成员对象。
- 避免菱形继承问题:多继承导致的歧义可通过组合解决。
总结: 在实际开发中,应遵循'优先使用组合,而非继承'(Favor Composition Over Inheritance)的设计原则,以构建更健壮、易维护的代码。


