C++ 基础系列——继承与派生


1. 继承和派生入门

继承可以理解为一个类在另一个类的成员变量和成员函数上继续拓展的过程。这个过程站的角度不同,概念也会不同,继承是儿子接收父亲,派生是父亲传承给儿子。

被继承的类称为父类或基类,继承的类称为子类或派生类。“子类”和“父类”通常放在一起称呼,“基类” 和“派生类”通常放在一起称呼。

// 基类 People
class People
{
public:
    void set_name(string name);
    void set_age(int age);
    string get_name();
    int get_age();
private:
    string m_name;
    int m_age;
};

// 派生类 Studeng
// public 表示公有继承,下一节会讲继承方式问题
class Student:public People
{
public:
    void set_score(float score);
    float get_score();
private:
    float m_score;
};

int main()
{
    Student stu;
    stu.set_name("小明");   // 继承基类
    stu.set_score(99.9);
    return 0;
}

继承方式有 public、private、protected,如果不写,默认 private。(结构struct 默认继承方式是 public)

2. 三种继承方式

继承方式限定了基类成员在派生类中的访问权限,三种方式分别是:public、private、protected。

类的public 成员可以通过对象来访问,private 成员不能通过对象和派生类访问,而 protected 也不能通过对象访问,但基类的 protected 成员可以在派生类中访问。

不同的继承方式会影响基类成员在派生类中的访问权限:

  • public 继承
    • 基类中 public、protected 成员在派生类保持基类的属性。(基类private 成员不能在派生类中使用)
  • protected 继承
    • 基类的 public、protected 成员在派生类中均为 protected 属性。
  • private 继承
    • 基类的 public、protected 成员在派生类中均为 private 属性。

对于基类中既不向外暴露(不能通过对象访问),还能在派生类中使用的成员,只能声明为 protected。

注意,基类的 private 成员是能够被继承的,并且成员变量一样会占用派生类的内存,只是在派生类中不可见。

public 成员 protected 成员 private 成员
public 继承 public protected 不可见
protected 继承 protected protected 不可见
private 继承 private private 不可见

由于 private 和 protected 继承方式会改变基类成员在派生类的访问权限,导致继承关系复杂,实际开发中通常使用 public。

派生类中访问基类 private 成员的唯一方法是借助基类的非 private 成员函数。如果基类未提供,则派生类中无法访问。

改变访问权限

使用 using 关键字可以改变基类成员在派生类的访问权限。只能改变基类中 public 和 protected 成员的访问权限。

// 基类
class People
{
public:
    void show();
protected:
    string m_name;
    int m_age;
};

// 派生类
class Student: public People
{
public:
    using People::m_name;  // 将 protected 改为 public
    float m_score;
private:
    using People::m_age; // 将 protected 改为 private
    using People::show;  // 将 public 改为 private
};

3. 继承时名字遮蔽问题

名字遮蔽指的是,当派生类成员与基类成员重名时,派生类使用的是该派生类新增的成员,基类的成员会被遮蔽

对于成员函数来说,只要派生类成员函数与基类名字一样,就会造成遮蔽,遮蔽与参数无关。也就是说,基类的成员函数与派生类成员函数不会构成函数重载

// 基类
class People
{
public:
    void show();
protected:
    string m_name;
    int m_age;
};

class Student: public People
{
public:
    Student(string name, int age, float score);
    void show();  // 基类 show 函数遮蔽
private:
    float m_score;
};


int main()
{
    Student stu("小明",16,99,9);
    stu.show();     // 派生类 show
    stu.People::show(); // 基类 show
}

如果派生类要访问基类中被遮蔽的函数,需要加上类名和域名解析符。

4. 继承时作用域的嵌套

类其实也是一种作用域,每个类都有自己的作用域,在这个作用域内再定义类的成员。当存在继承关系时,派生类的作用域嵌套在基类的作用域之内

