> 文章列表 > 【C++】继承---下(子类默认成员函数、虚继承对象模型的详解等)

【C++】继承---下(子类默认成员函数、虚继承对象模型的详解等)

【C++】继承---下(子类默认成员函数、虚继承对象模型的详解等)

前言:    

     上篇文章我们一起初步了解了继承的概念和使用,本章我们回家新一步深入探讨继承更深层次的内容。

    前文回顾——>继承---上

目录

(一)派生类的默认成员函数

(1)6个默认成员函数

 (2)派生类的默认成员函数使用规则

(3)实例化详解

 (4)应用:如何设计一个不能继承的类

(二) 继承与友元

(三)继承与静态成员

(四)多继承和菱形继承

(1)菱形继承的问题

(2)解决方法之虚拟继承

(3)虚拟继承的底层原理


(一)派生类的默认成员函数

在之前类和对象的学习中,我们详细学习了基类的默认成员函数

类和对象默认成员函数复习——>类和对象的默认成员函数

那么派生类的默认成员函数的使用规则是什么样的呢?

(1)6个默认成员函数

我们浅浅回顾一下~

我们6个成员函数对于一个普通的类适用,那么对于基类也适用

下面是我们6大默认成员函数

顾名思义,就是对于一些默认类型,我们自己不需要给出具体的成员函数,编译器可以自动生成。我们前面也详解过,对于自定义类型还是需要我们手动实现深拷贝,编译器自动生成的都是浅拷贝等等(遗忘的童鞋可以回顾之前的博文)

 (2)派生类的默认成员函数使用规则

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类
中,这几个成员函数是如何生成的呢?
  • 1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认 的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  • 2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
  • 3. 派生类的operator=必须要调用基类的operator=完成基类的复制
  • 4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能 保证派生类对象先清理派生类成员再清理基类成员的顺序。
  • 5. 派生类对象初始化先调用基类构造再调派生类构造
  • 6. 派生类对象析构清理先调用派生类析构再调基类的析构
  • 7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲 解)。那么编译器会对析构函数名进行特殊处理处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系

(3)实例化详解

 我们这里引用一个Person父类和Student的子类来一一验证。

Person类:

#include<iostream>
using namespace std;
class Person
{
public:Person(const char* name = "peter"): _name(name){cout << "Person()" << endl;}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person & p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}~Person(){cout << "~Person()" << endl;}
protected:string _name; // 姓名
};

Student类:
 

class Student : public Person
{
public:Student(const char* name, int num): Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s): Person(s), _num(s._num){cout << "Student(const Student& s)" << endl;}Student& operator = (const Student& s){cout << "Student& operator= (const Student& s)" << endl;if (this != &s){Person::operator =(s);_num = s._num;}return *this;}~Student(){cout << "~Student()" << endl;}
protected:int _num; //学号
};

我们下面开始测试:

父类成员构造、拷贝构造、赋值、析构:

测试代码:

运行结果:

此时父类和普通类的默认成员函数使用规则一样。


派生类成员构造、拷贝构造、赋值、析构 :

测试代码:

运行结果:

 这里我们可以清楚观察到对于派生类对象的构造函数、拷贝构造函数和赋值重载:

继承的派生类对象都必须先调用基类的构造(拷贝构造、赋值重载)然后再完成自己这部分的构造(拷贝构造、赋值重载)

对于析构函数:

派生类对象析构清理先调用派生类析构再调基类的析构

因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲 解)。那么编译器会对析构函数名进行特殊处理处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系

 (4)应用:如何设计一个不能继承的类

开门见山:

方法:把构造函数私有化。

class A
{
private:A(){}
};class B : public A
{};int main()
{B b;return 0;
}
  • 父类A的构造函数私有化以后,B就无法构造对象
  • 因为规定了子类的成员必须调用父类的构造函数初始化

但是问题来了:

这时候就还有一个问题A类想单独构造对象也不行了

解决办法:(单例设计模式)

这时又有一个问题 —— 先有鸡还是先有蛋的问题:

  •  调用成员函数需要对象,对象创建需要调用成员函数,调用成员函数需要对象…

解决办法:

用一个静态成员函数就能很好的解决问题

(二) 继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员

class Student;
class Person
{
public:friend void Display(const Person& p, const Student& s);
protected:string _name; // 姓名
};
class Student : public Person
{
protected:int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{cout << p._name << endl;cout << s._stuNum << endl;
}
void main()
{Person p;Student s;Display(p, s);
}

 报错:

  • 想两个都访问时,只要既变成父类的友元也变成子类的友元就可以了。
  • 不能说是父类的友元你就是子类的友元了。

(三)继承与静态成员

问题:

比如说父类有一个静态成员,那子类继承之后,子类会增加一个静态成员还是和父类共享一个静态成员呢? 

