欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 文旅 > 文化 > 【c++】继承详解

【c++】继承详解

2025/5/9 21:01:16 来源:https://blog.csdn.net/jiunian_thx_cn/article/details/147723907  浏览:    关键词:【c++】继承详解

目录

  • 继承的概念
  • 继承的定义方式
  • 继承方式与访问限定符
  • 父类和子类对象赋值转换
  • 继承中的作用域
  • 子类的默认构造函数
    • 构造函数
    • 拷贝构造函数
    • 赋值运算符重载函数
    • 析构函数
  • 继承与友元
  • 继承与静态成员
  • 多继承
  • 多继承引发的菱形继承
  • 虚拟继承
    • 使用方法
    • 虚继承的原理
  • 继承和组合
  • 总结和反思

继承的概念

说到继承,套用到生活中,我们尝常常会想到继承财产这样的场景。c++中也是如此,它允许程序员在保持原有类特性的基础上进行扩展。因为我们会遇到多个类具有部分相同的成员函数和成员变量,此时一个一个写未免有点累,此时我们将这些类中都会有的成员变量和函数定义出一个类,再创建我们想要的类,这些类继承自最开始创建的类。最开始创建的类就叫父类,之后继承父类的就叫子类,或者叫派生类。这样子类就不用再写父类中的成员了,这些成员都被继承进了子类中。

继承的定义方式

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{
public:void print(){cout << money << endl;}
protected:int money = 1000000;
};class son : public father
{};int main()
{son a;a.print();return 0;
}

以上就是继承的使用的一个小演示,笔者定义了一个父类叫father,又定义了一个子类叫son,在定义子类时在类名后面加上: public father,就表示公有继承father,这样父类的元素就继承进了子类,创建子类函数也能正常调用父类的成员函数。

继承方式与访问限定符

我们都知道,c++中的访问限定符有public、protected、private这三种,与之对应的,c++中的继承方式也有public、protected、private这三种,在没有学习继承之前,在类中使用protected、private这两个访问限定符是没有什么区别的,都是会限制类外对于处于protected、private范围内的成员,但在继承中,这两个访问限定符就有所不同了。总的来说,不同的继承方式会改变父类中public、protected、private区域中的成员继承到子类中所对应的访问限定符。具体如下,

类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员在派生类中不可见在派生类中不可见在派生类中不可见

这个表乍一看很难,实际上只需要记住public > protected > private,继承方式和访问限定符谁小就是谁,木桶效应。

这里的在派生类中不可见这种情况是指虽然父类的成员被继承到了子类,但是语法上限制无论是子类类内还是类外都不能访问。

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{
public:void print(){cout << money << endl;}
private:int money = 1000000;
};struct son : father
{
public:};int main()
{son a;a.print();return 0;
}

父类继承过去的函数还是可以正常访问,这与父子类的作用域有关,后文会讲解,简单来说,子类有子类的作用域,父类有父类的作用域,继承给予了子类访问父类的一些成员(继承过来是public和protected的成员)的权限,子类自身无法访问没有给予权限的父类的成员,但通过给予过权限的成员函数可以访问,因为那些函数的执行会在自己对应的作用域(也就是父类作用域)实现,所有就能透过父类的成员函数间接访问。本质与正常类在类外通过public函数访问private成员原理类似。

可以看出,在使用protected继承时,private父类中private的成员在在类中处于不可见状态,而protected成员还是可以在子类中访问的,这就是设计protected的原因,protected成员在保持类外不可访问的前提下又能被继承的子类内部访问,这在一些特殊场景下可以用到。

我们都知道c++的类声明时可以用class也可以用struct,能用struct是因为要兼容c语言,所以他的成员在不写访问限定符的情况下默认public,而class是默认private的,在写子类继承时,倘若我们不写出字类的继承方式,则使用class关键字声明类的默认private继承,使用struct关键字的默认public继承。当然这只是个冷知识,实际编程绝不提倡不写继承方式,会降低代码可读性,还可能会因此引发引发意想不到的问题。

实际上,c++在继承方式这块由于没有前车之鉴,所以设计的过于冗余了,比如这个private继承,private继承下来的父类成员在子类中不能访问,类外更不能访问,那我继承他干嘛?这只有在一些非常鸡肋的场景才会用到。在实际使用中,我们基本上只会使用public继承,protected都是很少使用的。因为public继承会将父类中成员的访问限定符状态原模原样的保留下来,使用最舒适,而protected会改变一部分保留一部分,一不小心可能会出错,只有特殊情况必须使用才会用。

