【C++杂货铺】继承由浅入深详细总结(下)

简介: 【C++杂货铺】继承由浅入深详细总结

菱形继承存在的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在 Assistant 的对象中 Person 的成员变量会有两份。

class Person
{
public:
    string _name;//姓名
    int _age;//年龄
};
class Teacher : public Person
{
protected:
    int _id;//职工号
};
class Student : public Person
{
protected:
    int _num;//学号
};
class Assistant : public Teacher, public Student
{
protected:
    string _majorCourse;//主修课程
};
void Test()
{
    Assistant a;
    a._age = 10;
}

小Tips:对 _age 访问不明确,其实就是二义性问题。想要解决二义性问题,我们可以通过指定类域去访问,像下面这样。

void Test()
{
    Assistant a;
    a.Teacher::_age = 10;
    a.Student::_age = 18;
}

小Tips:上面这样虽然解决了二义性问题,但是对于面向对象的语言来说,这样是不符合逻辑的,因为一个人不可能同时拥有两个年龄。并且数据冗余还是没有得到解决。因此下面需要引入虚拟继承,虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其它地方去使用。

class Person
{
public:
    string _name = "Peter";//姓名
    int _age = 0;//年龄
};
class Teacher : virtual public Person
{
protected:
    int _id = 0;//职工号
};
class Student : virtual public Person
{
protected:
    int _num = 0;//学号
};
class Assistant : public Teacher, public Student
{
protected:
    string _majorCourse;//主修课程
};

7.1 虚拟继承解决数据冗余和二义性的原理

为了研究虚拟继承的原理,我们给出了一个简化的菱形继承体系,再借助内存窗口观察对象成员的模型。

class A
{
public:
    int _a;
};
class B : public A
//class B : virtual public A
{
public:
    int _b;
};
class C : public A
//class C : virtual public A
{
public:
    int _c;
};
class D : public B, public C
{
public:
    int _d;
};
void Test()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._b = 3;
    d._c = 4;
    d._d = 5;
}

小Tips:上图是普通菱形继承的对象成员在内存中的模型。可以看出内存中存储了两个 _a 成员变量,一个是继承自 B 的,另一个是继承自 C 的。下面再来看看虚拟继承的对象成员在内存中的存储模型。

class A
{
public:
    int _a;
};
class B : virtual public A
//class B : virtual public A
{
public:
    int _b;
};
class C : virtual public A
//class C : virtual public A
{
public:
    int _c;
};
class D : public B, public C
{
public:
    int _d;
};
void Test()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._b = 3;
    d._c = 4;
    d._d = 5;
    d._a = 10;
}

小Tips:虚拟继承的对象成员在内存中的存储模型发生了很大的变化。首先 A 类型的成员变量 _a 从 B 类型和 C 类型中脱离了出来,普通的菱形继承,_a 会在 B 类型 和 C 类型中各自存储一份,造成数据冗余和二义性,而在菱形继承中,A 类型的成员变量 _a 只会在内存空间中存储一份。另一个变化是,B 类型和 C 类型中分别多出了一个数据,如 B 中的 00ff7bdc 和 C 中的 00ff7be4,这俩很明显都是地址,根据地址去查找对应内存空间中存储的数据,可以发现, B 类型中的这个地址空间中存储了一个 14,这里的 14 是十六进制,转化成十进制是 20,而 20 正好是 B 类型的首地址 0x00EFFBA0 和 A 类型首地址 0x00EFFBB4 之间的距离。同理,C类中的这个地址空间中存储的 0c 转化成十进制是 12,就是 C 类型的首地址 0x00EFFBA8 和 A 类型的首地址 0x00EFFBB4 之间的距离。这里可能就会有小伙伴想问,为什么不把这个相对距离直接存在 D 类型的对象中,而是单独在内存中开了块空间去存储。首先,因为这里不仅需要存储相对距离,还需要存储一些其它的信息。其次,我们可能同时创建多个 D 对象,对这多个 D 对象来说,它们的偏移量都相同,如果在所有的 D 类型对象中都存储一份,在数据量大的时候会造成极大地浪费,因此我们可以把这部分“固定不变”的信息提取出来,单独在内存中开辟一块空间去存储,然后在 D 类型的对象中存上该空间的地址即可,这样做可以在数据量大的时候,有效的节省空间,提高内存利用率。

小Tips:如上图所示,我们同时创建了两个 D 类型的对象,d 和 d1,它们在内存空间中都存了相同的地址 0x00337bdc0x00337be4,这两个地址空间中分别存储的是 B 类型 和 A 类型的相对距离,C 类型和 A 类型的相对距离。存储偏移量的这块空间也被形象的叫做虚基表(找基类偏移量的表),但是需要注意虚基表中不止会存储偏移量,还会存储一些其他信息,具体内容将在下一篇关于多态的文章中为大家揭晓,感兴趣的朋友们不妨先点一个关注👀。

7.2 存偏移量的意义

void Test()
{
    D d;
    d._a = 10;
}

