C++复习四-继承与派生

目录

一、概述

二、C++三种继承方式

2.1 public、protected、private 指定继承方式

2.2 using改变访问权限

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

2.4 具体解释一下和作用域有关

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

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

2.7 C++多继承(多重继承)

1.多继承下的构造函数

2.命名冲突

3.内存模型

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

2.9 C++虚继承和虚基类详解

1.虚继承(Virtual Inheritance)

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

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

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

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


一、概述

继承(Inheritance)可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。

在C++中,派生(Derive和继承是一个概念,只是站的角度不同。继承是儿子接收父亲的产业,派生是父亲把产业传承给儿子。

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

派生类除了拥有基类的成员,还可以定义自己的新成员,以增强类的功能。

以下是两种典型的使用继承的场景:
1) 当你创建的新类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承,这样不但会减少代码量,而且新类会拥有基类的所有功能。

2) 当你需要创建多个类,它们拥有很多相似的成员变量或成员函数时,也可以使用继承。可以将这些类的共同成员提取出来,定义为基类,然后从基类继承,既可以节省代码,也方便后续修改成员。

继承的一般语法为:

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

继承方式包括 public(公有的)、private(私有的)和 protected(受保护的),此项是可选的。
如果不写,那么默认为 private。我们将在下节详细讲解这些不同的继承方式。

二、C++三种继承方式

protected 成员和 private 成员类似,也不能通过对象访问。但是当存在继承关系时,protected 和 private 就不一样了:基类中的 protected 成员可以在派生类中使用,而基类中的 private 成员不能在派生类中使用。

2.1 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 成员在派生类中始终不能使用(不能在派生类的成员函数中访问或调用)。

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

实际上,基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法使用罢了。

#include<iostream>
using namespace std;

//基类People
class People{
public:
    void setname(char *name);
    void setage(int age);
    void sethobby(char *hobby);
    char *gethobby();
protected:
    char *m_name;
    int m_age;
private:
    char *m_hobby;
};
void People::setname(char *name){ m_name = name; }
void People::setage(int age){ m_age = age; }
void People::sethobby(char *hobby){ m_hobby = hobby; }
char *People::gethobby(){ return m_hobby; }

//派生类Student
class Student: public People{
public:
    void setscore(float score);
protected:
    float m_score;
};
void Student::setscore(float score){ m_score = score; }

//派生类Pupil
class Pupil: public Student{
public:
    void setranking(int ranking);
    void display();
private:
    int m_ranking;
};
void Pupil::setranking(int ranking){ m_ranking = ranking; }
void Pupil::display(){
    cout<<m_name<<"的年龄是"<<m_age<<",考试成绩为"<<m_score<<"分,班级排名第"<<m_ranking<<",TA喜欢"<<gethobby()<<"。"<<endl;
}

int main(){
    Pupil pup;
    pup.setname("小明");
    pup.setage(15);
    pup.setscore(92.5f);
    pup.setranking(4);
    pup.sethobby("乒乓球");
    pup.display();

    return 0;
}

在派生类中访问基类 private 成员的唯一方法就是借助基类的非 private 成员函数,如果基类没有非 private 成员函数,那么该成员在派生类中将无法访问。

2.2 using改变访问权限

using 只能改变基类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能使用,所以基类中的 private 成员在派生类中无论如何都不能访问。

#include<iostream>
using namespace std;

//基类People
class People {
public:
    void show();
protected:
    char *m_name;
    int m_age;
};
void People::show() {
    cout << m_name << "的年龄是" << m_age << endl;
}

//派生类Student
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
};
void Student::learning() {
    cout << "我是" << m_name << ",今年" << m_age << "岁,这次考了" << m_score << "分!" << endl;
}

int main() {
    Student stu;
    stu.m_name = "小明";
    stu.m_age = 16;
    stu.m_score = 99.5f;
    stu.show();  //compile error
    stu.learning();

    return 0;
}

因为 show() 函数是 private 属性的,所以代码第 36 行会报错。把该行注释掉,程序输出结果为:

我是小明,今年16岁,这次考了99.5分!

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

基类成员和派生类成员的名字一样时会造成遮蔽,这句话对于成员变量很好理解,对于成员函数要引起注意,不管函数的参数如何,只要名字一样就会造成遮蔽。换句话说,基类成员函数和派生类成员函数不会构成重载,因为函数所在的作用域不同。如果派生类有同名函数,那么就会遮蔽基类中的所有同名函数,不管它们的参数是否一样。

