目录
引言:
面向对象最经典的三大特性就是封装,继承,多态
封装在前面的文章中我们已经体会过了(比如类呀,迭代器呀等等),这篇文章我来讲讲新的特性——继承
本文完整梳理 C++ 继承全部核心知识点:继承访问权限、派生类默认成员函数、赋值兼容转换(对象切片)、成员隐藏、类模板依赖基类坑、多继承、菱形继承与虚继承、多继承指针偏移、继承与组合 is-a/has-a 辨析,附带大量可运行代码与面试思考题。
那么,话不多说,直接进入正文————>

1.继承的基本概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类(子类)。继承呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们也接触过函数层次的复用(比如经常交换,就把交换的逻辑提取出来写成函数)和模板的泛型复用,继承是类设计层次的复用
我们直接来看一个例子,比如说一个校园的系统,有俩个角色——学生和老师,这个系统对应的功能是身份认证和对应职位所做行为,学生和老师的行为不一样,存的信息也不一样,权限也不一样,所以按照我们先前所学的知识,我们会做成俩个类,如下图


但是我们不难发现,这俩个类的设计其实是有点冗余的,在成员变量中就只有一个成员变量是不一样的,学生是学号,教师是职称,在成员函数方面,双方都有身份认证的成员函数。这俩个类是有很多东西都是重复的
这个时候,我们就可以把公共的信息给提取出来,这就用到了继承,下面我将公共的成员都放到Person类中,Student和teacher都继承Person,就可以复用这些成员,不需要重复定义了,大幅减少了代码冗余(继承的语法后面再细讲,,这里先看下继承的用途)
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
void identity()
{
cout << "identity" << endl;
}
protected:
string _name;
string _address;
string _tel;
int _age;
};
class Student : public Person
{
public:
void study()
{
//...
}
protected:
int _stuid;
};
class Teacher : public Person
{
public:
void teach()
{
//...
}
protected:
string title;
};
int main()
{
return 0;
}
这里的Student和Teacher就是Person的子类,虽然子类内部成员看着很少,但是父类的那些成员他都有,暂且这么理解,实际怎么样到后面具体讲了再说
2.继承定义
2.1.定义格式
前面我们所写的Person就是基类(父类),Student和Teacher就是派生类(子类)
继承的定义格式就是 class 类名 : 继承方式 类名

继承方式和访问限定符一样,分为公有,保护,私有,如下图

2.2.继承基类成员访问方式的变化
继承方式的不同会导致基类的成员在派生类中的权限不同,看下图

