C++ 继承全解:从基础到高级特性

前言:继承这件事,比你想的要复杂

C++中的继承方式,有 publicprotectedprivate 三类。

如果你写过 Java 或 Python,你大概觉得继承不过就是 extends 一下,子类自动拥有父类的成员,天下太平。但在 C++ 里,继承是一个同时涉及访问控制、对象模型、构造析构顺序、内存布局的复杂话题

这篇文章的目标是用代码和图示,把 C++ 继承的每一个角落讲清楚——从最基础的概念,到菱形继承的内存模型,再到什么时候该用继承、什么时候该用组合。


一、继承的本质:代码复用 + 多态的基础

1.1 一个再简单不过的例子

class Person {
public:
    void identity() {
        cout << "姓名:" << _name << endl;
    }
protected:
    string _name = "张三";
    int _age = 18;
};

class Student : public Person {
public:
    void study() {
        identity();   // 直接用父类的方法
        // 做学生该做的事...
    }
protected:
    int _stuId;   // 学号
};

class Teacher : public Person {
public:
    void teaching() {
        identity();   // 同样复用父类方法
    }
protected:
    string _title;   // 职称
};

这里 StudentTeacher 都是 Person 的派生类。它们自动拥有 Personidentity() 方法和 _name_age 成员,无需重复写一遍。这就是继承最直接的价值:把公共部分抽取到基类,各派生类只关注自己独有的东西

1.2 继承的语法格式

class 派生类 : 继承方式 基类 {
    // ...
};

继承方式有三种:publicprotectedprivate。这个关键字决定了基类成员在派生类中的访问权限如何变化


二、继承方式与访问权限:一张表搞定

很多教程在这部分讲得云里雾里,其实核心就一张表:

基类成员访问权限public继承protected继承private继承
public派生类中为 public派生类中为 protected派生类中为 private
protected派生类中为 protected派生类中为 protected派生类中为 private
private派生类中不可见派生类中不可见派生类中不可见

无论哪种继承方式,基类的 private 成员在派生类中永远不可直接访问。 但请注意——这些 private 成员确实被继承下来了,存在于派生类对象的内存空间中,只是语法层面不让你直接碰。

记住一个口诀:权限只降不升,private 永远不让你看见。

class Base {
public:    int _pub = 1;
protected: int _pro = 2;
private:   int _pri = 3;   // 派生类永远看不到
};

class Derive : public Base {
public:
    void test() {
        cout << _pub << endl;   // OK, 仍是 public
        cout << _pro << endl;   // OK, 仍是 protected
        // cout << _pri << endl;  // ERROR! private 不可见
    }
};

实际工程中,99% 的场景用 public 继承就够了。 protected 和 private 继承属于"我知道有这么个东西,但基本不会故意去用"的知识点。


三、父子对象赋值转换:切片(Slicing)

这是 C++ 继承中最容易被忽视却又十分重要的特性。

class Person {
public:
    string _name;
    string _sex;
    int _age;
};

class Student : public Person {
public:
    int _stuNo;   // 学号——父类没有的
};

3.1 子类可以"向上转型"给父类

Student sobj;
sobj._name = "小明";

// 这三种写法都是合法的
Person pobj = sobj;      // (1) 子类对象赋值给父类对象——切片!
Person* pp = &sobj;      // (2) 父类指针指向子类对象
Person& rp = sobj;       // (3) 父类引用绑定子类对象

这里有一个重要的概念叫切片(Slicing):把子类对象赋给父类对象时,子类独有的成员(_stuNo)会被"切掉",只保留父类那部分。

子类对象内存布局:   [_name][_sex][_age][_stuNo]
                              ↓ 切片
父类对象:          [_name][_sex][_age]

指针和引用则不会切片——它们只是以"父类的视角"去看同一个对象,子类的部分仍然在内存中。

3.2 反过来不行——父类不能自动转成子类

Person pobj;
Student* ps = &pobj;   // ERROR! 父类指针不能直接转为子类指针

这很好理解:父类有的东西子类都有(继承来的),但子类有的东西(学号)父类没有,让父类对象去"装"子类,缺的字段填什么?

3.3 dynamic_cast:谨慎的向下转型

Student sobj;
Person* pp = &sobj;
Student* ps1 = dynamic_cast<Student*>(pp);  // 成功, pp实际指向Student
cout << ps1 << endl;   // 非空指针

Person pobj2;
pp = &pobj2;
Student* ps2 = dynamic_cast<Student*>(pp);  // 失败, pp实际指向Person
cout << ps2 << endl;   // 空指针!

dynamic_cast 会在运行时检查——安全但有一定开销。如果只是想强制转换(你知道它确实指向子类),也可以用 static_cast


