c++继承详解
目录
继承的概念及定义
继承是c++面向对象编程的三大特性之一 继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段 它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产⽣新的类,称为派生类。
为什么要有继承 继承的意义
假如我们需要实现以下两个类 一个Student类 一个Teacher类 两者都有很多公共的成员变量和成员函数 如果每一个都需要重复写这么多的话就会很麻烦


而继承就可以解决这样的问题
我们可以写一个Person类 如下 包含着学生和老师都有的成员变量和成员函数

然后如下的方式 student和teacher采用继承的方式继承person类后 两个类里面相当于就有了一份person类的成员变量和成员函数 然后在各自的类里面加上自己特有的 这就和上面方式实现的一模一样

我们发现 在student和teacher后面都加了:public person 这里public是什么意思呢
而且可以发现类中的成员变量用到的是protected没有用private 而之前我们都是用的都是private 且之前我们认为protected和private一样都是代表着私有 在类外不能访问 他们的区别在下面的学习也会了解到
继承的定义
定义格式
如下 我们看到Person是基类,也称作父类。Student是派生类,也称作子类。(因为翻译的原因 所以既叫基类/派生类,也叫父类/子类

继承基类成员访问方式的变化
之前我们学到的访问限定符有三种 public private protected 这里的继承方式也是这三种 继承方式是为了确定子类继承的父类的成员变量及成员函数在子类中的访问权限

上图的总结
1.在基类中private的成员不管用什么方式来继承 在子类中都不可见(在子类中会有这个private的成员 但是不管类内类外都不能访问) (也就是最后一行)
2.使用public继承的方式 在基类中原来是什么访问权限 在子类中就是什么访问权限 (也就是第一列)
3.那么对于剩下的四个 protected和private访问限定符内的成员继承 继承方式是什么 在子类中的成员就是什么访问权限(除了第一列public继承)
对于上述三点其实可以直接用min(访问限定符,继承方式) 即子类继承到的父类成员在子类中的访问权限为在父类中访问限定符和继承的方式更小的来记忆 public>protected>private
4.如果我们没有显示写继承的方式的话 对于class默认的继承方式是private 使⽤struct时默认的继承⽅式是public 不过最好自己显示写出来
其实只有public的继承方式才常用(基类中是什么访问权限 子类中就是什么权限) 基于这一点基类中也很少设立private的访问权限 否则只有基础类里面能访问
所以用到继承时候基类里面的访问权限一般就是public和protected 而子类的继承方式一般就是public继承
如下 父类A中private作用域内的成员变量是不可在子类B中访问的

如果我们想在子类中访问父类中private的成员变量的话 我们可以在父类中实现访问父类private作用域里成员的函数 并把这样的成员函数设为public或者protected的 子类在继承到后就可以用这个函数来访问到父类内私有的成员变量了 如下图的例子

继承类模版
之前我们实现stack用到的是适配器的方式 也就是组合的方式 这里我们用继承的方式来实现 stack(组合在本文最后再说)
namespace xx
{
template<class T>
class stack : public std::vector<T>
{
public:
void push(const T& x)
{
push_back(x);
}
void pop()
{
pop_back();
}
const T& top()
{
return back();
}
bool empty()
{
return empty();
}
};
}
int main()
{
xx::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
while (!st.empty())
{
cout << st.top() << " ";
st.pop();
}
return 0;
}

但是会发现报了上面的错误 告诉我们找不到这些函数
这样的问题就是因为类模版继承的问题
在我们创建stack对象的时候 用int来实例化模版 在把stack实例化的时候会把他的父类vector给实例化 但是并没有把vector里面的成员函数给实例化了(因为模版是按需实例化的) 此时在子类stack中调用vector里面函数的时候 里面的函数因为没有实例化找不到,并且如empty会和父类模版名字冲突。
所以 在调用父类里面的成员函数时候需要用父类作用域来指定要访问的成员函数

此外 也可以用下面这种方式来访问

这里的this是student*类型的 this->push_back告诉编译器 push_back是当前对象的成员(可能是基类的依赖名称)
根据 C++ 标准,在派生类模板中:
this 会隐式注入基类的名称(包括成员变量和成员函数)因此,this->push_back 会被解析为 vector<T>::push_back(如果 push_back 是 vector<T> 的成员)。
普通继承和继承类模版的区别
之前的普通继承 父类里面的成员相当于是工具 在子类继承之后就可以直接使用
但是这里的继承类模版 父类的成员相当于被封装起来了 子类继承到的相当于是一个工具箱 子类使用父类里面的成员时候需要用到父类域作用符来指定父类里面成员函数的方式来访问
基类和派生类间的转换
1.public继承的派生类对象可以赋值给基类的对象/指针/基类的引⽤。这⾥有个形象的说法叫切片或者切割 寓意把派生类中基类那部分切出来
2.基类对象不能赋值给派生类对象。
3.基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须让基类的指针指向派生类对象时才是安全的。可以用到dynamic_cast 来进行识别后进行安全转换。(这里简单了解 之后类型转换再详细了解)
第1点的例子
如下图

派生类student类型的s1把它的引用给了基类person类型的p1 (此时p1里面只有父类的部分 派生类s1里面继承到的父类部分的引用给了s1)
如下 父类的对象/指针/引用都可以分别指向子类的对象/指针/引用
父类对象指向子类对象的时候会发生切片,只保留父类部分的。
下面对于2 3点的例子
子类的对象绝不可以存父类的对象 通过强转也不行
而通过强转的方式可以让指向父类的父类类型的指针或者是指向子类的父类类型的指针转换为子类类型的指针 引用也是

但是通过标准的dynamic_cast来检验是否合法再转换发现
只有指向子类的父类类型的指针才可以转换为子类类型的指针 指向父类的父类类型的指向不能转换为子类类型的指针

继承中的作用域
隐藏规则
1.子类继承了父类的成员后 在子类中继承到父类的和派⽣类他们都有独立的作用域。
2. 派生类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。 (在派生类成员函数中,可以使用基类::基类成员显示访问)
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。
如下 还拿person类和student来举例
在父类中我们有一个成员变量-name默认初始值为火火
在子类student类继承父类后子类里面就有了这个名为火火的_name 但是在子类中还额外写了一个_name 此时在子类student类中就有两个_name类了

那么此时在子类中写了个打印_name的函数 默认优先打印的就是子类中的_name 如果想要打印继承到父类里的_name 我们就需要用到父类的访问限定符来打印继承到父类的_name

上面是对于成员变量重名的问题 那么对于成员函数重名的问题呢
接下来通过下面两个选择题就可以明白成员函数重名的问题

对于第一个题目
这里的答案是B
首先重载肯定是不对的 因为我们对重载的定义是在同一作用域内同名函数参数的个数 类型 顺序有一个不同就构成重载 而根据第一点子类继承的父类的内容和子类本身的内容其实都不在一个作用域中 所以一定不是重载 在这里这两个fun构成隐藏的关系
那么对应第二个题目呢
这里的答案是A
虽然两个函数的参数个数不同 但是子类本身的函数名字和继承的父类的函数名字同名了
只要子类里面的函数名和继承到父类的函数名相同了 那么继承到父类的这个函数就被隐藏了 无法直接使用 所以在调用fun() 时候找不到这个函数 就会编译报错
那么如果我们想访问继承到父类的成员函数呢 我们可以用父类域作用符来访问 如下

而且这种方式对于父类和子类同名函数里面参数个数类型顺序都相同的时候也是可以正常进行的

派生类的默认成员函数
我们知道类中默认成员函数有六个 默认的意思就是指我们不写编译器会变我们自动生成⼀个 我们主要需要注意的就是四个 构造函数 析构函数 拷贝构造函数 赋值重载函数
那么在派生类中,这几个成员函数是如何生成的呢? 又会有哪些问题呢 ?
先看一下构造函数的问题
在之前类和对象补充初始化列表那里我们详细了解了对于各种类型初始化的问题 总结如下
一个成员变量的初始化会先看在初始化列表中有没有显示写这个成员变量的初始化列表
1.如果写了(正常情况括号里就是对应形参) 该成员变量初始化值就是它
2.如果初始化列表中没有的话就会看声明时候有没有缺省值 有的话 初始化值就是它 没有的话再根据它的类型是内置类型还是自定义类型判断
①如果是内置类型 初始化值取决于编译器 可能随机值 可能0
②如果是自定义类型调用它的默认构造函数有的话就对应的初始化 没有的话编译报错
基于以上 对于内置类型我们可以在声明处给缺省值 这样我们不写构造函数 创建对象的时候这个对象内置类型的值就是声明处给的缺省值 但是一般对于内置类型都是需要我们在创建对象的时候显示传的
而自定义类型我们不写构造函数 就会先看在声明处有没有写缺省值 有的话就会用这个缺省值调用它的构造函数对它完成构造 如果没有写缺省值 就会调用这个自定义类型对象的默认构造函数 如果没有写默认构造就会报错
所以对于正常类的构造函数 因为要满足可以显示指定内置类型值得功能 需要我们写构造函数
上面是对之前知识的回顾 而在这里派生类里面除了需要对内置类型和自定义类型做处理外 我们还需要对继承的父类进行处理
这里需要把父类的内容当成了一个整体来处理 初始化继承的父类会调用父类的构造函数 如果父类有 无参构造函数的话 那么和自定义类型一样会直接调用它的无参构造函数
就像下面这之前我们写的一样 在父类里面没有写构造函数 都是之前都是可以正常进行的
对于父类 编译器会自动生成默认的构造函数 在子类中也没有构造函数 编译器也会自动生成默认的构造函数 对于子类里面的内置类型初始化为0或随机值(取决于编译器) 对于自定义类型会自动调用它的默认构造函数 而对于继承到的父类会自动调用父类里面默认的构造函数 正常情况就是满足我们需求的

但是当父类没有默认构造函数的话就出问题了 此时需要在子类构造函数初始化列表那里显示调用父类的构造函数
如下 父类写了一个带参构造函数就出问题了

如下 需要在子类构造函数初始化列表中显示调用 把父类当成一个整体 用类似匿名对象方式显示调用

拷贝构造的问题
子类的拷贝构造对于子类的成员变量的内置类型和有默认构造的自定义类型都可以正常进行 对于父类的处理也是会自动调用父类的拷贝构造 一般默认的就可以正常
如果父类里面有需要深拷贝的问题 那是父类自己拷贝构造要处理的 不关子类的什么事 而对与子类里面如果需要进行深拷贝(如里面有指针) 才需要自己实现
所以派生类里面的拷贝构造和正常类的拷贝构造没什么区别 默认生成的就够用了 如果有需要深拷贝的资源 才需要自己实现
另外一点 子类的构造函数里面一定是先调用父类的构造函数对继承的父类部分进行构造 再对子类自己部分进行构造
而对于赋值重载呢


如上我们在子类中写了赋值重载对父类部分显示调用父类的赋值重载
但是发现赋值重载这里出问题了 发生了无限递归栈溢出的问题
这里因为 子类的operator=和继承父类的operator=两个函数重名了 构成了隐藏关系 所以在子类operator=里面调用的operator=其实是调用它自己的 就会造成无限递归
所以 我们需要在子类里面调用父类的operator=的时候显示指定

最后说一下析构函数的问题
首先析构其实和拷贝一样基本不用自己写 默认的一般都会满足
对于自己的内置类型不需要处理自定义类型会调用它自己的 对于父类的会调用父类自己的 而只有当子类成员变量里面有动态申请空间的指针时候 才需要手动去写析构去释放
如下 对于子类调用父类的析构时候会报错 显示我们找不到~Person 这是为什么呢
之前的赋值这样是因为他们的函数名确实是相同的 但是这里的析构子类和父类析构的函数名字又不相同



这是因为 编译器会对析构函数名进⾏特殊处理,处理成destructor(),所以派生类析构函数和基类析构函数构成隐藏关系。(这样的设计是为了支持多态部分虚函数的方式使用)
解决了上面问题后 还有以下的问题
如果按照上面子类析构函数中写的 按照我们的预期正常情况下 应该是先析构s1子类自己成员变量 再析构s1继承的父类部分 然后析构s2子类自己的成员变量 再析构继承的父类部分 应该是调用一次子类一次父类一次子类一次父类 但是下面的结果显示是一次子类两次父类一次子类两次父类

而当子类析构里面没有写父类析构时候 子类和父类析构的次数就是正常的

这是因为在子类的析构函数结束之后会自动调用父类的析构函数把子类继承到的父类的部分给析构掉
这样设计能确保一定是先析构子类里面的内容再析构父类的内容
实现一个不能被继承的类
如果我们想让一个类不能被其他的类所继承 我们有以下的两种方法
1.让基类的构造函数为private 派生类的构造函数必须调用基类的构造函数对继承的部分进行初始化 在把基类的构造函数私有化以后 派生类就调用不了基类的构造函数了 就无法通过子类来实例化对象 这是c++98的方式 这种方法还是可以让基类被继承 只不过子类无法创建对象
2. C++11新增了⼀个final关键字 final修改基类 派⽣类就不能继承了 使用方法就是在父类名后面加final这个关键字 这样子类如果继承这个父类就会直接报错
继承与友元及静态成员的关系
1.友元
友元关系不能继承 也就是说一个函数和基类友元可以访问基类里面私有的 但不能访问派生类私有和保护成员 如果要想要访问派生类的 只需在子类中也声明为该函数的友元
2.静态成员
基类定义了static静态成员 则整个继承体系里面只有⼀个这样的成员。无论派生出多少个派⽣类,都只有⼀个static成员 继承体系里的公用这个static成员
多继承及其菱形继承问题
继承模型
单继承:⼀个派生类只有⼀个直接基类时称这个继承关系为单继承
多继承:⼀个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是 先继承的基类在前面 后面继承的基类在后面 派生类成员在放到最后面
菱形继承:菱形继承是多继承的⼀种特殊情况 菱形继承有数据冗余和二义性的问题
可以结合下图来理解

这里主要就是要注意菱形继承数据冗余及二义性的问题
就如上面菱形继承的图 家养动物和猫科动物的类都继承animal 这两个类里面都有一份animal的成员 宠物猫继承家养动物和猫科动物的时候 宠物猫里面就有两份animal的成员了 这就造成了数据冗余的问题
而二义性问题是 宠物猫里面有两份animal的成员 一份来自家养动物 而在家养动物里面可以对animal的成员做处理比如让一个成员变量_name=“黑黑” 而在猫科动物中让来自animal的成员变量_name=“白白”了 那么此时宠物猫继承两者后 宠物猫里面的_name 是“黑黑”还是“白白”我们就不知道了 就造成了二义性的问题
菱形继承是多继承的特殊情况 菱形继承可能会出问题 所以Java就直接不支持多继承 我们在平时也尽量不要使用菱形继承
虚继承
虚继承就是为了解决菱形继承的问题 要在产生歧义的根源处 让它的子类继承它的时候为虚继承
如下 就是要在家养动物和猫科动物继承animal时用到虚继承

使用方式就是在继承方式前面加上关键字virtual 如下
像上面这样实现后 对于DomesticAnimal和Feline就公用一份来自他们父类animal里面的成员 这样在PetCat里面就只会有一份animal的成员
所以 我们可以设计出多继承,但是不建议设计出菱形继承,因为菱形虚拟继承以后,无论是使用还是底层 都会复杂很多。
多继承中指针偏移问题?下面说法正确的是

这里的结果是是C
这里就是上面多继承介绍里面关于内存划分的知识
如下图 Derive先继承的Base1 后继承的Base2 而继承的内容会先存到里面 然后向下才会存自己的

继承和组合
1.public继承是⼀种is-a的关系。也就是说每个派生类对象都是⼀个基类对象。
2.组合是⼀种has-a的关系。假设B组合了A,每个B对象中都有⼀个A对象。
3.继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复⽤ (white-box reuse)。在继承方式中,基类的内部细节对派生类可见。继承⼀定程度破坏了基类的封装,基类的改变,对派生类有的影响。派生类和基类间的依 赖关系很强,耦合度高。
4.对象组合是类继承之外的另⼀种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对 象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-boxreuse), 因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
5.优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太 那么绝对,类之间的关系就适合继承(is-a)那就⽤继承,另外要实现多态,也必须要继承。类之间的 关系既适合⽤继承(is-a)也适合组合(has-a),就⽤组合。
耦合度(Coupling) 是衡量模块(类、函数、组件等)之间相互依赖程度的指标。耦合度越低,模块越独立,修改一个模块时对其他模块的影响越小,系统更易于维护和扩展。

更多推荐
所有评论(0)