总结一下上图
- 基类的private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是在类外面都不能去访问它(基类里的成员函数还是可以使用基类的private成员 的,所以我们可以通过基类的成员函数来间接访问基类的private成员,如下图)
- 如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行⼀下总结会发现,基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),public> protected >private。
- 继承方式可以不写,使用关键字class创建派生类时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
- 在实际运用中⼀般使用都是public继承,几乎很少使用protected/private继承,也不提倡使用protected/private继承,因为protected / private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
3.继承类模板
3.1 使用继承实现 Stack
我们先前在STL部分实现stack的时候是采用适配器的方式实现的,现在学了继承后,我们也可以通过继承的方式来实现stack
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
#include <vector>
using namespace std;
template<class T>
class stack : public std::vector<T>
{
public:
void push(const T& x)
{
vector<T>::push_back(x);
//push_back(x);
}
void pop()
{
vector<T>::pop_back();
}
const T& top()
{
return vector<T>::back();
}
bool empty()
{
return vector<T>::empty();
}
};
int main()
{
return 0;
}
所以实现栈就可以用我们先前的那种适配器的方式(组合),也可以用继承的方式来实现,继承和组合都是可以复用的
注意:普通的类的继承在派生类的成员函数中是可以直接调用基类的成员函数的,但是类模板不可以。这个的原因就跟派生类为类模板时,基类为类模板时的类型有关系(注意,这跟基类模板的按需实例化没关系)。
3.2 派生类模板的依赖型基类与名字查找规则
派生类为类模板时,基类会分为俩类——依赖型基类,非依赖型基类
依赖型基类:基类也是类模板,且实例化会受派生类的模板参数影响,就如上面那种
template<class T>
class stack : public std::vector<T>
非依赖型基类:非依赖性基类,如下面这俩种都算
template<class T>
class stack : public std::vector<int>
template<class T>
class stack : public Person
在派生类模板内,编译器不会去依赖型基类中查找普通标识符,所以上面如果直接写push_back(x),编译器不会进入基类中寻找,这个时候有俩种方法
- 显示指定域,就是我上面用的那种方法
- this指针,this->push_back(x)这样
为什么这个跟类模板的按需实例化没关系,这很简单,我们在继承这种基类的时候(基类为依赖型基类),我们需要显式指定
template<class T>
class stack : public std::vector<T>
但是这种情况下,我们是不需要显式指定的
template<class T>
class stack : public std::vector<int>
如果是按需实例化的原因的话,下面的vector<int>理论上而言也是无法直接找到对应的基类成员函数的,可是我们是可以直接找到的
3.3 拓展:通用容器版本的栈(继承方式)
我们还可以进一步改造,实现支持多种底层容器的栈。这里的基类依旧是依赖型基类,实现效果和 STL 适配器版本的 stack 非常相似,同样支持切换底层容器。(当然,这个实现的本质和我们先前用适配器实现的stack还是有巨大区别的,一个是继承实现的,一个是组合实现的——STL中的stack是用组合实现的)
template<class T1,class T2 = vector<T1>>
class stack3 : public T2
{
public:
void push(const T1& x)
{
T2::push_back(x);
//push_back(x);
}
void pop()
{
T2::pop_back();
}
const T1& top()
{
return T2::back();
}
bool empty()
{
return T2::empty();
}
};
4.基类和派生类间的转换(赋值兼容转换)
- 在public继承的前提下,派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个形象的说法叫切片或者切割(只指赋值给基类对象)。寓意把派生类中基类的那部分切割赋值过去
- 基类对象不能赋值给派生类对象
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用 。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast来进行识别后进行安全转换。(这个在后面类型转换部分会具体讲,这里先提一下)
第一点:
派生类对象中有基类子对象,这个时候只拷贝基类子对象,派生类独有的成员被丢弃,仿佛被切掉,所以我们常称切片,如下图

注意:下面指针、引用场景不会发生切片,仅仅是访问视角受限,没有内存拷贝。
派生类对象可以给基类的指针,意思是指向派生类中基类的一部分,如下图

派生类对象可以给基类引用,意思是派生类中基类的一部分会变成引用的别名,如下图

我来演示一下这个,这个在后面会很有用
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Person
{
public:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Student sobj;
// 1.派⽣类对象可以赋值给基类的指针/引⽤
Person* pp = &sobj;
Person& rp = sobj;
// 派⽣类对象可以赋值给基类的对象是通过调⽤后⾯会讲解的基类的拷⻉构造完成的
Person pobj = sobj;
return 0;
}
注意:Person pobj = sobj;会调用基类拷贝构造函数,直接拷贝派生对象内部的基类子对象,发生对象切片。
我们来演示一下,这三种的情况
首先是指针和引用
cout << sobj._age << endl;
rp._age = 10;
cout << sobj._age << endl;
我们调用这个函数,改变引用的_age,发现改变的是派生类中的_age

然后我们再调用这个函数,可以发现指针指向的也是派生类中的基类部分
cout << pp->_age << endl;

