Skip to content

C++ 继承和派生

C++ 的继承和派生是类之间的关系,被继承的类称为父类或基类,继承的类称为子类或派生类。

class 派生类: [继承方式] 基类名 {
    // 派生类的新成员
};

继承方式包括 public、protected、private,如果不写,默认继承方式是 private。

访问权限

下表所示是不同继承方式下派生类中成员的访问权限:

基类中的成员属性 public 派生方式 protected 派生方式 private 派生方式
public public protected private
protected protected protected private
private 不能使用 不能使用 不能使用

通过类的对象,能直接访问到的只有 public 成员。在继承基类的所有成员后,派生类只能访问基类的 public 和 protected 成员;如果要访问基类的 private 成员,只能通过基类的非 private 成员函数来访问。

这里的不能访问是基于 C++ 语法层面上的,但是仍可以通过指针加偏移量的方式绕过这个限制访问到 private 类型的成员变量。

访问权限的修改:使用 using 关键字可以改变基类成员在派生类中的访问权限,但是只能修改基类的 public 和 protected 成员,不能修改 private 成员(因为派生类根本访问不到 private 成员,所以没法修改访问权限)。

class Base {
    protected:
        int m_n;
};
class A: public Base {
    public:
        using Base::m_n; // 将 protected 属性成员 m_n 改为 public 属性
};

继承时的名字遮蔽

如果派生类的成员和基类的成员重名,那么派生类的成员会遮蔽从基类继承来的成员。如果要使用从基类继承来的成员,需要加上类名和域解析符。如下代码中,通过 a.m_n 只能访问到派生类 A 新增的成员变量 m_n,如果要访问基类 Base 中定义的成员变量 m_n,需要通过 a.Base::m_n 的方式。

class Base {
    public:
        int m_n;
        Base(int n): m_n(n) {}
        Base(): m_n(0) {}
};
class A: public Base {
    public:
        int m_n;
        A(int n): m_n(n) {};
};

由于派生类与基类同名的成员变量、同名成员函数会造成遮蔽,因此派生类成员函数和基类成员函数之间不会构成重载。如下例子中基类 Base 有成员函数 func(),派生类 A 有成员函数 func(int),他们虽然看似构成了重载,但其实并不然,派生类 A 的 func 会遮盖基类 Base 的 func,从而导致对象 a 只有成员 func(int),而没有无参数的 func(),要访问这个被遮蔽了的无参数的 func() ,必须通过 Base::func() 指定。

#include <iostream>

class Base {
    public:
        void func() { std::cout << "Base::func" << std::endl; }
};

class A: public Base {
    public:
        void func(int n) { std::cout << "A::func " << n << std::endl; }
};

int main() {
    A a;
    a.func();   // 编译报错
    a.func(10);     // 正确
    a.Base::func(); // 正确
}

究其原因,类其实也是一种作用域,类的成员会定义在这个类的作用域中。当存在继承关系时,派生类的作用域会嵌套在基类的作用域内。重载关系只会发生在同一个作用域中的同名函数上,基类和派生类的作用域不同,它们间的同名函数自然构不成重载。

当要访问某一个名字的变量或函数时,编译器首先会在当前作用域中寻找;如果在该层作用域中找不到这个名字,才会到外层的作用域中继续寻找,直到找到这个名字或者没有更外层的作用域可以查找。这个查找的过程叫做名字查找(name lookup)。因此,当编译器在派生类的作用域中找到对应名字的变量或成员函数时,就不会到外层的基类作用域中继续寻找了,从而造成了遮蔽。

继承时的对象内存模型

我们知道,对象的成员变量和成员函数会分开存储。对象的内存中只存储成员变量,而成员函数则存储在代码区。内存中成员变量的顺序和成员变量的定义顺序一致。当存在继承关系时,对象内存中首先会存放基类的成员变量,然后再是派生类新增的成员变量。当发生成员变量的名字遮蔽时,基类和派生类的同名成员变量都会各占一块内存。

