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

文章目录

  • 前言
  • 1. 派生类的默认成员函数
    • 1.1 子类默认生成的成员函数:
    • 1.2 子类显示写的成员函数:
      • 1.2 - 1 构造函数
      • 1.2 - 2 拷贝构造
      • 1.2 - 3 赋值重载
      • 1.2 - 4 析构函数
  • 2. 如何设计一个不能被继承的类
  • 3. 友元和继承
  • 4. 继承与静态成员
  • 5. 多继承和菱形继承
    • 5.1 菱形继承和虚继承:
    • 5.2 菱形虚拟继承的底层 - 对象模型:
    • 5.2 - 1 菱形继承对象模型
      • 5.2 - 2 菱形虚拟继承对象模型
      • 综上小结:一道练习题
  • 6. 总结

前言

上篇我们讲了继承的基本语法和使用规范,接下来我们将继续讲解继承的深层次的内容。

前情回顾: 继承 — 上篇回顾


1. 派生类的默认成员函数

在我们之前学类和对象中,已经清楚了基类中默认成员函数的规则。

  • 类和对象默认成员函数复习: 传送门

下面我们就要学习派生类中默认成员函数的规则。


1.1 子类默认生成的成员函数:

子类默认生成的成员函数原则:

  1. 调用父类构造函数初始化继承自父类成员
  2. 自己再初始化自己的成员 – 规则参考普通类
  3. 析构、拷贝构造、赋值重载也类似
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; 
};


class Student : public Person
{
public:
protected:
	int _num;
	string _address;
};

int main()
{
	Student s;

	return 0;
}

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第1张图片

父类调用父类的构造函数处理,子类的内置类型不处理,自定义类型调用该自定义类型的默认构造函数处理。

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第2张图片

1.2 子类显示写的成员函数:

1.2 - 1 构造函数

父类的构造函数完成了父类的那一部分的构造。

(1) 首先不能以下面的方式写:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第3张图片
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第4张图片
(2) 可以这样写,不初始化在函数体内赋值:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第5张图片
但是看一下运行结果,还是调用了父类的构造函数,但是我们并没有去显示调用,这是怎么回事?

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第6张图片

  • 这里是在初始化列表中调用的
  • 可以理解为子类把父类那一部分拿下来当成自定义成员
  • 不显示写该自定义类型的构造函,就会调用父类的默认构造

(3) 并且父类的构造函数没有提供全缺省是调不动的:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第7张图片
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第8张图片
C++的原则是父类的一定要调用父类的构造函数初始化。

(4) 正确写法:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第9张图片
代码如下:

class Person
{
public:
	Person(const char* name)
		: _name(name)
	{
		cout << "Person()" << endl;
	}

	~Person()
	{
		cout << "~Person()" << endl;
	}

protected:
	string _name; 
};

class Student : public Person
{
public:
	Student(const char* name = "", int num = 0)
		//像初始化一个匿名对象一样去写
		:Person(name)
		,_num(num)
	{
		_name = name;
	}
protected:
	int _num;
	string _address;
};

int main()
{
	Student s;

	return 0;
}

补充:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第10张图片
解释:

  • 初始化列表出现的顺序并不是实际执行的顺序
  • 真正初始化的顺序则是按照声明的顺序
  • 声明中会认为父类在前,子类在后

1.2 - 2 拷贝构造

父类的拷贝构造完成了父类的那一部分的拷贝。

(1) 子类默认生成的拷贝构造:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第11张图片

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第12张图片

对于子类剩下的那一部分成员,按照之前的规则处理,对于内置类型完成值拷贝,自定义类型去调用该自定义类型的拷贝构造。

(2) 和构造函数一样,同样不支持这样初始化:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第13张图片
(3) 正确写法:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第14张图片
问题:

这里要将Person的对象传过去,如何将子类当中父类继承的那一部分拿出来,传过去来拷贝构造呢?

  • 切片 – 子类的对象传给父类对象的引用

1.2 - 3 赋值重载

(1) 父类函数的隐藏导致的栈溢出:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第15张图片

int main()
{
	Student s1("李四", 1);
	Student s2(s1);
	Student s3("王五", 2);
	s2 = s3;

	return 0;
}

