关于C++的继承大致总结
继承简介: 面向对象的三大特性之一,是一种允许新创建的类(派生类)接收并使用一个或多个现有类(基类)的属性和方法的机制。这意味着派生类继承了基类的特性,并能够扩展新的功能或重写继承来的方法。
继承方式
无论是何种继承都无法继承(不可见)父类的private成员,即本质都是继承下来了,但私有的基类成员在语法上都无法被访问
- public继承(大部分情况都使用该种继承方式)
父类的所有对应的访问限定符都对应继承下来 - protected继承
所有的访问限定符都以protected的限定方式继承下来 - private继承
所有的访问限定符都以private的限定方式继承下来
tips:基类的成员在子类的访问权限=Min(成员在基类的访问限定符,继承方式)
继承方式默认是私有(可以不添加继承方式的语法进行使用)
关于父类(基类)与子类(派生类)的细节
作用域
- 父类与子类是不同的作用域,彼此间互相独立
- 父类与子类具有同名的成员时,父类的成员会被子类隐藏,默认访问时访问的是子类的成员
需要访问父类的成员时则显式(添加类域)进行访问
tips1:子类和父类最好不要定义同名的成员
tips2:子类和父类有同名函数时,不会构成重载,只要函数名相同即构成隐藏
- 函数重载要求:
- 1.在同一作用域下
- 2.参数个数不同/参数类型不同/不同类型的参数的顺序不同
ps:不能根据返回值不同而区别重载
- 类中的成员变量和成员函数存储分离
- 成员变量存在类中,占类的大小
- 成员函数存储在公共代码区(代码段)中,不占类的大小
基类与派生类的对象赋值兼容转换
- 子类对象可以赋值给父类的对象/指针/引用,不属于类型转换
- 基类与派生类之间的对象赋值时会产生“切片”(切割)
即把派生类中的父类那一部分切来赋值过去,指针或引用时即指向或者引用派生类中父类的那一部分 - 基类对象不能赋值给派生类对象
关于父、子类的构造与赋值
子类调用子类的构造、拷贝构造、析构,父类必须调用父类的构造、拷贝构造与析构
-
子类默认生成的构造函数的行为
-
自己的成员→调用自身的构造函数初始化(内置类型不处理,自定义类型调用自定义类型的构造)
-
继承的父类的成员→必须调用父类的构造函数进行初始化
-
编译器生成的拷贝构造函数的行为
-
自己的成员,内置类型进行值拷贝,自定义类型调用自定义类型的拷贝构造
-
继承的父类成员,必须调用父类的拷贝构造初始化
tips:编译器生成的operator=的行为(同上)
如果需要自定义构造、拷贝构造、赋值等则需要显式的去调用父类的对应函数
-
自定义构造或拷贝构造函数时,如果父类未提供对应的默认构造时,则需要显式去调用
-
调用赋值时,由于子类与父类的对应重载函数彼此间构成隐藏关系,所以需要显式调用父类的
如下实例(Person为父类,Student为子类)Student(const char* name, int num) :Person(name)//用**类似**匿名对象的方式,显式调用父类的构造函数 , _num(num) { cout << "Student()" << endl; } Student(const Student& s) //它会自动将s里的父类的部分"切割"进行拷贝构造父类对象 :Person(s)//用**类似**匿名对象的方式,显式调用父类的拷贝构造函数 , _num(s._num) { cout << "Student(const Student& s)" << endl; } Student& operator=(const Student& s) { if (this != &s) { Person::operator=(s);//显式调用父类的赋值重载 _num = s._num; } cout << "Student& operator=(const Student& s)" << endl; return *this; }
-
编译器生成的析构函数的作用(同上)
自己调用自己的析构,父类对象去调用父类的析构- 由于多态的需要,析构函数的名字会被统一处理成destructor()
所以析构函数也会与父类构成隐藏 - 由于语法与编译器要求,构造时需要先构造父类,再构成子类,析构则需要保证先析构子类,再析构父类,所以自定义析构函数时不需要显式调用父类的析构,编译器会在析构完子类后自动调用父类的析构
- 由于多态的需要,析构函数的名字会被统一处理成destructor()
一些tips
- 友元关系不能被继承(父亲的朋友不一定是你的朋友)
需要在子类使用友元关系需要在子类单独再声明友元 - 一个继承体系内,对应的静态成员在所有的子类内只会有一个,与父类共享
- 如何实现一个无法被继承的类
- C++98:将构造函数声明为私有
间接实现:无法调用父类的私有成员去实例化子类对象 - C++11:final关键字修饰类(最终类)
直接实现:final修饰后则该类无法被继承(编译时都会报错)
菱形继承
日常代码内尽可能不要使用菱形继承
什么是菱形继承
-
单继承:每个子类只有一个父类
-
多继承:每个子类有多个父类
-
造成菱形继承问题:两个派生类继承于基类,这两个派生类又被另一个派生类继承
- 数据冗余(基类内的同一份数据会在发生了菱形继承的子类中会存在多份)
- 会产生二义性(当发生菱形继承的子类想要访问其继承的类的数据时不知道是访问哪一个类内的数据)
-
解决菱形继承:使用虚继承解决(virtual修饰被基类继承的派生类)
class Entity : virtual public Base{...};
-
菱形继承与虚继承的内存模型
通过内存模型可以了解虚继承是如何解决菱形继承的问题
-
菱形继承时,基类会同时存储在派生类中(即在两个派生类内都存储有基类),再由两个派生类继承给其子类
(两个红色框所框起来的即为基类,每个派生类中都有一份) -
虚继承时,基类会存储在两个派生类之外,派生类内仅存一个指针,即虚基表指针(指针内主要存储了一个地址,指向了一个虚基表,该地址上的数据保存了离派生类内的基类对象所在的地址的偏移量)
此时继承这两个派生类的子类就只会拥有一份基类数据,与两个派生类是共享的(修改会同步发生)- 当使用子类对象初始化父类指针或引用时,此时则会使用到偏移量指针寻址找到父类对象的那一部分(即进行”切片”操作)
- 进行了虚继承时,位于中间的两个派生类的存储模型也会变成与菱形继承的子类相同,都是通过偏移量指针寻址访问父类对象
- 原因一:存储模型不一样时,当派生类指针进行函数传参时,编译器不知道该指针会指向其子类还是指向本身这个类型,用该指针访问则会出现问题
- 原因二:由于无法区分指针指向的是什么类型,只有存储模型相同时,才能都通过相同的方式访问基类(汇编代码大体相同)
- 所以派生类的大小不仅是父类成员的大小加上其派生类成员的大小,还会多出一个指针(4字节)指向父类成员
测试代码
此处为虚继承的代码,菱形继承即去掉virtual关键字
// 虚继承
#include <iostream>
class A {
public:
int a = 1;
};
class B : virtual public A {
public:
int b = 2;
};
class C : virtual public A {
public:
int c = 3;
};
class D : public B, public C {
public:
int d = 4;
};
int main()
{
A a;
B b;
C c;
D d;
return 0;
}
继承与组合
-
public继承下,基类与派生类为”is-a“关系,即每个派生类对象”is-a“基类对象
-
组合,即是一种“has-a”关系,即某个类内包含了另一个类对象(为该类的成员)
假设B组合了A,即每个B中都有一个A对象,与继承的区别主要为访问限定符会受到一定限制class A{}; class B{protected:A _a};//B中有A类型对象
tips:当一些类型的关系既符合“is-a”又符合“has-a”时,优先使用对象组合,而不是类继承
-
通过继承复用代码被称为”白盒复用“(white-box reuse)
继承时,基类的内部细节基本对子类可用,派生类与子类的依赖关系很强,耦合度高 -
通过组合复用代码被称为“黑盒复用”
组合的对象一般都是提供封装好的接口,对象的内部细节是不可见的,对象与组合类之间的耦合度低,依赖关系较弱 -
在实际开发中,要讲究低耦合,高内聚,在实际开发时,尽量多使用组合,组合的耦合度低,代码维护性好。
但使用多态时也必须使用继承,所以有些类之间的关系适合使用继承时则使用继承,可以用组合就尽量用组合