派生类的构造函数与析构函数

构造函数

类的构造函数并不会被继承。所有初始化需要通过派生类自己的构造函数完成,但是基类的 private 成员变量并不能在派生类的构造函数中访问,自然也无法直接初始化。为此,在派生类的构造函数中,需调用基类的构造函数,格式如下:

派生类::派生类(形参列表): 基类(实参列表), 成员变量(实参), 成员变量(实参) {}

派生类的构造函数定义必须调用基类的构造函数,如果不指明怎么调用基类构造函数,将默认调用无参数的基类构造函数。派生类在构造时,会先调用基类的构造函数,再调用派生类的构造函数。需要注意的是,当有多层的继承关系时,派生类只能显式调用直接基类的构造函数,不能显式调用间接基类的构造函数(例如 A 派生 B,B 派生 C,那么 C 的构造函数只能显式调用 B 的构造函数,而不能直接调用 A 的)。

析构函数

类的析构函数也不能被继承。因为每个类有且只有一个析构函数,编译器知道需要怎么调用析构函数,因此派生类的析构函数中无需显式调用基类的析构函数。析构函数的调用顺序恰和构造函数的相反,首先会执行派生类的析构函数,然后再执行基类的析构函数。

多重继承

当派生类只有一个基类时,称为单继承;而当派生类拥有多个基类时,则称为多继承。多继承的语法如下:

class 派生类: [继承方式] 基类1, [继承方式] 基类2, ... {
    // 类 D 的新成员
};

多继承的派生类的构造函数定义与单继承的类似,其基类的构造函数的调用顺序和声明派生类时基类的书写顺序一致。而析构函数的调用顺序与构造函数的调用顺序恰好相反。

命名冲突:当多个基类中存在同名的成员时,会导致命名冲突问题。因此在访问这类成员时,需要在成员名字前加上 基类名:: 以指定需要访问哪个基类的成员,以消除二义性。

内存模型:多继承的派生类对象的内存布局:首先是按派生类声明时基类顺序存放基类成员变量,最后按定义顺序存放派生类新增的成员变量。

虚继承和虚基类

多继承容易发生命名冲突的问题。如经典的菱形继承:A 派生 B 和 C,B 和 C 又派生 D。

class A {
    public: 
        int value;
};
class B: public A {};
class C: public A {};
class D: public B, public C {};

在这个例子中,B 和 C 都拥有从 A 继承来的成员 value,因此 D 对象的内存空间中,既存放了来自 B 的成员变量,也存放了来自 C 的成员变量,因此来自 A 的成员变量自然各有一份,这两份成员变量造成了命名冲突,也存在内存空间的浪费(如果设计者并不打算拥有两份的 A 成员变量)。

为了解决多继承时的命名冲突与数据冗余问题,C++ 提出了虚继承,使得派生类中只会保留一份间接基类的成员变量。虚继承需要在继承方式前加上 virtual 关键字:

class 派生类: virtual [继承方式] 基类名 {
    // 派生类的新成员
};

如上面的菱形继承,若定义为:

class A {
    public: 
        int value;
};
class B: virtual public A {};
class C: virtual public A {};
class D: public B, public C {};

虚基类 A 无论在继承体系中出现了多少次,派生类中都只会包含一份虚基类 A 的成员。

虚继承的构造函数:虚继承中,虚基类的初始化需要由最终派生类完成。所以,最终派生类的构造函数必须要调用虚基类的构造函数。即,如上所说的菱形继承,D 类的构造函数必须用初始化列表对 A、B、C 都进行初始化。与普通继承时不同,编译器会先调用虚基类的构造函数,然后再按照多继承时的顺序调用直接基类的构造函数。

虚继承下的内存模型:对于虚继承,大部分编译器会把虚基类的成员变量放在派生类成员变量的后面。