一、概念
继承是类设计层次的复用。在 C++ 中,基类(父类)与派生类(子类)构成了这种层次关系。
语法示例:
class Person { /* ... */ };
class Student : public Person { /* ... */ };
继承关系决定了访问权限的传递方式。基类的私有成员虽然被继承到了派生类对象中,但在派生类内部和外部都无法直接访问。如果希望基类成员在派生类中可访问,但对外不可见,应将其定义为 protected。
访问规则遵循以下逻辑:基类其他成员在子类的访问方式取决于 Min(成员在基类的访问限定符,继承方式),即 public > protected > private。
二、基类和派生类对象赋值转换
不同类型对象赋值需要类型转换。例如 int 转 double 是隐式的,但类之间则不同。
父类对象不能赋值给子类对象,即使强转也不行。因为父类对象缺少子类特有的成员变量,强行赋值会导致数据丢失或随机值。
父类对象可以赋值给子类的指针或引用。这是多态的基础。
子类对象可以赋值给父类的对象、指针、引用。 当子类对象赋值给父类对象时,发生的是'切片'操作(Slicing),只拷贝了父类那部分成员,没有发生真正的类型转换。
三、继承中的作用域
作用域查找发生在编译时。子类和父类拥有独立的类域。如果子类定义了与父类同名的成员,会隐藏父类的同名成员,这被称为重定义。
在子类成员函数中,若需访问被隐藏的父类成员,可以使用作用域解析运算符 :: 显式指定。
注意: 尽量不在继承体系中定义同名成员,以免混淆。如果是成员函数的隐藏,只要函数名相同即构成隐藏,参数列表是否一致不影响隐藏的发生(这与重载不同,重载要求在同一作用域且参数不同)。
class Person {
public:
void fun() { cout << "Person::func()" << endl; }
protected:
string _name = "小李子";
};
class Student : public Person {
public:
// 隐藏了父类的 fun()
void fun(int) { cout << "Student::func(int)" << endl; }
void Print() {
cout << _name << endl; // 访问子类自己的
cout << Person::_num << endl; // 显式访问父类
}
protected:
int _num = 999;
};
四、派生类的默认成员函数
设计理念遵循:父类负责初始化父类部分,子类负责初始化自身部分。
1. 构造函数
子类不能在初始化列表中直接初始化基类的私有成员。必须通过调用基类的构造函数来完成初始化。
- 默认情况: 如果子类构造函数未显式调用基类构造函数,编译器会自动调用基类的默认构造函数。若基类没有默认构造函数,则会报错。
- 显式调用: 推荐在初始化列表中显式调用基类构造函数。
class Person {
public:
Person(const char* name) : _name(name) { cout << "Person()" << endl; }
protected:
string _name;
};
class Student : public Person {
public:
// 必须在初始化列表中调用基类构造函数
Student(const char* name, int id) : Person(name), _id(id) { }
protected:
int _id;
};
初始化顺序严格遵循成员声明顺序,通常先执行基类构造,再执行成员变量初始化。
2. 拷贝构造
派生类的拷贝构造函数必须调用基类的拷贝构造函数。
class Student : public Person {
public:
Student(const Student& s) : Person(s), _id(s._id) { }
};
这里利用了子类对象可以隐式转换为基类引用的特性,将 s 传给 Person 的拷贝构造。
3. 赋值重载
赋值运算符同样需要处理基类部分的赋值。由于派生类可能隐藏基类的赋值运算符,通常需要显式调用基类的版本。
class Student : public Person {
public:
Student& operator=(const Student& s) {
if (this != &s) {
Person::operator=(s); // 显式调用基类赋值
_id = s._id;
}
return *this;
}
};
4. 析构函数
构造和析构的顺序是相反的:构造是先基后子,析构是先子后基。
子类析构函数完成后,系统会自动调用基类析构函数。如果手动调用 ~Base(),可能会破坏自动调用的机制,导致重复析构或顺序错误。
为什么要保证先子后父? 因为子类对象中可能依赖父类成员的资源(如动态内存)。如果父类先析构,子类访问父类成员就会变成悬空指针或非法访问。
五、继承与友元
友元关系不能继承。父类的友元无法访问子类的私有或保护成员。如果需要在子类中使用友元,必须在子类中重新声明。
六、继承与静态成员
静态成员属于类而非对象。在继承体系中,无论派生出多少个子类,静态成员在整个继承链中只有一个实例,不会在子类中单独拷贝一份。
七、多继承、菱形继承与虚拟继承
实践中建议谨慎使用多继承,尽量避免菱形继承结构。
单继承:一个子类只有一个直接父类。 多继承:一个子类有两个或以上直接父类。 菱形继承:多继承的特殊情况,导致数据冗余和二义性。
问题演示
class Person { public: string _name; int _age; };
class Student : public Person { protected: int _num; };
class Teacher : public Person { protected: int _id; };
class Assistant : public Student, public Teacher { protected: string _majorCourse; };
// 访问 _age 会产生二义性
Assistant as;
as._age = 19; // 报错:对'_age'的访问不明确
虽然可以通过 as.Student::_age 解决二义性,但这违背常理且浪费空间(存在两份 _age)。
虚拟继承解决方案
引入 virtual 关键字进行虚拟继承,确保基类在最终派生类中只有一份实例。
class Student : virtual public Person { ... };
class Teacher : virtual public Person { ... };
这样,Assistant 对象中只包含一份 Person 的数据,解决了冗余和二义性问题。底层实现上,编译器会通过虚基表指针计算偏移量来定位共享的基类成员。
八、继承和组合
class D : public C {}; // 继承
class E { private: C _cc; }; // 组合
- 继承(白箱复用):父类细节对子类可见,耦合度高。改变父类可能影响子类。适合表达
is-a关系(如学生是人)。 - 组合(黑箱复用):对象内部细节不可见,只能通过公共接口交互,耦合度低。适合表达
has-a关系(如车有轮胎)。
实际开发中,优先使用组合。只有在明确需要多态行为或表达本质上的 is-a 关系时,才考虑使用继承。