四、继承中的作用域:同名不是重载,是隐藏

这是一个常见的"踩坑点"。

4.1 成员变量同名——就近访问,但要小心

class Person {
protected:
    string _name = "小明";   // 父类的
    int _num = 111;          // 身份证号
};

class Student : public Person {
public:
    void Print() {
        cout << _num << endl;          // 999——子类自己的 _num
        cout << Person::_num << endl;  // 111——显式指定父类的 _num
    }
protected:
    int _num = 999;   // 学号——和父类成员同名!
};

int main() {
    Student s;
    s.Print();   // 输出: 999 \n 111
}

子类和父类有同名成员时,子类成员会"隐藏"父类成员。如果想访问父类的,必须用 父类名::成员名

4.2 成员函数同名——只要函数名相同,就是隐藏

这个坑更大:

class A {
public:
    void fun() { cout << "fun()" << endl; }
};

class B : public A {
public:
    void fun(int i) { cout << "fun(int i): " << i << endl; }
};

int main() {
    B b;
    // b.fun();     // ERROR! B::fun 隐藏了 A::fun
    b.A::fun();     // 这样才能调用父类的无参版本
    b.fun(1);       // OK
}

很多人会误以为 A::fun()B::fun(int) 构成重载,因为函数名相同、参数不同。错了! 重载的条件是两个函数在同一作用域中。父子类是两个不同的作用域,所以这里的规则是隐藏(Hide),不是重载。

铁律:派生类和基类中有同名函数时,只要函数名相同(不看参数),基类的该函数就被隐藏。 想用基类版本,必须 基类名::函数名


五、派生类的默认成员函数:继承体系下的构造、拷贝、赋值、析构

这一部分是面试高频考点,也是实际写代码最容易出 Bug 的地方。

先定义一个有完整六大函数(构造、拷贝构造、赋值、析构 + C++11 移动构造、移动赋值)的基类和派生类框架:

class Person {
public:
    Person(const char* name = "xxx")
        : _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=" << endl;
        if (this != &p) _name = p._name;
        return *this;
    }

    ~Person() { cout << "~Person()" << endl; }

protected:
    string _name;
};

5.1 构造函数:先父后子

派生类构造时,必须先调用基类构造函数,初始化继承来的成员,然后再初始化自己的成员。

class Student : public Person {
public:
    Student(const char* name, int num, const char* addr)
        : Person(name)        // 显式调用基类构造函数——必须放在初始化列表最前面
        , _num(num)
        , _addr(addr) {
        // 构造函数体
    }
protected:
    int _num;
    string _addr;
};

如果初始化列表中没有显式调用基类构造函数,编译器会自动调用基类的默认构造函数。如果基类没有默认构造函数?那就编译报错

5.2 拷贝构造函数:别忘了"切片传参"

Student(const Student& s)
    : Person(s)       // 把子类对象 s 传给基类的拷贝构造——切片!
    , _num(s._num)
    , _addr(s._addr) {
    // 如果有深拷贝资源,在这里处理
}

Person(s) 这里编译器把 Student& 自动切片成 const Person&,调用基类的拷贝构造函数。这一步很多人会漏掉,但漏掉的话编译器会自动调用基类的默认构造——你继承来的 _name 等成员就变成默认值了,这是 Bug。

5.3 赋值运算符重载:必须显式调用基类的 operator=

Student& operator=(const Student& s) {
    if (this != &s) {
        Person::operator=(s);   // 必须显式调用!否则基类成员不会被赋值
        _num = s._num;
        _addr = s._addr;
    }
    return *this;
}

这里有个容易忘记的陷阱:派生类的 operator= 不会自动调用基类的 operator=(不同于构造函数)。如果你不写 Person::operator=(s) 这一行,基类那部分成员就保持原样,不会从 s 那里拷贝过来。另外,这里一定要标明作用域,否则会构成隐藏,导致子类的operator=不断循环调用自己,编译器会报stack overflow的错误。

5.4 析构函数:先子后父,自动调用

~Student() {
    // 先释放子类自己的资源
    // delete _ptr;

    // 不需要显式调用 ~Person()——编译器会自动加上
    // 析构顺序:先子后父(跟构造完全相反)
}

基类析构函数会被编译器在派生类析构函数体执行完毕后自动调用,确保析构顺序是"先子后父"。这和构造函数的"先父后子"完全对称。

记忆技巧:盖房子从上往下(先父后子构造),拆房子从下往上(先子后父析构)。

5.5 编译器默认生成的行为小结

class Student : public Person {
protected:
    int _num = 1;
    string _addr = "高新区";
};
// 上面什么都没写,编译器会生成什么?

如果派生类没有显式定义,编译器生成的默认行为是:

