Table of Contents:

 C++继承和派生简明教程

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

 C++三种继承方式

继承方式默认为 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 关键字可以改变基类成员在派生类中的访问权限,例如将 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++继承时的名字遮蔽问题

 所谓遮蔽,就是在子类和父类成员重名,在派生类中使用该成员时,实际上使用的是派生类新增的成员,而不是从基类继承来的,就像作用域里的遮蔽一样
 基类成员函数和派生类成员函数不构成重载,如果派生类有同名函数(不管它们的参数是否一样),那么就会遮蔽基类中的所有同名函数。

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

* 当存在继承关系时,派生类的作用域嵌套在基类的作用域之内,如果一个名字在派生类的作用域内无法找到,编译器会继续到外层的基类作用域中查找该名字的定义。

* 只有一个作用域内的同名函数才具有重载关系,不同作用域内的同名函数是会造成遮蔽,使得外层函数无效。派生类和基类拥有不同的作用域,所以它们的同名函数不具有重载关系。

 C++继承时的对象内存模型

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

 C++基类和派生类的构造函数

类的构造函数不能被继承。构造函数不能被继承是有道理的,因为即使继承了,它的名字和派生类的名字也不一样,不能成为派生类的构造函数
在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有 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 v) :_value(v), _c('0'){}
    Base(char c): _value(0), _c(c){}
private:
    int _value;
    char _c;
};


class Derived :public Base {
public:
    // 使用继承构造函数
    using Base::Base;


    // 假设派生类只是添加了一个普通的函数
    void display() {
        //dosomething        
    }
};

Derived derived(1,'a');  //会调用基类的构造函数

 C++基类和派生类的析构函数

 和构造函数类似,析构函数也不能被继承。
与构造函数不同的是,在派生类的析构函数中不用显式地调用基类的析构函数,因为每个类只有一个析构函数,编译器知道如何选择,无需程序员干涉。所有析构函数都有编译器完成了。
 先执行派生类析构函数,再执行基类析构函数。

 C++多继承

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

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

 多继承下的构造函数

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

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

 多继承的问题:命名冲突

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

 C++多继承时的对象内存模型

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

 借助指针突破访问权限的限制,访问private、protected属性的成员变量

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

 虚基类成员的可见性

因为在虚继承的最终派生类中只保留了一份虚基类的成员,所以该成员可以被直接访问,不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,那么仍然可以直接访问这个被覆盖的成员。但是如果该成员被两条或多条路径覆盖了,那就不能直接访问了,此时必须指明该成员属于哪个类。

如果继承的层次再多一些,关系更复杂一些,程序员就很容易陷人迷魂阵,程序的编写、调试和维护工作都会变得更加困难,因此我不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。

 C++虚继承时的构造函数

在虚继承中,虚基类是由最终的派生类初始化
例子:B C虚继承A,D多继承B C
C++规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。
虚继承时构造函数的执行顺序与普通继承时不同:在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序如何,编译器总是先调用虚基类的构造函数,再按照出现的顺序调用其他的构造函数

 C++虚继承下的内存模型

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

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

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

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

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

共享部分放在最后原因?
虚基类的成员的处理方式不同,比如菱形继承只有一份会继承,那如何单独处理只保留一份虚继承的数据呢,放在对象之后,编译器就会根据后面的虚继承的成员单从而去重,以达到只保留一份虚基类的数据。

假设 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 类的继承关系为

内存模型为:

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

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

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

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

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

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

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

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

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

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

我们通常认为,赋值就是将一个变量的值交给另外一个变量,这种想法虽然没错,但是有一点要注意,就是赋值以前编译器可能会对现有的值进行处理。例如将 double 类型的值赋给 int 类型的变量,编译器会直接抹掉小数部分,导致赋值运算符两边变量的值不相等。
将派生类的指针赋值给基类的指针时也是类似的道理,编译器也可能会在赋值前进行处理。
继承关系:class D: public B, public C{}   class B: public A{}    

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