尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++继承机制全解析:从单继承到虚继承的实战指南

C++继承机制全解析:从单继承到虚继承的实战指南 1. 从“复用”到“扩展”为什么我们需要继承如果你写过一段时间的C尤其是当项目规模逐渐变大代码量开始膨胀的时候你一定会遇到一个头疼的问题重复。你会发现好几个类里都有name、id这样的成员变量或者都有printInfo()、saveToFile()这样的成员函数。最开始你可能会用“复制粘贴大法”但很快噩梦就来了——当需要修改printInfo()的格式时你得在所有粘贴过的地方逐一修改漏掉一个就可能引发bug。这时继承Inheritance就像救星一样出现了。它不是什么高深莫测的黑魔法本质上就是一种代码复用和关系建模的机制。想象一下你要开发一个图形编辑器里面有圆形、矩形、三角形。与其为每个形状都从头写一遍位置坐标(x, y)、颜色color和移动move()、绘制draw()函数不如先定义一个通用的“形状”基类Base Class把这些共有的东西放进去。然后让圆形、矩形、三角形这些派生类Derived Class去继承这个基类。派生类自动拥有了基类的所有成员除了构造/析构和友元同时可以添加自己特有的东西比如圆形的半径、矩形的长宽或者重写draw()函数来实现各自的绘制逻辑。这不仅仅是偷懒。从设计层面看继承清晰地表达了“是一个is-a”的关系。比如“圆形”是一个“形状”“轿车”是一个“汽车”。这种关系让代码的逻辑层次变得非常清晰也符合我们对现实世界的认知。更重要的是它为C的另一大特性——多态Polymorphism——奠定了基础。通过基类的指针或引用去操作派生类对象可以实现运行时绑定写出更灵活、更通用的代码。没有继承多态就无从谈起。所以理解继承绝不是为了应付考试或面试。它是你从编写零散函数迈向设计可维护、可扩展的软件系统的关键一步。接下来我会带你由浅入深拆解C继承的四种核心模式单继承、多继承、菱形继承以及解决其副产品的虚继承。我会用大量代码示例和我在实际项目中踩过的坑帮你把这块硬骨头啃下来。2. 单继承面向对象大厦的第一块基石单继承是继承中最基本、最常用的形式它描述了一个派生类只从一个基类继承属性和行为的关系。这是构建清晰类层次结构的最佳起点。2.1 基本语法与内存布局初窥定义一个继承关系语法非常简单class BaseClass { // 基类成员 }; class DerivedClass : access-specifier BaseClass { // 派生类成员 };这里的access-specifier是访问说明符可以是public、protected或private。它决定了派生类内部以及通过派生类对象访问基类成员的方式。最常用的是public继承因为它严格体现了“是一个”的关系。来看一个具体的例子我们模拟一个简单的员工管理系统#include iostream #include string class Person { public: Person(const std::string name, int age) : name_(name), age_(age) {} void introduce() const { std::cout Hello, Im name_ , age_ years old. std::endl; } protected: std::string name_; private: int age_; }; class Employee : public Person { // public继承 public: Employee(const std::string name, int age, const std::string dept) : Person(name, age), department_(dept) {} // 初始化基类子对象 void work() const { std::cout name_ is working in the department_ department. std::endl; // std::cout age_ std::endl; // 错误age_是Person的private成员不可访问 } private: std::string department_; };在这个例子中Employee公有继承了Person。这意味着在Employee类内部可以访问Person的public和protected成员如name_但不能访问private成员如age_。对于一个Employee对象外界可以将其视为一个Person来使用因为Person的public接口在Employee中仍然是public的。注意派生类并不继承基类的构造函数、析构函数、赋值运算符和友元关系。派生类需要在自己的构造函数初始化列表中显式调用基类的构造函数否则编译器会尝试调用基类的默认构造函数。如果基类没有默认构造函数编译就会失败。这是新手常犯的错误。内存布局上可以简单理解为派生类对象中包含了一个完整的基类子对象Base Class Subobject。一个Employee对象在内存中大致是[Person部分 | Employee部分]的顺序。这也是为什么派生类的构造函数需要先初始化基类部分的原因——子对象必须先被构建。2.2 名字隐藏与函数重写随着继承层次加深两个容易混淆的概念出现了名字隐藏Name Hiding和函数重写/覆盖Overriding。名字隐藏发生在派生类定义了与基类同名的成员变量或函数时。此时无论参数是否相同基类的同名成员在派生类作用域内都会被“隐藏”起来。class Base { public: void func(int x) { std::cout Base::func(int) std::endl; } }; class Derived : public Base { public: void func(double x) { std::cout Derived::func(double) std::endl; } // 隐藏了Base::func(int) }; int main() { Derived d; d.func(5); // 输出Derived::func(double) // 5被隐式转换为5.0 // d.Base::func(5); // 正确使用作用域解析运算符显式调用 }注意Derived::func(double)并没有重载Base::func(int)而是直接把它隐藏了。即使参数列表不同基类的版本也需要通过Base::来显式访问。函数重写则是面向对象多态的核心。它特指派生类重新定义基类中的virtual虚函数。为了实现重写必须满足严格的条件基类函数必须是virtual的。函数名、参数类型、常量性必须完全相同。返回类型通常也必须相同除非是协变返回类型即派生类重写函数的返回类型是基类函数返回类型的派生类。class Animal { public: virtual void speak() const { // virtual关键字是关键 std::cout Some animal sound std::endl; } virtual ~Animal() {} // 虚析构函数确保正确释放派生类资源 }; class Dog : public Animal { public: void speak() const override { // C11起可用override关键字明确意图编译器会检查是否成功重写 std::cout Woof! std::endl; } }; int main() { Dog dog; Animal* animalPtr dog; // 基类指针指向派生类对象 animalPtr-speak(); // 输出Woof! 动态绑定调用的是Dog::speak }这里Dog::speak重写了Animal::speak。通过基类指针调用时实际执行哪个版本由指针实际指向的对象类型在运行时决定这就是多态。override关键字不是必须的但强烈建议使用它让编译器帮你检查重写签名是否正确避免因手误导致的名字隐藏你以为重写了实际上没有。实操心得在设计基类时如果某个函数未来很可能被派生类以不同的方式实现就应该将其声明为virtual。同时基类的析构函数几乎总是应该声明为虚函数。否则通过基类指针删除派生类对象时只会调用基类的析构函数导致派生类部分的资源泄漏。这是一个经典的、可能导致严重内存泄漏的坑。3. 多继承能力组合的双刃剑单继承描绘的是清晰的树状层次结构但现实世界的关系往往更复杂。一个“大学生助教”既是一个“学生”也是一个“教师”。为了直接建模这种多重“是一个”的关系C提供了多继承Multiple Inheritance机制。3.1 语法与潜在问题多继承的语法就是在派生类后列出多个基类用逗号分隔class Teacher { /* ... 教学相关属性和方法 ... */ }; class Student { /* ... 学习相关属性和方法 ... */ }; class TeachingAssistant : public Teacher, public Student { // 多继承 /* ... 助教特有的属性和方法 ... */ };这样TeachingAssistant对象就同时包含了Teacher和Student两个基类子对象继承了它们的所有能力。然而多继承引入了单继承中没有的复杂性最主要的就是名字冲突。class A { public: void doWork() { std::cout A working std::endl; } }; class B { public: void doWork() { std::cout B working std::endl; } }; class C : public A, public B { // 继承了A::doWork和B::doWork }; int main() { C c; // c.doWork(); // 错误歧义编译器不知道调用A的还是B的 c.A::doWork(); // 正确显式指定 c.B::doWork(); // 正确显式指定 }当派生类调用一个在多个基类中都有定义的成员时编译器会因为歧义而报错。解决方案是使用作用域解析运算符::来显式指明或者在派生类内部定义一个同名函数来“覆盖”或“整合”这些冲突的函数。3.2 钻石型继承与数据冗余问题多继承最著名的陷阱是“菱形继承”或“钻石型继承”Diamond Inheritance。考虑这样一个经典场景class Person { public: std::string name; int age; }; class Student : public Person { /* ... */ }; class Employee : public Person { /* ... */ }; class PartTimeStudent : public Student, public Employee { /* ... */ };PartTimeStudent通过Student和Employee两条路径间接继承了Person。这意味着在一个PartTimeStudent对象中将包含两份Person基类子对象一份来自Student路径一份来自Employee路径。这会导致两个严重问题数据冗余PartTimeStudent对象里有两份name和age浪费内存且逻辑上不合理一个人不应该有两个名字和年龄。歧义性直接访问name或age会产生歧义。PartTimeStudent pts; // pts.name Alice; // 错误歧义是Student::Person::name还是Employee::Person::name? pts.Student::name Alice; // 必须显式指定路径 pts.Employee::name Bob; // 可以给两个“名字”赋不同的值这很荒谬内存布局上PartTimeStudent对象大致是这样的[Student部分(内含Person子对象) | Employee部分(内含另一个Person子对象) | PartTimeStudent部分]。显然中间的两个Person子对象是重复的。4. 虚继承解决菱形继承的钥匙为了解决菱形继承带来的数据冗余和歧义问题C引入了虚继承Virtual Inheritance。虚继承的核心思想是让某个基类在继承体系中只存在一个共享的实例无论它在派生路径上出现多少次。4.1 虚继承的语法与原理语法上在继承时使用virtual关键字即可声明为虚继承。class Person { /* ... */ }; class Student : virtual public Person { /* ... */ }; // 虚继承 class Employee : virtual public Person { /* ... */ }; // 虚继承 class PartTimeStudent : public Student, public Employee { /* ... */ };通过将Student和Employee对Person的继承声明为virtual我们告诉编译器Person是一个“虚基类”。在最终派生类PartTimeStudent的对象中Person子对象将只有一份由Student和Employee共享。现在访问name和age不再有歧义PartTimeStudent pts; pts.name Alice; // 正确只有一份Person没有歧义。 pts.age 20; // 同样正确。4.2 虚继承下的构造与析构顺序虚继承彻底改变了对象的构造和析构顺序这是理解其原理和正确使用的关键。构造顺序首先初始化虚基类子对象无论它在继承列表中出现在哪里都只初始化一次。这意味着虚基类的构造函数由最终派生类如PartTimeStudent在其初始化列表中直接调用。如果最终派生类没有显式调用虚基类的构造函数则调用其默认构造函数。然后按照继承列表中声明的顺序初始化非虚基类子对象。接着按照类定义中声明的顺序初始化派生类自己的数据成员。最后执行派生类构造函数的函数体。对于PartTimeStudent pts;其构造顺序是Person的构造函数虚基类由PartTimeStudent直接初始化。Student的构造函数非虚基类但此时其初始化列表中对于Person的初始化会被忽略因为Person已由最终派生类初始化。Employee的构造函数同上。PartTimeStudent自身成员的初始化。PartTimeStudent构造函数体。析构顺序则完全相反。注意事项由于虚基类由最终派生类负责初始化中间类如Student、Employee在其构造函数初始化列表中对虚基类的初始化调用是无效的。这是一个非常重要的细节容易出错。最佳实践是在虚继承体系中只为虚基类提供默认构造函数或者确保最终派生类总是显式调用虚基类的正确构造函数。4.3 虚继承的代价与使用建议虚继承不是免费的午餐它带来了额外的开销和复杂性性能开销为了实现共享的虚基类子对象编译器通常会在对象中插入一个或多个指向虚基类的指针vbase pointer这会增加对象的内存占用。访问虚基类成员也需要通过指针间接进行可能比直接访问稍慢。复杂性构造/析构顺序变得复杂对程序员的要求更高。因此我的建议是谨慎使用多继承优先考虑组合Has-a或单继承加接口抽象基类的方式来设计。例如TeachingAssistant可以包含Student和Teacher的成员对象而非继承它们。仅在菱形继承场景下使用虚继承如果你明确设计了菱形继承结构并且确实需要共享基类数据再使用虚继承。接口继承优先如果多继承主要用于继承多个“接口”即只包含纯虚函数的抽象类那么通常不会形成菱形结构也无需虚继承这是一种相对安全的多继承用法。5. 继承中的构造、析构与拷贝控制继承关系深刻影响着类的“三巨头”构造函数、析构函数和拷贝控制成员拷贝构造、拷贝赋值、移动构造、移动赋值。理解它们的调用顺序和交互是写出正确代码的基础。5.1 构造与析构的调用链对象的构建是从基类到派生类“自底向上”的而销毁则是“自顶向下”的。class Base { public: Base() { std::cout Base() std::endl; } ~Base() { std::cout ~Base() std::endl; } }; class Derived : public Base { public: Derived() { std::cout Derived() std::endl; } ~Derived() { std::cout ~Derived() std::endl; } }; int main() { Derived d; // 输出 // Base() // Derived() // ~Derived() // ~Base() }派生类对象的构造总是先调用基类构造函数初始化基类子对象再调用派生类构造函数。析构则相反先调用派生类析构函数再调用基类析构函数。对于多继承基类构造顺序严格按照派生类定义中的继承列表顺序进行。5.2 拷贝控制成员的继承困境默认情况下派生类会合成自己的拷贝构造函数和拷贝赋值运算符。它们会自动调用基类对应的拷贝控制成员来处理基类部分。class Base { public: Base() default; Base(const Base other) : data(other.data) { std::cout Base copy constructor std::endl; } Base operator(const Base other) { data other.data; std::cout Base copy assignment std::endl; return *this; } int data 0; }; class Derived : public Base { public: // 编译器合成的拷贝构造函数会先调用Base的拷贝构造函数 // 编译器合成的拷贝赋值运算符会先调用Base的拷贝赋值运算符 int extra 0; }; int main() { Derived d1; d1.data 1; d1.extra 2; Derived d2 d1; // 调用合成的拷贝构造输出Base copy constructor Derived d3; d3 d1; // 调用合成的拷贝赋值输出Base copy assignment }但是这里有一个大坑如果你在派生类中自己定义了拷贝构造函数或拷贝赋值运算符编译器就不会再为你自动合成调用基类版本的那部分代码了你必须显式地在初始化列表或函数体中调用基类的对应函数。class DerivedManual : public Base { public: int extra 0; // 错误自定义了拷贝构造但没有调用基类拷贝构造 DerivedManual(const DerivedManual other) : extra(other.extra) { // 忘记了 Base(other) Base部分将被默认初始化而非拷贝 } // 正确做法 DerivedManual(const DerivedManual other) : Base(other), extra(other.extra) { // 显式调用基类拷贝构造 std::cout DerivedManual copy constructor std::endl; } // 错误自定义了拷贝赋值但没有调用基类拷贝赋值 DerivedManual operator(const DerivedManual other) { if (this ! other) { extra other.extra; // 忘记了 Base::operator(other) Base部分不会被赋值 } return *this; } // 正确做法 DerivedManual operator(const DerivedManual other) { if (this ! other) { Base::operator(other); // 显式调用基类拷贝赋值 extra other.extra; std::cout DerivedManual copy assignment std::endl; } return *this; } };忘记在派生类的拷贝控制成员中调用基类版本会导致基类部分的数据成员没有被正确拷贝或赋值这是非常隐蔽的错误来源。移动构造和移动赋值运算符同理。实操心得遵循“三/五法则”。如果你需要为派生类定义拷贝构造函数、拷贝赋值运算符或析构函数中的任何一个那么你很可能需要全部定义它们包括考虑移动操作并且务必记得在派生类的实现中显式调用基类的对应版本。这是一个需要养成肌肉记忆的好习惯。6. 类型转换与对象切片继承关系在C类型系统中引入了隐式转换的可能性同时也带来了一个经典陷阱——对象切片。6.1 向上转型与向下转型向上转型将派生类的指针、引用或对象转换为基类的指针、引用或对象。这是安全的并且通常是隐式进行的。Dog dog; Animal* animalPtr dog; // 向上转型安全 Animal animalRef dog; // 向上转型安全通过基类指针/引用调用虚函数会触发多态执行派生类的版本。向下转型将基类的指针或引用转换为派生类的指针或引用。这是不安全的因为基类指针可能并不实际指向一个派生类对象。Animal* someAnimal new Dog(); // Dog* dogPtr someAnimal; // 错误不能隐式向下转型 Dog* dogPtr dynamic_castDog*(someAnimal); // 正确使用dynamic_cast进行安全的向下转型 if (dogPtr) { // 转换成功someAnimal确实指向一个Dog } else { // 转换失败someAnimal指向的不是Dog } delete someAnimal;dynamic_cast在运行时检查转换的安全性需要基类至少有一个虚函数以拥有RTTI信息。如果转换失败对于指针类型返回nullptr对于引用类型抛出std::bad_cast异常。相比之下static_cast也可以用于向下转型但它不做运行时检查不安全仅在确定安全时使用。6.2 对象切片值语义的陷阱这是C继承中一个非常容易出错的地方尤其是对于从Java、Python等语言转过来的开发者。class Animal { public: virtual void speak() const { std::cout ??? std::endl; } std::string type Animal; }; class Dog : public Animal { public: void speak() const override { std::cout Woof! std::endl; } std::string breed Golden Retriever; }; void printAnimal(Animal a) { // 按值传递 a.speak(); std::cout a.type std::endl; // std::cout a.breed std::endl; // 错误Animal类没有breed成员 } int main() { Dog dog; printAnimal(dog); // 发生对象切片 }当dog被传递给printAnimal函数时发生了按值传递。为了初始化形参Animal a编译器需要从Dog对象构造一个Animal对象。这个过程调用的是Animal的拷贝构造函数。它只会拷贝Dog对象中属于Animal基类子对象的那部分type而Dog特有的部分breed以及Dog::speak的虚函数表信息会被无情地“切掉”。这种现象就叫对象切片。在函数内部a是一个纯粹的Animal对象不再是Dog。因此调用a.speak()即使它是虚函数也只会调用Animal::speak()输出“???”多态失效。无法访问breed成员。如何避免对象切片使用指针或引用传递将函数参数改为const Animal或Animal*。这样传递的是对象的地址或别名不会发生拷贝和切片多态得以保留。void printAnimal(const Animal a) { a.speak(); // 正确如果a实际是Dog则调用Dog::speak() }避免在容器中直接存储基类对象std::vectorAnimal存储的是Animal对象放入Dog会发生切片。应该使用std::vectorAnimal*或更好的std::vectorstd::unique_ptrAnimal来存储指针或智能指针。对象切片是C值语义和继承机制结合产生的特有现象理解它对于正确使用继承和多态至关重要。7. 实战设计一个简单的图形系统让我们用一个综合性的小例子来串联大部分概念。假设我们要设计一个简单的图形系统支持多种形状并能计算面积、绘制图形。7.1 基类与接口设计首先定义一个抽象的Shape基类。它应该是一个接口类包含所有形状共有的操作。// shape.h #pragma once #include string #include iostream class Shape { public: // 构造函数 Shape(const std::string color) : color_(color) {} // 虚析构函数确保通过基类指针删除派生类对象时行为正确 virtual ~Shape() default; // 纯虚函数使Shape成为抽象类不能实例化 virtual double area() const 0; // 计算面积 virtual void draw() const 0; // 绘制图形 // 非虚函数所有形状共享的实现 std::string getColor() const { return color_; } void setColor(const std::string color) { color_ color; } // 虚函数提供默认实现但允许派生类重写 virtual void printInfo() const { std::cout Shape color: color_ std::endl; } protected: std::string color_; };Shape是一个抽象基类因为它包含了纯虚函数area()和draw()。它定义了图形对象的接口契约任何具体的形状都必须实现这两个函数。7.2 实现具体派生类现在实现两个具体的形状圆形和矩形。它们单继承自Shape。// circle.h #pragma once #include shape.h #include cmath class Circle : public Shape { public: Circle(const std::string color, double radius) : Shape(color), radius_(radius) { if (radius_ 0) { throw std::invalid_argument(Radius must be positive.); } } // 重写纯虚函数 double area() const override { return M_PI * radius_ * radius_; } void draw() const override { std::cout Drawing a color_ circle with radius radius_ std::endl; } // 重写虚函数提供更具体的信息 void printInfo() const override { std::cout Circle - Color: color_ , Radius: radius_ , Area: area() std::endl; } double getRadius() const { return radius_; } void setRadius(double radius) { radius_ radius; } private: double radius_; };// rectangle.h #pragma once #include shape.h class Rectangle : public Shape { public: Rectangle(const std::string color, double width, double height) : Shape(color), width_(width), height_(height) { if (width_ 0 || height_ 0) { throw std::invalid_argument(Width and height must be positive.); } } double area() const override { return width_ * height_; } void draw() const override { std::cout Drawing a color_ rectangle ( width_ x height_ ) std::endl; } // 注意这里没有重写printInfo将使用Shape基类的默认实现 double getWidth() const { return width_; } double getHeight() const { return height_; } void setDimensions(double width, double height) { width_ width; height_ height; } private: double width_; double height_; };7.3 引入多继承与虚继承现在假设我们想引入一种新的形状TexturedCircle它既有圆形的几何属性又拥有纹理属性。我们可以通过多继承来实现。// texture.h #pragma once #include string class Texture { public: Texture(const std::string texPath) : texturePath_(texPath) {} virtual ~Texture() default; virtual void loadTexture() { std::cout Loading texture from: texturePath_ std::endl; // 模拟加载纹理 isLoaded_ true; } virtual void applyTexture() const { if (isLoaded_) { std::cout Applying texture: texturePath_ std::endl; } else { std::cout Texture not loaded! std::endl; } } std::string getTexturePath() const { return texturePath_; } void setTexturePath(const std::string path) { texturePath_ path; isLoaded_ false; } protected: std::string texturePath_; bool isLoaded_ false; };// textured_circle.h #pragma once #include circle.h #include texture.h class TexturedCircle : public Circle, public Texture { // 多继承 public: TexturedCircle(const std::string color, double radius, const std::string texPath) : Circle(color, radius), Texture(texPath) {} // 重写draw函数在绘制圆形前应用纹理 void draw() const override { applyTexture(); // 调用Texture的方法 Circle::draw(); // 调用Circle的方法 std::cout (with texture) std::endl; } // 重写printInfo整合两个基类的信息 void printInfo() const override { Circle::printInfo(); // 先打印圆形信息 std::cout Texture: getTexturePath() std::endl; // 再打印纹理信息 } };TexturedCircle同时继承了Circle和Texture因此它拥有两者的所有功能。在draw()中它协调了来自两个基类的行为。7.4 使用与多态演示最后在主函数中演示多态和对象切片。// main.cpp #include shape.h #include circle.h #include rectangle.h #include textured_circle.h #include vector #include memory int main() { // 1. 使用具体类 Circle redCircle(red, 5.0); Rectangle blueRect(blue, 4.0, 6.0); TexturedCircle greenTexCircle(green, 3.0, grass.png); std::cout --- Direct calls --- std::endl; redCircle.draw(); std::cout Area: redCircle.area() std::endl; greenTexCircle.draw(); // 2. 多态使用基类指针/引用 std::cout \n--- Polymorphism via vector of unique_ptr --- std::endl; std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(yellow, 2.0)); shapes.push_back(std::make_uniqueRectangle(purple, 3.0, 5.0)); shapes.push_back(std::make_uniqueTexturedCircle(cyan, 4.0, water.png)); for (const auto shape : shapes) { shape-draw(); // 动态绑定调用各自的重写版本 std::cout Area: shape-area() std::endl; shape-printInfo(); // 注意Rectangle会调用Shape的默认printInfo std::cout std::endl; } // 3. 演示对象切片错误示例 std::cout \n--- Object Slicing Demo (Problematic) --- std::endl; std::vectorShape slicingVector; // 错误存储的是对象不是指针 // slicingVector.push_back(redCircle); // 如果取消注释会发生对象切片 // slicingVector.push_back(greenTexCircle); // TexturedCircle会被切得只剩Shape部分 // 4. 向下转型示例 std::cout \n--- Downcasting Demo --- std::endl; Shape* someShape shapes[2].get(); // 获取TexturedCircle的指针 TexturedCircle* texCirclePtr dynamic_castTexturedCircle*(someShape); if (texCirclePtr) { std::cout Downcast successful. Texture path: texCirclePtr-getTexturePath() std::endl; } // 尝试将Rectangle指针向下转型为Circle会失败 Shape* rectShape shapes[1].get(); Circle* circlePtr dynamic_castCircle*(rectShape); if (!circlePtr) { std::cout Downcast from Rectangle* to Circle* failed (as expected). std::endl; } return 0; }这个例子涵盖了单继承、抽象类、纯虚函数、虚函数重写、多态、多继承以及向下转型。它也暗示了对象切片的问题。通过智能指针管理对象生命周期避免了手动内存管理和切片问题是现代C推荐的做法。8. 常见陷阱、性能考量与最佳实践继承是强大的工具但误用也会带来很多问题。这里总结一些关键的注意事项和实战建议。8.1 继承使用误区滥用“是一个”关系继承必须严格满足“派生类是一个基类”的里氏替换原则。例如“正方形”是一个“矩形”在数学上成立但在编程中如果矩形有setWidth和setHeight方法正方形继承矩形并重写这两个方法就会违反里氏原则修改正方形一边会同时影响另一边。这种情况下组合或实现公共接口更合适。过度使用继承层次过深的继承层次比如超过3层会使代码难以理解和维护。考虑使用组合或策略模式来替代。为复用而使用私有/保护继承private或protected继承表示的是一种“按...实现”的关系而非“是一个”的关系。它们几乎总是可以被“组合将一个类作为成员变量”所替代而且组合通常更清晰、耦合度更低。除非你需要重写基类的虚函数否则优先使用组合。// 通常不推荐 class Stack : private std::vectorint { // 私有继承 public: void push(int val) { this-push_back(val); } int pop() { int val this-back(); this-pop_back(); return val; } }; // 更推荐组合 class BetterStack { private: std::vectorint data_; public: void push(int val) { data_.push_back(val); } int pop() { int val data_.back(); data_.pop_back(); return val; } };8.2 性能影响虚函数调用通过指针或引用调用虚函数比调用非虚函数慢一点因为它需要通过虚函数表vtable进行间接跳转。但在绝大多数应用中这点开销微不足道。不要因为微小的性能顾虑而放弃设计良好的多态结构。虚继承开销如前所述虚继承会引入额外的指针和间接访问开销。仅在确实需要解决菱形继承问题时使用。对象大小每个有虚函数的类都会有一个指向虚函数表的指针vptr。多继承可能导致对象包含多个vptr。虚继承也会增加额外的指针vbase pointer。8.3 现代C中的继承final关键字C11引入了final关键字。用于类表示该类不能被继承用于虚函数表示该函数在派生类中不能被重写。class NoDerived final { /* ... */ }; // 这个类不能被继承 class Base { virtual void func() final; // 这个虚函数不能被重写 };override关键字如前所述始终在意图重写基类虚函数的派生类函数后加上override。这是一个重要的安全特性。使用智能指针管理继承层次使用std::unique_ptrBase或std::shared_ptrBase来持有派生类对象可以自动管理内存避免泄漏并且配合多态工作得很好。考虑替代方案对于接口定义C没有像Java那样的interface关键字但可以通过只包含纯虚函数的抽象类来模拟。对于代码复用模板泛型编程和组合往往是比继承更灵活的选择。C20的概念Concepts进一步增强了泛型编程的能力。继承是C面向对象编程的基石之一理解单继承、多继承、菱形继承和虚继承是写出健壮、可扩展C代码的关键。从简单的“是一个”关系建模开始谨慎地使用多继承和虚继承时刻注意构造/析构顺序、拷贝控制以及对象切片等陷阱并善用现代C提供的final、override等工具你就能驾驭好这把强大的双刃剑。记住没有最好的设计只有最适合当前场景的设计。多思考类之间的关系本质是选择继承、组合还是其他模式的首要前提。
返回列表