Table of Contents:

继承和派生简明教程

派生(Derive)和继承(Inheritance)是一个概念,只是站的角度不同。继承是儿子接收父亲的产业,派生是父亲把产业传承给儿子
子类和父类通常放在一起称呼,基类和派生类通常放在一起称呼。

三种继承方式

继承方式默认为 private(成员变量和成员函数默认也是 private)
继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的
1) public继承方式
基类中所有 public 成员在派生类中为 public 属性;
基类中所有 protected 成员在派生类中为 protected 属性;
基类中所有 private 成员在派生类中不能使用

2) protected继承方式
基类中的所有 public 成员在派生类中为 protected 属性;
基类中的所有 protected 成员在派生类中为 protected 属性;
基类中的所有 private 成员在派生类中不能使用。

3) private继承方式
基类中的所有 public 成员在派生类中均为 private 属性;
基类中的所有 protected 成员在派生类中均为 private 属性;
基类中的所有 private 成员在派生类中不能使用。

其规律是:基类成员在派生类中的访问权限不得高于继承方式中指定的权限

我们这里说的是基类的 private 成员不能在派生类中使用,并没有说基类的 private 成员不能被继承。实际上,基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法使用罢了。private 成员的这种特性,能够很好的对派生类隐藏基类的实现,以体现面向对象的封装性
在派生类中访问基类 private 成员的唯一方法就是借助基类的非 private 成员函数,如果基类没有非 private 成员函数,那么该成员在派生类中将无法访问。

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

用using改变访问权限

使用 using 关键字可以改变基类成员在派生类中的访问权限,例如将 public 改为 private、将 protected 改为 public。提供一个细粒度的权限的修改
但是不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能使用。

//派生类Student, 基类是People
class Student : public People {
public:
    void learning();
public:
    using People::m_name; //将protected改为public
    using People::m_age; //将protected改为public
    float m_score;
private:
    using People::show; //将public改为private
};

C++继承时的名字遮蔽问题

三个概念:重载、复写、遮蔽
1. 重载(Overloading):
- 重载是指在同一个作用域中可以定义多个同名的成员函数,但这些函数的参数列表必须不同
2. 覆盖,重写(Overriding):
- 覆盖是子类对父类的虚函数重写
3. 隐藏(Hiding):
- 不同作用域内的同名函数是会造成遮蔽,使得外层函数无效

类继承时的作用域嵌套,破解C++继承的一切秘密!

继承时的对象内存模型

有继承关系时,派生类的内存模型可以看成是基类成员变量和新增成员变量的总和,而所有成员函数仍然存储在另外一个区域——代码区,由所有对象共享。
成员变量按照派生的层级依次排列,新增成员变量始终在最后,当基类的成员变量被遮蔽时,仍然会留在派生类对象的内存中
在派生类的对象模型中,会包含所有基类的成员变量。这种设计方案的优点是访问效率高,能够在派生类对象中直接访问基类变量,无需经过好几层间接计算。
A类的m_b 和 B类的m_c,被C类给遮蔽了。如果访问A类的m_b成员,得加上作用域符号  A::m_b

基类和派生类的构造函数

在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有 private 属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。
这种矛盾在C++继承中是普遍存在的,解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数

Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }

派生类构造函数总是先调用基类构造函数再执行其他代码(包括参数初始化表以及函数体中的代码)

构造函数的调用顺序

基类构造函数调用规则

派生类创建对象时必须要调用基类的构造函数,要不然基类的成员变量的内容将不会被初始化。
定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);
如果没有默认构造函数,那么编译失败。

c++11构造函数继承

书写多个派生类构造函数只为传递参数完成基类初始化,这种方式无疑给开发人员带来麻烦,降低了编码效率。从 C++11 开始,推出了继承构造函数(Inheriting Constructor),使用 using 来声明继承基类的构造函数,我们可以这样书写。

class Base {
public:
    Base(int value) : baseValue(value) {}
    void showValue() {
        std::cout << "Base value: " << baseValue << std::endl;
    }
private:
    int baseValue;
};

class Derived : public Base {
public:
    using Base::Base; // 继承基类的构造函数
    void showDerivedValue() {
        std::cout << "Derived value: " << baseValue * 2 << std::endl;
    }
};