栈溢出(爆栈) !!

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第16张图片
我们来看一下堆栈调用:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第17张图片
从上图可见,一直在递归调用赋值重载,究竟问题何在?

  • 原来子类中的赋值重载和父类中的赋值重载函数名相同
  • 子类和父类中的这两个函数构成了 – 隐藏
  • 我们只需要指定类域,就可以解决这个问题

(3) 正确写法:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第18张图片

同时赋值重载中,子类调用父类赋值重载时,隐藏的this指针传过去也会被切片,和Student对象一样都要被切片,两个切片。

1.2 - 4 析构函数

  • 按照我们之前的理解,应该显示去调用父类的析构再去完成自己的析构

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第19张图片

int main()
{
	Student s1("李四", 1);
	Student s2(s1);
	Student s3("王五", 2);
	s2 = s3;

	return 0;
}

这里编译会报错。

这里有隐藏的很深的问题:

  • 父子类的析构函数构成隐藏关系
  • 原因:下一节多态的需要,析构函数名统一会被处理成destructor( )
  • 所以在无形当中就构成了隐藏

我们显示调用一下:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第20张图片
看一下运行结果:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第21张图片

这里我们发现好像多调用了多次父类析构,原因是什么呢?

补充:

为了保证析构顺序,子类的析构完成后,会直接调用父类的

  • 为了保证析构顺序,先子后父
  • 子类析构函数完成后会自动调用父类析构函数,所以不需要我们显示调用
  • ~ 是按位取反,前面加一个~,是和构造函数呼应起来的

析构时要保证先子后父的原因是:

  • 和对象的存储有关系
  • 例如:栈里面的存储对象是,先定义的先初始化,先定义的后析构
  • 如果自己显示调用就很有可能会先父后子的调用析构函数

正确写法:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第22张图片
看一下运行结果:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第23张图片


2. 如何设计一个不能被继承的类

  1. 构造函数私有
class A
{
private:
	A()
	{}
};

class B : public A
{

};

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

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

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

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第24张图片
这时又有一个问题 —— 先有鸡还是先有蛋的问题:

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

解决办法:

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

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第25张图片
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第26张图片

3. 友元和继承

友元不能被继承

//友元关系不能被继承 -- 父类的友元不会继承到子类当中
class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; //姓名
};

class Student : public Person
{
	//friend void Display(const Person& p, const Student& s);
protected:
	int _stuNum; //学号
};

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._stuNum << endl;
}

int main()
{
	Person p;
	Student s;
	Display(p, s);

	return 0;
}

在这里插入图片描述

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

4. 继承与静态成员

问题:

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

答案是共享同一个。

//继承与静态成员
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;  //研究科目
};

int main()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	Person s;

    //用任何一个类都可以访问 -- 用类域或者是对象都能访问
	cout << "人数 :" << Person::_count << endl;
	cout << "人数 :" << Student::_count << endl;
	cout << "人数 :" << s4._count << endl;
	
	//并且地址都是一样的
	cout << "人数 :" << &Person::_count << endl;
	cout << "人数 :" << &Student::_count << endl;
	cout << "人数 :" << &s4._count << endl;

	return 0;
}

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第27张图片
所以,父类有的静态成员继承下来都是同一个


5. 多继承和菱形继承

5.1 菱形继承和虚继承:

单继承:一个子类只有一个直接父类时称这个继承关系为单继承
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第28张图片
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第29张图片

早期多继承没什么问题,直到菱形继承的出现。

菱形继承:菱形继承是多继承的一种特殊情况。
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第30张图片
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。

在Assistant的对象中Person成员会有两份。
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第31张图片
见如下代码:

class Person
{
public:
	string _name; 
	int _a[10000];
};

class Student : public Person
{
protected:
	int _num;	  
};

class Teacher : public Person
{
protected:
	int _id;	 
};

class Assistant : public Student, public Teacher
{
protected:
	string _major; 
};

int main()
{
	Assistant a;
	
	//二义性
	//a._name = "peter";

	//通过指定作用域来访问
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";

	cout << sizeof(a) << endl;

	return 0;
}
  • 虽然可以通过指定作用域来访问来解决二义性的问题,但是数据冗余还没有得到解决。
  • 数据冗余带来的问题就是空间的浪费
  • 当父类中的成员变量很大的时候

