首先我们先回忆一下,在派生类(子类)中默认的成员函数做了什么事情?我们现在可以这么认为对于普通类来说呢?只需要看待两个部分的成员:内置类型和自定义类型。而对于派生类而言序言看待三个部分的成员:内置类型,自定义类型以及父类
类型 | 构造和析构 | 拷贝构造 |
普通类 | 对于内置类型一般不处理,自定类型调用自定义类型的构造和析构 | 对于内置类型完成值拷贝,对于自定义类型调用自定义类型的拷贝构造函数 |
派生类 | 和上面的基本一样,但是还需要去显示调用父类的构造和析构 | 依旧和上面的一样,但是依旧需要显示的去调用父类 |
即派生类是将父类有看成了一个整体,无论你是显示的去写派生类的构造和析构还是编译器自己生成的构造和析构,都是去显示的调用父类的构造和析构的。
下面我们来实现一个小问题:即如何实现一个不能被继承的类(继承无意义的类)?
思路很简单:把构造函数私有化
原因很简单,当你创建B类对象的时候,肯定需要调用父类的构造函数,但是因为父类的构造函数为私有,无法在除了父类以外的地方调用构造导致B无法调用父类的构造,所以无法创建对象,即使你要在B中显示的调用父类的构造也无法调用因为父类的private成员和成员函数,对于子类而言是不可见的。
但是这种方法呢有点不够直观,即你B类还是继承了A类只是无法创建对象而已。而在c++11中添加了一个关键字final,能够让子类在继承的时候就直接报错。
继承与友元
这里需要注意一点:友元关系是不能够被继承的也就是说,基类(父类)的友元函数不能访问子类私有成员和保护成员。
可以用一句通俗的话来讲:即你父亲的朋友不一定是你的朋友。若想要让友元也能够访问子类,也需要在子类中声明友元。
继承与静态成员
我们首先思考一个问题静态成员会不会被继承下来呢?
下面便存在一个父类成员中含有一个静态变量,那么我们在子类中能否访问这个静态变量呢?
可以看到正常的运行打印了,没有报错。
但是我们现在在思考一个问题,此时子类的这个静态成员变量是独立的还是是父类的那个静态成员变量呢?
我们可以对这两个静态成员变量去一个地址看一看
可以看到地址一摸一样。那么这里我们可以认为静态成员并没有被继承,被继承的是静态成员的使用权,因为如果静态成员是被继承了的,在子类中应该会产生一份独立的静态成员,而这里显然是没有。
除此之外,静态成员通常是属于一个类的,只要你的静态成员是public并且突破了类域,那么你就能够访问到静态成员,所以这里子类应该是继承了父类静态成员的使用权。
当然上面的静态成语还有一个能力,即能够记录你创建了几个student和person对象
请看下面的代码:
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; // 研究科目
};
Student func()
{
Student c;
return c;
}
int main()
{
Person a;//创建person对象会调用person的构造让_count++
Student b;//子类依旧会去调用父类的构造
Student c;
func();
cout << Student::_count << endl;
}
菱形继承和菱形虚拟继承
首先我们来看一下下面的单继承和多继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
记住是一个直接父类
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
记住是两个或以上直接父类
那么c++为什么要存在多继承呢?
因为在现实生活中,是存在一个人具有多个角色的,例如某些人因为经济原因,不得不一个人在白天工作完成之后,晚上还会出去跑滴滴。那么这个人白天的时候,可能是个厨师,那么晚上的时候,可能就是个司机,这是很正常的。还有一个例子假设存在一片桃园,那么在春天的时候,可能这就是一片观赏桃园。而在夏天的时候,这里又会变成一个果园。那么这个时候,果园和花园都是这个桃园的属性。那么在现实生活中,一定会有一些对象会兼具多个属性,从这一点出发,那么多继承是很合理的。
但是你使用多重继承就有可能导致下面的情况:菱形继承
菱形继承会导致Assistant类中出现二义性,和数据冗余的问题。为什么呢?我们从Assistant的角度出发,Assistant类继承了一个Studet类和一个Teacher类,而这两个类中又分别含有了一个Person类,这就导致Assistant类中出现了两份Person类。这也就导致了数据冗余,同时两个person类也就意味着存在了两个_name,也就导致了二义性,请看下面的实际代码:
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中存在两份Person类对象,所以就存在两个_name导致出现了二义性
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";//指定一个类域,能够解决二义性,但是无法解决数据冗余的问题。
a.Teacher::_name = "yyy";
}
但是也许你可以认为我作为老师的时候,别人叫我XX老师,作为学生的时候,别人叫我XX学生是很合理的,但是这只是因为我在上面的Person类中只设定了_name而已,如果我在设定一个_age呢?难道你作为学生和老师的时候,你的年龄就不一样了吗?所以并不是所有人的信息都需要两份,只有少部分需要两份。
这个代码的第一个问题二义性:
从监控就可以看到在a中Student里面有一个名字,在Teacher中也存在一个名字,所以如果你直接使用a._name就会导致歧义,这也就是二义性。
而解决二义性的第一个方法也就是我上面写的指定类域,
从上图也能看出,在a中确实是存在两个Person对象的。虽然指定类域能够解决二义性的问题,但是无法解决数据冗余的问题。
那么为了解决这个问题c++提供了一个关键字virtual(虚),而这个关键字的引入也就引起了一个虚继承。
我们下面来看使用了虚继承后的效果:
从上面就可以看出,在a中的Teacher对象和Student对象中的Person对象变成了同一份。所以我们任意修改一份/或者不指定类域都可以进行修改。
当然现在的这个监视窗口变得不太准确了。我们可以认为在a中的Teacher对象和Student对象中存了一份Person对象的引用。
对于下面的这张图我们的virtual是加在了Student和Teacher那里。
那么如果是下面的这种情况呢?
应该这么加:
这里就需要记住,我们现在是哪里出现了数据冗余,是Person。所以我们就加在直接继承Person的那些类上。
那么virtual是如何解决这个问题的呢?
首先我们先拿下面的代码作为参考的例子:
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;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
int main()
{
return 0;
}
这里简化实现了一个菱形继承,类型ABCD中都存在一个小写的abcd变量。
继承关系:
因为监视窗口已经不能很准确的表明内存中各个类的关系了,所以下面我们要采用内存窗口去查看这些类对象。
上图中的那个地址也就是d的地址,现在调试的时候,并没有增加virtual关键字,所以是会出现数据冗余的。其中在B中出现了一个_a为1,而在C中也出现了一个_a等于2。
那么c++的virtual是如何解决二义性和数据冗余的问题呢?首先这两个问题会出现的根本原因也就是在D中出现了2份A。那么为了解决这个问题,所以我们不能在B中存一份A,也不能在C中存一份A,我们需要重新找一个地方存这个A。怎么存的呢?我们首先将上面的代码修改为使用虚继承的代码。
然后依旧是使用内存窗口来查看d里面内容的储存。
此时我用d:B去访问的a在红色标记处。(上图红色处)
使用c去访问的a发现也在这里(上图红色处)。
然后d._b储存的位置也确定了(上图红色处)
d._c储存的位置也确定了(上图红色处)
d._d储存的位置也确定了(上图红色处)
因为我的vs2022的内存窗口不太好看所以请看下面的图来理解其中深层次的原理:
所以这个虚继承就是这样去解决多份储存A的问题,此时的这个A既不存在了B中,也不存在了C中,而是存在了一个额外的地方。此时自然也就不存在二义性,以及数据冗余的问题了。但是我们看B和C里面多了一行东西,这个东西是什么呢?为啥要需要这两个东西呢?
我们先不思考这个问题,我们假设如果没有虚继承,然后遇到下面的代码:
那么此时的这个代码就非常的好理解:利用切片将d中B类型的成员赋值过来(第一行)
第二行利用切片将d中B所对应的地址直接拿取出来即可。因为此时的整个D类型的B中是含有A的所以可以这样理解去使用切片。
但是如果是使用了虚继承呢?
此时如果还是和上面一样使用切片的话,是无法完成的,因为B中应该是要含有A的但是此时的B中是没有A的(因为A已经被放到了一个其它的位置),所以我们这里就猜测一下B中的这个东西应该是能够帮助B,找到A所在的位置的。那么帮助B找到A的位置的方法有很多种这里选择的是下图中的这种方法。
此时我们回到B中dc的那个位置往下移动20个字节(1行是4个字节),刚好就能够到达A的位置。
同理对于C也是这样从e4的那个位置开始往下移动12个字节,刚好也能到达A的位置。
这里编译器在DC的那个地址处生成了一个表,这个表里面储存的就是距离A的偏移量,至于为什么不是在dc7b的那个位置储存而是在下一个位置储存,是因为在dc7b的那个位置需要保存一些多态才会学的东西。我们暂时可以不用管。
那么如果我们现在执行下图中的第三步是如何执行的呢?
这里是直接让ptr的指针指向了A中的_a吗?并不是,他是先通过B中的dc7b地址找到偏移量表,从表中得到此时的B距离A的字节数,然后再去移动ptr指针,找到A然后找到A中的_a,再让_a++。
还有更加神奇的地方,我们下面去创建一个B类型的对象,然后去看一下这个B对象的对象模型
也就是说,当B类虚继承以后,B类会保持和下面的类一样的模型,同理这对于C类型也是一样的。那么为什么要这么设计呢?
因为可能还会存在下面的这种场景。
即一个B类型的指针,他可能指向的是B对象也有可能指向是D对象,如果你光看这一个代码(ptr->_a++),你是无法分清这个ptr指向的是B对象还是D对象的。所以为了统一操作都是通过这个指针找到对应的这个表,再表里面找到对应的偏移量。通过偏移量找到A,然后让_a++,这样就统一了操作,无论你的这个指针指向的是B对象还是D对象。如果B对象不和下面的保持一致,那么又要进行单独的处理,更加的麻烦。因为ptr指向的是哪一个对象我们是不知道的。当然将A成员放到下面只是vs编译器做的处理,其它的编译器可能放到上面,只要能够保证虚继承能够解决二义性和数据冗余的问题,就可以了。但是大部分的编译器都是按照我们上面的规则做的。
那么现在我们再提出一个问题,我能不能在下图的cc 7b那里直接存A的地址可不可以,或者说我直接在这个位置存偏移量可不可以。
答案是都可以,但是为什么要选择这一种呢(即找到偏移量表,再来找偏移量)?
那是因为我们现在并没有考虑到多态,或者说难道那个表中就只会保存偏移量吗?肯定不是,如果没有使用偏移量表的方式,在遇到多态的时候,我们将其他的什么偏移量都放到对象当中就会导致对象内存变大。
初次之外难道D对象就只有一个吗?如果存在多个D对象,如果采用的是在类中储存偏移量的方法(或是其他的方法)都会造成一定量的大小变大,而如果使用的是表的方法,那么无论多少个D对象,都只需要指向这个表就能够通过这个表找到偏移量,然后找到A。
如下图
其中的dd为左图内存监视,右图为ddd内存监视。使用这种方法很明显是更加省空间的。如果创建的D对象很少的话,无论使用那个方法都是差别不太大的,但是如果存在很多个D对象呢?此时很明显使用表的方法更加省空间。
使用这个方法A越大你赚的就越多,因为这里解决的就是储存了多个A的情况。
到这里菱形继承就已经基本完全了,但是在日常中还是尽量不要去使用菱形继承。菱形继承如果在套上其它的语法是会变得非常复杂的。
但是在库中还是存在使用菱形继承的情况的
这就是库中使用菱形继承的地方,如果这里要解决数据冗余的话,要在istream和ostream处加上virtual
继承的总结和反思
1.很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设 计出菱形继承。否则在复杂度及性能上都有问题。
- 多继承可以认为是C++的缺陷之一,很多后来的面向对象语言都没有多继承,如Java。
继承和组合
首先公有继承的关系是is-a的关系,而组合的关系则是has-a的关系,那么什么是组合呢?
下面就是组合:
下面是继承:
首先组合和继承的共同点都是可以复用。
然后从大小上来看呢?从下图可以得到两者的大小是一样大的。
那么其它方面有没有什么区别呢?
首先从权限上面来说:
父类的公有子类能够使用,父类的保护子类也能够使用,对于组合来说,组合类的共有子类能够使用,但是组合类的保护子类不能使用。当然无论是父类还是组合类的private,子类都不能使用。所以从这里能够得出继承的权限是大于组合的。
下面请看详细的说明:
总的来说继承被认为是白箱复用(白箱即我知晓底层的逻辑),黑箱复用(黑箱即我不知晓底层实现的原理),在这里还存在一个白盒测试:即我知晓底层实现的原理,从底层实现的逻辑上去测试。而黑盒测试即我不知晓底层实现的原理,而只是从用的角度去测试。这也是测试时使用的方法。那么换回到这里白盒就非常的适合继承,因为对于继承而言,除了private我子类不能访问外,其余的细节我子类都是可以访问的。对于组合而言,被组合的那个类对于我而言是不可见的(不能用)。这个类对于我和对于其它类都是一样的,其它类只能使用这个被组合类公有的部分,对于组合了这个类的我而言,我也依旧只能使用这个被组合类的公有部分。
从方便使用的角度而言使用继承更好,因为没有很多的限制,但是从更长远的角度来看使用组合更好。因为组合类的依赖关系很弱,耦合度也就越低。
那么为什么使用组合比使用继承好呢?
如果这一个大框是一个大的功能模块,而其中的这些小圈就是一个又一个代码,如果耦合度高(上图),那么任何一个代码出现了错误,都会导致这一整个代码出现错误。
而上图的这个耦合度就不是很高,即使一个代码出现了问题,并不会影响一整个代码。
所以在工业的软件开发的时候,可能就会让我们画类关系图。尽可能地让耦合度低一点。
所以尽可能的去使用组合,但是这也并不是说,继承就一定不能用,一切都要看你现在所在的情况而定。如果两个类的关系是has-a的关系,你使用组合更好。如果两个类的关系是is-a的关系你使用继承更好。除此之外,如果你要使用多态那么你必须使用继承,多态就是建立在继承的基础上的。
例如学生和人,你使用继承的关系更好,但是轮胎和车很明显是组合更为符合,如果两者的关系都是符合的那就考虑使用组合。
下面我们来看一道菱形继承的题目:
上面的class A为S1 class B 为S2 class C 为S3 ,class D为S4
然后要求你输出程序的打印。
首先这里创建了一个D对象,那么就去看D但是,D是存在继承的,然后因为
这里B在前C在后所以这里就先会去构建B然后去构建C对象。既然要构建B对象,所以就去B的构造因为B也是子类,所以需要先构建A,所以这里首先就会打印class A,因为构建了一个A对象,构建完了A对象之后,构建B对象,所以接着会打印class B,然后会去构建C对象,因为这里的虚继承不会让A多次创建所以,这里的C直接构建一个C对象即可。所以会打印class C,但是我们看到在D的初始化列表中含有一个A(s1),但是不要忘了我们这里是使用了虚继承的,所以这里只需要存在一份A即可。所以这里最后打印一个class D即可。
所以这道题目最后打印的结果是 class A,class B,class C,class D。
希望这篇博客能帮助到正在阅读的你。写的不好请见谅,也请提出您的评判,如果发现了任何错误,也烦请指出,感谢。