int main() {
    Derived derivedObj(5);
    derivedObj.showValue();         // 调用基类的成员函数
    derivedObj.showDerivedValue();  // 调用派生类的成员函数

    return 0;
}

基类和派生类的析构函数

多继承

多继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中较少使用,后来的 Java、C#、PHP 等干脆取消了多继承。

class D: public A, private B, protected C{
    //类D新增加的成员
}

多继承下的构造函数

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

基类构造函数的调用顺序和声明派生类时基类出现的顺序相同

多继承的问题:命名冲突

当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。
这个时候需要在成员名字前面加上类名和域解析符::,以显式地指明到底使用哪个类的成员,消除二义性。
而且会继承下来两份成员变量,占用内存比较大。

多继承时的对象内存模型

基类对象的排列顺序和继承时声明的顺序相同。
class C: public A, public B{ } 的示意图:

通过指针访问成员变量

借助指针突破访问权限的限制,访问private、protected属性的成员变量
一旦知道了对象的起始地址,再加上偏移量就能够求得成员变量的地址,知道了成员变量的地址和类型,也就能够轻而易举地知道它的值。
当成员变量的访问权限为 private 时,我们也可以手动转换,只要能正确计算偏移即可,这样就突破了访问权限的限制。
C++ 的成员访问权限仅仅是语法层面上的,是指访问权限仅对取成员运算符.->起作用,而无法防止直接通过指针来访问。你可以认为这是指针的强大,也可以认为是 C++ 语言设计的瑕疵。

虚继承和虚基类详解

多继承时很容易产生命名冲突,即使我们很小心地将所有类中的成员变量和成员函数都命名为不同的名字,命名冲突依然有可能发生,比如典型的是菱形继承:A 派生出类 B 和类 C,类 D 继承自类 B 和类 C
在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突(为了消除歧义,需要在成员变量前指明它具体来自哪个类和作用域符)。

虚继承(Virtual Inheritance)

虚继承一般涉及三个类:1.虚基类 2.虚基类的派生类 3.虚基类的派生类的派生类
为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类(很重要)。其中,这个被共享的基类就称为虚基类(Virtual Base Class)
虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不影响派生类本身

//间接基类A
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;
};

C++标准库中的 iostream 类就是一个虚继承的实际应用案例。iostream 从 istream 和 ostream 直接继承而来,而 istream 和 ostream 又都继承自一个共同的名为 base_ios(虚基类) 的类,是典型的菱形继承。此时 istream 和 ostream 必须采用虚继承,否则将导致 iostream 类中保留两份 base_ios 类的成员。

虚基类成员的可见性

因为在虚继承的最终派生类中只保留了一份虚基类的成员,所以该成员可以被直接访问,不会产生二义性。
如果继承的层次再多一些,关系更复杂一些,程序员就很容易陷人迷魂阵,程序的编写、调试和维护工作都会变得更加困难,因此我不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。

虚继承时的构造函数

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

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

虚继承下的内存模型

编译器在知道对象首地址的情况下,通过计算偏移来存取成员变量。对于普通继承,基类成员变量的偏移是固定的,不会随着继承层级的增加而改变,存取起来非常方便。

而对于虚继承,恰恰和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量的后面,这样随着继承层级的增加,基类成员变量的偏移就会改变,就得通过其他方案来计算偏移量。

虚继承时的派生类对象被分成了两部分:
* 偏移量不会随着继承层次的增加而改变,称为固定部分
* 偏移量会随着继承层次的增加而改变(虚继承,基类成员变量放在派生类成员变量的后面),称为共享部分

当要访问对象的成员变量时,需要知道对象的首地址和变量的偏移,对象的首地址很好获得,关键是变量的偏移。
对于固定部分,偏移是不变的,很好计算;
而对于共享部分,偏移会随着继承层次的增加而改变,这就需要设计一种方案,在偏移不断变化的过程中准确地计算偏移
各个编译器正是在设计这一方案时出现了分歧,不同的编译器设计了不同的方案来计算共享部分的偏移

 对于虚继承,将派生类分为固定部分和共享部分,并把共享部分放在最后,几乎所有的编译器都在这一点上达成了共识。主要的分歧就是如何计算共享部分的偏移,可谓是百花齐放,没有统一标准。