函数编译器默认行为
默认构造调用基类的默认构造函数,再对派生类成员做默认初始化
拷贝构造调用基类的拷贝构造,再逐个拷贝派生类成员(浅拷贝)
operator=调用基类的 operator=,再逐个赋值派生类成员(浅拷贝)
析构函数函数体为空,先逆序销毁派生类成员,之后自动调用基类析构函数
移动构造/赋值 (C++11)类似拷贝的规则,调用基类对应移动版本

如果派生类不涉及动态资源管理,编译器默认生成的版本就够用。一旦有 new/malloc 出来的资源,就必须自己写深拷贝。


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

6.1 友元关系不能继承

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

class Student : public Person {
    // 即使 Person 把 Display 当朋友,
    // Display 也不能直接访问 Student 的 protected/private 成员
    friend void Display(const Person& p, const Student& s);  // 必须重新声明!
protected:
    int _stuNum;
};

父类的友元不会自动成为子类的友元。如果想访问子类的私有/保护成员,需要在子类中重新声明友元。

6.2 静态成员——整个继承体系中只有一份

class Person {
public:
    static int _count;
};
int Person::_count = 0;

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

运行这段代码:

Person p;
Student s;

cout << &p._name << endl;   // 普通成员的地址不同——每个对象各自一份
cout << &s._name << endl;

cout << &p._count << endl;  // 静态成员地址相同——整个继承体系共享!
cout << &s._count << endl;  // 四个输出全是同一个地址

cout << Person::_count << endl;  // 都可以这样访问
cout << Student::_count << endl; // 派生类也可以直接访问

Person::_count++;  // 这里改了
cout << s._count;  // 那里也变了

静态成员属于"类"而非"对象",在整个继承层次结构中只有一份。 不管有多少派生类、多少个对象,静态变量在内存中只有一个实例。


七、多继承与菱形继承:C++ 最让人头疼的问题

7.1 多继承的基本语法

class Base1 { public: int _b1 = 1; };
class Base2 { public: int _b2 = 2; };

class Derive : public Base1, public Base2 {
public:
    int _d = 3;
};

内存布局(32位环境为例):

Derive 对象内存:
[Base1::_b1]        // 偏移 0
[Base2::_b2]        // 偏移 sizeof(Base1)
[Derive::_d]        // 偏移 sizeof(Base1) + sizeof(Base2)

多继承时,基类在内存中按声明顺序排列。这一点在有指针转换时很重要:

Derive d;
Base1* p1 = &d;       // p1 指向 d 的首地址(Base1 的起始位置)
Base2* p2 = &d;       // p2 指向 d 中 Base2 的起始位置(非首地址!)

cout << (void*)&d << endl;  // 这三个地址可能不同!
cout << (void*)p1 << endl;
cout << (void*)p2 << endl;  // p2 可能会比前两个多几个字节的偏移

7.2 菱形继承——噩梦的开始

class Person { public: string _name; };

class Student : public Person { public: int _num; };
class Teacher : public Person { public: int _id; };

class Assistant : public Student, public Teacher {
public:
    string _majorCourse;
};

继承关系图:

        Person
       /      \
   Student   Teacher
       \      /
      Assistant

问题来了: Assistant 的对象中有两份 Person 的成员——一份从 Student 路径来,一份从 Teacher 路径来。

Assistant a;
// a._name = "peter";    // ERROR! 对 _name 的访问不明确——编译器不知道你要哪个 _name
a.Student::_name = "小明";   // 必须这样指定
a.Teacher::_name = "小红";

两个 _name?这显然浪费空间、违反直觉。如果把 Person 当成一个"人"的抽象,一个助教当然只有一个名字。这就是菱形继承的数据冗余和二义性问题。

7.3 虚继承——解决菱形问题的钥匙

class Student : virtual public Person {  // 注意 virtual
public:
    Student(const char* name, int num)
        : Person(name), _num(num) {}
protected:
    int _num;
};

class Teacher : virtual public Person {  // 注意 virtual
public:
    Teacher(const char* name, int id)
        : Person(name), _id(id) {}
protected:
    int _id;
};

class Assistant : public Student, public Teacher {
public:
    Assistant(const char* name1, const char* name2, const char* name3)
        : Student(name1)
        , Teacher(name2)
        , Person(name3)    // 注意:必须显式调用虚基类构造函数!
    {}
};

关键变化:

  1. StudentTeacher 在继承 Person 时加了 virtual 关键字
  2. Assistant 的构造函数中必须显式调用虚基类 Person 的构造函数

不加第2条会怎样?编译器不会帮你自动推断用哪个 Person——你自己必须说清楚。

