🔥铅笔小新z:个人主页
🎬博客专栏:C++学习
💫滴水不绝,可穿石;步履不休,能至渊。

引言

大家好,这篇文章我们来聊聊C++面向对象编程非常重要的一个概念-----继承。

很多初学者会觉得继承很难理解,其实它就像现实生活中的"子承父业"。

比如,儿子继承了父亲的基因,既有父亲的特点,又发展出自己的独特之处。在C++中,继承也是这个道理。


一、继承的概念及定义

1.1 为什么要用继承?

想象一下,我们要在学校系统设置两个类:Student(学生)和Teacher(老师)。

没有继承时,代码是这样的:

class Student
{
public:
    void identity() { /* 身份认证 */ }
    void study()    { /* 学习 */ }
protected:
    string _name;     // 姓名
    string _address;  // 地址
    string _tel;      // 电话
    int _age;         // 年龄
    int _stuid;       // 学号(学生独有)
};

class Teacher
{
public:
    void identity() { /* 身份认证 */ }
    void teaching() { /* 授课 */ }
protected:
    string _name;     // 姓名
    string _address;  // 地址
    string _tel;      // 电话
    int _age;         // 年龄
    string _title;    // 职称(老师独有)
};

发现没有?学生和老师都有姓名、地址、电话、年龄这些共同的属性和身份认证这个行为,代码重复了!这就是冗余。

继承就能解决这个问题:把公共的部分提取出来,作为一个“基类”(也叫父类),然后让学生和老师去“继承”它,这样公共代码就能服用了。

1.2 继承的定义格式

// 先定义一个基类 Person
class Person
{
public:
    void identity()
    {
        cout << "身份认证" << endl;
    }
protected:
    string _name = "张三";
    string _address;
    string _tel;
    int _age = 18;
};

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

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

int main()
{
    Student s;
    Teacher t;
    s.identity();   // 调用从Person继承来的成员函数
    t.identity();
    return 0;
}

格式说明:

class 子类(派生类)名 :继承方式 父类(基类)名

  • 继承的方式有三种:public(公有继承)、protected(保护继承)、private(私有继承)。
  • 如果不写,class 默认是 private 继承,struct 默认是 public 继承。实际开发中最常用的是 public 继承。 

1.3 继承后成员访问权限的变化

基类成员在派生类中的访问权限,是由基类中的访问限定符和继承方式共同决定的,规则如下表:

基类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

简单记忆:

  • 基类的 private 成员,无论什么继承,在派生类中都不能直接访问(但确实被继承下来了,只是语法限制不能访问)。
  • 其他成员的访问权限 = min(成员在基类的权限,继承方式),其中 public > protected > private
  • 如果想在派生类中访问基类的某个成员,又不希望类外直接访问,就把它定义为 protected 。保护成员限定符就是为继承而生的。

小提示:实际工程中几乎只用 public 继承,因为 protected\private 继承会限制派生类的使用,维护性差。

1.4 继承类模板

C++ 允许我们继承一个模板类。比如STL中的 vector ,我们可以继承它来实现一个栈:

#include <iostream>
#include <vector>
using namespace std;

namespace xx
{
    template<class T>
    class stack : public std::vector<T>
    {
    public:
        void push(const T& x)
        {
            // 注意:必须用 vector<T>::push_back 指定类域,否则编译报错
            vector<T>::push_back(x);
        }
        void pop()
        {
            vector<T>::pop_back();
        }
        const T& top()
        {
            return vector<T>::back();
        }
        bool empty()
        {
            return vector<T>::empty();
        }
    };
}

int main()
{
    bit::stack<int> st;
    st.push(1);
    st.push(2);
    st.push(3);
    while (!st.empty())
    {
        cout << st.top() << " ";
        st.pop();
    }
    return 0;
}

注意:因为模板是按需实例化,如果直接写 push_back(x),编译器可能找不到,所以要加上 vector<T>:: 来指定类域。


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

派生类对象可以赋值给基类的对象/指针/引用,这叫“切片”或“切割”,就是把派生类中从基类继承来的那部分切出来。

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

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

int main()
{
    Student sobj;
    
    // 1. 派生类对象 赋值给 基类指针/引用
    Person* pp = &sobj;   // 指向派生类中的基类部分
    Person& rp = sobj;    // 引用派生类中的基类部分
    
    // 2. 派生类对象 赋值给 基类对象(调用基类的拷贝构造)
    Person pobj = sobj;
    
    // 3. 基类对象 不能 赋值给派生类对象
    // sobj = pobj;   // 编译错误!
    
    return 0;
}