第二点:
基类对象不能赋值给派生类对象,比如这样就不行
Person Pobj;
Student Sobj = Pobj;
你派生类赋值给基类是可以进行切片来操作,但你基类赋值给派生类,派生类特有的那些给不了。所以这是不允许的
第三点:
基类的指针或者引用可能指向基类,也可能指向派生类,那在指向派生类的前提下,将这个指针强转成对应的派生类后赋值给子类的指针或者引用是完全没问题,举个例子如下
Student* sp = (Student*)pp;
但我们无法确保这个pp一定指向派生类,所以我们可以用dynamic_cast转换(但这个要求是多态才可以),这里我就演示一下,后面会细讲
class Person
{
#if 0
新增
#endif
virtual void func()
{
}
public:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
Student* sp = dynamic_cast<Student*>(pp);
如果pp指向的不是派生类对象,就会转换不成功
Person pobj = sobj;
pp = &pobj;
Student* sp = dynamic_cast<Student*>(pp);
这个后面会细讲,这里简单了解下
这里我们最需要关注的就是第一点
5.继承中的作用域
5.1.隐藏规则
- 在继承体系中基类和派生类都有独立的作用域(基类有基类的类域,派生类有派生类的类域)。
- 派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。 (在派生类成员函数中,可以使用 基类::基类成员 显式访问。注:不能在基类的成员函数中显式访问派生类的成员函数)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
5.2.考察继承作用域小测试
下面这个代码会怎么样
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class A
{
public :
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public :
void fun(int i)
{
cout << "func(int i)" << i << endl;
}
};
int main()
{
B b;
b.fun(10);
b.fun();
return 0;
}
结果是编译报错,函数重载是在同一个作用域下的函数名相同才可能发生,但是func(int i)和func()这俩个函数不在同一个作用域,所以无法函数重载,所以在调用b.func()的时候无法找到这个函数,就报错了。
我们上面第三点也讲了这个,基类和派生类如果函数重名,就隐藏,那么,想要访问就需要显示访问,如下
b.A::fun();
6.派生类的默认成员函数
总览
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
派生类的 operator = 必须要调用基类的 operator = 完成基类的赋值。需要注意的是派生类的 operator = 隐藏了基类的 operator=,所以显式调用基类的 operator=,需要指定基类作用域
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
派生类对象初始化先调用基类构造再调用派生类构造。
派生类对象析构清理先调用派生类析构再调用基类的析构。
因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同 (这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成 destructor (),所以基类析构函数不加 virtual 的情况下,派生类析构函数和基类析构函数构成隐藏关系。
这几点我们一点点讲
1.
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
首先,我们先前所学,内置类型对于编译器自动生成的默认构造而言是不确定的,自定义类型而言会调用自定义类型的默认构造,如果此时自定义类型没有默认构造会报错,此时我们可以限定调用构造
派生类的成员分三种——基类的成员,派生类独有的内置类型,派生类独有的自定义类型
派生类独有的内置类型:对于编译器自动生成的默认构造,是不确定的
派生类独有的自定义类型:对于编译器自动生成的默认构造,会调用自定义类型的默认构造(没有会报错)
上面俩个和之前没什么区别,主要区别在这——基类的成员:看做一个整体对象,对于编译器自动生成的默认构造,调用基类的默认构造(如果基类没有默认构造,这个时候我们就需要显式定义构造函数,然后在初始化列表显式调用基类的构造)
我们通过代码来观察
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(string name = "xixi", string sex = "男")
:_name(name)
,_sex(sex)
{
}
protected:
string _name;
string _sex;
};
class Student : public Person
{
public:
void Print()
{
cout << "基类:" << _name << " " << _sex << endl
<< "派生类:" << _id << " " << _class << endl;
}
private:
int _id;
string _class;
};
void test1()
{
Student st1;
st1.Print();
}
int main()
{
test1();
return 0;
}
通过调试我们可以发现,对于基类的那一块成员,编译器默认生成的构造函数是会调用基类的构造函数的。

那么,接下来我们把基类的默认构造函数去掉,改成构造函数
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(string name, string sex = "男")
:_name(name)
, _sex(sex)
{
}
protected:
string _name;
string _sex;
};
class Student : public Person
{
public:
void Print()
{
cout << "基类:" << _name << " " << _sex << endl
<< "派生类:" << _id << " " << _class << endl;
}
private:
int _id;
string _class;
};
void test1()
{
Student st1;
st1.Print();
}
int main()
{
test1();
return 0;
}
这个时候,就编译报错了,因为编译器对派生类中基类的成员会自动调用基类的默认构造函数,但是因为我们显示构造了构造函数,默认构造就没了,找不到就报错了
这个时候我们就需要在派生类中显式实现构造函数显式调用基类的构造函数,如下
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(string name, string sex = "男")
:_name(name)
, _sex(sex)
{
}
protected:
string _name;
string _sex;
};
class Student : public Person
{
public:
Student()
:Person("xixi")
{
}
void Print()
{
cout << "基类:" << _name << " " << _sex << endl
<< "派生类:" << _id << " " << _class << endl;
}
private:
int _id;
string _class;
};
void test1()
{
Student st1;
st1.Print();
}
int main()
{
test1();
return 0;
}
这个时候就OK了

