C++ 继承机制详解:从基础到菱形继承

一、为什么需要继承?

在面向对象设计中,当多个类存在大量重复的成员变量和成员函数时,代码冗余问题会变得非常严重。例如,学生(Student)和老师(Teacher)都有姓名、地址、电话、年龄,以及身份认证功能,但他们又各自拥有独特的属性(学号 vs 职称)和行为(学习 vs 授课)。

继承允许我们在保持原有类特性的基础上进行扩展,将公共部分提取到基类(父类)中,让派生类(子类)复用这些成员,从而实现类设计层次的代码复用

二、继承的基本概念与定义

2.1 语法格式

继承的基本写法如下,基类称为父类,派生类称为子类:

class Student final : public Person
{
    \\
};
//Person为Student的父类

2.2 继承方式与访问权限

继承方式有三种:publicprotectedprivate,与类的访问限定符对应。

访问规则的核心逻辑:基类成员在派生类中的最终访问权限,等于 Min(基类中的访问限定符, 继承方式),权限大小关系为 public > protected > private

关键规则总结

  1. 基类 private 成员:无论以何种方式继承,在派生类中都是不可见的。注意,不可见不等于没继承——这些成员仍然存在于派生类对象的内存中,只是语法上限制派生类无法访问。

  2. protected 因继承而生:如果基类成员不想在类外被直接访问,但需要在派生类中访问,就定义为 protected。它是专门为继承场景设计的访问级别。

  3. 默认继承方式:使用 class 时默认为 private 继承;使用 struct 时默认为 public 继承。但强烈建议显式写出继承方式,避免歧义。

  4. 实践建议:实际开发中几乎只使用 public 继承protectedprivate 继承下来的成员只能在派生类内部使用,扩展性和维护性极差,不提倡使用。

2.3 类模板继承的特殊性

当基类是类模板(如 std::vector<T>)时,派生类中访问基类成员需要显式指定类域

原因在于:模板是按需实例化的。当 stack<int> 实例化时,虽然连带实例化了 vector<int>,但如果派生类中没有直接调用 push_back 等函数,编译器不会实例化这些成员函数,导致找不到标识符的编译错误。

因此,在派生类中访问基类模板成员时,必须写成 vector<T>::push_back(x)this->push_back(x)

三、基类与派生类间的转换

public 继承体现的是 "is-a" 关系,即派生类对象就是一个基类对象。这种关系带来了天然的赋值兼容性:

3.1 向上转换(安全)

  • 派生类对象可以直接赋值给基类对象 / 基类指针 / 基类引用

  • 这个过程被称为切片(Slicing):将派生类中属于基类的那一部分"切"出来,基类指针或引用只指向这部分。

  • 注意:对象赋值时发生切片会截断派生类特有的成员;指针和引用则不会发生物理切割,只是访问范围受限。

3.2 向下转换(危险)

  • 基类对象不能赋值给派生类对象(编译报错)。

  • 基类指针/引用可以通过强制类型转换赋值给派生类指针/引用,但这仅在基类指针确实指向派生类对象时才是安全的。

  • 如果基类是多态类型,应使用 dynamic_cast 进行运行时安全转换(RTTI)。

class Person {
protected:
    string _name = "peter";
    string _sex;
    int _age = 18;
};

class Student : public Person {
public:
    int _No = 42;  // 学号
};

int main() {
    Student sobj;
    
    // 1. 派生类对象/指针/引用 → 基类(向上转换,安全,自动切片)
    Person* pp = &sobj;       // 切片:pp 只指向 Person 部分
    Person& rp = sobj;        // 引用切片
    Person pobj = sobj;       // 对象切片:_No 被截断!
    
    // 2. 基类 → 派生类(向下转换,不安全)
    // sobj = pobj;           // 编译报错!
    
    // 3. 强制转换(仅当基类指针确实指向派生类对象时才安全)
    Person* pp2 = &sobj;
    Student* ps = (Student*)pp2;  // 这里安全,因为 pp2 实际指向 Student
    
    return 0;
}

四、继承中的作用域与隐藏

4.1 独立作用域

基类和派生类拥有各自独立的作用域

4.2 隐藏(Hide)规则

当派生类与基类存在同名成员时,派生类成员会屏蔽基类同名成员的直接访问,这种现象称为隐藏(也叫重定义)。

  • 对于成员变量:只要名字相同就构成隐藏。

  • 对于成员函数只需要函数名相同就构成隐藏,与参数列表无关!这意味着基类的 fun() 和派生类的 fun(int) 并不构成重载,而是隐藏关系。

访问被隐藏的基类成员时,需要显式指定作用域:基类::成员名

工程建议:在继承体系中最好不要定义同名成员,否则极易造成混淆和维护困难。