#include<iostream>
using namespace std;

//基类Base
class Base{
public:
    void func();
    void func(int);
};
void Base::func(){ cout<<"Base::func()"<<endl; }
void Base::func(int a){ cout<<"Base::func(int)"<<endl; }

//派生类Derived
class Derived: public Base{
public:
    void func(char *);
    void func(bool);
};
void Derived::func(char *str){ cout<<"Derived::func(char *)"<<endl; }
void Derived::func(bool is){ cout<<"Derived::func(bool)"<<endl; }

int main(){
    Derived d;
    d.func("c.biancheng.net");
    d.func(true);
    d.func();  //compile error
    d.func(10);  //compile error
    d.Base::func();
    d.Base::func(100);

    return 0;
}

2.4 具体解释一下和作用域有关

本例中,B 继承自 A,C继承自 B,它们作用域的嵌套关系如下图所示:

obj 是 C 类的对象,通过 obj 访问成员变量 n 时,在 C 类的作用域中就能够找到了 n 这个名字。虽然 A 类和 B 类都有名字 n,但编译器不会到它们的作用域中查找,所以是不可见的,也即派生类中的 n 遮蔽了基类中的 n。

通过 obj 访问成员函数 func() 时,在 C 类的作用域中没有找到 func 这个名字,编译器继续到 B 类的作用域(外层作用域)中查找,仍然没有找到,再继续到 A 类的作用域中查找,结果就发现了 func 这个名字,于是查找结束,编译器决定调用 A 类作用域中的 func() 函数。

这个过程叫做名字查找(name lookup),也就是在作用域链中寻找与所用名字最匹配的声明(或定义)的过程。

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

没有继承时对象内存的分布情况,成员变量和成员函数会分开存储:

  • 对象的内存中只包含成员变量,存储在栈区或堆区(使用 new 创建对象);
  • 成员函数与对象内存分离,存储在代码区。

当存在继承关系时,内存模型会稍微复杂一些。

#include <cstdio>
using namespace std;

//基类A
class A{
public:
    A(int a, int b);
public:
    void display();
protected:
    int m_a;
    int m_b;
};
A::A(int a, int b): m_a(a), m_b(b){}
void A::display(){
    printf("m_a=%d, m_b=%d\n", m_a, m_b);
}

//派生类B
class B: public A{
public:
    B(int a, int b, int c);
    void display();
private:
    int m_c;
};
B::B(int a, int b, int c): A(a, b), m_c(c){ }
void B::display(){
    printf("m_a=%d, m_b=%d, m_c=%d\n", m_a, m_b, m_c);
}

int main(){
    A obj_a(99, 10);
    B obj_b(84, 23, 95);
    obj_a.display();
    obj_b.display();

    return 0;
}

obj_a 是基类对象,obj_b 是派生类对象。假设 obj_a 的起始地址为 0X1000,那么它的内存分布如下图所示:


假设 obj_b 的起始地址为 0X1100,那么它的内存分布如下图所示:


可以发现,基类的成员变量排在前面,派生类的排在后面。

有成员变量遮蔽时的内存分布规则仍然不变:

假设 obj_c 的起始地址为 0X1300,那么它的内存分布如下图所示:


当基类 A、B 的成员变量被遮蔽时,仍然会留在派生类对象 obj_c 的内存中,C 类新增的成员变量始终排在基类 A、B 的后面。

总结:在派生类的对象模型中,会包含所有基类的成员变量。这种设计方案的优点是访问效率高,能够在派生类对象中直接访问基类变量,无需经过好几层间接计算。从图下往图上即从高地址到低地址找,找到就停了,所以会遮蔽。

假设 obj_c 的起始地址为 0X1300,那么它的内存分布如下图所示:


当基类 A、B 的成员变量被遮蔽时,仍然会留在派生类对象 obj_c 的内存中,C 类新增的成员变量始终排在基类 A、B 的后面。

总结:在派生类的对象模型中,会包含所有基类的成员变量。这种设计方案的优点是访问效率高,能够在派生类对象中直接访问基类变量,无需经过好几层间接计算。

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

类的构造函数不能被继承。

在设计派生类时,对继承过来的成员变量的初始化工作也要由派生类的构造函数完成,但是大部分基类都有 private 属性的成员变量,它们在派生类中无法访问,更不能使用派生类的构造函数来初始化。