虚继承让内存中只有一份 Person 的数据StudentTeacher 共享同一份。二义性问题解决了。

7.4 虚继承的底层实现(简化版)

虚继承不是魔法,它通过一张**虚基类表(vbtable)**来实现:

虚继承下 Assistant 的内存布局(简化):
+---------------------------+
| Student 部分               |
|   _vbptr_stu  ------> vbtable(记录 Person 部分的偏移量)
|   _num                    |
+---------------------------+
| Teacher 部分               |
|   _vbptr_tea  ------> vbtable(记录 Person 部分的偏移量)
|   _id                     |
+---------------------------+
| Assistant 自己的成员        |
|   _majorCourse            |
+---------------------------+
| Person 部分(只有一份!)    |
|   _name                   |
+---------------------------+

虚基类 Person 的数据被放在了整个对象的最末尾。StudentTeacher 各有一个虚基类表指针(_vbptr),指向各自的虚基类表,表中记录了当前对象起始位置到 Person 部分的偏移量。这样无论通过 Student* 还是 Teacher* 去访问 _name,都能通过 vbtable 偏移找到唯一的那份 Person 数据。


八、C++11 的 final:拒绝被继承

如果你希望某个类不能被继承,C++11 提供了 final 关键字:

class Base final {
    // ...
};

// class Derive : public Base {};   // ERROR! Base 被标记为 final,不能继承

简洁明了。比 C++98 时代的"把构造函数设为 private"的 方式优雅多了。


九、继承 vs 组合:is-a 还是 has-a?

这是面向对象设计中一个经典的设计决策。

9.1 什么时候用继承?

// is-a 关系:学生"是"一个人
class Student : public Person { ... };

继承表达的是 is-a(是一个)关系。 学生是一个人,教师是一个人,可以复用 Person 的属性和行为。

9.2 什么时候用组合?

// has-a 关系:车"有"引擎,但车不是引擎
class Engine { ... };
class Car {
    Engine _engine;  // 组合
};

组合表达的是 has-a(有一个)关系。

9.3 一个反面案例——Stack 不应该继承 vector

// 错误示范:Stack is-a vector?不是!
template<class T>
class Stack : public vector<T> {
public:
    void push(const T& x) { push_back(x); }
    void pop() { pop_back(); }
    const T& top() { return back(); }
    bool empty() { return vector<T>::empty(); }
};

Stack<int> st;
st.push(1);
st.push_back(2);   // Stack 不应该有 push_back!但它有!
st[0] = 100;       // Stack 不应该有随机访问!但它有!

Stack 继承 vector 之后,把 push_backoperator[] 等不属于栈语义的操作全都暴露了出来。这是设计灾难——你的 Stack 不是一个受限的 LIFO 容器,而是一个 vector 的别名。

正确的做法是用组合:

template<class T>
class Stack {
public:
    void push(const T& x) { _con.push_back(x); }
    void pop() { _con.pop_back(); }
    const T& top() { return _con.back(); }
    bool empty() { return _con.empty(); }
private:
    vector<T> _con;  // 组合——vector 是实现细节,不暴露给用户
};

设计原则:能用组合就用组合,只有在明确的 is-a 关系时才用继承。


十、总结:一张图回顾全部要点

C++ 继承知识体系
│
├── 基本概念
│   ├── 继承方式:public(最常用)/protected/private
│   └── 访问权限变化:只降不升,private 永远不可见
│
├── 父子对象关系
│   ├── 向上转型(切片):天然支持,子→父
│   ├── 向下转型:需要 dynamic_cast,不安全
│   └── 同名隐藏:只要函数名相同就隐藏,不构成重载
│
├── 派生类六大默认函数
│   ├── 构造:先父后子,初始化列表调用基类构造
│   ├── 拷贝构造:Person(s) 切片传参
│   ├── operator=:必须显式调用 Person::operator=(s)
│   ├── 析构:先子后父,自动调用
│   └── 移动构造/赋值 (C++11):类似上述规则
│
├── 特殊关系
│   ├── 友元:不被继承
│   ├── 静态成员:整个继承体系共享
│   └── final (C++11):禁止被继承
│
├── 高级话题
    ├── 多继承:按声明顺序排列基类
    ├── 菱形继承:数据冗余 + 二义性
    ├── 虚继承:通过 vbtable 实现共享基类
    └── 继承 vs 组合:is-a 用继承,has-a 用组合
 


C++ 的继承体系远比 Java 和 Python 要复杂和精细。这种复杂性不是语言设计上的缺陷,而是 C++ 赋予程序员对内存布局和访问控制的完全自主权的必然代价。理解了"为什么 C++ 这么设计",你就能写出既高效又健壮的继承体系。


欢迎在评论区留言讨论~

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值