注意:基类子对象和自定义类型成员,在初始化列表构造逻辑上高度相似:都需要调用自身构造函数,缺少默认构造时都要求显式初始化; 但是二者语法、初始化优先级、析构调用规则、类作用域体系存在明显区别,只能类比理解,不能完全等价看待。
2.
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
还是和上面一样,我们分三个部分分析,派生类中特有的内置类型和自定义类型还是和正常拷贝构造一样
我们关注派生类中基类的成员,派生类中基类的成员会默认调用基类的拷贝构造函数,所以我们基类中如果存在堆相关内存管理方面的内容,最好显式定义拷贝构造函数,因为系统默认生成的拷贝构造函数是浅拷贝,如果用系统默认生成的拷贝构造函数容易引发double free等问题。
注意:如果我们显式定义了派生类拷贝构造,这个时候编译器不会自动调用基类的拷贝构造(因为拷贝构造也是构造,他就走初始化列表,如果我们不显式调用基类的拷贝构造,编译器就会调用基类的默认构造,不符合我们预期,如果基类没有默认构造,还会报错),我们需要显式调用基类的拷贝构造
我这里演示一下如果派生类写拷贝构造函数的情况下,需要怎么写
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(string name, string sex = "男")
:_name(name)
, _sex(sex)
{
}
Person(const Person& P)
{
_name = P._name;
_sex = P._sex;
}
protected:
string _name;
string _sex;
};
class Student : public Person
{
public:
Student()
:Person("xixi")
{
}
Student(const Student& S)
:Person(S)
, _id(S._id)
, _class(S._class)
{
}
void Print()
{
cout << "基类:" << _name << " " << _sex << endl
<< "派生类:" << _id << " " << _class << endl;
}
private:
int _id;
string _class;
};
void test1()
{
Student st1;
Student st2(st1);
st1.Print();
st2.Print();
}
int main()
{
test1();
return 0;
}
显式调用基类拷贝构造的时候,我们可以像上面那样,这是因为S是派生类,Person是基类,基类拷贝构造的参数是引用,派生类对象中的基类成员是可以作为基类的引用的,不需要对象切割(这就是我们上面提的赋值兼容转换)
:Person(S)
也可以这样(但这调用的是基类构造函数,不是拷贝构造)——是旧版写法,维护风险高
:Person(S._name,S._sex)
拷贝构造和赋值析构其实是一体的,当需要深拷贝的时候,这三个都要显式实现,但如果浅拷贝就足以解决问题的时候,其实派生类的拷贝构造,赋值和析构都不用我们手动实现
3.
派生类的 operator = 必须要调用基类的 operator = 完成基类的赋值。需要注意的是派生类的 operator = 隐藏了基类的 operator=,所以显式调用基类的 operator=,需要指定基类作用域
赋值跟拷贝构造的行为就基本类似了,如果编译器自动合成派生类operator=,会自动调用基类operator=;一旦我们手动显式实现派生类赋值重载,编译器不会自动调用基类赋值重载,必须手动调用。但有一处本质区别:拷贝构造依靠初始化列表,赋值重载是函数体内调用,不存在初始化列表参与
这里需要注意的是,如果我们显式实现了赋值运算符重载,就会让基类的赋值运算符重载隐藏,这个时候我们想要调用基类的赋值运算符重载就要指定基类作用域
我这里就演示一下显式实现赋值运算符重载
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(string name, string sex = "男")
:_name(name)
, _sex(sex)
{
}
Person(const Person& P)
{
_name = P._name;
_sex = P._sex;
}
Person& operator=(const Person& P)
{
_name = P._name;
_sex = P._sex;
return *this;
}
protected:
string _name;
string _sex;
};
class Student : public Person
{
public:
Student()
:Person("xixi")
{
}
Student(const Student& S)
:Person(S)
, _id(S._id)
, _class(S._class)
{
}
Student& operator=(const Student& S)
{
Person::operator=(S);
_id = S._id;
_class = S._class;
return *this;
}
void Print()
{
cout << "基类:" << _name << " " << _sex << endl
<< "派生类:" << _id << " " << _class << endl;
}
private:
int _id;
string _class;
};
void test1()
{
Student st1;
Student st2;
st2 = st1;
st1.Print();
st2.Print();
}
int main()
{
test1();
return 0;
}
4.
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
没有堆内存问题也不需要我们自己显式实现这个东西
基类析构没有 virtual 时,子类的析构跟父类的析构也构成隐藏关系,这个在第7点说明了,这跟多态有关系,现在先不讲,知道就好哦。
但需要注意的是:
不管我们是显式写析构还是编译器默认生成的析构,都会调用基类的析构。所以我们显式写析构的情况下也不要主动调用基类的析构函数,这样会造成基类对象的double free
~Student()
{
}
5.
派生类对象初始化先调用基类构造再调用派生类构造。
这原因从逻辑层面理解的话就是派生类成员函数可能在构造函数体内访问基类成员,必须保证基类成员先初始化就绪。
6.
派生类对象析构清理先调用派生类析构再调用基类的析构。
这个可以从俩个方面理解
一方面:后构造的先析构
一方面:派生类的成员在析构过程中可能会访问基类中的成员或依赖基类子对象的数据,如果基类先析构了,派生类中成员析构就会出问题了
7.
因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同 (这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成 destructor (),所以基类析构函数不加 virtual 的情况下,派生类析构函数和基类析构函数构成隐藏关系。
总结:

派生类的构造析构顺序就是如上图,下面总结下基类在各默认成员函数下的情况
构造:
编译器自己生成的——基类成员自动调用基类的默认构造,没有默认构造报错
显式实现的——我们不限时调用的情况下,基类成员会自动调用基类的默认构造,没有默认构造报错,这个时候我们可以在初始化列表显式调用基类构造函数
拷贝构造:
编译器自己生成的——基类成员自动调用基类的拷贝构造
显式实现的——基类成员调用拷贝构造需要我们显式调用,当派生类中特有成员有堆内存涉及需要深拷贝时,就需要在派生类中显式实现拷贝构造
赋值运算符重载:
编译器自己生成的——基类成员自动调用基类的赋值运算符重载
显式实现的——基类成员调用赋值运算符重载需要我们显式调用,显式调用时候需要指定基类作用域,当派生类中特有成员有堆内存涉及,就需要在派生类中显式实现赋值运算符重载
析构:
编译器自己生成的——会自动调用基类析构函数
显式实现的——会自动调用基类析构函数,不要显式调用基类析构函数
7.实现不能被继承的类
方法 1:基类的构造函数私有,派生类的构造必须调用基类的构造函数,但是基类的构造函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。(这种方法不明显,如果我们不实例化派生类对象,编译不报错)
方法 2:C++11 新增了一个 final 关键字,final 修改基类,派生类就不能继承了(final代表最终类)
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
// C++11的⽅法
class Base final
{
public :
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
private:
// C++98的⽅法
/*Base()
{}*/
};
class Derive :public Base
{
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
8.继承与友元
友元关系不能被继承,也就是说基类的友元不能访问派生类私有和保护成员
我举个正确使用的例子,下面的Display需要基类也需要派生类,所以基类和派生类都需要友元,然后基类中的友元函数因为那个时候Student派生类还没声明,所以我们需要前置声明下,不然会找不到
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Student;//前置声明
class Person
{
public :
friend void Display(const Person& p, const Student& s);
protected:
string _name = "wao"; // 姓名
};
class Student : public Person
{
public:
friend void Display(const Person& p, const Student& s);
protected :
int _stuNum = 10; // 学号
};
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;
}
9.继承与静态成员
基类定义了static静态成员,则整个集成体系里面只有一个这样的成员,无论派生出多少个派生类。都只有一个static成员实例
举个例子如下代码
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum;
};
int main()
{
Person p;
Student s;
// 这⾥的运⾏结果可以看到⾮静态成员_name的地址是不⼀样的
// 说明派⽣类继承下来了,⽗派⽣类对象各有⼀份
cout << &p._name << endl;
cout << &s._name << endl;
// 这⾥的运⾏结果可以看到静态成员_count的地址是⼀样的
// 说明派⽣类和基类共⽤同⼀份静态成员
cout << &p._count << endl;
cout << &s._count << endl;
// 公有的情况下,⽗派⽣类指定类域都可以访问静态成员
cout << Person::_count << endl;
cout << Student::_count << endl;
Person::_count++;
cout << Person::_count << endl;
cout << Student::_count << endl;
return 0;
}