这种矛盾在C++继承中是普遍存在的,解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数。

因为基类构造函数不会被继承,不能当做普通的成员函数来调用。换句话说,只能将基类构造函数的调用放在函数头部,不能放在函数体中,即默认参数初始化列表中

派生类构造函数总是先调用基类构造函数再执行其他代码(包括参数初始化表以及函数体中的代码),所以初始化的二者顺序无所谓。

具体看代码:

#include<iostream>
using namespace std;

//基类People
class People{
protected:
    char *m_name;
    int m_age;
public:
    People(char*, int);
};
People::People(char *name, int age): m_name(name), m_age(age){}

//派生类Student
class Student: public People{
private:
    float m_score;
public:
    Student(char *name, int age, float score);
    void display();
};
//People(name, age)就是调用基类的构造函数
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
void Student::display(){
    cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<"。"<<endl;
}

int main(){
    Student stu("小明", 16, 90.5);
    stu.display();

    return 0;
}

也可以将基类构造函数的调用放在参数初始化表后面::

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

这么写错误:

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

那么创建 C 类对象时构造函数的执行顺序为:

A类构造函数 --> B类构造函数 --> C类构造函数

派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。以上面的 A、B、C 类为例,C 是最终的派生类,B 就是 C 的直接基类,A 就是 C 的间接基类。

事实上,通过派生类创建对象时必须要调用基类的构造函数,这是语法规定。换句话说,定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);如果没有默认构造函数,那么编译失败

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

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

  • 创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类构造函数,再执行派生类构造函数。
  • 而销毁派生类对象时,析构函数的执行顺序和继承顺序相反,即先执行派生类析构函数,再执行基类析构函数

2.7 C++多继承(多重继承)

C++也支持多继承(Multiple Inheritance),即一个派生类可以有两个或多个基类。

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

多继承的语法也很简单,将多个基类用逗号隔开即可。例如已声明了类A、类B和类C,那么可以这样来声明派生类D:

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

D 是多继承形式的派生类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。D 根据不同的继承方式获取 A、B、C 中的成员,确定它们在派生类中的访问权限。

1.多继承下的构造函数

多继承形式下的构造函数和单继承形式基本相同,只是要在派生类的构造函数中调用多个基类的构造函数。以上面的 A、B、C、D 类为例,D 类构造函数的写法为:

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

基类构造函数的调用顺序和和它们在派生类构造函数中出现的顺序无关,而是和声明派生类时基类出现的顺序相同。仍然以上面的 A、B、C、D 类为例,即使将 D 类构造函数写作下面的形式:

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

那么也是先调用 A 类的构造函数,再调用 B 类构造函数,最后调用 C 类构造函数。

2.命名冲突

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

修改上面的代码,为 BaseA 和 BaseB 类添加 show() 函数,并将 Derived 类的 show() 函数更名为 display():

#include <iostream>
using namespace std;

//基类
class BaseA{
public:
    BaseA(int a, int b);
    ~BaseA();
public:
    void show();
protected:
    int m_a;
    int m_b;
};
BaseA::BaseA(int a, int b): m_a(a), m_b(b){
    cout<<"BaseA constructor"<<endl;
}
BaseA::~BaseA(){
    cout<<"BaseA destructor"<<endl;
}
void BaseA::show(){
    cout<<"m_a = "<<m_a<<endl;
    cout<<"m_b = "<<m_b<<endl;
}

//基类
class BaseB{
public:
    BaseB(int c, int d);
    ~BaseB();
    void show();
protected:
    int m_c;
    int m_d;
};
BaseB::BaseB(int c, int d): m_c(c), m_d(d){
    cout<<"BaseB constructor"<<endl;
}
BaseB::~BaseB(){
    cout<<"BaseB destructor"<<endl;
}
void BaseB::show(){
    cout<<"m_c = "<<m_c<<endl;
    cout<<"m_d = "<<m_d<<endl;
}

//派生类
class Derived: public BaseA, public BaseB{
public:
    Derived(int a, int b, int c, int d, int e);
    ~Derived();
public:
    void display();
private:
    int m_e;
};
Derived::Derived(int a, int b, int c, int d, int e): BaseA(a, b), BaseB(c, d), m_e(e){
    cout<<"Derived constructor"<<endl;
}
Derived::~Derived(){
    cout<<"Derived destructor"<<endl;
}
void Derived::display(){
    BaseA::show();  //调用BaseA类的show()函数
    BaseB::show();  //调用BaseB类的show()函数
    cout<<"m_e = "<<m_e<<endl;
}

