C++ 继承机制完全解析:从基础原理到菱形继承问题

引言

继承是面向对象程序设计中实现代码复用的核心手段。它允许在保持原有类特性的基础上进行扩展,增加新的成员函数和成员变量,从而形成新的类(称为派生类或子类)。继承呈现了面向对象程序的层次结构,体现了从简单到复杂的认知过程。与函数级别的复用不同,继承是类设计层面的复用。

本文基于C++继承的完整知识点,从基本概念、语法定义、访问控制、作用域规则、默认成员函数行为,到多继承、菱形继承问题及虚继承解决方案,再到继承与组合的设计选择,进行系统性的客观阐述。


目录

引言

一、继承的概念及定义

[1.1 继承的概念](#1.1 继承的概念)

[1.2 继承的定义格式](#1.2 继承的定义格式)

[1.3 继承方式与成员访问变化](#1.3 继承方式与成员访问变化)

[1.4 继承类模板](#1.4 继承类模板)

二、基类和派生类间的转换(切片)

三、继承中的作用域与隐藏规则

[3.1 隐藏规则](#3.1 隐藏规则)

[3.2 典型例题分析](#3.2 典型例题分析)

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

[4.1 构造函数](#4.1 构造函数)

[4.2 拷贝构造函数](#4.2 拷贝构造函数)

[4.3 赋值运算符重载](#4.3 赋值运算符重载)

[4.4 析构函数](#4.4 析构函数)

[4.5 完整示例](#4.5 完整示例)

[4.6 实现一个不能被继承的类](#4.6 实现一个不能被继承的类)

五、继承与友元

六、继承与静态成员

七、多继承及其菱形继承问题

[7.1 继承模型分类](#7.1 继承模型分类)

[7.2 菱形继承的问题](#7.2 菱形继承的问题)

[7.3 虚继承解决方案](#7.3 虚继承解决方案)

[7.4 多继承中的指针偏移问题](#7.4 多继承中的指针偏移问题)

[7.5 IO 库中的菱形虚拟继承](#7.5 IO 库中的菱形虚拟继承)

八、继承与组合

[8.1 两种关系](#8.1 两种关系)

[8.2 白箱复用与黑箱复用](#8.2 白箱复用与黑箱复用)

[8.3 设计原则](#8.3 设计原则)

总结


一、继承的概念及定义

1.1 继承的概念

在没有继承的情况下,若存在StudentTeacher两个类,它们都包含姓名、地址、电话、年龄等成员变量,以及身份认证identity()成员函数。这些公共成员需要在两个类中重复定义,造成代码冗余。通过继承,可以将公共成员提取到一个基类(如Person)中,然后让StudentTeacher分别继承该基类,从而复用这些成员。

示例

cpp

复制代码
class Person {
public:
    void identity() {
        cout << "identity()" << name << endl;
    }
protected:
    string _name = "张三";
    string _address;
    string _tel;
    int _age = 18;
};

class Student : public Person {
public:
    void study() { /* ... */ }
protected:
    int _stuid;   // 学号
};

class Teacher : public Person {
public:
    void teaching() { /* ... */ }
protected:
    string _title; // 职称
};

1.2 继承的定义格式

cpp

复制代码
class 派生类 : 继承方式 基类 {
    // 派生类成员
};
  • 基类:被继承的类,也称父类。

  • 派生类:继承而来的类,也称子类。

1.3 继承方式与成员访问变化

继承方式分为三种:publicprotectedprivate。基类成员在派生类中的访问方式遵循以下规则:

基类成员 \ 继承方式 public 继承 protected 继承 private 继承
public 成员 派生类中为 public 派生类中为 protected 派生类中为 private
protected 成员 派生类中为 protected 派生类中为 protected 派生类中为 private
private 成员 派生类中不可见 派生类中不可见 派生类中不可见

要点说明

  • 基类的 private 成员在派生类中不可见。这里的"不可见"是指语法上禁止访问,但这些成员实际上仍被继承到了派生类对象中。

  • 如果基类成员不想在类外被直接访问,但又希望在派生类中能够访问,应定义为 protectedprotected 限定符正是因继承而引入的。

  • 访问方式的简化规则:Min(成员在基类的访问限定符, 继承方式),其中 public > protected > private

  • 使用 class 定义类时,默认继承方式为 private;使用 struct 时,默认继承方式为 public。建议显式写出继承方式。

  • 实际开发中,绝大多数场景使用 public 继承。protectedprivate 继承很少使用,因为它们会限制派生类成员的访问性,不利于扩展和维护。

1.4 继承类模板

当派生类继承的基类是类模板时,在派生类成员函数中调用基类成员函数需要显式指定基类作用域,否则可能因模板按需实例化而导致编译错误。

cpp

复制代码
template<class T>
class stack : public std::vector<T> {
public:
    void push(const T& x) {
        vector<T>::push_back(x);  // 需要指定类域
    }
    void pop() {
        vector<T>::pop_back();
    }
    const T& top() {
        return vector<T>::back();
    }
    bool empty() {
        return vector<T>::empty();
    }
};

二、基类和派生类间的转换(切片)

  • 派生类对象可以赋值给基类的指针或引用 。编译器会将派生类对象中属于基类的部分"切出来",基类指针/引用指向这部分。这种操作称为切片(slicing)。

  • 派生类对象可以赋值给基类对象(通过调用基类的拷贝构造函数完成)。

  • 基类对象不能赋值给派生类对象,因为基类缺少派生类新增的成员。

  • 基类的指针或引用可以通过强制类型转换转换为派生类的指针或引用。但只有当基类指针实际指向派生类对象时,这种转换才是安全的。在多态场景下,可使用 dynamic_cast 进行安全的向下转换。

cpp

复制代码
class Person { /* ... */ };
class Student : public Person { /* ... */ };

int main() {
    Student s;
    Person* pp = &s;   // 派生类指针 → 基类指针
    Person& rp = s;    // 派生类对象 → 基类引用
    Person pobj = s;   // 派生类对象 → 基类对象(拷贝构造)
    // s = pobj;       // 错误:基类对象不能赋值给派生类对象
    return 0;
}

三、继承中的作用域与隐藏规则

3.1 隐藏规则

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

  2. 如果派生类和基类中存在同名成员 (包括成员变量和成员函数),派生类的成员会屏蔽 基类同名成员的直接访问。这种情况称为隐藏(hidden)。

  3. 在派生类成员函数中,可以通过 基类::成员名 显式访问被隐藏的基类成员。

  4. 对于成员函数,只需函数名相同即构成隐藏,与参数列表无关。

  5. 实际开发中,应尽量避免在继承体系中定义同名成员,以免造成混淆。

cpp

复制代码
class Person {
protected:
    string _name = "小李子";
    int _num = 111;      // 身份证号
};

class Student : public Person {
public:
    void Print() {
        cout << "姓名:" << _name << endl;
        cout << "身份证号:" << Person::_num << endl;  // 显式访问基类成员
        cout << "学号:" << _num << endl;             // 访问派生类成员
    }
protected:
    int _num = 999;      // 学号,与基类 _num 构成隐藏
};

3.2 典型例题分析

题目 :A 和 B 类中的两个 func 构成什么关系?

cpp

复制代码
class A {
public:
    void fun() { cout << "func()" << endl; }
};
class B : public A {
public:
    void fun(int i) { cout << "func(int i)" << i << endl; }
};

答案 :构成隐藏 。因为函数名相同(均为 fun),派生类的 fun(int) 隐藏了基类的 fun(),即使参数列表不同,也不构成重载(重载要求在同一作用域内)。

调用 b.fun() 会编译报错,因为派生类中 fun(int) 隐藏了基类的无参版本,编译器在派生类作用域中找不到匹配的 fun()


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

派生类中,6个默认成员函数(构造、拷贝构造、赋值重载、析构、取地址重载、const取地址重载)的生成规则有其特殊性。以下重点说明前四个。

4.1 构造函数

  • 派生类的构造函数必须调用基类的构造函数来初始化从基类继承的那部分成员。

  • 如果基类没有默认构造函数(即无参构造函数),则必须在派生类构造函数的初始化列表中显式调用基类的某个构造函数。

  • 初始化顺序:先调用基类构造函数,再调用派生类构造函数。

4.2 拷贝构造函数

  • 派生类的拷贝构造函数必须调用基类的拷贝构造函数来完成基类部分的拷贝初始化。

  • 在初始化列表中调用基类拷贝构造时,可以直接传入派生类对象(会切片)。

4.3 赋值运算符重载

  • 派生类的 operator= 必须调用基类的 operator= 来完成基类部分的复制。

  • 由于派生类的 operator= 会隐藏基类的 operator=(同名构成隐藏),因此需要显式指定基类作用域来调用。

4.4 析构函数

  • 派生类的析构函数在执行完自身的清理工作后,会自动调用基类的析构函数。

  • 清理顺序:先调用派生类析构函数,再调用基类析构函数。

  • 由于多态场景下析构函数需要构成重写(函数名相同),编译器会对析构函数名进行特殊处理,统一处理为 destructor()。因此,如果基类析构函数不加 virtual,派生类析构函数与基类析构函数将构成隐藏关系,而非重写。

4.5 完整示例

cpp

复制代码
class Person {
public:
    Person(const char* name = "peter") : _name(name) {
        cout << "Person()" << endl;
    }
    Person(const Person& p) : _name(p._name) {
        cout << "Person(const Person& p)" << endl;
    }
    Person& operator=(const Person& p) {
        cout << "Person operator=(const Person& p)" << endl;
        if (this != &p) _name = p._name;
        return *this;
    }
    ~Person() {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
};

class Student : public Person {
public:
    Student(const char* name, int num) : Person(name), _num(num) {
        cout << "Student()" << endl;
    }
    Student(const Student& s) : Person(s), _num(s._num) {
        cout << "Student(const Student& s)" << endl;
    }
    Student& operator=(const Student& s) {
        cout << "Student& operator=(const Student& s)" << endl;
        if (this != &s) {
            Person::operator=(s);  // 显式调用基类赋值运算符
            _num = s._num;
        }
        return *this;
    }
    ~Student() {
        cout << "~Student()" << endl;
    }
protected:
    int _num;
};

4.6 实现一个不能被继承的类

  • C++98 方法 :将基类的构造函数设为 private。由于派生类的构造函数必须调用基类构造函数,而私有成员不可见,因此派生类无法实例化对象。

  • C++11 方法 :使用 final 关键字修饰基类,阻止被继承。

cpp

复制代码
// C++11
class Base final {
    // ...
};

// C++98
class Base {
private:
    Base() {}
};

五、继承与友元

友元关系不能继承。即基类的友元函数不能访问派生类的私有或保护成员。

cpp

复制代码
class Student;  // 前向声明

class Person {
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name;
};

class Student : public Person {
protected:
    int _stuNum;
};

void Display(const Person& p, const Student& s) {
    cout << p._name << endl;   // OK
    // cout << s._stuNum << endl; // 错误:无法访问派生类的 protected 成员
}

解决方法是也将 Display 声明为 Student 的友元函数。


六、继承与静态成员

如果基类定义了一个 static 静态成员,则在整个继承体系中只有一个该成员的实例。无论派生多少个派生类,所有基类和派生类共享同一个静态成员。

cpp

复制代码
class Person {
public:
    string _name;
    static int _count;
};
int Person::_count = 0;

class Student : public Person {
protected:
    int _stuNum;
};

int main() {
    Person p;
    Student s;
    cout << &p._count << endl;   // 地址相同
    cout << &s._count << endl;   // 地址相同
    cout << Person::_count << endl;
    cout << Student::_count << endl;  // 可以通过派生类访问
    return 0;
}

七、多继承及其菱形继承问题

7.1 继承模型分类

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

  • 多继承:一个派生类有两个或以上直接基类。多继承对象在内存中的布局为:先继承的基类成员在前,后继承的基类成员在后,派生类自身成员放在最后。

  • 菱形继承 :多继承的一种特殊情况,形成菱形结构。例如:PersonStudentTeacher 继承,而 Assistant 同时继承 StudentTeacher

7.2 菱形继承的问题

菱形继承带来两个主要问题:

  1. 数据冗余Assistant 对象中包含两份 Person 的成员。

  2. 二义性 :访问 Person 的成员时,编译器无法确定访问的是 Student 中的 Person 还是 Teacher 中的 Person

cpp

复制代码
class Person { public: string _name; };
class Student : public Person { protected: int _num; };
class Teacher : public Person { protected: int _id; };
class Assistant : public Student, public Teacher { protected: string _majorCourse; };

int main() {
    Assistant a;
    // a._name = "peter";  // 错误:二义性
    a.Student::_name = "xxx";  // 显式指定,可解决二义性,但数据冗余仍存在
    a.Teacher::_name = "yyy";
    return 0;
}

7.3 虚继承解决方案

C++ 通过虚继承 解决菱形继承的数据冗余和二义性问题。在继承时使用 virtual 关键字。

cpp

复制代码
class Person { public: string _name; };
class Student : virtual public Person { protected: int _num; };
class Teacher : virtual public Person { protected: int _id; };
class Assistant : public Student, public Teacher { protected: string _majorCourse; };

int main() {
    Assistant a;
    a._name = "peter";   // 唯一,无二义性
    return 0;
}

虚继承的底层实现较为复杂,通常通过虚基类指针和偏移量来保证只有一份虚基类成员。实践中应尽量避免设计菱形继承。C++ 的多继承被认为是语言的一个复杂点,后来的许多编程语言(如 Java)直接不支持多继承,从而规避了菱形继承问题。

7.4 多继承中的指针偏移问题

例题:以下说法正确的是?

cpp

复制代码
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };

int main() {
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    // 比较 p1, p2, p3
}

答案p1 == p3 != p2。原因:p1p3 都指向对象的起始地址(因为 Base1 是最先继承的基类),而 p2 指向 Base2 子对象的起始地址,位于 Base1 之后,因此地址值不同。

7.5 IO 库中的菱形虚拟继承

C++ 标准库中的 IO 流体系使用了菱形虚拟继承。例如 basic_ostreambasic_istream 均虚继承自 basic_ios,而 basic_iostream 同时继承前两者,通过虚继承保证了 basic_ios 部分只有一份。

cpp

复制代码
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits> {};

template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits> {};

八、继承与组合

8.1 两种关系

  • public 继承 表示 is-a 关系:每个派生类对象都是一个基类对象。例如,BMW 是一种 Car

  • 组合 表示 has-a 关系:每个对象中包含另一个对象作为成员。例如,Car 包含四个 Tire 对象。

8.2 白箱复用与黑箱复用

  • 继承(白箱复用):基类的内部细节对派生类可见。这在一定程度上破坏了基类的封装性。基类的任何改变都可能影响派生类,导致类之间的依赖关系强、耦合度高。

  • 组合(黑箱复用):被组合对象的内部细节不可见,仅通过良好定义的接口进行交互。组合类之间依赖关系弱、耦合度低,有利于代码维护。

8.3 设计原则

优先使用组合,而不是继承。组合带来的耦合度更低,代码维护性更好。但以下情况适合使用继承:

  • 类之间确实存在 is-a 关系。

  • 需要实现多态(虚函数机制必须基于继承)。

如果类之间的关系既可以用 is-a 描述也可以用 has-a 描述(例如 stackvector),则优先选择组合。

cpp

复制代码
// 组合示例:Car has-a Tire
class Tire { /* ... */ };
class Car {
    Tire _t1, _t2, _t3, _t4;
};

// 继承示例:BMW is-a Car
class BMW : public Car {
public:
    void Drive() { cout << "好开-操控" << endl; }
};

// stack 与 vector:既可以用继承,也可以用组合,优先选择组合
template<class T>
class stack {
    vector<T> _v;  // 组合
};

总结

本文系统梳理了 C++ 继承机制的核心知识点:

  1. 继承概念:通过提取公共成员到基类,实现类层面的代码复用。

  2. 访问控制private 成员在派生类中不可见;protected 是为继承而设计的访问级别;实际开发以 public 继承为主。

  3. 切片与转换:派生类对象可赋值给基类指针/引用/对象,反之不行。

  4. 作用域与隐藏 :基类和派生类有独立作用域,同名成员构成隐藏,可通过 基类:: 显式访问。

  5. 默认成员函数:派生类的构造、拷贝构造、赋值、析构函数需正确调用基类对应函数,注意初始化顺序和析构顺序。

  6. 友元与静态成员:友元不继承;静态成员在整个继承体系中共享一份实例。

  7. 多继承与菱形继承:多继承带来菱形继承问题(数据冗余、二义性),通过虚继承解决,但应尽量避免设计菱形继承。

  8. 继承与组合:继承是 is-a 关系(白箱复用,耦合度高),组合是 has-a 关系(黑箱复用,耦合度低)。优先使用组合,仅在明确存在 is-a 关系或需要多态时使用继承。

理解继承的这些细节,是掌握 C++ 面向对象编程的基础,也是写出健壮、可维护代码的关键。

相关推荐
superior tigre2 小时前
45 跳跃游戏2
算法·leetcode·游戏
武子康2 小时前
大数据-278 Spark MLib-GBDT梯度提升决策树详解:从原理到实战案例
大数据·后端·spark
盐焗鹌鹑蛋2 小时前
【C++】vector类
c++
SamDeepThinking2 小时前
适合中小型企业的出口入口网关微服务
java·后端·架构
不知名的忻2 小时前
并查集(QuickUnion)
java·数据结构·算法·并查集
leo__5202 小时前
基于时延的麦克风声源定位 - C实现
c语言·开发语言·算法
攻防_SRC2 小时前
面向分组密码差分故障分析的属性推导与验证平台
人工智能·算法·机器学习
jf加菲猫2 小时前
第15章 文件和目录
开发语言·c++·qt·ui
likerhood2 小时前
Java实现选择题选项乱序算法
java·开发语言·算法