【C++进阶】C++ 继承家族史,也就那么回事儿

【C++进阶】C++ 继承家族史,也就那么回事儿

目录

  • 💥1、继承
    • 💥1.1 继承的概念
    • 💥1.2 继承的定义
  • 💥2、父类和子类对象赋值兼容转换
  • 💥3、继承中的作用域
  • 💥4、子类的默认成员函数
    • 💥4.1 四个常见默认成员函数
    • 💥4.2 实现一个不能被继承的类
  • 💥5、继承与友元
  • 💥6、继承与静态成员
    • 派生类都继承了基类哪些东西?哪些没继承?
      • 会被继承,但有访问限制
      • 不会被继承
  • 💥7、多继承及菱形继承问题
    • 💥7.1 继承模型
    • 💥7.2 菱形继承解决方法
      • 7.2.1 虚继承
      • 7.2.2 接口继承 + 组合实现
    • 💥7.3 多继承中指针偏移问题
  • 💥8、继承和组合

💥1、继承

💥1.1 继承的概念

继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称子类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用

classStudent{public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证voididentity(){// ...}// 学习voidstudy(){// ...}protected:string _name="peter";// 姓名string _address;// 地址string _tel;// 电话int_age=18;// 年龄int_stuid;// 学号};classTeacher{public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证voididentity(){// ...}// 授课voidteaching(){//...}protected:string _name="张三";// 姓名int_age=18;// 年龄string _address;// 地址string _tel;// 电话string _title;// 职称};

上面的StudentTeacher中成员函数和成员变量有相同的地方,下面我们将公共的部分放到Person中,然后让StudentTeacher都继承Person,就可以复用这些成员,使代码不再冗杂。

classPerson{public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证voididentity(){// ...}protected:string _name="peter";// 姓名string _address;// 地址string _tel;// 电话int_age=18;// 年龄};classStudent:publicPerson{public:// 学习voidstudy(){// ...}protected:int_stuid;// 学号};classTeacher:publicPerson{public:// 授课voidteaching(){//...}protected:string _title;// 职称};

💥1.2 继承的定义

Person是父类,也称基类;Student是子类,也称派生类。

类成员 / 继承方式public继承protected继承private继承
父类的public成员子类的public成员子类的protected成员子类的private成员
父类的protected成员子类的protected成员子类的protected成员子类的private成员
父类的private成员子类中不可见子类中不可见子类中不可见
  1. 父类private成员在子类中无论以什么方式继承都是不可见的。不可见是指父类的私有成员还是被继承到了子类中,但是语法上限制子类对象不管在类里面还是类外面都不能去访问它
  2. 父类private成员在子类中是不能被访问的,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的
  3. 实际上面的表格我们进行总结就会发现,父类的私有成员在子类都是不可见的,而父类的其他成员在子类中的访问方式== Min(成员在父类的访问限定符,继承方式),public > protected > private
  4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
  5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在子类的类里面使用,实际中扩展维护性不强

💥2、父类和子类对象赋值兼容转换

  • public继承的子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。有个形象的说法叫切片或者切割。寓意把子类中父类那部分切来赋值过去
  • 父类对象不能赋值给子类对象
  • 父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用。但是必须是父类的指针是指向子类对象时才是安全的

classPerson{protected:string _name;string _sex;size_t _age;};classStudent:publicPerson{public:int_no;//学号};intmain(){Student s;Person p=s;Person*pp=&s;Person&rp=s;return0;}

💥3、继承中的作用域

  1. 在继承体系中父类和子类都有独立的作用域
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,在子类成员函数中,可以使用 父类::父类成员 显示访问
  3. 如果是成员函数,只要是函数名相同就构成隐藏
  4. 在实际的继承体系中最好不要定义同名成员
classA{public:voidfun(){cout<<"func()"<<endl;}};classB:publicA{public:voidfun(inti){cout<<"func(int i)"<<i<<endl;}};intmain(){B b;b.fun(10);b.fun();return0;};
  • A类和B类中的两个fun函数构成的是隐藏关系,不是重载
  • 上面的程序编译运行的结果是编译报错,因为A类中的fun函数被隐藏,B类中的fun函数形参没有缺省值,所以主函数中的代码b.fun()会报错,如果想要调用A类中的fun函数,需要指定作用域:b.A::fun();

💥4、子类的默认成员函数

💥4.1 四个常见默认成员函数

  1. 子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显示调用
  2. 子类的拷贝构造函数必须调用父类的拷贝构造函数完成父类的拷贝初始化。一般来说子类的拷贝构造函数默认生成的就够用了,如果有需要深拷贝的资源才需要我们自己实现
  3. 子类的赋值重载必须调用父类的赋值重载完成父类的赋值
  4. 规定:子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员。这样做是为了保证子类对象先清理子类成员再清理父类成员的顺序
  5. 子类对象初始化先调用父类构造再调用子类构造
  6. 子类对象析构清理先调用子类析构再调用父类析构
  7. 因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系
#include<iostream>usingnamespacestd;classPerson{public:Person(constchar*name="peter"):_name(name){cout<<"Person()"<<endl;}Person(constPerson&p):_name(p._name){cout<<"Person(const Person& p)"<<endl;}Person&operator=(constPerson&p){cout<<"Person operator=(const Person& p)"<<endl;if(this!=&p)_name=p._name;return*this;}~Person(){cout<<"~Person()"<<endl;}protected:string _name;// 姓名};classStudent:publicPerson{public:Student(constchar*name,intnum):Person(name),_num(num){cout<<"Student()"<<endl;}Student(constStudent&s):Person(s),_num(s._num){cout<<"Student(const Student& s)"<<endl;}Student&operator=(constStudent&s){cout<<"Student& operator=(const Student& s)"<<endl;if(this!=&s){// 构成隐藏,所以需要显示调用Person::operator=(s);_num=s._num;}return*this;}~Student(){//子类的析构和父类的析构也构成隐藏关系,如果调用需要指定类域//但是子类析构后会自动调用父类析构,所以不需要显示调用//Person::~Person();cout<<"~Student()"<<endl;}protected:int_num;//学号};intmain(){Students1("jack",18);Students2(s1);Students3("rose",17);s1=s3;return0;}

💥4.2 实现一个不能被继承的类

  1. 父类的构造函数私有,子类的构成必须调用父类的构造函数,但是父类的构成函数私有化以后,子类看不见就不能调用了,那么子类就无法实例化出对象
  2. C++11新增了一个final关键字,final修饰父类,子类就不能继承了
// C++11的⽅法classBasefinal{public:voidfunc5(){cout<<"Base::func5"<<endl;}protected:inta=1;private:// C++98的⽅法/*Base() {}*/};classDerive:publicBase{voidfunc4(){cout<<"Derive::func4"<<endl;}protected:intb=2;};intmain(){Base b;Derive d;return0;}

💥5、继承与友元

  • 友元关系不能被继承,也就是说父类友元不能访问子类私有和保护成员
//前置声明classStudent;classPerson{public:friendvoidDisplay(constPerson&p,constStudent&s);protected:string _name;// 姓名};classStudent:publicPerson{protected:int_stuNum;// 学号};voidDisplay(constPerson&p,constStudent&s){cout<<p._name<<endl;cout<<s._stuNum<<endl;}intmain(){Person p;Student s;// 编译报错:error C2248: “Student::_stuNum”: ⽆法访问 protected 成员// 解决⽅案:Display也变成Student 的友元即可Display(p,s);return0;}

💥6、继承与静态成员

  • 父类定义了静态成员,则整个继承体系里面只有一个这样的成员,无论派生出多少个子类,都只有一个静态成员实例化
classPerson{public:string _name;staticint_count;};intPerson::_count=0;classStudent:publicPerson{protected:int_stuNum;};intmain(){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;return0;}

派生类都继承了基类哪些东西?哪些没继承?

会被继承,但有访问限制

  1. 成员变量(public / protected / private)
    全部继承,占用派生类对象内存。
  • public:派生类内部、外部均可直接访问
  • protected:仅派生类内部、友元可访问
  • private派生类内部不能直接访问,但是内存里依然存在!
  1. 普通成员函数(非构造、非析构)
    全部继承。同样受访问控制限定能不能调用。

  2. 静态成员:不属于某个对象,属于类本身,只有一份全局实例
    无论派生类创建多少对象、多少层派生,基类 static 变量唯一一份。

不会被继承

  1. 构造函数(包括拷贝构造、移动构造)
    派生类不会继承基类构造函数。
    派生类构造时,自动调用基类构造函数初始化基类部分,但派生类不能直接使用基类构造函数;C++11using Base::Base;继承构造函数的语法糖,不是原生意义上继承。

既然基类构造函数会被自动调用,为什么还需要using Base::Base;?

派生类本来确实会自动调用基类构造,但默认只能调用【基类无参构造】;
如果想直接调用【基类带参构造】,没有 using Base::Base 的话,你必须手动挨个写转发构造函数。

classBase{public:Base(intx){}Base(doubled){}Base(conststd::string&s){}};classDerived:publicBase{public:usingBase::Base;// 编译器自动生成:// Derived(int x) : Base(x) {}// Derived(double d) : Base(d) {}// Derived(const string& s) : Base(s) {}};
  1. 析构函数
    不会被继承。
    派生类析构执行完后,编译器自动调用基类析构;如果基类析构不是virtual,多态场景会产生内存泄漏。

  2. 友元函数 / 友元类
    友元关系不能继承,也不能传递
    基类的友元,无权访问派生类新增成员;派生类不会自动获得基类友元权限。

  3. operator=赋值运算符
    默认不继承。派生类默认合成拷贝赋值只会处理派生自己的成员,不会自动调用基类赋值;如需完整赋值,要手动调用Base::operator=

💥7、多继承及菱形继承问题

💥7.1 继承模型

  • 单继承:一个子类只有一个直接父类时称这个继承关系为单继承
  • 多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承,多继承对象在内存中的模型是:先继承的父类在前面,后面继承的父类在后面,子类成员在放到最后面。
  • 菱形继承:菱形继承是多继承的一种特殊情况。菱形继承有数据冗余和二义性的问题,支持多继承就一定会有菱形继承,像Java就直接不支持多继承,规避掉了这里的问题,实践中不建议设计出菱形继承这样的模型

单继承:

多继承:

菱形继承:

classPerson{public:string _name;// 姓名};classStudent:publicPerson{protected:int_num;//学号};classTeacher:publicPerson{protected:int_id;// 职⼯编号};classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;// 主修课程};intmain(){// 编译报错:error C2385: 对“_name”的访问不明确Assistant a;a._name="peter";// 需要显⽰指定访问哪个⽗类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决a.Student::_name="xxx";a.Teacher::_name="yyy";return0;}

💥7.2 菱形继承解决方法

7.2.1 虚继承

中间层继承时增加 virtual

classBase{};classA:virtualpublicBase{};classB:virtualpublicBase{};classC:publicA,publicB{};

核心变化

  1. C对象只存在唯一一份Base虚基类子对象
  2. 由最底层派生类C负责构造虚基类Base
  3. A、B不再各自持有独立Base,而是通过虚基指针间接指向共享的Base。
┌─────────────────────────────────────────────┐ │ C 对象内存布局 │ ├─────────────────────────────────────────────┤ │ Base(A继承来的副本) │ ├─────────────────────────────────────────────┤ │ A自有成员 │ ├─────────────────────────────────────────────┤ │ Base(B继承来的副本) │ ├─────────────────────────────────────────────┤ │ B自有成员 │ ├─────────────────────────────────────────────┤ │ C自有成员 │ └─────────────────────────────────────────────┘ 特点:存在两份独立Base实体,数据冗余,直接访问base成员产生二义性
┌─────────────────────────────────────────────┐ │ C 对象内存布局 │ ├─────────────────────────────────────────────┤ │ vbptr_A (虚基类指针) │ ├─────────────────────────────────────────────┤ │ A自有成员 │ ├─────────────────────────────────────────────┤ │ vbptr_B (虚基类指针) │ ├─────────────────────────────────────────────┤ │ B自有成员 │ ├─────────────────────────────────────────────┤ │ C自有成员 │ ├─────────────────────────────────────────────┤ │ 【唯一共享 Base】 │ └─────────────────────────────────────────────┘ 特点:只有一份Base;A/B不再内嵌Base;通过vbptr查表得到Base偏移地址

Q:虚继承代价是什么?
A:引入虚基指针、虚基表,间接寻址,带来少量运行时开销;内存布局变复杂。

Q:虚继承和虚函数的虚表是一回事吗?
A:不是

  • 虚函数 → 虚函数表 vtable、虚指针vptr,用于多态;
  • 虚继承 → 虚基表,用来定位共享虚基类子对象;两套独立机制。

Q:什么时候不要用虚继承?
没有菱形结构不要随便加virtual,无端增加开销;只有出现钻石继承场景才考虑。

Q:private virtual继承合法吗?
语法合法,只是访问权限受到限制。

7.2.2 接口继承 + 组合实现

工程上规避菱形继承的主流设计手段:接口继承,实现使用组合,杜绝实现类参与菱形继承。

继承只用来继承接口(is-a)
复用实现逻辑优先用组合(has-a),不要用继承复用实现。

// 纯接口structIBase{virtualvoidfunc()=0;};// 实现类,不参与对外继承structBaseImpl{voidfunc_impl(){// 真实业务逻辑}};// A对外继承接口,内部组合持有实现structA:publicIBase{BaseImpl impl;voidfunc()override{impl.func_impl();}};// B同理structB:publicIBase{BaseImpl impl;voidfunc()override{impl.func_impl();}};structC:publicA,publicB{};
  1. IBase只是接口;
  2. 真正实现放在BaseImpl,由A、B各自组合持有;
  3. 如果想要全局一份实现,可以把Impl做成单例或外部传入,A、B共享同一个Impl实例;

从根源上避免让携带数据的实现类进入菱形继承链路,自然不需要虚继承。

和虚继承的定位区别:

  1. virtual虚继承
    语言层面补救措施,用来解决不得已出现菱形继承时的数据冗余问题;属于“语法补丁”。

  2. 接口+组合的方式
    面向对象设计层面的方案,从设计上杜绝产生带数据的菱形继承,属于最优工程实践。

C++开发规范普遍提倡:

优先用组合复用代码;尽量不要依靠继承复用实现。

抽象基类就不会有重复继承成员问题吗?

  • 如果抽象类含有成员变量,哪怕带纯虚函数,普通菱形继承依然产生多份子对象;
  • 只有不含任何成员变量的纯抽象接口类,才没有重复数据负担,但依然存在函数访问歧义。

Q:什么时候用虚继承,什么时候用接口+组合?
A:如果历史代码无法改动,已经形成菱形继承,只能用virtual虚继承兜底;新项目优先采用接口继承+组合实现,从源头避免菱形继承。

Q:纯虚类(抽象类)能不能有成员变量?
A:语法允许。只要含有成员变量,菱形继承下依旧会产生多份副本。

Q:多重继承多个无关接口会不会有菱形问题?
多个独立无继承关系的接口多重继承一般没问题;只有多个父类又共同继承同一个基类,才形成菱形结构。


💥7.3 多继承中指针偏移问题

classBase1{public:int_b1;};classBase2{public:int_b2;};classDerive:publicBase1,publicBase2{public:int_d;};intmain(){Derive d;Base1*p1=&d;Base2*p2=&d;Derive*p3=&d;return0;}

  • 先继承的在前面,所以三个指针的关系是:p1 == p3 != p2

💥8、继承和组合

  • public继承是一种is-a的关系,也就是说每个子类对象都是一个父类对象
  • 组合是一种has-a的关系,如果B组合了A,则每个B对象中都有一个A对象
  • 继承允许我们根据父类的实现来定义子类的实现,这种通过生成子类的复用通常被称为 “白箱复用” 。白箱是相对可视性而言,在继承方式中,父类的内部细节对子类可见,所以继承一定程度上破坏了父类的封装,父类的改变,对子类有很大的影响,子类和父类之间的依赖关系很强,耦合度高
  • 对象组合是类继承之外的另一种复用选择,新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口,这种复用风格被称为 “黑箱复用” ,因为对象的内部细节是不可见的,对象只以黑箱的形式出现。组合类之间没有很强的依赖关系,耦合度低
  • 优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合
// Tire(轮胎)和Car(⻋)更符合has-a的关系classTire{protected:string _brand="Michelin";// 品牌size_t _size=17;// 尺⼨};classCar{protected:string _colour="⽩⾊";// 颜⾊string _num="陕ABIT00";// ⻋牌号Tire _t1;// 轮胎Tire _t2;// 轮胎Tire _t3;// 轮胎Tire _t4;// 轮胎};classBMW:publicCar{public:voidDrive(){cout<<"好开-操控"<<endl;}};// Car和BMW/Benz更符合is-a的关系classBenz:publicCar{public:voidDrive(){cout<<"好坐-舒适"<<endl;}};// stack和vector的关系,既符合is-a,也符合has-atemplate<classT>classvector{};template<classT>classstack:publicvector<T>{};template<classT>classstack{public:vector<T>_v;};intmain(){return0;}