10.多继承及其菱形继承问题
10.1.继承模型
单继承:一个派生类只有一个直接基类时称这个继承关系为单继承
多继承:一个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员放到最后面。
菱形继承:菱形继承是多继承的一种特殊情况。菱形继承的问题,从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在 Assistant 的对象中 Person 成员会有两份。支持多继承就一定会有菱形继承,像 Java 就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。拓展:解决菱形继承方案:虚继承 virtual public,让公共祖先 Person 只保留一份,消除冗余与二义性。
单继承如下图

多继承如下图

这个其实可以想象成这样
菱形继承如下,这就是多继承的特殊情况了,如果设计成了这种情况就会很冗余,还会产生二义性的问题

二义性产生的原因就是父类有多个同样的成员,此时访问就不明确,我具体举个代码例子
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
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; // 主修课程
};
int main()
{
// 编译报错:error C2385: 对“_name”的访问不明确
Assistant a;
a._name = "peter";
// 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
return 0;
}
那现实里有没有人设计菱形继承呢
有的,我们经常提的IO流就是个菱形继承,如下图(红框这些其实都是菱形继承)

10.2.虚继承
这个主要就是为了解决菱形继承的坑而设计的
很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂,性能也会有一些损失(虚继承会引入虚基表,带来少量内存开销与寻址开销),所以最好不要设计出菱形继承。多继承可以认为是 C++ 的缺陷之一,后来的一些编程语言都没有多继承,如 Java
虚继承引入了一个新的关键字——virtual
虚继承是在可能引发虚继承的某次单继承进行虚继承,而不是在多继承的时候进行虚继承,这个通过看代码样例你们就能理解了,这个时候不仅可以直接访问,也可以通过基类作用域访问,并且 多个基类作用域指的基类成员是同一个。用了虚继承后,数据冗余和二义性就都解决了(前提是虚继承别加少了,就比如下面student和Teacher如果只加一个虚函数,另一个不加,还是会出问题)
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
string _name;
int _age;
};
// 使⽤虚继承Person类
class Student : virtual public Person
{
protected:
int _num; //学号
};
// 使⽤虚继承Person类
class Teacher : virtual public Person
{
protected:
int _id; // 职⼯编号
};
// 教授助理
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
int main()
{
// 使⽤虚继承,可以解决数据冗余和⼆义性
Assistant a;
a._name = "peter";
return 0;
}
库里面的iostream虽然是菱形继承,但也用了虚继承解决数据冗余和二义性的问题
下图的E也算虚继承,因为D和C有共有祖先,这个其实就可以想象成算法里的那个最近公共祖先的感觉,只要会产生数据冗余和二义性的基本都算菱形继承