如上面代码所示,定义一个普通的 D 类型对象 d,然后去访问它里面的成员变量 _a,这种情况下是用不到偏移量的。偏移量的作用是去找 D 对象中“爷爷类”的成员(即父类的父类,这也就是 A 类),对于普通的 D 类型对象 d 来说,在定义该对象的时候,编译器会根据声明顺序依次为成员创建栈帧(依次分配空间进行存储),所以对编译器来说,它知道 _a 这个成员变量就存储在哪,所以当我们执行 d._a = 10 的时候,编译器会直接找到这块内存空间,并不需要通过虚基表去查找偏移量。存偏移量的真正用途是为了下面这种情况。

void Test()
{
    D d;
    d._a = 1;
    d._b = 2;
    d._c = 3;
    d._d = 4;
    B b;
    b._a = 1;
    b._b = 2;
  B* ptr = &b;
    ptr->_a++;
    ptr = &d;
    ptr->_a++;
}

小Tips:这里我们首先需要明确一点,在虚继承体系中,B 对象成员在内存中的存储模型相较于普通的继承也发生了改变,它也会涉及到虚基表。

明确了这一点后,我们继续看看上面的代码,我们定义了一个 B 类型的指针 ptr,该指针可以指向一个 B 类型的对象,也可以指向一个 D 类型的对象(注意,只能访问到 D 类型对象中 B 中的成员)。虽然 ptr 可以指向不同类型的对象,但是 ptr 始终都是 B 类型,这就决定了,无论你 ptr 指向什么类型的对象,你都只能访问到 B 类中有的成员,即 ptr 最多只能访问到 _a_b 这两个成员。ptr 作为一个指针变量,在转换成指令后,它并不知道它指向的是谁,它只是存了一个地址,如果 ptr 存的是一个 B 类型对象的地址,那它的 _a_b 在内存空间上是连续的,但是如果 ptr 存的是一个 D 类对象的地址,那它的 _a_b 在内存空间中并非连续的。中间可能隔着一些其他类型。而指针的工作原理是,首先指针一定存的是一个变量的首地址,其次指针的类型决定了它从该变量的首地址开始,可以访问到多少个连续的空间。以 int 型的指针为例,他可以访问到连续的四个字节,对 int 型的指针 +1,会自动跳过四个字节。再回到这里,当 ptr 存的是一个 D 类对象的首地址,ptr 可以访问到的成员并不连续,那指针就无法找到 _a 了,此时存偏移量的作用就体现出来了,ptr 可以通过虚基表,查找到偏移量,进而找到 _a 成员。此时无论 ptr 是指向 B 类型的对象还是指向 D 类型的对象,当 ptr 要去访问 _a 的时候,都会转化成先取偏移量,再计算 _a 在对象中的地址,再去访问。

7.2 虚继承解决数据冗余问题

还是以上面的代码为例,一个 D 类型对象中和 A 类型有关的成员变量的大小(字节数)在比较小的情况下,那么这个 D 类型对象在虚继承体系下的大小(字节数)可能还会大于普通继承体系下创建的 D 类型对象,上面的代码在普通继承体系下,一个 D 类型对象的大小是 20 字节,在虚继承体系下是 24 字节。

出现这种虚拟继承体系下创建的对象比普通继承体系下创建的对象大的主要原因是 A 类型中的成员变量太少了,所占用的内存空间太小了,导致虚拟继承的支出大于收益,如果 A 中的成员变量比较多,或者是一个大数组,那么,虚拟继承解决普通继承体系下的数据冗余功能就可以体现出来了。

八、继承的总结和反思

很多小伙伴觉得 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就存在数据冗余和二义性问题,为了解决数据冗余和二义性问题,又引入了菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。多继承可以认为是 C++ 的缺陷之一,后来很多的 OO 语言都没有多继承,如 Java。

8.1 继承和组合

  • public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
  • 组合是一种 has-a 的关系。即在 B 类里面声明一个 A 类型的成员变量。这样以来每个 B 对象中都有一个 A 对象。
  • 优先使用对象组合,而不是继承。
  • 继承允许我们根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承在一定程度上破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类之间的依赖关系很强,耦合度高。
  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组长或者组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为“黑箱复用”,因为对象的内部细节是不可见的。对象只是以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助与我们保持每个类被封装。
  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。

九、继承常见面试问题

//下面这段代码的输出结果是?
class A {
public:
    A(const char* s) 
    { 
        cout << s << endl; 
    }
    ~A() 
    {}
};
class B :virtual public A
{
public:
    B(const char* s1, const char* s2) 
        :A(s1) 
    { 
        cout << s2 << endl; 
    }
};
class C :virtual public A
{
public:
    C(const char* s1, const char* s2) 
        :A(s1) 
    { 
        cout << s2 << endl; 
    }
};
class D :public B, public C
{
public:
    D(const char* s1, const char* s2, const char* s3, const char* s4) 
        :B(s1, s2)
        ,C(s1, s3)
        ,A(s1)
    {
        cout << s4 << endl;
    }
};
int main() {
    D* p = new D("class A", "class B", "class C", "class D");
    delete p;
    return 0;
}