class Person {
protected:
    string _name = "小李子";
    int _num = 111;           // 身份证号
public:
    void fun() { cout << "Person::fun()" << endl; }
};

class Student : public Person {
public:
    // 隐藏基类 _num,而非重定义
    int _num = 999;           // 学号
    
    // 隐藏基类 fun()!不是重载!(函数名相同即隐藏,与参数无关)
    void fun(int i) { cout << "Student::fun(int): " << i << endl; }
    
    void Print() {
        cout << "姓名: " << _name << endl;
        cout << "身份证号: " << Person::_num << endl;  // 显式访问被隐藏成员
        cout << "学号: " << _num << endl;
    }
};

int main() {
    Student s;
    s.Print();
    
    s.fun(10);        // OK:调用 Student::fun(int)
    // s.fun();       // 编译错误!基类 fun() 被隐藏,参数不匹配
    s.Person::fun();  // OK:显式调用基类版本
    
    return 0;
}

五、派生类的默认成员函数

派生类不主动实现时,编译器会自动生成 6 个默认成员函数。但继承关系使它们的行为变得复杂:

5.1 构造与析构的调用顺序

  • 构造:先调用基类构造函数初始化基类部分,再调用派生类构造函数

  • 析构:先调用派生类析构函数清理派生类部分,再自动调用基类析构函数

这个顺序保证了"先构造的后析构"原则,避免资源泄漏。

5.2 四大默认成员函数的实现要点

  1. 构造函数:必须在初始化列表中调用基类构造函数。如果基类没有默认构造,必须显式调用

  2. 拷贝构造函数:必须在初始化列表中调用基类的拷贝构造,通常传参为派生类对象(切片会自动发生)。

  3. 赋值运算符 operator=:必须显式调用基类的 operator=。由于派生类的 operator= 隐藏了基类版本,调用时必须加基类作用域限定

  4. 析构函数:派生类析构完成后,自动调用基类析构,不需要也不应该在派生类析构中手动调用。

5.3 析构函数的隐藏问题

编译器会对析构函数名做特殊处理,统一改名为 destructor()。因此,如果基类析构函数不加 virtual,基类和派生类的析构函数会构成隐藏关系而非重写。这也是多态章节中要求基类析构函数必须加 virtual 的根本原因。

5.4 如何设计一个不能被继承的类?

  • C++98 方案:将基类的构造函数设为 private。派生类构造时必须调用基类构造,但无法访问私有成员,因此无法实例化。

  • C++11 方案:使用 final 关键字修饰基类,简洁直接。


六、继承与友元、静态成员

6.1 友元关系不能继承

基类的友元函数不能访问派生类的私有和保护成员。如果友元函数需要访问派生类私有成员,必须同时成为派生类的友元

6.2 静态成员唯一共享

如果基类定义了 static 静态成员,那么整个继承体系只存在一份该成员实例。无论派生出多少子类,所有基类和派生类对象共享同一个静态成员。

可以通过 基类::静态成员派生类::静态成员 访问(在公有情况下),但底层地址相同。


七、多继承与菱形继承

7.1 继承模型分类

  • 单继承:一个派生类只有一个直接基类。

  • 多继承:一个派生类有两个及以上直接基类。对象内存模型中,先继承的基类在前,后继承的在后,派生类成员在最后。

  • 菱形继承:多继承的特殊情况。例如 Assistant 同时继承 StudentTeacher,而 StudentTeacher 又都继承自 Person

7.2 菱形继承的两大问题

  1. 二义性:通过 Assistant 访问 _name 时,编译器无法确定是通过 Student 还是 Teacher 的路径。必须显式指定 a.Student::_name

  2. 数据冗余Assistant 对象中包含两份 Person 的成员(一份来自 Student,一份来自 Teacher),造成内存浪费。

7.3 虚继承(virtual inheritance)

使用 virtual public(或 public virtual)继承基类,可以解决菱形继承的数据冗余和二义性问题。

原理:虚继承后,公共基类 PersonAssistant 对象中只保留一份,通过虚基表指针(vbptr)间接访问。

构造函数的特殊性:虚继承中,最终的派生类必须直接在初始化列表中调用虚基类的构造函数,中间层的基类对虚基类的构造调用会被忽略。例如 Assistant 的初始化列表中必须直接写 Person(name3),而最终 _name 的值由 Assistant 直接传给 Person 的那个参数决定。

7.4 多继承中的指针偏移

在多继承场景下,将派生类对象地址赋给不同基类指针时,指针值可能不相等。编译器需要进行指针偏移,使基类指针指向对象中对应基类子对象的起始位置。

7.5 标准库中的菱形继承

C++ 标准 IO 库是菱形虚拟继承的典型应用:basic_istreambasic_ostream 都虚继承自 basic_ios,而 basic_iostream 多继承自前两者。

更多推荐