共享部分放在最后原因?

  1. 解决二义性问题: 在多重继承中,如果派生类通过多个路径继承同一个基类,就会导致二义性,因为同一份基类成员在派生类中存在多次。这使得编译器无法确定应该使用哪个基类成员。通过将共享部分放在派生类对象的最后,可以确保基类对象只有一份,从而消除了二义性问题。
  2. 减少空间浪费: 将共享部分放在派生类对象的最后可以减少内存空间的浪费。由于共享部分只有一份,它不会随着派生类的增加而重复,从而避免了重复存储相同的数据。这在多重继承场景中尤为重要,可以有效减少内存占用。
  3. 统一偏移计算: 将共享部分放在最后可以统一偏移计算的方法。无论派生类的层级有多深,每个派生类对象的固定部分始终位于相同的位置,而共享部分在固定部分的后面。这简化了偏移计算的逻辑,使得编译器可以更容易地计算访问基类成员的偏移。

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

1.虚基类指针方式(cfront解决方案)

早期的 cfront 编译器会在派生类对象中安插一些指针,每个指针指向一个虚基类的子对象,要存取继承来的成员变量,可以使用指针间接完成。
实质是增加一个指向虚基类的指针作为虚派生类的一个成员。
1. A 是 B 的虚基类

2. A 是 B 的虚基类,同时 B 也是 C 的虚基类

3. 假设 A、B、C、D 类的继承关系为

内存模型为:

缺点:
* 随着虚继承层次的增加,访问顶层基类需要的间接转换会越来越多,效率越来越低。
* 当有多个虚基类时,派生类要为每个虚基类都安插一个指针,会增加对象的体积。

2.虚基类表方式(VC解决方案)

VC 引入了虚基类表,如果某个派生类有一个或多个虚基类,编译器就会在派生类对象中安插一个指针,指向虚基类表。虚基类表其实就是一个数组,数组中的元素存放的是各个虚基类的偏移。
实质是增加一个指向虚基类的指针的数组(虚基类表),然后派生类再指向这个虚基类表。
1. A 是 B 的虚基类

2. A 是 B 的虚基类,同时 B 又是 C 的虚基类

3.  A、B、C、D 类的继承关系为

内存模型为:

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

将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,为向上转型(Upcasting)。
相应地,将基类赋值给派生类称为向下转型(Downcasting)。
向上转型后通过基类的对象、指针、引用只能访问从基类继承过去的成员(包括成员变量和成员函数),不能访问派生类新增的成员。

将派生类对象赋值给基类对象

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

将派生类对象赋值给基类对象时,会舍弃派生类新增的成员,这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。
理由很简单,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值。

将派生类指针赋值给基类指针

与对象变量之间的赋值不同的是,对象指针之间的赋值并没有拷贝对象的成员,也没有修改对象本身的数据,仅仅是改变了指针的指向。通过基类指针只能使用派生类的成员变量,但不能使用派生类的成员函数
概括起来说就是:
编译器通过指针来访问成员变量,指针指向哪个对象就使用哪个对象的数据,但是不能访问派生类的成员变量
编译器通过指针的类型来访问成员函数,指针属于哪个类的类型就使用哪个类的函数。

将派生类引用赋值给基类引用

基类的引用也可以指向派生类的对象,并且它的表现和指针是类似的

将派生类指针赋值给基类指针时到底发生了什么?

赋值就是将一个变量的值交给另外一个变量,这种想法虽然没错,但是有一点要注意,就是赋值以前编译器可能会对现有的值进行处理。例如将 double 类型的值赋给 int 类型的变量,编译器会直接抹掉小数部分,并转成int格式的内存模型
将派生类的指针赋值给基类的指针时也是类似的道理,编译器也可能会在赋值前进行处理。
继承关系:class D: public B, public C{}   class B: public A{}    

下面的代码演示了将 pd 赋值给 pc 时编译器的调整过程:
pc = (C*)( (int)pd + sizeof(B) );
首先要明确的一点是,对象的指针必须要指向对象的起始位置.如果父类地址不是从起始位置开始的,则需要加上相应的偏移值。