当派生类对象访问成员时,会在作用域链中寻找最匹配的成员。对于成员变量,会直接查找,但是对于成员函数,编译器仅仅根据函数名字来查找,当内层作用域有同名函数时,不管有几个,编译器都不会再到外层作用域中查找,而是将这些同名函数作为一组重载候选函数。

// 基类
class Base
{
public:
    void func();
    void func(int);
};

// 派生类
class Derived: public Base
{
public:
    void func(string);
    void func(bool);
};

int main()
{
    Derived d;
    d.func("test"); // 派生类 Derived 域中匹配
    d.func(true);   // 派生类 Derived 域中匹配
    d.func();   // 编译错误,在派生类中找到了同名函数,因此不会再去基类匹配,但派生类中无法匹配
    d.func(10); // 编译错误,在派生类中找到了同名函数,因此不会再去基类匹配,但派生类中无法匹配
    d.Base::func();
    d.Base::func(100);
    return 0;
}

5. 继承时的对象内存模型

  • 对于没有继承时的对象内存模型很简单,成员变量和成员函数会分开存储:对象的内存中只包含成员变量,存储在栈区或堆区(new),成员函数与对象内存分离,存储在代码区。

  • 有继承关系时,派生类的内存模型可以看成是基类成员变量和新增成员变量的总和,所有成员函数仍存储在代码区,由所有对象共享。

  • 在派生类的对象模型中,会包含所有基类的成员变量,这种设计方案的优点是访问效率高,能直接访问。当存在遮蔽问题时,被遮蔽的成员变量仍然会留在内存中,只是对于存在遮蔽问题的成员变量,会增加类名和域名解析符::。

6. 基类和派生类的构造函数

类的构造函数不能被继承,并且对于继承过来的基类的成员变量的初始化,需要派生类的构造函数完成,通常是通过调用基类的构造函数完成。

class People
{
public:
    People(string, int);
protected:
    string m_name;
    int m_age;
};

class Student:public People
{
public:
    Student(string name, int age, float score);
private:
    float m_score;
};

// 派生类构造函数,调用基类的构造函数完成基类成员变量的初始化
// 基类构造函数的调用只能放在函数头部,不能放在函数体中。
Student::Student(string name, int age, float score): People(name, age), m_score(score){ }

构造函数调用顺序

在创建派生类对象时,会先调用基类构造函数, 再调用派生类构造函数。构造函数的调用顺序是按照继承的层次自顶向下、从基类再到派生类的。

派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。

基类构造函数调用规则

通过派生类创建对象时必须调用基类构造函数,如果没有指明基类构造函数,会调用基类的默认构造函数,如果基类默认构造函数不存在,会编译错误。

7. 基类和派生类的析构函数

析构函数也不能被继承。并且在派生类的析构函数中不用显式调用基类的析构函数,因为每个类只有一个析构函数。

此外析构函数的执行顺序和构造函数的执行顺序也刚好相反:

创建派生类时,构造函数调用顺序自顶向下、从基类到派生类;
销毁派生类时,析构函数执行顺序是自下向顶,从派生类到基类。

8. 多继承

多继承语法:

class D : public A, private B, protected C {
//
};

多继承形式下的构造函数和单继承形式基本相同,只是要在派生类的构造函数中调用多个基类的构造函数。

D(形参列表): A(实参列表), B(实参列表), C(实参列表){ 
    //其他操作
}

基类构造函数的调用顺序和它们在派生类构造函数的出现顺序无关,只和声明派生类时基类出现的顺序相同。

当多个基类拥有同名的成员时,派生类调用时需要加上类名和域解析符::。

9. 指针突破访问权限的限制

C++不能通过对象来访问 private、protected 属性的成员变量,但是通过指针,能够突破这种限制。

class A{ 
public:

private:
    int m_a;
    int m_b;
    int m_c;
}; 

A::A(int a, int b, int c): m_a(a), m_b(b), m_c(c){ } 