为什么基类对象不能赋值给派生类对象?

因为基类对象缺少派生类的特有成员,强行赋值会导致信息丢失。

基类指针/引用 强制转换为 派生类的指针/引用:

如果确定基类指针指向的是派生类对象,可以用强制类型转换。但存在风险,后续学了多态和 dynamic_cast 会更安全。


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

  • 每个类都有自己的独立作用域。
  • 如果派生类和基类有同名成员,派生类成员会隐藏基类同名成员(及直接访问时访问到的时派生类的)。要访问基类的隐藏成员,必须加上基类的作用域:基类::成员
  • 特别注意:成员函数的隐藏只要求函数名相同,不要求参数相同!哪怕参数列表不同,也是隐藏,不是重载(重载必须在同一个作用域)。

示例1:同名成员变量

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;              // 这里的_num是派生类的
    }
protected:
    int _num = 999;   // 学号
};

示例2:成员函数隐藏

看下面这个选择题:

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

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

int main()
{
    B b;
    b.fun(10);   // 调用 B::fun(int)
    // b.fun();   // 编译错误!因为B中的fun隐藏了A中的fun,无参版本不可见
    return 0;
}

问题:A B 中的两个 fun 构成什么关系?

A. 重载  B. 隐藏  C. 没关系

答案:B(隐藏)。因为函数名相同,即使参数不同,也构成隐藏。重载必须在同一个作用域,这里 AB 是两个作用域。

建议:在实际开发中,尽量避免在继承体系中定义同名成员,以免混淆。


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

我们知道,一个类有6个默认成员函数(构造、拷贝构造、赋值、析构、取地址、const 取地址)。在派生类中,这些函数的生成规则则有特别之处。

4.1 构造函数

  • 派生类的构造函数必须调用基类的构造函数来初始化基类部分。
  • 如果基类没有默认构造函数(即需要传参的构造函数),那么派生类必须在初始化列表中显式调用基类的构造函数。

4.2 拷贝构造函数

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

4.3 赋值运算符重载

  • 派生类的 operator= 必须调用基类的 operator= 来完成基类部分的赋值。
  • 注意:派生类的 operator= 会隐藏基类的 operator= ,所以要显式调用,加上基类作用域。

4.4 析构函数

  • 派生类的析构函数在执行完自己的清理后,会自动调用基类的析构函数,保证先派生后基类的析构顺序。
  • 编译器会对析构函数名做特殊处理,统一处理成 destructor() ,所以基类和派生类的析构函数也会构成隐藏(如果基类析构没有加 virtual,后面多态会讲)。

4.5 构造和析构顺序

  • 构造顺序:先基类构造,再派生类构造。
  • 析构顺序:先派生类析构,再基类析构(与构造相反)。

看一个完整例子:

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;
};

int main()
{
    Student s1("jack", 18);
    Student s2(s1);
    Student s3("rose", 17);
    s1 = s3;
    return 0;
}

运行结果:

Person()
Student()
Person(const Person& p)
Student(const Student& s)
Person()
Student()
Student& operator=(const Student& s)
Person operator=(const Person& p)
~Student()
~Person()
~Student()
~Person()
~Student()
~Person()

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

有时候我们希望某个类不能被其他类继承,C++98 和 C++11 有两种方法:

方法1(C++98):将基类的构造函数设为私有。这样派生类无法调用基类构造,就不能实例化派生类对象。

class Base
{
private:
    Base() {}   // 私有构造
public:
    static Base getInstance() { return Base(); }   // 可以提供一个静态方法创建对象
};

// class Derive : public Base { };   // 编译错误,无法调用Base的私有构造

方法2(C++11):使用 final 关键字,直接禁止继承。

class Base final
{
    // ...
};

// class Derive : public Base { };   // 编译错误:无法从final类型继承

五、继承与友元

友元关系不能继承。意思是:如果基类有一个友元函数,这个友元函数不能访问派生类的私有和保护成员。

class Student;   // 前向声明

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

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

// 这个友元函数只能访问Person的成员,不能访问Student的成员
void Display(const Person& p, const Student& s)
{
    cout << p._name << endl;      // 正确
    // cout << s._stuNum << endl; // 错误!无法访问
}

解决方案:如果想让 Display 也能访问 Student 的私有成员,就需要在 Student 中也将其声明为友元。


六、继承与静态成员

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

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._name << endl;  // 不同对象的_name地址不同
    cout << &s._name << endl;

    cout << &p._count << endl;  // 静态成员地址相同
    cout << &s._count << endl;

    // 可以通过类名访问
    cout << Person::_count << endl;
    cout << Student::_count << endl;   // Student::_count 实际上就是 Person::_count
    return 0;
}

