preface
这篇文章主要是想记录一下 C++ 继承的访问权限以及多继承问题
继承
继承(inheritance)是面向对象软件技术当中的一个概念。如果一个类别B“继承自”另一个类别A,就把这个B称为“A的子类”,而把A称为“B的父类别”也可以称“A是B的超类”。继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码。在令子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能。另外,为子类追加新的属性和方法也是常见的做法。 –维基百科
C++ 的继承有三种,分别是公有继承(public),私有继承(private),保护继承(protected),一般情况下我们只会使用公有继承,几乎不会用到后面两种继承方式。
说到继承,就要说下基类的成员在派生类中的访问属性了:
访问属性 | public | private | protected |
---|---|---|---|
公有继承 | public | no | protected |
私有继承 | private | no | private |
保护继承 | protected | no | protected |
总之,基类的私有成员是不能直接访问的,只能通过基类的成员函数进行访问,另外,有些东西是不能通过基类继承给派生类的,具体有以下这些:
- 基类的构造函数、析构函数和拷贝构造函数
- 基类的重载运算符
- 基类的友元函数
继承的写法就像下面这样,其中 access-specifier
就是 public,private,protected 中的一种
class derived-class: access-specifier base-class
多继承:
多继承是说一个类同时继承自多个类的情况,如下就是一个简单的 C++ 多继承
class Base
{
public:
void func();
};
class A: public Base
{
public:
void func();
};
class B: public Base
{
public:
void func();
};
class Derived: public A, public B
{
public:
void func();
};
C++ 的多继承容易带来很多问题,因此在上课的时候老师也没怎么讲,自己之前理解也不到位,在这里看了很多资料之后整理了一下,将这些问题记录了下来。C++ 多继承最主要有两个问题,一个是同名二义性,另一个就是菱形继承带来的数据拷贝问题
菱形继承
菱形继承就是说一个基类派生出两个类,然后又由这两个类派生出同一个类,图解为下:
多继承带来的问题很难用语言来描述清楚,所以我结合代码来讲解,在下面的代码中,Base 基类拥有一个 int 型成员变量,A 和 B 通过公有继承由 Base 派生,然后 Derived 类多继承于 A 和 B
class Base
{
public:
Base():a(1){}
int a;
};
class A: public Base{};
class B: public Base{};
class Derived: public A, public B{};
int main(int argc, char *argv[])
{
Base a;
std::cout << sizeof(a) << std::endl;
A b;
std::cout << sizeof(b) << std::endl;
B c;
std::cout << sizeof(c) << std::endl;
Derived d;
std::cout << sizeof(d) << std::endl;
}
这段代码的输出是 4 4 4 8
,d 的大小为 8 个字节,说明什么呢,说明 d 对象继承了两次 A,相当于拷贝了两次基类的数据,这显然不是我们想要的效果,我们只想让派生类 Derived 获取一份基类的数据,那么这样的话就有问题了,会耗大类的存储空间(不是因为这个问题,是因为这样的话 A B C 中都有成员变量 a,调用成员函数和变量的话编译器不知道该调用哪一个)因此解决这个问题就得靠虚继承
不信的话我们将 main 函数改动一下,变成下面这样子:
int main(int argc, char *argv[])
{
Derived d;
std::cout << d.B::a << std::endl;
}
编译不通过,报错了:
error: request for member ‘a’ is ambiguous
candidates are: int Base::a
note: int Base::a
这就是因为 d 拷贝了两份 a ,编译器并不知道用户想用 A 中的 a 还是 B 中的 a,导致报错,可以通过 debug 查看, d 确实包含了两个 a :
虚继承
class Base
{
public:
Base():a(1){}
int a;
};
class A: virtual public Base{};
class B: virtual public Base{};
class Derived: public A, public B{};
int main(int argc, char *argv[])
{
Base a;
std::cout << sizeof(a) << std::endl;
A b;
std::cout << sizeof(b) << std::endl;
B c;
std::cout << sizeof(c) << std::endl;
Derived d;
std::cout << sizeof(d) << std::endl;
}
同样的一份代码,现在我们用虚继承的方式改写,这样子就不会有刚刚的问题,这时 Base 类被成为虚基类(virtual base class),d 只会拥有 a 的一份拷贝,但是这样的话会存在一个什么问题呢,那就是 d 所需的内存空间更多了,我们来看看上面这段代码的输出是啥,运行之后上面的结果为 4 16 16 24
,老实说,这里我想了很久想不懂为什么,后来去问大佬才知道自己还是太嫩了!这个 4 我可以理解,因为一个 int 型的数据占四个字节,然后 b 和 c 都加了一个虚指针,所以一起应该是 8 个字节,为什么会是 16 呢??? d 的空间为 24 就更加奇怪了
Attention
这里就涉及到两个概念,字节对齐和不同位长的操作系统下各种数据类型的大小可能是不一样的。
字节对齐(结构体):
1) 结构体的首地址能够被其最宽基本类型成员的大小所整除
2) 结构体每个成员相对于结构体首地址的偏移量都是该成员大小的整数倍,如有需要,编译器会在成员之间加上填充字节;(当成员大小大于处理器位数时,偏移量应该为系统中最宽类型成员大小的整数倍)
3) 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要,编译器会在最末一个成员之后加上填充字节。(如果结构体最大成员的大小大于处理器位数,侧结构体的总大小为系统中最宽类型成员大小的整数倍)
现在我已经搞懂了,准备写一篇文章专门记录一下字节对齐,这里简单说一下(我说的不一定对,仅供参考),是因为我的机器是 64 位的操作系统,因此指针的大小为 8 个字节,而且 64 位系统 8 字节对齐。对于 b 对象,操作系统先存储虚指针的 8 个字节,再存储从 Base 类继承过来的 int a,大小为 4 个字节,一共是 12 个字节,因为要八字节对齐,离 12 最近的 8 的倍数就是 16 ,因此 b 的大小为 16 字节,c 同理。对于 d 对象呢,这是通过菱形继承而来的对象,因此先存储从 A 类 和 B 类中的虚指针,一共 16 个字节,然后存储一份 int 型的 a 数据成员,一共 20 个字节,由于 8 字节对齐,所以 d 对象最后的大小为 24 个字节。
我们通过 debug 可以看到,d 对象中包含了 A 和 B 类的所有元素,表面上看,d 同时拷贝了 a 和 b 中的 a 成员对象,但其实真的只是拷贝了一份,因为这两个 a 的地址是一样的(Qt 的调试器真的有毒)
同名二义性
多继承暴露的另一个问题就是数据的同名二义性问题,这个就算是虚继承也不能解决,就像下面这段代码依然会报错,因为 B 和 C 类中都有 a 变量,编译器依然不知道派生的 d 要用哪个 a ,这就导致了二义性。而且 B 和 C 中也都有 print 函数,即使这两个函数的实现并不相同,编译器也还是不知道应该用谁的成员函数
class Base
{
public:
Base():x(1){}
int x;
void print(){
std::cout << x << std::endl;
}
};
class A: virtual public Base{
public:
A():Base(),x(2){}
int x;
void print(){
std::cout << 233 << std::endl;
}
};
class B: virtual public Base{
public:
B():Base(),x(3){}
int x;
void print(){
std::cout << 666 << std::endl;
}
};
class Derived: public A, public B{};
int main(int argc, char *argv[])
{
Base a;
A b;
B c;
Derived d;
std::cout << d.x << std::endl;
d.print();
}
运行后无情报错
a.cpp:86:20: error: request for member ‘x’ is ambiguous
std::cout << d.x << std::endl;
^
a.cpp:72:9: note: candidates are: int B::x
int x;
^
a.cpp:63:9: note: int A::x
int x;
^
a.cpp:87:7: error: request for member ‘print’ is ambiguous
d.print();
^
a.cpp:73:10: note: candidates are: void B::print()
void print(){
^
a.cpp:64:10: note: void A::print()
void print(){
^
那这种的话,我们只有在对象 d 中重新改写 print 函数,或者用作用解析符来访问特定的成员,像下面这样都可以正常访问
class Derived: public A, public B{
public:
void print(){
std::cout << "hhh" << std::endl;
}
};
int main(int argc, char *argv[])
{
Base a;
A b;
B c;
Derived d;
std::cout << d.B::x << std::endl;
d.B::print();
d.print();
}
总结
这几天看 C++ 真是头秃,不过深入底层了解程序的运作真的很有必要,尤其是对内存的操作,是看得见摸得着的,摒弃了 IDE 的傻瓜式操作,还是得赶紧学会 GDB 才是!
- 虚继承解决的问题是菱形继承中多次基类内存拷贝的问题,并不能解决多重继承的二义性的问题,用虚继承防止了基类的数据在子类中存在多份拷贝的情况,也许上面的例子还不够清楚,因为上面的例子用了虚继承之后,对象占用的空间反而比不用虚继承还要大,这是因为我们上面只有一个数据成员,如果有很多数据成员的话,不用虚继承每次都要在子类中拷贝一份数据,浪费的空间可想而知,但是用了虚继承的话就只用在子类中添加一个虚基类表(一个指针)而已,可以大大减少空间的浪费,同时也解决了多重派生类调用基类数据产生的二义性(因为这时已经没有两个相同数据混淆编译器了)
- 不要轻易相信网上的教程,很多都是假的,这种东西一定要自己尝试了调试过才知道背后的原理。不过没找到官方的菱形继承讲解,有的话最好还是看官方的
- 一般不会用到多重继承,会用到的情况大概就是面试官考你的时候
- 字节对齐要考虑到当前操作系统的位数
- 同名二义性通过访问限定符和重写父类函数解决
TODO
搞懂为什么虚继承之后 d 的大小变成了 24 byte(因为涉及到字节对齐和操作系统位长)- 学会 GDB 之后再来调试一遍
- 写一篇字节对齐的文章
reference
https://www.jianshu.com/p/ab96f88e5285
https://blog.csdn.net/songshiMVP1/article/details/51173469
https://blog.csdn.net/liu_zhen_kai/article/details/81590467
https://blog.csdn.net/songshiMVP1/article/details/51173469
https://blog.csdn.net/chengonghao/article/details/51679743