C++自己的族谱规则:继承详解下

发布时间:2026/10/7 8:25:47
C++自己的族谱规则:继承详解下 文章目录前言一、派生类的默认成员函数1.派生类的默认构造2.派生类的拷贝构造函数3.派生类的赋值运算符 operator4.派生类的析构函数5.派生类的调用顺序二、实现一个不能被继承的类方法一方法二三、继承的特殊情况1.继承与友元2.继承与静态成员四、多继承及其菱形继承问题1.继承模型2.虚继承3.具体实例4.总结五、继承和组合前言本篇博客是对C继承知识剩余的内容做的补充。一、派生类的默认成员函数6个默认成员函数默认的意思就是指我们不写编译器会帮我们自动生成一个那么在派生类中这几个成员函数是如何生成的呢1.派生类的默认构造派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数则必须在派生类构造函数的初始化列表阶段显式调用。1.基类存在默认构造函数如果基类有可访问的无参默认构造函数派生类构造函数可以不显式调用。编译器会自动在派生类构造函数的初始化列表阶段插入基类默认构造函数的调用。classBase{public:Base(){coutBase默认构造endl;}};classDerived:publicBase{public:Derived(){coutDerived构造endl;}// 编译器自动等价于Derived() : Base() { ... }};2.基类没有默认构造函数如果基类只定义了带参构造函数编译器不会再生成默认构造那么派生类的所有构造函数都必须在初始化列表中显式调用基类的某个带参构造函数否则编译直接报错。classBase{public:inta;Base(intx):a(x){coutBase带参构造endl;}};classDerived:publicBase{public:intb;// 错误编译器无法自动调用Base的默认构造不存在// Derived(int x, int y) : b(y) {}// 正确初始化列表显式调用基类带参构造Derived(intx,inty):Base(x),b(y){coutDerived构造endl;}};2.派生类的拷贝构造函数拷贝构造是特殊的构造函数同样遵循 “先基类后派生类” 的初始化顺序。派生类的拷贝构造函数必须显式调用基类的拷贝构造函数否则编译器会自动调用基类的默认构造函数导致基类子对象被默认初始化而非拷贝源对象的基类部分出现 “拷贝不完整” 的切片问题。classBase{public:inta;Base(intx0):a(x){}Base(constBaseother):a(other.a){coutBase拷贝构造endl;}};classDerived:publicBase{public:intb;Derived(intx0,inty0):Base(x),b(y){}// 正确显式调用基类拷贝构造Derived(constDerivedother):Base(other),b(other.b){coutDerived拷贝构造endl;}};错误写法不调用Base拷贝构造Derived(const Derived other) : b(other.b) {}编译器自动调用Base()默认构造基类a被初始化为0而非other.a3.派生类的赋值运算符 operator派生类的operator必须要调用基类的operator完成基类的复制。需要注意的是派生类的operator隐藏了基类的operator所以显式调用基类operator需要指定基类作用域。classBase{public:inta;Base(intx0):a(x){}Baseoperator(constBaseother){if(this!other){aother.a;coutBase赋值运算符endl;}return*this;}};classDerived:publicBase{public:intb;Derived(intx0,inty0):Base(x),b(y){}Derivedoperator(constDerivedother){if(this!other){// 自赋值检查避免自己给自己赋值出错Base::operator(other);// 显式调用基类赋值运算符必须加Base::作用域bother.b;// 处理派生类自身成员coutDerived赋值运算符endl;}return*this;// 支持链式赋值}};4.派生类的析构函数派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。classBase{public:~Base(){coutBase析构endl;}};classDerived:publicBase{public:~Derived(){coutDerived析构endl;}};// 定义局部对象 Derived d; 程序输出顺序// Derived析构// Base析构无需显式调用基类析构派生类析构函数中不需要手动写Base::~Base()编译器会自动在派生类析构的末尾插入基类析构的调用。5.派生类的调用顺序派生类对象初始化先调用基类构造再调派生类构造派生类对象析构清理先调用派生类析构再调基类的析构。即初始化时先父后子析构时先子后父。原因因为派生类可能依赖基类的资源运行必须先释放派生类的资源可能还在使用基类成员再释放基类资源反之如果先释放基类派生类析构时再访问基类资源就会出现野访问错误。同时为什么要子类自动调用父类的析构函数也是因为了保证这个顺序。特殊点因为多态中一些场景析构函数需要构成重写重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理处理成destructor()所以基类析构函数不加virtual的情况下派生类析构函数和基类析构函数构成隐藏关系。这里后边的多态我会详细介绍。二、实现一个不能被继承的类方法一基类的构造函数私有派生类的构成必须调用基类的构造函数但是基类的构造函数私有化以后派生类看不见就不能调用了那么派生类就无法实例化出对象。方法二三、继承的特殊情况1.继承与友元友元关系不能继承也就是说基类友元不能访问派生类私有和保护成员 。其实也很好理解你父亲的朋友不可能一定是你的朋友。classStudent;classPerson{public:friendvoidDisplay(constPersonp,constStudents);protected:string _name;// 姓名};classStudent:publicPerson{protected:int_stuNum;// 学号};voidDisplay(constPersonp,constStudents){coutp._nameendl;couts._stuNumendl;}intmain(){Person p;Student s;// 编译报错error C2248 : “Student::_stuNum” :⽆法访问protected成员// 解决⽅案Display也变成Student的友元即可Display(p,s);return0;}另外这里的小细节是前置声明class是为了告诉编译器这是个类2.继承与静态成员基类定义了static静态成员则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类都只有一个static成员实例。可继承性派生类会继承基类的静态成员变量和静态成员函数访问权限由基类成员的访问级别 继承方式共同决定规则与普通非静态成员完全一致。全局唯一性整个继承层次中基类的静态成员只有唯一一份实例所有派生类、所有对象共享该静态成员内存地址完全相同。初始化规则静态成员变量必须在类外全局初始化且只能由定义它的基类完成初始化派生类不能初始化基类的静态成员。四、多继承及其菱形继承问题1.继承模型单继承一个派生类只有一个直接基类时称这个继承关系为单继承多继承一个派生类有两个或以上直接基类时称这个继承关系为多继承多继承对象在内存中的模型是先继承的基类在前面后面继承的基类在后面派生类成员放到最后面。菱形继承菱形继承是多继承的一种特殊情况。菱形继承的问题从下面的对象成员模型构造可以看出菱形继承有数据冗余和二义性的问题在 Assistant 的对象中 Person 成员会有两份。支持多继承就一定会有菱形继承像 Java 就直接不支持多继承规避掉了这里的问题所以实践中我们也是不建议设计出菱形继承这样的模型的。其实从现实出发一个人怎么可能有多个父亲。这里确实是c会产生一些问题的地方。usingnamespacestd;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._namepeter;a.Student::_namexxx;a.Teacher::_nameyyy;return0;}这里产生的两个核心问题数据冗余在普通继承机制下B 和 C 都会各自继承一份 A 的完整成员比如上面的person的name对象当 D 同时继承 B 和 C 时D 的对象中会包含两份独立的 A 类子对象一份通过 B 路径继承一份通过 C 路径继承造成内存数据冗余。访问二义性由于 D 对象中存在两份 A 的成员当直接访问 A 中的成员时编译器无法判断要访问的是 B 路径下的 A 成员还是 C 路径下的 A 成员会产生二义性编译错误。a.Student::_namexxx;a.Teacher::_nameyyy;显示指定访问哪个基类的成员可以解决二义性问题但是数据冗余问题无法解决。2.虚继承这时候就需要虚继承的出现。很多人说 C 语法复杂其实多继承就是一个体现。有了多继承就存在菱形继承有了菱形继承就有菱形虚拟继承底层实现就很复杂性能也会有一些损失所以最好不要设计出菱形继承。多继承可以认为是 C 的缺陷之一后来的一些编程语言都没有多继承如 Java。后续我们的学习中我们会发现c有很多为了修补某些错误而设计出来的语法比如说的多态里的虚函数。classPerson{public:string _name;// 姓名/*int _tel; int _age; string _gender; string _address;*/// ...};// 使用虚继承Person类classStudent:virtualpublicPerson{protected:int_num;//学号};// 使用虚继承Person类classTeacher:virtualpublicPerson{protected:int_id;// 职工编号};// 教授助理classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;// 主修课程};intmain(){// 使用虚继承可以解决数据冗余和二义性Assistant a;a._namepeter;return0;}使用记住在两个中间派生类第一次继承顶层基类时使用virtual关键字修饰继承方式声明为虚继承原理虚继承的底层依赖虚基类指针vbptr, virtual base pointer和虚基类表vbtable, virtual base table实现虚基类表vbtable每个使用虚继承的类编译器会生成一张虚基类表表中存储虚基类子对象相对于当前类起始地址的偏移量。虚基类指针vbptr每个派生类对象中会包含一个虚基类指针指向自身对应的虚基类表。当最终派生类 D 访问虚基类 A 的成员时通过 D 对象中 B 部分的vbptr找到 B 的虚基类表从表中读取 A 子对象相对于 B 的偏移量加上 B 的起始地址计算出唯一的 A 子对象地址完成成员访问。通过这种偏移寻址保证了无论有多少条继承路径最终都指向同一份虚基类子对象。3.具体实例问题一运行结果为多少。classPerson{public:Person(constchar*name):_name(name){}string _name;};// virtual public 虚继承classStudent:virtualpublicPerson{public:Student(constchar*name,intnum):Person(name)// 虚继承场景这个Person构造【不会执行】,_num(num){}protected:int_num;};classTeacher:virtualpublicPerson{public:Teacher(constchar*name,intid):Person(name)// 虚继承场景这个Person构造【不会执行】,_id(id){}protected:int_id;};classAssistant:publicStudent,publicTeacher{public:Assistant(constchar*name1,constchar*name2,constchar*name3):Person(name3)// 【唯一执行的Person构造】,Student(name1,1),Teacher(name2,2){}protected:string _majorCourse;};intmain(){Assistanta(张三,李四,王五);return0;}这里最终的运行结果为王五虚继承 virtual public整个继承体系只存在一份虚基类 Person。虚基类的构造由最底层派生类这里就是 Assistant负责调用上层子类 Student、Teacher 初始化列表里写的Person(…)编译器直接跳过不调用。所以执行顺序Assistant初始化列表先执行Person(name3) → Person(“王五”)_name“王五”接着构造Student(name1,1)进入 Student 构造Student 初始化列表里Person(name1)被忽略只初始化_num接着构造Teacher(name2,2)进入 Teacher 构造Person(name2)被忽略只初始化_idStudent 里的Person(“张三”)、Teacher 里的Person(“李四”)完全不会跑。最终 a._name “王五”。问题二 p1,p2,p3的关系classBase1{public:int_b1;};classBase2{public:int_b2;};classDerive:publicBase1,publicBase2{public:int_d;};intmain(){Derive d;Base1*p1d;Base2*p2d;Derive*p3d;return0;}Derive* p3 d;p3 保存的是整个 Derive 对象起始地址。Base1* p1 d;Base1 是第一个继承的父类Base1 子对象刚好在 Derive 对象开头所以p1 的值 p3 的值。Base2* p2 d;Base2 子对象在 Base1 后面编译器自动对指针做偏移向后偏移sizeof(Base1)4 字节所以p2 地址 p1、p3。4.总结我们可以设计出多继承但是不建议设计出菱形继承因为菱形虚拟继承以后无论是使用还是底层都会复杂很多。五、继承和组合public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语 “白箱” 是相对可视性而言在继承方式中基类的内部细节对派生类可见。继承一定程度破坏了基类的封装基类的改变对派生类有很大的影响。派生类和基类间的依赖关系很强耦合度高。组合是一种 has-a 的关系。假设 B 组合了 A每个 B 对象中都有一个 A 对象。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse)因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系耦合度低。优先使用对象组合有助于你保持每个类被封装。优先使用组合而不是继承。实际尽量多去用组合组合的耦合度低代码维护性好。不过也不太那么绝对类之间的关系就适合继承 (is-a) 那就用继承另外要实现多态也必须要继承。类之间的关系既适合用继承 (is-a) 也适合组合 (has-a)就用组合。