父类和子类对象赋值转换

我们都知道,在c++中,在对象之间赋值时倘若出现类型不匹配的问题时,编译器就会尝试进行隐式类型转换,那么在父类与子类的赋值操作时,会出现类型转换的情况吗?答案是会的,但是会遵循一些特殊规则。

子类对象可以赋值给基类的对象 / 指针 / 引用,子类类型的指针与引用也可以赋值给父类类型的指针与引用,但是父类对象不能赋值给子类的对象 / 指针 / 引用,对于使用父类类型的指针与引用赋值给子类类型的指针与引用,直接的赋值也是不行的,但是可以通过强制类型转换进行赋值,为什么这么设计呢?

先来讲子类对象为什么可以赋值给基类的对象 / 基类的指针 / 基类的引用。众所周知,从一个类所包含的成员来说,子类是大于等于父类的,至少是等于,等于时就是子类只继承了父类的成员,自己什么都没有。那么用子类来给父类对象 / 指针 / 引用赋值,至少子类包含了父类的全部成员,我们对于子类赋值给父类对象 / 指针 / 引用这个操作还有一个形象的说法叫切片或切割,即把子类中父类的那一部分切过去赋值,在实际的存储空间中,子类对象中是有一部分专门存储父类的成员的,如图所示,
在这里插入图片描述

对象赋值直接用切出来的那一部分初始化就好了,指针赋值就是将指针指向切出来的那一部分(解引用时也可以通过类型信息解引用出来),引用的底层还是用指针,所以也是通过指向切出来的那一部分实现的。需要注意的是,切片这个操作并不会产生临时对象,验证如下,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{
public:void print(){cout << fa_money << endl;}
private:int fa_money = 1000000;
};class son : public father
{int so_money = 1000000;
};int main()
{son a;father& x = a;x.print();return 0;
}

我们都知道,引用讲究权限平移或缩小,倘若 father& x = a; 如果这步操作因为切片产生了临时对象,因为临时对象具有常性,不使用 const father& x = a; 则会报错,而这里没有,所以切片就是直接从子类对象上取的,这一定程度上节省了资源,减少了性能开销。

之后再来讲讲为什么子类类型的指针与引用也可以赋值给父类类型的指针与引用,之前我们也提过,在实际的存储空间中,子类对象中是有一部分专门存储父类的成员的,在子类类型的指针与引用赋值给父类类型的指针与引用时,编译器会自动调整指针的偏移,将子类对象中父类所对应的存储空间的指针与引用赋值给父类类型的指针与引用。当然实际上,在很多编译器的处理下,单继承中父类成员的存储空间就在子类对象的存储空间的开头,也就是不需要处理偏移就能直接赋值,但在多继承中还是有用的,多继承因为有多个父类,还是有偏移的父类的,多继承之后再讲。

之后再来讲讲父类对象为什么不能赋值给子类的对象 / 指针 / 引用,对于使用父类类型的指针与引用赋值给子类类型的指针与引用,直接的赋值也是不行的,原因与上文类似,因为父类可能会缺一些子类有的成员,因为从一个类所包含的成员来说,子类是大于等于父类的,使用父类对象赋值给子类的对象,会有一些子类的对象无法初始化,所以语法直接禁止了这种行为,即使是子类等于父类的情况也不行。对于指针来说,使用强制类型转换可以实现父类类型的指针与引用赋值给子类类型的指针与引用,因为指针与引用不像实际的对象,本身的性质是一样的,只是指向的对象是不一样的,这样使用强制类型转换是可以完成这样的操作的,但是也要考虑到安全性的问题,使用这种方法使子类类型指针与引用指向父类对象会引发越界访问或未定义行为,如下所示,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money = 1000000;
public:void fa_print(){cout << fa_money << endl;}
};class son : public father
{int so_money = 1000;
public:void so_print(){cout << so_money << endl;}
};int main()
{father a;son* b = (son*)&a;(*b).so_print();//未知数return 0;
}

但是倘若是指向子类对象的父类类型指针与引用(父类指针与引用可以直接指向子类对象,也能被子类指针与引用赋值),直接强转可以直接使用。这点在多继承下也是一样的。

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money = 1000000;
public:void fa_print(){cout << fa_money << endl;}
};class son : public father
{int so_money = 1000;
public:void so_print(){cout << so_money << endl;}
};int main()
{son a;father& x = a;son& y = (son&)x;y.so_print();return 0;
}