int main(){
    A obj(10, 20, 30);
    int a = obj.m_a;  // 编译错误,无法访问 protected、private 成员
    A *p = new A(40, 50, 60);
    int b = p -> m_b;  // 编译错误
    return 0;
}

在对象的内存模型中,成员变量和对象的开头位置会有一定的距离。以上面的 obj 对象为例,它的内存模型:

一旦知道了对象的起始地址,再加上偏移就能求得成员变量的地址,如果知道了成员变量的类型,就能轻易获得其值。

实际上,通过对象访问成员变量时,编译器也是通过这种方式来取得它的值:(假设 m_b 成员变量此时为 public)

int b = p->m_b;
此时编译器会将其转换为:
int b = *(int*)( (int)p + sizeof(int) );

p 是对象 obj 的指针,(int)p 将指针转换为一个整数,这样才能进行加法运算;sizeof(int)用来计算 m_b 的偏 移;(int)p + sizeof(int)得到的就是 m_b 的地址,不过因为此时是 int 类型,所以还需要强制转换为 int*类型;开头的*用来获取地址上的数据。

// 通过指针突破访问权限限制访问 private 成员
int main(){
    A obj(10, 20, 30); 
    int a1 = *(int*)&obj;    // 10
    int b = *(int*)( (int)&obj + sizeof(int) );  // 20

    A *p = new A(40, 50, 60); 
    int a2 = *(int*)p;    // 40 
    int c = *(int*)( (int)p + sizeof(int)*2 );    // 60

    cout << "a1=" << a1 << ", a2=" << a2 << ", b=" << b << ", c=" << c << endl;
    return 0;

C++ 的成员访问权限仅仅是语法层面上的,是指访问权限仅对取成员运算符. 和 -> 起作用,而无法 防止直接通过指针来访问。

10. 虚继承和虚基类

在多继承中,很容易产生命名冲突,例如典型的菱形继承:

为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员

在继承方式前面加上 virtual 关键字就是虚继承

// 基类
class A
{
protected:
    int m_a;
};

// 直接基类 B
class B: virtual public A
{
protected:
    int m_b;
};

// 直接基类 C
class C: virtual public A
{
protected:
    int m_c;
};

// 派生类 D
class D : public B : public C
{
public:
    void seta(int a){ m_a = a; } //正确 
    void setb(int b){ m_b = b; } //正确 
    void setc(int c){ m_c = c; } //正确
    void setd(int d){ m_d = d; } //正确
private:
    int m_d;
};

这段代码使用虚继承重新实现了上图所示的菱形继承,这样在派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。

  • 虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class)
  • 虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上例中,当定义 D 类时才出现了对虚派生的需求,但是如果 B 类和 C 类不是从 A 类虚派生得到的,那么 D 类还是会保留 A 类的两份成员。
  • 虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身。
  • 在实际开发中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。

虚基类成员的可见性

虚继承的最终派生类中会只保留了一份虚基类的成员,所以该成员可以被直接访问,不会产生二义性。

如果虚基类的成员只被一条派生路径覆盖,那么仍然可以直接访问这个被覆盖的成员。但是如果该成员被两条或多条路径覆盖了,那就不能直接访问了,此时必须指明该成员属于哪个类。

假设 A 定义了一个名为 x 的成员变量,当我们在 D 中直接访问 x 时,会有三种可能性:

  • 如果 B 和 C 中都没有 x 的定义,那么 x 将被解析为 A 的成员,此时不存在二义性。
  • 如果 B 或 C 其中的一个类定义了 x,也不会有二义性,派生类的 x 比虚基类的 x 优先级更高。
  • 如果 B 和 C 中都定义了 x,那么直接访问 x 将产生二义性问题。

不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。

11. 虚继承的构造函数

对于普通继承,派生类构造函数只能调用直接基类的构造函数,不能调用间接基类的。

虚继承中,虚基类由最终的派生类初始化,且必须调用,对于最终派生类来说,虚基类是间接基类。