那么,这种情况下虚继承加法如下图,虚继承是为了避免数据冗余和二义性。那么,会造成E数据冗余的起始是在哪,是不是在B和C,所以在B和C位置加虚继承就好了

总结:哪个类(公共基类)产生数据冗余和二义性,派生类继承时用虚继承(但是不要乱加,影响效率还花费内存的,就比如下面这种也是个错误用法,等构成菱形继承了再加也不迟,其实加虚继承就是找最近公共祖先的逻辑)

注意:我们可以设计出多继承,但是不建议设计出菱形继承,因为菱形虚拟继承以后,⽆论是使⽤还是底层都会复杂很多。这里给你们举个神奇例子
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(const char* name)
: _name(name)
{}
string _name; // 姓名
};
class Student : virtual public Person
{
public:
Student(const char* name, int num)
: Person(name)
, _num(num)
{}
protected:
int _num; //学号
};
class Teacher : virtual public Person
{
public:
Teacher(const char* name, int id)
: Person(name)
, _id(id)
{}
protected:
int _id; // 职⼯编号
};
// 不要去玩菱形继承
class Assistant : public Student, public Teacher
{
public:
Assistant(const char* name1, const char* name2, const char* name3)
: Person(name3)
, Student(name1, 1)
, Teacher(name2, 2)
{}
protected:
string _majorCourse; // 主修课程
};
int main()
{
// 思考⼀下这⾥a对象中_name是"张三", "李四", "王五"中的哪⼀个?
Assistant a("张三", "李四", "王五");
return 0;
}
10.3.多继承中指针偏移问题
我来演示下多继承中指针偏移问题,如下
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class A
{
public:
int _a;
};
class B
{
public:
int _b;
};
class C : public A,public B
{
public:
int _c;
};
int main()
{
C c;
A* pa = &c;
B* pb = &c;
C* pc = &c;
cout << pa << endl << pb << endl << pc << endl;
return 0;
}
此时pa,pb,pc的关系是什么
结论是pa==pc!=pb

10.4.IO库中的菱形虚拟继承
继承体系如下图

源码如下,从源码我们就能看出用了虚继承

11.继承和组合
- public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
- 组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语 “白箱” 是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse),因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承 (is-a) 那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承 (is-a) 也适合组合 (has-a),就用组合

如上图就是继承和组合的关系,所以我们该篇通过继承实现stack就是继承的方式实现,stack篇中通过适配器实现就是组合的方式,stack底层就是用的组合的方式实现的。is-a可以理解为stack是特殊的list,has-a可以理解为stack里有list
我们设计程序的时候要尽量保持高内聚低耦合
高内聚——模快里面关联度越高越好
低耦合——模块与模块之间关联度越低越好



结语:
那么,C++继承部分的内容就全部讲解完毕啦,希望以上内容对你有所帮助,感谢观看,若觉得写的还可以,可以分享给朋友一起来看哦,毕竟一起进步更有动力嘛,当然能关注一下就更好啦。


&spm=1001.2101.3001.5002&articleId=163353429&d=1&t=3&u=6bd9f79a91744161b3bde47d6a7dc112)
298

被折叠的 条评论
为什么被折叠?