输出显示,_count 的地址在 ps 中是一样的,说明所有对象共享同一个静态成员。


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

7.1 继承模型

  • 单继承:一个派生类只有一个直接基类。
  • 多继承:一个派生类有两个或以上直接基类。
  • 菱形继承:多继承的一种特殊情况,形如:

          Person
         /      \
      Student   Teacher
         \      /
          Assistant

菱形继承的问题:

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";   // 编译错误:对_name的访问不明确(二义性)
    
    // 解决方法:指定访问哪个基类的成员
    a.Student::_name = "xxx";
    a.Teacher::_name = "yyy";
    
    // 但数据冗余仍然存在:a对象中有两份Person的成员(_name)
    return 0;
}

问题总结:

  1. 二义性:访问 _name 时不知道是从 Student 来的还是从 Teacher 来的。
  2. 数据冗余:Assistant 对象中包含两份 Person 的成员,浪费内存。

7.2 虚继承解决菱形继承

C++ 提供了虚继承来解决菱形继承问题。通过在中间层(StudentTeacher)继承 Person 时加上 virtual 关键字,使得最终派生类 Assistant 中只保留一份 Person 的成员。

class Person
{
public:
    string _name;
};

// 虚继承 Person
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";   // 现在可以直接访问,没有二义性,且只有一份_name
    return 0;
}

虚继承的底层原理:编译器会在对象中增加一些额外信息(如虚基类指针),用来定位虚基类子对象的位置,从而实现共享。这会使对象模型变复杂,性能略有损失,所以不要轻易设计菱形继承。

7.3 多继承中指针偏移问题

看下面这道选择题:

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 的值有什么关系?
    return 0;
}

答案:p1 == p3 != p2 (选项C)。

解释:因为先继承 Base1,后继承 Base2,在内存布局中,Base1 的子对象在低地址,Base2 的子对象在高地址。p1 指向 dBase1 部分,p3 指向整个 Derive 对象,它们的起始地址相同(因为 Base1 在最最前面)。而 p2 指向 Base2 部分,地址偏移了 sizeof(Base1),所以 p2p1p3 不同。


八、继承和组合

8.1 两种复用方式

  • 继承(is-a关系):“派生类是一个基类”。比如 Student 是一个人(Person)。
  • 组合(has-a关系):“类中包含另一个类的对象”。比如 Car 包含 Tire(轮胎)。

继承示例:

class Car {
public:
    string _colour = "白色";
    string _num = "陕A12345";
};

class BMW : public Car {
public:
    void Drive() { cout << "好开-操控" << endl; }
};

组合示例:

class Tire {
protected:
    string _brand = "Michelin";
    size_t _size = 17;
};

class Car {
protected:
    string _colour;
    string _num;
    Tire _t1, _t2, _t3, _t4;   // 汽车包含轮胎
};

8.2 继承 vs 组合的选择

  • 继承是“白箱复用”:基类的内部细节对派生类可见,因此破坏封装性,耦合度高。基类的改变会影响所有派上类。
  • 组合是“黑箱复用”:对象之间通过接口交互,内部细节不可见,耦合度底,维护性好。

优先使用对象组合,而不是继承----这是面向对象设计的一个重要原则。但也不是绝对的:

  • 如果类之间确实存在明确的 is-a 关系,或者需要利用多态(后续会讲),就用继承。
  • 如果即可以用继承也可以用组合,优先选择组合。

一个有趣的例子:栈(stack)和向量(vector)的关系。栈可以继承 vector(is-a?栈是一个向量?不太合理),也可以组合 vector(has-a:栈内部包含一个 vector)。实际 STL 中的 stack 是一个容器适配器,它通过组合实现,而不是继承。这体现了组合的灵活性。


 总结

今天我们详细学习了C++继承的方方面面:

  1. 继承的概念:代码复用,is-a关系。

  2. 继承的语法:继承方式,访问权限变化。

  3. 切片:派生类对象可以赋值给基类指针/引用。

  4. 隐藏规则:同名成员,基类成员被隐藏,需加作用域访问。

  5. 派生类的默认成员函数:构造、拷贝、赋值、析构的调用规则。

  6. 友元与静态成员:友元不继承,静态成员共享一份。

  7. 多继承与菱形继承:菱形继承的问题及虚继承的解决方案。

  8. 继承与组合:两种复用方式的对比,优先使用组合。


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