目录
本节最重要的两点就是派生类的默认成员函数和菱形虚拟继承。特别是菱形虚拟继承,一点更要把文字和图片结合着看,图片中的地址一定要看清楚。(本文代码均在win10系统vs2019中验证)
1.派生类的默认成员函数
在前面的文章中就讲过当涉及到资源管理时,类的拷贝构造函数、赋值运算符重载、析构函数一定要用户根据实际情况显式定义。在这里着重讲一下当不涉及资源管理时前两个函数和构造函数在派生类中的要点,其余的默认构造函数都比较好理解。
这里强调一点:构造哪个类的对象,就调用哪个类的构造方法。给哪个类拷贝构造对象,就调用哪个类的拷贝构造方法。析构哪个类的对象,就调用哪个类的析构函数。
(1)构造函数
[1]规则1
基类中没有显示定义构造函数,子类中可以不定义。
代码一:当成员变量均为public,可以通过对象.变量名赋值。成员变量是protected和private时可以在类中提供公有的设置函数。
//代码一
#include "iostream"
using namespace std;
class Base {
public:
int a;
};
class Son :public Base {
public:
int b;
};
int main() {
Son s;
s.a = 1;
s.b = 2;
}
[2]规则2
基类中显式定义了无参或全缺省构造函数,子类可以不定义。全缺省和无参只能存在一个。
代码二:
//代码二
#include "iostream"
using namespace std;
class Base {
public:
int a;
public:
Base(int _a = 4)
:a(_a)
{}
//Base() {}
};
class Son :public Base {
public:
int b;
};
int main() {
Son s;
s.a = 1;
s.b = 2;
cout << s.a << endl;//1
cout << s.b << endl;//2
}
[3]规则3
当基类构造函数有参数且不是全缺省,子类必须显示提供构造函数,并且在子类构造函数的初始化列表的位置显式调用基类的构造函数将子类对象中从基类继承下来的部分初始化。
代码三:注意必须要在子类的初始化列表调用父类的构造函数。
//代码三
#include "iostream"
using namespace std;
class Base {
public:
int a;
public:
Base(int _a)
:a(_a)
{}
};
class Son :public Base {
public:
int b;
public:
Son(int _a,int _b)
:Base(_a)
,b(_b)
{}
};
int main() {
Son s(1,2);
cout << s.a << endl;//1
cout << s.b << endl;//2
}
[4]总结
当默认成员函数没有显示定义,编译器会自动给出(其实这句话不太准确,因为当编译器觉得默认成员函数没用,它就不会生成了),但如果显示定义,编译器就不会再出。当编译器调用自己给出的默认构造函数,会将成员变量赋默认值。如果你显示定义基类构造函数且不是空参或全缺省,子类却不显式定义构造函数。编译器因为没有合适的方法调用,就不知道该给变量赋什么值,就会报错。
(2)拷贝构造函数
拷贝构造函数要分两种情况,当类涉及资源管理时,拷贝构造函数必须用户显示提供,因为编译器给出的默认拷贝构造函数可能会造成浅拷贝,这一点在之前的文章已经验证过了。
这里给出的三条规则均是不涉及资源管理的类中的情况。
[1]规则1
基类中没有显示定义拷贝构造函数,子类中可以不定义。
代码四:此时编译器会自动生成。
//代码四
#include "iostream"
using namespace std;
class Base {
public:
int a;
};
class Son :public Base {
public:
int b;
};
int main() {
Son s1;
s1.a = 1;
s1.b = 2;
Son s2(s1);
cout << s2.a << endl;//1
cout << s2.b << endl;//2
}
[2]规则2
基类中显示定义拷贝构造函数,子类中必须定义。并且要在子类的拷贝构造函数中显示调用基类的拷贝构造函数,用参数中从基类继承下来的部分赋值。
代码五:
//代码五
#include "iostream"
using namespace std;
class Base {
public:
int a;
public:
Base(int _a)
:a(_a)
{
cout << "父类构造" << endl;
}
Base(const Base& bas)
:a(bas.a)
{
cout << "父类拷贝" << endl;
}
};
class Son :public Base {
public:
int b;
public:
Son(int _a, int _b)
:Base(_a)
, b(_b)
{
cout << "子类构造" << endl;
}
Son(const Son& son)
:Base(son)
, b(son.b)
{
cout << "子类拷贝" << endl;
}
};
int main() {
Son s1(4, 5);
cout << "=================" << endl;
Son s2(s1);
cout << s2.b << endl;//2
}
代码五结果显示:为什么给子类构造对象,先调用的是父类的构造函数?其实不是,仔细观察代码,只是因为在打印子类构造函数的文本前,子类构造函数在初始化列表中调用父类构造函数了,所以才是这种结果。这里请记住开头红色背景的那段话。
(3)赋值运算符重载函数
赋值运算符函数重载也要分两种情况,当类涉及资源管理时,赋值运算符函数重载必须用户显示提供,因为编译器给出的默认赋值运算符函数重载可能会造成浅拷贝。这里给出的是不涉及资源管理的情况。
代码六:注意这里不可以用 *this = son 来赋值,因为*this就是Son类型,自然调用的是Son类的赋值运算符重载,这不就死循环了吗?
//代码五
#include "iostream"
using namespace std;
class Base {
public:
int a;
public:
Base(int _a)
:a(_a)
{}
Base(const Base& bas)
:a(bas.a)
{}
Base& operator=(const Base& bas) {
if (this != &bas) {
a = bas.a;
}
return *this;
}
};
class Son :public Base {
public:
int b;
int* p;
public:
Son(int _a, int _b)
:Base(_a)
, b(_b)
{
p = new int[10];
}
Son(const Son& son)
:Base(son)
, b(son.b)
{
p = new int[100];
}
Son& operator=(const Son& son) {
if (this != &son) {
//使用Base的赋值运算符用son中从Base继承来的变量给*this中的从Base继承来的部分赋值
Base::operator=(son);
//这里不可以使用 *this = son
//先释放原本空间
delete[] p;
//为子类新增加的变量赋值
p = new int[100];
b = 12;
}
return *this;
}
};
int main() {
Son s1(4, 5);
cout << "=================" << endl;
Son s2(3, 4);
s2 = s1;
}
(4)总结
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用派生类构造再调基类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构
2.继承与友元
这里只有一句话:友元关系不能继承。
3.继承与静态成员
这里也是一句话:静态对象在整个程序中只有一份,为多个对象共享。
代码七:这里把三个对象中的num地址都打印出来发现,三个num地址一样。
//代码七
#include "iostream"
using namespace std;
class AAA {
public:
static int num;
};
int AAA::num = 10;
class AA : public AAA {
public:
void Add() {
num++;
}
};
class A : public AA {
public:
void Add() {
num++;
}
};
int main() {
AAA a1;
AA a2;
A a3;
cout << &a1.num << endl;
cout << &a2.num << endl;
cout << &a3.num << endl;
}
4.复杂的菱形继承及菱形虚拟继承
继承分为单继承和多继承。单继承是一个子类只有一个父类,多继承是一个子类有多个父类。
(1)普通多继承
[1]使用方法
class 子类名 : 限定符 父类1,修饰符 父类2 {}
代码八:父类都必须给出限定符,否则编译器的默认继承方式是private
//代码八
#include "iostream"
using namespace std;
class B1 {
public:
int a;
};
class B2 {
public:
int b;
};
class S :public B1, public B2 {
public:
int c;
};
int main() {
S s;
s.a = 1;
s.b = 2;
s.c = 3;
}
[2]对象模型验证
如下验证代码八对象模型:
必备知识:这里内存窗口中的是小端模式下的16进制数字,咱们的常规写法是高位在前,低位在后,这个刚好相反。将它们写成常规写法就是:00 00 00 01。
注意类中继承顺序是先B1,后B2。
单继承s对象模型如图所示,对象中B1继承的在前,B2继承的在后,s自身新增的在最后。这点从内存窗口中的赋值就可以看出来。
(2)菱形继承
菱形继承如下图所示:
[1]使用方法
在继承体系中,S1单继承B,S2单继承B,G多继承S1和S2。
代码九:
//代码九
#include "iostream"
using namespace std;
class B {
public:
int a;
};
class S1 :public B {
public:
int b;
};
class S2 :public B {
public:
int c;
};
class G :public S1, public S2 {
public:
int d;
};
int main() {
G g;
g.S1::a = 1;
g.b = 3;
g.S2::a = 2;
g.c = 4;
g.d = 5;
}
按照普通多继承的对象模型来推测菱形继承的对象模型:
[2]对象模型验证
利用内存窗口验证代码九:可以发现,确实跟猜想一样,内存中的排列顺序是:S1::a,S1::b,S2::a,S2::c,G::d。恰好与推测的模型一致。
[3]缺陷及解决办法
菱形继承具有二义性问题。如代码九中,不能用g.a的方式访问a,因为编译器不知道你要访问的是S1中的还是S2中的,所以需要你使用g.类名::a的方式访问。如何解决?
两种办法:1.就是上段的g.类名::a的方式访问。2.让顶层基类中的成员只有一份就可以了。这就引出了虚拟继承。
(3)普通虚拟继承(超重要)
一定要先理解普通虚拟继承,然后才能理解菱形虚拟继承。
[1]使用方法
class 子类名 : virtual 限定符 父类名{}
代码十:这里直接告诉大家,虚拟继承时,下述代码中s对象大小是12字节,普通继承时大小是8字节。虚拟继承比普通继承多了四个字节。
//代码十
#include "iostream"
using namespace std;
class B {
public:
int a;
};
class S :virtual public B {
public:
int b;
};
int main() {
S s;
s.a = 7;
s.b = 9;
cout << sizeof(s);
}
这是虚拟继承的粗略对象模型:
虚拟继承中,子类新增在最上面,顶层基类元素在最下面,且只有一份。
[2]对象模型分析
上文中提到了编译器如果觉得没有用就不会生成默认构造函数,当代码十是普通继承时就不会生成,大家可以进入汇编看一下。但在虚拟继承中编译器自动生成了,为什么?
因为编译器需要给多出来的4字节赋值。因为用户并无法给这四个字节赋值,所以如果类中没有定义构造函数,编译器必须生成。类中定义了构造函数,编译器就会修改它,在内部增加给4字节赋值的代码。
那么那4字节是干什么的?那4字节指向一块空间,那块空间存放的是顶层基类中的元素位置离虚基表指针所在的位置相差多少字节。我们把这个记录距离长短的量叫做偏移量。那块空间叫做偏移量表格或者虚基表,那4个字节叫做虚基表指针。表格的第一项总是 00 00 00 00,这是代表虚基表指针离它自己的偏移量,自然是0。
代码十的对象模型和详细内存布局:配合文字讲解一起看图,文字比较多,请耐心看完。
虚拟继承的对象模型是:子类新增在最上面,最下面是顶层基类中的元素,并且顶层基类中的元素只有一份。
对象的前四个字节是虚基表指针,指向一块空间,这块空间保存的是顶层基类中的元素和对象首地址相差多少字节,第一项是虚基表指针的位置离它自己相差多少字节,那当然是0。第二项存的是a变量离虚基表指针所在的位置相差8字节,可以看一下对象模型,正好差8个。
这里再看一下汇编指令,配合着上端叙述一起理解。eax中存的就是对象前四个字节,eax+4不就是虚基表指针往后再移动四个字节吗,那这就移动到a的偏移量的位置了,然后用ecx保存a的偏移量,s[ecx]指的是,虚基表指针所在的位置往后移动ecx个字节,那就是移动8个字节,不就到a的位置了吗,然后再把7赋值给a。
(4)菱形虚拟继承
[1]使用方法
两个父类虚拟继承顶层基类,子类普通多继承两个父类,示意图如下:
代码十一:这里 看一下如何使用:
//代码十一
#include "iostream"
using namespace std;
class B {
public:
int a;
};
class S1 :virtual public B {
public:
int b;
};
class S2 :virtual public B {
public:
int c;
};
class G :public S1, public S2 {
public:
int d;
};
int main() {
G g;
g.a = 1;
g.b = 2;
g.c = 3;
g.d = 4;
}
[2]对象模型验证
这里来看一下菱形虚拟继承的对象模型:本文最复杂的图,没有之一!
咱们一点一点分析:首先根据继承顺序,对象模型中依次是:S1的虚基表指针,S1的b,S2的虚基表指针,S2的c,子类新增的d,最后是顶层基类的a。这一点在内存1中可以体现出来,注意看里面的值。
再来看一下S1的虚基表,第一项依旧是0,第二项是小端模式的16进制的14,也就是10进制的20,代表a离S1的虚基表指针的位置距离是20字节,看一下对象模型发现没错。
再看S2的虚基表,第二项是10进制的12,代表a离S2的虚基表指针的位置距离是12个字节,看对象模型,也没有错。
[3]总结
顶层基类中不止成员变量可以被虚拟继承,成员函数也可以。
菱形虚拟继承可以解决菱形继承的二义性问题,但一般还是不要使用,因为太过复杂。
5.继承的总结
1.尽量不要使用多继承,因为比较容易出错。
2.尽量多使用对象与对象之间的组合,少使用继承,因为对象的组合耦合度更低。