小Tips:解决本题的关键在于我们要知道知道以下两点。第一点:对与虚拟继承来说,虽然 D 类看上去只继承了 B 类和 C 类,但是它是一种菱形继承,B 类和 C 类都继承了 A 类,所以从某种意义上讲,D 类也继承了 A 类,又因为这里是菱形虚拟继承,A 类中的成员变量在 D 类中只有一份。这里要求在 D 类构造函数的初始化列表中先去调用 A 类的构造函数,因此如果在 A 类没有默认构造函数的情况下就需要我们自己在初始化列表中显式的去写调用 A 类构造函数的语句。在第四小节中我们提到过:派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。第二点需要我们掌握的是:在派生类构造函数的初始化列表中成员变量的初始化顺序,是按照继承的先后顺序来的,先去调用第一个被继承的类的构造函数,再去调用第二个被继承的类,依此类推,最后再去初始化当前类自己的成员变量,以上面代码为例:class D :public B, public C,D 类先继承 B,再继承 C,但是需要注意,这是个菱形虚拟继承,B 类和 C 类都继承自 A 类,所以编译器最先去调用 A 类的构造函数,接下来再去调用 B 类的构造函数,然后再去调用 C 类的构造函数,这里还需要注意一点,虽然在 B 类和 C 类构造函数的初始化列表中都显示的写了调用了 A 类构造函数的语句,但是这是菱形虚拟继承,创建 D 对象的第一步就去调用了 A 类的构造函数,所以在调用 B 类和 C 类构造函数的过程中编译器并不会再去调用 A 类的构造函数。虽然在创建 D 对象的过程中编译器并不会去执行 B 类和 C 类构造函数中调用 A 类构造函数的语句,但是我们不能把这条语句给删了(除非 A 类有默认构造),因为这条语句不执行仅仅是在创建 D 类对象的过程中,我们也可能会去创建 B 类对象和 C 类对象,此时该语句就会被执行。有了上面这些知识储备就不难知道上面这段代码的打印结果了。

//p1、p2、p3它们三个的关系是什么?
class Base1 
{ 
public:  
    int _b1; 
};
class Base2 
{ 
public:  
    int _b2; 
};
class Derive : public Base1, public Base2 
{ 
public: 
    int _d; 
};
int main() 
{
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
  printf("p1:%p\n", p1);
    printf("p2:%p\n", p2);
    printf("p3:%p\n", p3);
    return 0;
}

小Tips:这里的结果和继承顺序有关,p3 作为一个 Derive 类型的指针,它必定是指向 d 对象的首地址,首地址一定是存储当前对象的内存空间中地址编号最小的,即低地址处。从上面的代码中我们可以看出,Derive 对象先继承了 Base1,这就决定了一个 Derive 对象 d 在内存中 Base1 的成员变量一定是存储在最前面的,即低地址处,也就是 Derive 对象的首地址。p1 作为一个 Base1 类型的指针,它只能指向 d 对象中继承自 Base1 的成员变量,而 Base1 的成员变量就存在地址编号最小的那块内存空间上,因此 p1 == p3。Derive 第二个继承的是 Base2,存储 Base2 成员变量的空间依次往后,自然就不是 d 的首地址,这就导致了 p1 == p3 != p2

十、结语

今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!


目录
相关文章
|
2月前
|
安全 Java 编译器
C++进阶(1)——继承
本文系统讲解C++继承机制,涵盖继承定义、访问限定符、派生类默认成员函数、菱形虚拟继承原理及组合与继承对比,深入剖析其在代码复用与面向对象设计中的应用。
|
6月前
|
存储 安全 Java
c++--继承
c++作为面向对象的语言三大特点其中之一就是继承,那么继承到底有何奥妙呢?继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用,继承就是类方法的复用。
144 0
|
9月前
|
安全 C++
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
477 6
|
11月前
|
C++ 开发者
C++学习之继承
通过继承,C++可以实现代码重用、扩展类的功能并支持多态性。理解继承的类型、重写与重载、多重继承及其相关问题,对于掌握C++面向对象编程至关重要。希望本文能为您的C++学习和开发提供实用的指导。
164 16
|
11月前
|
编译器 数据安全/隐私保护 C++
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
313 5
|
编译器 C++ 开发者
【C++】继承
C++中的继承是面向对象编程的核心特性之一,允许派生类继承基类的属性和方法,实现代码复用和类的层次结构。继承有三种类型:公有、私有和受保护继承,每种类型决定了派生类如何访问基类成员。此外,继承还涉及构造函数、析构函数、拷贝构造函数和赋值运算符的调用规则,以及解决多继承带来的二义性和数据冗余问题的虚拟继承。在设计类时,应谨慎选择继承和组合,以降低耦合度并提高代码的可维护性。
236 1
【C++】继承
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
158 1
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
152 1
|
安全 编译器 程序员
C++的忠实粉丝-继承的热情(1)
C++的忠实粉丝-继承的热情(1)
128 0
|
编译器 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
180 0