最后笔者想要说的是,切片的前提是公有继承,为什么这么说呢?切片现象的发生需要满足:
​​(1)类型兼容性​​:派生类必须能隐式转换为基类类型;
(2)​​内存布局兼容性​​:基类对象的内存布局必须是派生类对象的一部分。
三种继承方式中只有公有继承满足,因为只有公有继承会将父类原模原样地继承下来,其他继承方式都会改变访问限定符,破坏类型兼容性(不会破坏​​内存布局兼容性,该咋存咋存,只是权限被改了),使子类无法转换成父类,毕竟成员与父类中的都不一样了,所以说切片的前提是公有继承。

继承中的作用域

在继承体系中,父类与子类都有独立的作用域,这个我之前也讲过。作用域的效果在父类中并不会体现,啥也没继承体现个毛,但在子类中有着重要的效果。

在子类与父类中有同名成员时,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。就像下面一样,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money = 1000000;
public:void print(){cout << fa_money << endl;}
};class son : public father
{int so_money = 1000;
public:void print(){cout << so_money << endl;}
};int main()
{son a;a.print();//1000return 0;
}

父类与子类有同名函数 void print() ,这里要注意的是只要是函数名相同就行,参数返回值不同无所谓,就像下面,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money = 1000000;
public:void print(){cout << fa_money << endl;}
};class son : public father
{int so_money = 1000;
public:void print(int a){cout << so_money << endl;}
};int main()
{son a;a.print();//会报错return 0;
}

代码会报错,因为子类中的 void print(int a) 隐藏了父类中的void print() 。但是,这是不是就意味着我们就无法在子类中调用父类的成员了呢,答案也不是,我们可以通过作用域解析运算符来显式访问父类中的同名成员,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money = 1000000;
public:void print(){cout << fa_money << endl;}
};class son : public father
{int so_money = 1000;
public:void print(int a){cout << so_money << endl;}
};int main()
{son a;a.father::print();//加域访问限定符return 0;
}

需要注意的是,这里子类与父类的print函数并不是构成重载,我们应明确重载的前提是在同一个域中,很明显这两个函数不在同一个域中,这里两个函数是构成了重定义。

其实看完继承这一套重定义逻辑,我们就会发现其实这与我们普通域的访问逻辑是一致的,我们普通的域也是局部域的变量优先使用,没有就用全局的,都有的情况下也可以用与访问限定符指定访问。总的来说,在子类中定义与父类同名的成员是一种不好的行为,应尽量避免。

子类的默认构造函数

我们都知道,类中有6个默认构造函数,这些函数即使我们不写编译器也会自动生成,那么对于继承中的子类这6个默认构造函数是怎么生成的呢?

构造函数

对于构造函数,子类生成的默认构造函数会先先调用父类的构造函数初始化父类成员变量再初始化自己的变量,我这里写了一个类似的,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money;
public:father(int a = 1000000):fa_money(a){cout << "father(int a = 1000000)" << endl;}void print(){cout << fa_money << endl;}
};class son : public father
{int so_money;
public:son(int a = 1000, int b = 1000000):father(b),so_money(a){cout << "son(int a = 1000, int b = 1000000)" << endl;}void print(int a){cout << so_money << endl;}
};int main()
{son a;//结果://father(int a = 1000000)//son(int a = 1000, int b = 1000000)return 0;
}

我们可以在初始化列表直接调用父类的构造函数,调用的方法就像写一个父类的临时变量一样,这是c++自己规定的,需要注意即使我们不写编译器在初始化列表也是会调用父类的默认构造函数的,如果父类没有默认构造函数,就会报错。同时,与普通的类类似,初始化列表的初始化顺序与变量在类中实际定义的顺序有关,与初始化列表的初始化顺序无关,而一般父类对象成员是被视为最先定义的,所以不论父类在初始化列表写在第几位,都是最先初始化的,这点要注意。

拷贝构造函数