前人栽树后人乘凉,正是因为多继承会导致很多麻烦,所以java中直接就取消了多继承。

介绍一个新的关键字:

  • virtual – 虚

只需要在菱形继承的腰部加上虚继承,数据冗余的问题就解决了。

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第32张图片
统称:菱形虚拟继承

5.2 菱形虚拟继承的底层 - 对象模型:

Vs的监视窗口在复杂的情况下被处理过,看到的就不准了,此时就需要我们看内存窗口了。

对象模型: 就是其在内存当中到底如何存储

5.2 - 1 菱形继承对象模型

先来代码:

class A
{
public:
	int _a;
	//static int _a;
};

//int A::_a = 0;

class B : public A
{
public:
	int _b;
};

class C : 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;
}
  • B 和 C 分别继承 A,D继承了 B 和 C

菱形继承示意图:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第33张图片
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第34张图片
从内存中可以看出来,数据在内存中是挨个挨个放的,先继承的就在前面。

5.2 - 2 菱形虚拟继承对象模型

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

先来代码:

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

菱形虚拟继承示意图:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第35张图片

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第36张图片我们此时发现_a存储到最下面的位置去了

  • 这时A并没有放在B中,也没有放在C中
  • 因为B和C中都有A,干脆直接将A放在一个公共的区域

对象模型和上述模型变了:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第37张图片
菱形虚拟继承调整了对象的模型。

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

不懂就问,新增的两个指针是用来干嘛的呢?真的是指针?会不会是随机值?

下面我们就来探索一下:

  • 自习观察一下,首先排除随机值的可能
  • 因为它们很相似,并且这两个数值相差不大 —— 相差八个字节
  • 这里可以初步确定B和C开头存的应该是指针

接下来我们拿着这指针去看看其指向的空间中的数据是什么:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第38张图片

  • 经过探索我们发现通过B和C头上的指针找到的数据都是空,值都是0
  • 但是它们下面一个地址的数据却另有玄机

其实它们都是存着一个叫偏移量的东西:

  • 存的都是自己距离A对象位置的偏移量
  • 分别是B和C距离A的偏移量,20 和 12

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

场景一:

  • 在赋值转换 —— 切片的时候就能用得到
  • 假设 D d;B b;b = d;此时就要切片
  • 切片切割的时候,能找到_b,但是要通过偏移量计算出A的位置

场景二:

  • B * ptrb = &d; 这里也是切片,ptrb->_a = 1;
  • B的指针能找到_b,但是找_a是要通过偏移量来算出A的位置

菱形虚拟继承的缺点:

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

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

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

模型的优点:

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

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

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第39张图片
补充:

  • 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++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第40张图片
刨根问底的问题:

  • C++有多继承:
  • 多继承产生的问题 ——》菱形继承;
  • 菱形继承产生的问题 ——》数据冗余和二义性;
  • 数据冗余二义性如何解决 ——》菱形虚拟继承(太复杂了);
  • 菱形虚拟继承是如何解决的 ——》上述菱形虚拟继承对象模型;

下面的成员该如何访问:

【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第41张图片

  • _b肯定是在上述指针指向的空间里面
  • _a该如何访问?
  • 如果说B* 类型的指针指向的是d对象,那么就是按照上述的通过虚表指针找偏移量,再通过偏移量找A对象。
  • 如果说B* 类型的指针指向的是b对象,神奇的事情来了,它还是按照虚继承的对象模型来找。

B对象的模型也被改了~

B对象模型:
【C++】继承 — 子类默认成员函数、虚继承对象模型 - 详解(下篇)_第42张图片
原因:

  • ptr1和ptr2不知道自己指向是b对象还是d对象的
  • 在d对象里面,A成员跟B的距离远: 20
  • 在b对象里面,A成员跟B的距离进: 8
  • ptr1和ptr2无法知道也不关心自己指向的是谁!
  • 都使用同样的方式去找A成员,先找到虚基表中偏移量,然后计算A的位置

根本原因是:切片的情况下,距离A的距离是不一样的


6. 总结

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

你可能感兴趣的:(C++,1024程序员节,c++)