因为虚继承中,如果由中间基类初始化虚基类的成员变量,那么在最终派生类中,将因为不同路径问题,出现歧义。

// 基类
class A
{
public:
    A(int a);
protected:
    int m_a;
};
A::A(int a): m_a(a){ }

// 直接基类 B
class B: virtual public A
{
public:
    B(int a, int b);
protected:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }

// 直接基类 C
class C: virtual public A
{
public:
    C(int a, int c);
protected:
    int m_c;
};
C::C(int a, int c): A(a), m_c(c){ }

// 派生类 D
class D : public B : public C
{
public:
    D(int a, int b, int c, int d);
private:
    int m_d;
};
D::D(int a, int b, int c, int d): A(a), B(90, b), C(100, c), m_d(d){ }

C++ 规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。

虚继承时构造函数的执行顺序与普通继承时不同:在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序如何,编译器总是先调用虚基类的构造函数,再按照出现的顺序调用其他的构造函数;而对于普通继承,就是按照构造函数出现的顺序依次调用的。

因此上述派生类 D 的构造函数中,即使将 A 的构造函数放置最后,也会最先调用。

上述代码构造函数调用顺序:A -> B -> C;

12. 虚继承下内存模型

对于普通继承,基类子对象始终位于派生类对象的前面。而对于虚继承,和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量的后面。

假设 A 是 B 的虚基类,B 又是 C 的虚基类,那么各个对象的内存模型如下图所示:

  • 不带阴影的一部分偏移量固定,不会随着继承层次的增加而改变,称为固定部分;
  • 带有阴影的一部分是虚基类的子对象,偏移量会随着继承层次的增加而改变,称为共享部分。
    如何计算共享部分的偏移,没有统一标准。

虚基类表

如果某个派生类有一个或多个虚基类,编译器就会在派生类对象中安插一个指针,指向虚 基类表。虚基类表其实就是一个数组,数组中的元素存放的是各个虚基类的偏移。
假设 A 是 B 的虚基类,同时 B 又是 C 的虚基类,那么各对象的内存模型如下图所示:

虚继承表中保存的是所有虚基类(包括直接继承和间接继承到的)相对于当前对象的偏移,这样通过派生类指 针访问虚基类的成员变量时,不管继承层次都多深,只需要一次间接转换就可以。

另外,这种方案还可以避免有多个虚基类时让派生类对象额外背负过多的指针,只会存在一个指向虚基类表的指针。

13. 派生类赋值给基类(向上转型)

类也是一种数据类型,也可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型(Upcasting)。相应地,将基类赋值给派生类称为向下转型(Downcasting)。

赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。

将派生类对象赋值给基类对象时,会舍弃派生类新增的成员,这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。

class A
{
    // ...
};

class B : public A
{
    // ...
};

int main()
{
    A a(10);
    B b(66, 99);
    a.display();
    b.display();

    a = b;  // 向上转型
    a.display();    // a 此时仅保留 b 中属于基类 A 的成员变量
    b.dispaly();
}

将派生类指针赋值给基类指针(对象指针之间的赋值)

// 基类
class A
{
public:
    A();
    void display();
protected:
    int m_a;
};

A::A(int a): m_a(a){ }

void A::display(){
    cout<<"Class A: m_a="<将派生类引用赋值给基类引用

引用本质上是通过指针的方式实现的,基类的引用也可以指向派生类的 对象,并且它的表现和指针是类似的。

int main(){
    D d(4, 40, 400, 4000);
    A &ra = d; 
    B &rb = d; 
    C &rc = d;
    
    ra.display();   // 派生类对象的数据,引用类型的成员函数
    rb.display(); 
    rc.display();
    
    return 0;

运行结果:
Class A: m_a=4
Class B: m_a=4, m_b=40
Class C: m_c=400

向上转型后通过基类的对象、指针、引用只能访问从基类继承过去的成员(包括成员变量 和成员函数),不能访问派生类新增的成员。

cpp