 验证代码:
 


class Person
{
public:Person() { ++_count; }
protected:string _name; // 姓名
public:static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum; // 学号
};
class Graduate : public Student
{
protected:string _seminarCourse; // 研究科目
};
void TestPerson()
{Student s1;Student s2;Student s3;Graduate s4;cout << " 人数 :" << Person::_count << endl;Student::_count = 0;cout << " 人数 :" << Person::_count << endl;
}
int main()
{TestPerson();return 0;
}

这里我们输出的会是3还是4呢?

通过输出我们可以发现:

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子
类,都只有一个static成员实例 。

(四)多继承和菱形继承

(1)菱形继承的问题

多继承:

一个子类有两个或以上直接父类时称这个继承关系为多继承

 菱形继承:

菱形继承是多继承的一种特殊情况。

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。

在Assistant的对象中Person成员会有两份。

 样例代码:


class Person
{
public:string _name; // 姓名
};
class Student : public Person
{
protected:int _num; //学号
};
class Teacher : public Person
{
protected:int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};
void Test()
{// 这样会有二义性无法明确知道访问的是哪一个Assistant a;a._name = "peter";}

报错:

 我们可以指定作用域来解决二义性问题:

// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";

但是数据冗余的问题无法解决:

  • 数据冗余带来的问题就是空间的浪费
  • 当父类中的成员变量很大的时候

(2)解决方法之虚拟继承

菱形虚拟继承解决了数据冗余和二义性的问题。

先看代码:


class Person
{
public:string _name; // 姓名
};
class Student : virtual public Person
{
protected:int _num; //学号
};
class Teacher : virtual public Person
{
protected:int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};
void Test()
{Assistant a;a._name = "peter";cout << a._name << endl;
}
int main()
{Test();
}

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。
如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用

(3)虚拟继承的底层原理

样例代码:

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

运行调试我们发现虚继承之后无论有没有指定作用域,各个作用域_a的值是一样的:

我们再调用内存管理来更深入的探究:

 

我们&d后找到d的内存,大致能看出 _a,_b,_c,_d的存放位置,按照常理来说,_a按照顺序应该是在前面存放,但事实上确是在最后,那第一个位置存放的地址中到底是什么呢?

我们访问第一个位置存放的地址:

我们发现存放了00 00 00 00 然后有一个14 00 00 00,14是十六进制换算十进制就是16+4=20,20个字节正好够存放五个地址 ,我们惊奇的发现02 00 00 00和这个地址的偏移量就恰好是20个字节(5个地址),那么我们可以假设第一个地址中存放的是_a的偏移量。

而_c上面的地址54 7b b1 00 我们同样方法访问可以得到:

 0c转换成十六进制就是12,也就是12个字节,而_a正好也和它相差3个地址的偏移量,我们更可以确信这是存放偏移量的地址。


模型总结:

菱形虚拟继承调整了对象的模型。

  • 我们发现B和C对象的开头都存了一个指针, 这种对象模型是省了四个字节(_a),却又增加了两个指针(八个字节),反而变大了四个字节。
  • 但是如果A很大的情况下,剩下来的空间和这两个指针(八个字节)相比
  • 整体空间是节省了不少的空间了

 而且B和C虚继承后第一个地址经过我们分析存放的是偏移量,用来找到存储_a的地址。

为什么要搞这个偏移量呢?

  • 场景一:
  • 在赋值转换 —— 切片的时候就能用得到
  • 假设 D d;B b;b = d;此时就要切片
  • 切片切割的时候,能找到_b,但是要通过偏移量计算出A的位置
  • 场景二:
  • B * ptrb = &d; 这里也是切片,ptrb->_a = 1;
  • B的指针能找到_b,但是找_a是要通过偏移量来算出A的位置、

 我们发现找偏移量的时候,第一个位置存放的总是00 00 00 00。

为什么偏移量存储在第二个位置,而不是存在第一个位置

  • 第一个位置是预留的,可能其他地方要用

菱形虚拟继承的缺点:

  • 对于编译器和人们的理解都变复杂了
  • 虽然将数据冗余和二义性一概解决了,但是付出了很大的代价 — 多了两层间接
  • 代价就是这个存储模型,该模型也一定程度影响了访问数据的效率
     

模型的优点:

因为不同的编译的设计的不同,A对象存储的位置也会不一样,但是只要有指针去找偏移量,再通过偏移量去找A就能找到,这是通用的方法,统一模型

  • 这个表也叫做 —— 虚基表
  •  A叫做虚基类
  •  该指针叫做虚基表指针

 

补充:

  • 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;
}

输出是什么?

原因:

  • 结合初始化列表初始化的顺序和声明的顺序有关系
  • 子类在调用构造函数前会调用父类的构造函数

总结

  • 很少有人设计菱形继承,但是C++标准库中就有菱形继承,IO流的类就是菱形继承。
  • 继承的意义是用子类去复用父类
  • 实际当中可以设计多继承,但是尽量不要设计菱形继承,更不要设计菱形虚拟继承,太复杂了!还有一定程度的效率损失。
  • 我们正常使用的时候要尽量避开继承中语法的坑

感谢您的阅读,祝您学业有成!