对于拷贝构造函数,子类同样需要调用父类的拷贝构造函数来完成父类对象的拷贝初始化,这里模拟实现了一个,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money;
public:father(int a = 1000000):fa_money(a){cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){cout << "father(const father& x)" << endl;}void print(){cout << fa_money << endl;}
};class son : public father
{int so_money;
public:son(int a = 1000, int b = 1000000):father(b),so_money(a){cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){cout << "son(int a = 1000, int b = 1000000)" << endl;}void print(int a){cout << so_money << endl;}
};int main()
{son a;son b(a);//结果://father(int a = 1000000)//son(int a = 1000, int b = 1000000)//father(const father& x)//son(const son& x):return 0;
}

拷贝构造函数本质还是构造函数,所以同样是初始化列表不写会自动调用默认构造,不过这里是拷贝构造,调用默认构造就不符合函数的用途了,而且初始化列表同样会先调用父类的拷贝构造,再去拷贝初始化初始化列表的其他成员变量。

赋值运算符重载函数

赋值运算符重载函数同样需要在函数内父类的赋值运算符重载函数来完成父类成员的赋值操作,这里模拟实现了一个,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money;
public:father(int a = 1000000):fa_money(a){cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){cout << "father(const father& x)" << endl;}father& operator=(const father & x){cout << "father& operator=(const father & x)" << endl;fa_money = x.fa_money;return *this;}void print(){cout << fa_money << endl;}
};class son : public father
{int so_money;
public:son(int a = 1000, int b = 1000000)://father(b),so_money(a){cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){cout << "son(const son& x):" << endl;}son& operator=(const son& x){cout << "son& operator=(const son& x)" << endl;so_money = x.so_money;father::operator=(x);return *this;}void print(int a){cout << so_money << endl;}
};int main()
{son a;son b;b = a;//结果//father(int a = 1000000)//son(int a = 1000, int b = 1000000)//father(int a = 1000000)//son(int a = 1000, int b = 1000000)//son& operator=(const son& x)//father& operator=(const father & x)return 0;
}

赋值运算符重载函数就不会像构造函数一样,必须要先调用父类的初始化完才能初始化子类的,这里可以先让子类成员进行赋值,再调用父类的也是可以的,这里要注意继承中同名函数的重定义,使用作用域解析运算符防止自己调用自己形成无限死循环。

析构函数