int main(){
    Derived obj(1, 2, 3, 4, 5);
    obj.display();
    return 0;
}

3.内存模型

A、B 是基类,C 是派生类,假设 obj_c 的起始地址是 0X1000,那么 obj_c 的内存分布如下图所示:
 

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


基类对象的排列顺序和继承时声明的顺序相同。

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

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


图中假设 obj 对象的起始地址为 0X1000,m_a、m_b、m_c 与对象开头分别相距 0、4、8 个字节,我们将这段距离称为偏移(Offset)。一旦知道了对象的起始地址,再加上偏移就能够求得成员变量的地址,知道了成员变量的地址和类型,也就能够轻而易举地知道它的值。

当通过对象指针访问成员变量时,编译器实际上也是使用这种方式来取得它的值。为了说明问题,我们不妨将上例中成员变量的访问权限改为 public,再来执行第 18 行的语句:

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 *类型;开头的*用来获取地址上的数据。


如果通过 p 指针访问 m_a:

int a = p -> m_a;

那么将被转换为下面的形式:

int a = * (int*) ( (int)p + 0 );

经过简化以后为:

int a = *(int*)p;

上述的转换过程是编译器自动完成的,当成员变量的访问权限为 private 时,我们也可以手动转换,只要能正确计算偏移即可,这样就突破了访问权限的限制。

修改上例中的代码,借助偏移来访问 private 属性的成员变量:

#include <iostream>
using namespace std;

class A{
public:
    A(int a, int b, int c);
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 a1 = *(int*)&obj;
    int b = *(int*)( (int)&obj + sizeof(int) );

    A *p = new A(40, 50, 60);
    int a2 = *(int*)p;
    int c = *(int*)( (int)p + sizeof(int)*2 );
   
    cout<<"a1="<<a1<<", a2="<<a2<<", b="<<b<<", c="<<c<<endl;

    return 0;
}

运行结果:
a1=10, a2=40, b=20, c=60

前面我们说 C++ 的成员访问权限仅仅是语法层面上的,是指访问权限仅对取成员运算符.->起作用,而无法防止直接通过指针来访问。你可以认为这是指针的强大,也可以认为是 C++ 语言设计的瑕疵。

2.9 C++虚继承和虚基类详解

多继承时很容易产生命名冲突,即使我们很小心地将所有类中的成员变量和成员函数都命名为不同的名字,命名冲突依然有可能发生,比如典型的是菱形继承,如下图所示:

 


图1:菱形继承


类 A 派生出类 B 和类 C,类 D 继承自类 B 和类 C,这个时候类 A 中的成员变量和成员函数继承到类 D 中变成了两份,一份来自 A-->B-->D 这条路径,另一份来自 A-->C-->D 这条路径。

在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A -->B-->D 这条路径,还是来自 A-->C-->D 这条路径。下面是菱形继承的具体实现:

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

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

//直接基类C
class C: 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;
};

int main(){
    D d;
    return 0;
}

这段代码实现了上图所示的菱形继承,第 25 行代码试图直接访问成员变量 m_a,结果发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。

为了消除歧义,我们可以在 m_a 的前面指明它具体来自哪个类:

void seta(int a){ B::m_a = a; }

这样表示使用 B 类的 m_a。当然也可以使用 C 类的:

void seta(int a){ C::m_a = a; }

1.虚继承(Virtual Inheritance)

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

在继承方式前面加上 virtual 关键字就是虚继承,请看下面的例子:

//间接基类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;
};
int main(){
    D d;
    return 0;
}

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

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

现在让我们重新梳理一下本例的继承关系,如下图所示:


图2:使用虚继承解决菱形继承中的命名冲突问题


观察这个新的继承体系,我们会发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上图中,当定义 D 类时才出现了对虚派生的需求,但是如果 B 类和 C 类不是从 A 类虚派生得到的,那么 D 类还是会保留 A 类的两份成员。

换个角度讲,虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身。

在实际开发中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。通常情况下,使用虚继承的类层次是由一个人或者一个项目组一次性设计完成的。对于一个独立开发的类来说,很少需要基类中的某一个类是虚基类,况且新类的开发者也无法改变已经存在的类体系。

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


图3:虚继承在C++标准库中的实际应用

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

在虚继承中,虚基类是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。

下面我们以菱形继承为例来演示构造函数的调用:

#include <iostream>
using namespace std;

//虚基类A
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);
public:
    void display();
protected:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"m_a="<<m_a<<", m_b="<<m_b<<endl;
}

//直接派生类C
class C: virtual public A{
public:
    C(int a, int c);
public:
    void display();
protected:
    int m_c;
};
C::C(int a, int c): A(a), m_c(c){ }
void C::display(){
    cout<<"m_a="<<m_a<<", m_c="<<m_c<<endl;
}

//间接派生类D
class D: public B, public C{
public:
    D(int a, int b, int c, int d);
public:
    void display();
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){ }
void D::display(){
    cout<<"m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<", m_d="<<m_d<<endl;
}

int main(){
    B b(10, 20);
    b.display();
   
    C c(30, 40);
    c.display();

    D d(50, 60, 70, 80);
    d.display();
    return 0;
}

运行结果:
m_a=10, m_b=20
m_a=30, m_c=40
m_a=50, m_b=60, m_c=70, m_d=80

为了避免出现这种矛盾的情况,C++ 干脆规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。在第 50 行代码中,调用 B 的构造函数时试图将 m_a 初始化为 90,调用 C 的构造函数时试图将 m_a 初始化为 100,但是输出结果有力地证明了这些都是无效的,m_a 最终被初始化为 50,这正是在 D 中直接调用 A 的构造函数的结果。

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

修改本例中第 50 行代码,改变构造函数出现的顺序:

D::D(int a, int b, int c, int d): B(90, b), C(100, c), A(a), m_d(d){ }

虽然我们将 A() 放在了最后,但是编译器仍然会先调用 A(),然后再调用 B()、C(),因为 A() 是虚基类的构造函数,比其他构造函数优先级高。如果没有使用虚继承的话,那么编译器将按照出现的顺序依次调用 B()、C()、A()。

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

简单的面向对象,只有单继承或多继承的情况下,内存模型很好理解,编译器实现起来也容易,C++ 的效率和 C 的效率不相上下。一旦和 virtual 关键字扯上关系,使用到虚继承或虚函数,内存模型就变得混乱起来,各种编译器的实现也不一致,让人抓狂。

这是因为 C++ 标准仅对 C++ 的实现做了框架性的概述,并没有规定细节如何实现,所以不同厂商的编译器在具体实现方案上会有所差异。
修改上面的代码,使得 A 是 B 的虚基类:

class B: virtual public A

此时 obj_b、obj_c、obj_d 的内存模型就会发生变化,如下图所示:


不管是虚基类的直接派生类还是间接派生类,虚基类的子对象始终位于派生类对象的最后面。

从上面的两张图中可以发现,虚继承时的派生类对象被分成了两部分:

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

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

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

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

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

向上转型非常安全,可以由编译器自动完成;向下转型有风险,需要程序员手动干预。

赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。运行结果也有力地证明了这一点,虽然有a=b;这样的赋值过程,但是 a.display() 始终调用的都是 A 类的 display() 函数。换句话说,对象之间的赋值不会影响成员函数,也不会影响 this 指针。

将派生类对象赋值给基类对象时,会舍弃派生类新增的成员,也就是“大材小用”,如下图所示:


可以发现,即使将派生类对象赋值给基类对象,基类对象也不会包含派生类的成员,所以依然不同通过基类对象来访问派生类的成员。对于上面的例子,a.m_a 是正确的,但 a.m_b 就是错误的,因为 a 不包含成员 m_b。

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

要理解这个问题,还得从赋值的本质入手。赋值实际上是向内存填充数据,当数据较多时很好处理,舍弃即可;本例中将 b 赋值给 a 时(执行a=b;语句),成员 m_b 是多余的,会被直接丢掉,所以不会发生赋值错误。但当数据较少时,问题就很棘手,编译器不知道如何填充剩下的内存;如果本例中有b= a;这样的语句,编译器就不知道该如何给变量 m_b 赋值,所以会发生错误。

#include <iostream>
using namespace std;

//基类
class A{
public:
    A(int a);
public:
    void display();
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

//派生类
class B: public A{
public:
    B(int a, int b);
public:
    void display();
public:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}


int main(){
    A a(10);
    B b(66, 99);
    //赋值前
    a.display();
    b.display();
    cout<<"--------------"<<endl;
    //赋值后
    a = b;
    a.display();
    b.display();

    return 0;
}

运行结果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99

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

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章