析构函数是这些函数中最与众不同的一个,子类析构函数的实现不需要显式调用父类的析构函数,编译器会自动调用。这点看起来与构造函数类似,但是构造函数是倘若我们写明了编译器就会用我们写明的,编译器自己就不会再去隐式调用了;但析构函数是若我们写明,编译器还是会自己隐式调用,这样就会调用析构函数两次,倘若是在堆上申请了空间的类,就会出现重复释放堆上同一块空间两次从而报错的现象。

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{int fa_money;
public:father(int a = 1000000):fa_money(a){cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){cout << "father(const father& x)" << endl;}~father(){cout << "~father()" << endl;}father& operator=(const father & x){cout << "father& operator=(const father & x)" << endl;fa_money = x.fa_money;return *this;}void print(){cout << fa_money << endl;}
};class son : public father
{int so_money;
public:son(int a = 1000, int b = 1000000):father(b),so_money(a){cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){cout << "son(const son& x):" << endl;}~son(){//father::~father();//写了会调用两次cout << "~son()" << endl;}son& operator=(const son& x){cout << "son& operator=(const son& x)" << endl;so_money = x.so_money;father::operator=(x);return *this;}void print(int a){cout << so_money << endl;}
};int main()
{son x;//结果://father(int a = 1000000)//son(int a = 1000, int b = 1000000)//~son()//~father()return 0;
}

可以看到,不写的话也是能正常调用的,但是写了会调用两次,这点务必要注意。那么为什么要这么设计呢,这是编译器为了安全考虑,需要严格保证先析构子类的成员再析构父类的成员(父类成员先被初始化,先初始化的后析构)。这里还有一个不起眼的小点就是我在子类的析构函数显式调用父类析构函数时(当然这么做是不对的)用了作用域解析运算符,注意这里倘若不使用作用域解析运算符时会报错说找不到的,这其实是很奇怪的,因为我们都知道只有父子类中的同名函数才会构成函数的重定义,这里的析构函数和构造函数一样是以类名为基础命名的,两个名字不一样,为什么会报错呢?其实这里编译器在后期操作将父类和子类的析构函数处理成了一样的,我们可以将这个名称简单认定为destrutor()(实际也可能不是,这个看编译器怎么命名),为了将这两个函数形成重定义,构成隐藏关系,至于为什么后面会讲解。

取地址运算符重载和const取地址运算符重载一般不用我们实现,默认生成的一般够用,就算要实现,注意同名函数的重构定义就好了,实现起来很简单。

继承与友元

友元关系不能继承,就像你爸爸的朋友不一定就是你的朋友。

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class father
{friend class father_friend;int fa_money;
public:father(int a = 1000000):fa_money(a){cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){cout << "father(const father& x)" << endl;}~father(){cout << "~father()" << endl;}father* operator&(){return this;}const father* operator&()const{return this;}father& operator=(const father & x){cout << "father& operator=(const father & x)" << endl;fa_money = x.fa_money;return *this;}void print(){cout << fa_money << endl;}
};class son : public father
{//friend class father_friend;int so_money;
public:son(int a = 1000, int b = 1000000):father(b),so_money(a){cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){cout << "son(const son& x):" << endl;}~son(){//father::~father();cout << "~son()" << endl;}son* operator&(){return this;}const son* operator&()const{return this;}son& operator=(const son& x){cout << "son& operator=(const son& x)" << endl;so_money = x.so_money;father::operator=(x);return *this;}void print(int a){cout << so_money << endl;}
};class father_friend
{
public:void print(){father x;cout << x.fa_money;son y;cout << y.so_money;//会报错,子类中也声明一下友元就不会报错}
};

继承与静态成员

静态成员是可以被继承的,但由于静态成员的特殊性,其是被父类和子类所有的成员所共有的,都可以使用,所以与其说是继承变量自身,不如说是继承变量的使用权。继承中的静态成员满足静态成员的所有性质:
(1)静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区(所以不在类里面定义)
(2)静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明(不能给缺省值,因为不走初始化列表)
(3)类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问(静态成员函数可以不通过对象访问,直接用作用域解析符访问)
(4)静态成员函数没有隐藏的this指针,不能访问任何非静态成员(非要访问也能自己传)
(5)静态成员也是类的成员,受public、protected、private 访问限定符的限制(初始化时可以破一次例)

多继承

之前我们所介绍的一个子类对应一个父类的叫作单继承,c++还设计了多继承,即一个子类对应多个父类,具体使用如下,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class person
{string name;
public:person(const string& x = string()):name(x){}void name_print(){cout << name << endl;}
};class father 
{int fa_money;
public:father(int a = 1000000):fa_money(a){//cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){//cout << "father(const father& x)" << endl;}~father(){//cout << "~father()" << endl;}father* operator&(){return this;}const father* operator&()const{return this;}father& operator=(const father & x){//cout << "father& operator=(const father & x)" << endl;fa_money = x.fa_money;return *this;}void print(){cout << fa_money << endl;}
};class son : public father, public person
{int so_money;
public:son(int a = 1000, int b = 1000000, const string& x = string("big head")):father(b),person(x),so_money(a){//cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){//cout << "son(const son& x):" << endl;}~son(){//father::~father();//cout << "~son()" << endl;}son* operator&(){return this;}const son* operator&()const{return this;}son& operator=(const son& x){//cout << "son& operator=(const son& x)" << endl;so_money = x.so_money;father::operator=(x);return *this;}void print(int a){cout << so_money << endl;}
};int main()
{son x;x.name_print();//结果:big headx.father::print();return 0;
}

通过多继承继承了father和person,使其既有了father的成员,也有了person的成员。

多继承引发的菱形继承

多继承有着很好的初衷,一个类继承自多个类的想法很美好,但在设计时忽略了个很严重的问题,就是菱形继承,菱形继承就是指多个继承自同一个父类的子类又被一个他们的子类多继承,造成了数据冗余和二义性的问题。
在这里插入图片描述
这里写了一段示例代码,

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>#include<string>using namespace std;class person
{string name;
public:person(const string& x = string()):name(x){}void name_print(){cout << name << endl;}
};class father : public person
{int fa_money;
public:father(int a = 1000000, const string& x = string("little head")) :fa_money(a),person(x){//cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){//cout << "father(const father& x)" << endl;}~father(){//cout << "~father()" << endl;}father* operator&(){return this;}const father* operator&()const{return this;}father& operator=(const father & x){//cout << "father& operator=(const father & x)" << endl;fa_money = x.fa_money;return *this;}void print(){cout << fa_money << endl;}
};class son : public father, public person
{int so_money;
public:son(int a = 1000, int b = 1000000, const string& x = string("big head")):father(b),person(x),so_money(a){//cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){//cout << "son(const son& x):" << endl;}~son(){//father::~father();//cout << "~son()" << endl;}son* operator&(){return this;}const son* operator&()const{return this;}son& operator=(const son& x){//cout << "son& operator=(const son& x)" << endl;so_money = x.so_money;father::operator=(x);return *this;}void print(int a){cout << so_money << endl;}
};int main()
{son x;x.father::name_print();x.person::name_print();return 0;
}

这里将son多继承father和person,而person又继承了person,这样就形成了一个最简单的菱形继承,只要出现了因多继承引发的一个类多次继承同一个类的情况就算是菱形继承。像这里,father继承了person,son继承了father和person,那son就继承了两次person,这样创建son对象时,son对象中就会有两份person类成员的存储空间,
在这里插入图片描述
在这里插入图片描述
可以看到son真正初始化的名字存在了直接继承的person中,father中存储的是father类自己的名字,这时我们调用person的成员,就会出现二义性,比如我们调用 void name_print() 函数,这个函数在直接继承的person类中有,在father类中也有(father继承了person的),且都是公有成员公有继承的,编译器无法确定我们要调用的是谁,虽然我们可以通过作用域解析符来指定,但这很明显偏离了我们的初衷。所以,到这里我们明白了菱形继承是一种危险的行为,其造成数据冗余。浪费空间,还会造成二义性,使代码逻辑混乱。

虚拟继承

使用方法

为了解决菱形继承的问题,c++推出了虚拟继承,这里给出一个简单的菱形虚拟继承的例子,

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;cout << d._a << endl; //结果: 2, _a可以直接调用,不会有二义性,即使使用域名调用也不会出错d._b = 3;d._c = 4;d._d = 5;return 0;
}

虚继承就是在继承方式的前面加 virtual 就行,需要注意虚继承关键字是要加在会因多继承被重复继承多次的类的后面,这里的A类会因多继承在D类中重复继承,所以在菱形继承的腰部也就是B类和C类继承时在 public A 前面加。有些人记知识只记用法,看菱形继承要在腰部位置加 virtual 关键字就草草了事,但是遇到我之前写的这种,
在这里插入图片描述
类似三角的菱形继承就不知怎么加了,还是那句话,在会重复继承的类前面加 virtual 就行。

class person
{string name;
public:person(const string& x = string()):name(x){}void name_print(){cout << name << endl;}
};class father : virtual public person
{int fa_money;
public:father(int a = 1000000, const string& x = string("little head")) :fa_money(a),person(x){//cout << "father(int a = 1000000)" << endl;}father(const father& x):fa_money(x.fa_money){//cout << "father(const father& x)" << endl;}~father(){//cout << "~father()" << endl;}father* operator&(){return this;}const father* operator&()const{return this;}father& operator=(const father & x){//cout << "father& operator=(const father & x)" << endl;fa_money = x.fa_money;return *this;}void print(){cout << fa_money << endl;}
};class son : public father, virtual public person
{int so_money;
public:son(int a = 1000, int b = 1000000, const string& x = string("big head")):father(b),person(x),so_money(a){//cout << "son(int a = 1000, int b = 1000000)" << endl;}son(const son& x):father(x),so_money(x.so_money){//cout << "son(const son& x):" << endl;}~son(){//father::~father();//cout << "~son()" << endl;}son* operator&(){return this;}const son* operator&()const{return this;}son& operator=(const son& x){//cout << "son& operator=(const son& x)" << endl;so_money = x.so_money;father::operator=(x);return *this;}void print(int a){cout << so_money << endl;}
};int main()
{son x;x.name_print();return 0;
}

这种在继承声明中在继承方式前面加 virtual 关键字声明确保在多继承中仅保留一份的类称为虚基类。

虚继承的原理

我们先来看看在不使用虚继承的情况下菱形继承之后的类长什么样,这里为了方便讲解,使用上面的ABCD类的例子讲解,

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;cout << d._a << endl; //结果: 2, _a可以直接调用,不会有二义性,即使使用域名调用也不会出错d._b = 3;d._c = 4;d._d = 5;return 0;
}

在这里插入图片描述
可以看到明显的数据冗余和二义性问题,那么使用了虚继承的代码会怎么样呢?

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;cout << d._a << endl; //结果: 2, _a可以直接调用,不会有二义性,即使使用域名调用也不会出错d._b = 3;d._c = 4;d._d = 5;return 0;
}

在这里插入图片描述
我们可以看到,原本重复出现的_a只出现了一次,被放在了最下面,普通多继承中原本放_a的位置放了个未知数。实际上,这个位置上放的是一个指针,指向了另一块空间,我把另一块空间中的数据也截了出来,空间中第一个数是0,可能是编译器的优化处理,重点是第二个数,它是指针所在地址与虚基类所在地址的差值,也就是偏移量,可以猜到编译器通过使用单独空间保存偏移量使得通过原本存放_a的地址也能间接寻址找到_a,而且这种设计使得即使在菱形继承下也能保证类中始终只有一个_a。事实上,这片单独用来存放偏移量表的空间叫做虚积表,只要在继承过程中有使用 virtual 关键字继承也就是虚继承下来的父类都会被放到内存的最末尾(当然这也取决于编译器,至少VS是这么处理的,这个最末尾也是相对的,如果有多个虚继承,就是谁先虚继承谁先放到最下面,只有最后一个虚继承的是在真正的最下面)。知道了虚继承的原理,我们还应明白为什么这么设计,为什么要单独开一块空间出来呢,直接存_a的地址不行吗?答案是不行,每次运行地址都会变,没有固定的地址的。那直接存偏移量呢?这点看起来好像可行,但实际上一旦情况复杂,就会浪费空间。虚基表之所以叫虚基表,是因为它是一张表,在只虚继承了一个的情况下,表中只有一个数,存一个数要4字节,存一个地址也要4字节,这样看来用虚基表还亏了。但是倘若有多个虚基类,这时你若只想创建一个这个类的对象也是没问题的,甚至省了4字节,因为这时使用虚基表还是亏的,我们假如有3个虚基类,用虚基表就是一个指针加三个偏移量,不用就是三个偏移量,但是若果我们要创建多个变量呢?存偏移量就亏大了,假如创建了100个对象,那用虚基表就是100个指针加虚基表3个偏移量,不用就是300个偏移量。这时就体现虚基表的好处了,用虚基表是小亏大赚,不用是小赚大亏,你用谁?

最后笔者还想要说的是,菱形继承本身就是一种不健康的代码,菱形虚拟继承终究只是c++给出的补救措施,能少用就少用,在不是菱形继承的情况,更是不要使用虚继承,这会引发未知的问题,比如下面的这种情况,

class A
{int _a;
public:A(int x):_a(x){}
};class B : virtual public A
{int _b;
public:B(int x = 1, int y = 2):A(x),_b(y){}
};class C : public B
{int _c;
public:C(int x = 3):_c(x),//B(1, 2),A(1){}
};int main()
{C c;return 0;
}

我们看向C类的构造函数,按常理来讲即使是不写A类的构造函数也行的,但是事实是,不写就会报错,即使是不写A的构造显示调用B的构造也不行。首先,一般来说,因为C继承了B,所以即使不写也会调用默认构造才是,而B的构造函数中有调用A类的初始化,但是这样会报错,即使是显式调用,也会报错,只有明确在C类中显式调用A类的构造函数才行。就是因为B虚继承了A类,这时当B再被C虚继承时,即使此时不是虚继承,编译器也会认为是,此时会直接覆盖忽略掉B中的A类构造函数,通过B类构造来调用A类的构造是行不通的。因为倘若A是虚基类,在菱形继承的情况下就必须由C来初始化,编译器也是这么强制要求的,所以会报错。除了这个问题外,虚继承还会引发很多其他的问题,此外,虚继承的间接寻址也会带来额外的性能损耗,所以再不是菱形继承的情况下绝对不要用虚继承。

继承和组合

public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。

组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。

继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称
为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的
内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很
大的影响。派生类和基类间的依赖关系很强,耦合度高。

对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象
来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复
用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被
封装。

实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有
些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用
继承,可以用组合,就用组合。

总结和反思

c++的继承内容多而复杂,是c++设计相对失败的一块,有很多意义不明的语法,还有多继承以及为了填多继承的坑而引出的虚继承,笔者写这篇文章费了很大的劲,相信很多读者读着也费劲,而且最后实际用到也就是很小一块,但是不可否认笔者在写这篇文章的过程中也收获了很多,我也希望读者们可以收获一些东西。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词