☰
C++继承与派生全解:内存布局、虚函数与多重继承实战
2026/9/30 4:13:41 网站建设 项目流程

从写完第一个能跑起来的class Student : public Person那一刻起,很多人就以为继承这一章过去了。真正干活的时候才发现,坑全在后面:基类析构函数忘了加virtual,程序在delete的时候莫名其妙崩了;派生类里写了个同名函数,基类那一堆重载一夜之间全调不到;多重继承写出来一堆"二义性"报错,编译器说的话半个字都看不懂。这篇就把C++ 类的继承与派生这一章从语法表层挖到内存布局,把三种继承方式的访问权限变化、构造析构的调用链条、虚函数表和动态绑定、多重继承与虚继承的内存代价,再到一个完整可编译的图形计算实战,全部摊开讲一遍。不管你是刚学完类和对象、准备啃继承的初学者,还是写了几年业务代码但一直靠"试出来"解决继承问题的开发者,这篇内容都能直接拿去用。

1. 继承这道坎,到底难在哪里

1.1 继承解决的不是"少写代码",而是类型关系

最常见的误解是把继承当成省代码的工具。看到几个类有一堆重复成员,第一反应就是抽个基类出来,把所有公共字段塞进去。这个出发点本身没错,但它只看到了语法层面,没看到语义层面。

继承真正表达的是is-a关系:Circle是一种Shape,Manager是一种Employee。这个判断至关重要。因为一旦你写出class Circle : public Shape,你就等于向整个程序承诺了两件事:第一,任何接受Shape&或Shape*的地方,你都能塞一个Circle进去;第二,Shape承诺的所有行为,Circle必须能完整履行。这叫里氏替换原则,是继承体系能不能撑住的地基。

我见过太多代码死在第二条上。有人写了个class Bird带fly(),然后让class Penguin : public Bird继承下来,把fly()重写成打印一句"企鹅不会飞"。编译器不报错,运行也正常,但任何拿着Bird*遍历所有鸟并调用fly()的代码,逻辑上就已经错了。这不是技术问题,是建模问题。遇到这种情况,正确做法是把fly()提到一个Flyable抽象接口里,让会飞的鸟去实现它,企鹅不继承这个接口。

所以选继承之前先问自己一句话:派生类对象替换掉基类对象之后,依赖基类的代码还能不能正确工作?答案是否定的,就不要用继承,改用组合。

1.2 派生类对象的物理结构长什么样

理解继承的所有诡异行为,最好的办法是先搞清楚一个派生类对象在内存里到底是什么。

拿一个最朴素的例子:

class Base { public: int a = 1; void f() { std::cout << "Base::f\n"; } }; class Derived : public Base { public: int b = 2; void g() { std::cout << "Derived::g\n"; } };

sizeof(Derived)在 64 位 g++ 上通常是 8,也就是Base的a在前,Derived自己的b在后,连续排布,没有额外间隙。这就是所谓的基类子对象在前、派生类新增成员在后。

如果Base里有虚函数,布局就会变成"虚表指针 + 数据成员"。Derived对象最开始那 8 个字节是一个指向虚函数表的指针(vptr),后面才是a、b。派生类如果新增了虚函数,会扩展虚表;如果重写了基类虚函数,会替换虚表里对应槽位的地址。

这个认知有两个直接用途。第一,你能解释为什么把Derived*隐式转成Base*是安全的、不需要任何地址调整——因为基类子对象就在对象头部,地址相同。第二,你能解释多重继承里为什么Derived*转第二个基类指针时编译器要给你加上一个偏移量——因为第二个基类子对象根本不在对象头部。搞不清这一点,多重继承的调试基本靠运气。

提示:想亲眼看到布局,可以用g++ -fdump-lang-class把类布局 dump 出来,或者用clang -Xclang -fdump-record-layouts。比死记规则管用得多。

1.3 从"能编译"到"写对"的分界线

这一章之所以是很多人的分水岭,因为它把 C++ 的几条主线全串起来了:访问控制、名字查找、对象生命周期、动态绑定、内存布局。任何一条没吃透,写出来的继承体系都是定时炸弹。

接下来我会按"访问权限 → 名字查找 → 构造析构 → 动态绑定 → 多重继承"的顺序拆,这正好也是编译器处理一个派生类时的大致流程。

2. 三种继承方式:权限账一次算清

2.1 权限变化对照表

很多人背过这张表,但只是背,没想明白它是怎么推出来的。其实规则只有一句:继承方式相当于在基类的每个成员前面,再套一层访问修饰符,取两者中更严格的那个。

基类中的成员public 继承后protected 继承后private 继承后
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可访问不可访问不可访问

关键在于"更严格"这个判定顺序:private 严于 protected,protected 严于 public。

这里最容易被忽略的是最后一行:无论用哪种继承方式,基类的 private 成员在派生类里都是不可直接访问的。它确实被继承下来了,实实在在占着对象的内存,派生类的构造函数也确实会去初始化它,但派生类的成员函数里就是写不出this->privateMember。

这不是设计缺陷,而是封装的必然结果。基类作者把成员设成 private,就是在说"你不许碰",派生类再怎么亲也不行。派生类想影响这些私有数据,唯一合法途径是调用基类提供的 public 或 protected 接口。

2.2 protected 成员到底该不该用

protected是继承体系里的一个"半开放"通道:对外封闭,对后代开放。听起来很贴心,实际用起来要谨慎。

我踩过的一个典型坑:基类里有个protected的std::vector<Node> children_,脚手架阶段派生类直接children_.push_back(x)用得飞起。后来发现需要在每次插入时同步维护一个计数和索引,就把push_back包成了一个addChild()函数。但原来那些直接操作children_的派生类代码全都没走新函数,索引和计数直接错乱。

问题的根源就是protected数据成员给了派生类"绕过接口"的能力。一旦放开这条路,基类后续的任何内部重构都变得极其昂贵,因为你不知道有多少个子类在偷偷摸你的字段。

我的经验是:protected用来开放函数(尤其是虚函数和钩子函数),尽量不用来开放数据成员。派生类需要读数据,给它 protected 的 getter;需要改数据,给它 protected 的 setter 或者模板方法。多写几行代码,换来的是后续改基类内部实现时的自由度。

2.3 私有继承和组合,什么时候选哪个

private继承在语义上是"用……实现"而不是"是……"。它把基类的所有 public 和 protected 成员都变成自己的 private,外界完全看不到这层继承关系,所以它本质上是把继承降级成了一种实现细节。

看这段代码:

class Timer { public: void start(); void stop(); double elapsed() const; }; // 方案 A:私有继承 class JobRunner : private Timer { public: void run() { start(); doWork(); stop(); std::cout << elapsed(); } private: void doWork(); }; // 方案 B:组合 class JobRunner { public: void run() { timer_.start(); doWork(); timer_.stop(); std::cout << timer_.elapsed(); } private: Timer timer_; void doWork(); };

两个方案功能完全等价。但方案 B 明显更好:语义更清楚(JobRunner有一个Timer,而不是JobRunner是一个Timer),而且当Timer需要换成另一个计时实现时,改一处成员声明就行,不需要动继承关系。

私有继承唯一还算合理的场景是EBO(空基类优化):当基类是个没有任何数据成员的空类,用它做基类可以让派生类对象里不占额外空间,而组合一个空类成员至少会占 1 个字节,还得考虑对齐。标准库里的std::allocator就是靠这个技巧避免给每个容器对象增加负担。除此之外,能组合就组合。

注意:protected 继承在实务中几乎没有存在价值。它既不像 public 继承那样能对外表达"是一种"的关系,又不像 private 继承那样把实现细节彻底藏住。遇到需要 protected 继承的场景,八成是设计出了问题。

3. 名字隐藏:继承体系里最容易翻车的一环

3.1 名字查找发生在重载解析之前

先看一段让无数人抓狂的代码:

class Base { public: void print(int x) { std::cout << "int " << x << "\n"; } void print(double x) { std::cout << "double " << x << "\n"; } void print(const char* s) { std::cout << "str " << s << "\n"; } }; class Derived : public Base { public: void print(const std::string& s) { std::cout << "string " << s << "\n"; } }; int main() { Derived d; d.print(42); // 编译错误 d.print("hello"); // 编译错误 }

d.print(42)报错,说找不到匹配的函数。可Base明明有个print(int)啊,而且Derived是 public 继承的。

原因在于 C++ 的名字查找规则:一旦在派生类作用域里找到了名字print,查找就立刻停止,不会再往基类作用域找。找到了之后才做重载解析,而此时候选集里只有一个print(const std::string&),42转不成std::string,于是报错。

注意这里的关键点:同一作用域内的同名函数才构成重载,不同作用域的同名函数是隐藏关系。基类和派生类是不同作用域,所以基类那三个print被派生类的print全部隐藏了,一个都不剩。

3.2 用 using 声明把重载集拉回来

解决方式就是一条using声明:

class Derived : public Base { public: using Base::print; // 把基类所有名为 print 的成员引入本作用域 void print(const std::string& s) { std::cout << "string " << s << "\n"; } };

加上这一行之后,Derived的print名字查找会把基类的三个print和派生类自己的一个合并成一个重载集,d.print(42)和d.print("hello")就都能正确匹配了。

这个坑在真实项目里非常隐蔽。比如基类有个void setName(const std::string&),派生类想加一个void setName(const char*),结果所有传std::string的调用点全部报错。报错位置在调用方,而问题在类定义里,隔了好几层文件,排查起来相当费劲。

3.3 数据成员之间的隐藏更阴险

函数隐藏会报编译错误,还算好。数据成员的隐藏则是悄无声息地出问题:

class Base { public: int id = 100; }; class Derived : public Base { public: int id = 200; // 隐藏了 Base::id }; Derived d; std::cout << d.id; // 200 std::cout << d.Base::id; // 100

两个id同时存在于对象里,各占 4 字节,sizeof(Derived)是 8。派生类里不加限定地写id永远访问到自己那个,基类那个只能靠Base::id或者Base&引用来访问。

这种设计基本没有正当理由,属于应该重命名解决的范畴。但更常见的情况是基类后来新增了一个成员,恰好和某个派生类的成员重名,这类问题在跨团队维护的框架代码里经常出现。我的习惯是:派生类的成员名统一加一个前缀或后缀,用命名约定物理上杜绝重名可能。

4. 构造与析构:对象的出生与死亡顺序

4.1 构造链自下而上,析构链自上而下

构造一个Derived对象时的完整顺序是:

  1. 分配内存(如果是栈对象,就是栈指针下移)。
  2. 构造虚基类子对象(如果有)。
  3. 按继承声明顺序构造直接基类子对象。
  4. 按成员声明顺序初始化本类数据成员。
  5. 执行本类构造函数体。

析构顺序严格相反:先执行派生类析构函数体,再析构成员,再析构基类,最后析构虚基类。

这里有两个必须记住的点。第一,基类子对象是按继承列表的顺序构造的,不是按初始化列表里写的顺序。写class D : public A, public B就一定先构造A再构造B,哪怕初始化列表里把B写在前面。第二,成员初始化顺序只看声明顺序,这也是老生常谈的坑了:

class Broken { int b_; int a_; public: Broken(int v) : a_(v), b_(a_) {} // 危险:b_ 先初始化,此时 a_ 还没赋值 };

b_(a_)读到的是尚未初始化的a_,属于未定义行为。编译器一般会给-Wreorder警告,但警告默认不一定开,得自己加上。

4.2 派生类初始化列表里必须显式调用基类构造

如果基类没有默认构造函数,派生类就必须在自己的初始化列表里显式调用基类的带参构造:

class Person { public: explicit Person(std::string name) : name_(std::move(name)) {} private: std::string name_; }; class Student : public Person { public: Student(std::string name, int score) : Person(std::move(name)) // 必须写,否则编译不过 , score_(score) {} private: int score_; };

不写这一行的话,编译器会尝试调用Person(),而它不存在,直接编译错误。这是好事——报错总比默默构造一个空对象强。

成员初始化列表还有一个性能上的理由。如果基类的name_是std::string,你在派生类构造体里写name_ = someName;,那是先默认构造一个空串,再赋值,多了一次构造加一次析构。写进初始化列表则是一次拷贝/移动构造,省一次。对std::string这种还算轻量的类型,差别不大;但如果是std::vector、std::map这类重家伙,差距就明显了。

实操心得:初始化列表里成员的书写顺序,我习惯严格按照声明顺序写,哪怕当前编译器不警告。这样 review 代码的人一眼就能看出有没有依赖未初始化成员。

4.3 虚析构函数:漏一个就可能崩

这是继承章节里最有名、后果也最严重的一条规则。

class Base { public: ~Base() { std::cout << "~Base\n"; } }; class Derived : public Base { public: ~Derived() { std::cout << "~Derived\n"; } private: int* buf_ = new int[1024]; }; int main() { Base* p = new Derived(); delete p; // 只打印 ~Base,Derived 的析构函数没有被调用 }

结果就是buf_指向的那 4KB 内存永远泄漏了,而且Derived如果持有文件句柄、锁、socket 之类的资源,全部泄漏。更糟的情况是行为未定义,可能在别的编译器上直接崩溃。

把基类析构函数改成virtual就能解决:

class Base { public: virtual ~Base() = default; };

此时delete p会通过虚表找到Derived的析构函数,先跑派生类析构,再自动调用基类析构,两个都执行。

那什么时候不需要虚析构?当这个类确定不会被多态删除时。判断标准很简单:如果这个类有任何虚函数,说明设计上就打算被继承和多态使用,那析构函数必须虚。如果这个类一个虚函数都没有,且你不打算拿基类指针去 delete 派生类对象,那可以不虚,省一个虚表指针的空间。

但实务中我的做法更保守:只要这个类是给别人继承的,析构函数一律加 virtual。一个虚表指针在 64 位系统上是 8 字节,相比内存泄漏和未定义行为的代价,完全不值一提。只有明确标记为final的类,或者纯粹做数据聚合的 struct,才省掉这个。

4.4 拷贝控制在这个体系里怎么处理

派生类默认生成的拷贝构造函数,会自动调用基类的拷贝构造函数;默认生成的拷贝赋值运算符,也会自动调用基类的拷贝赋值。这是符合直觉的。

但如果你手动实现了派生类的拷贝构造,又忘了在初始化列表里调用基类的拷贝构造,编译器会去调基类的默认构造,结果就是基类那部分数据被默认初始化了,而不是拷贝过来:

class Derived : public Base { public: Derived(const Derived& other) : /* 忘了写 Base(other) */ extra_(other.extra_) {} };

这时候Base那部分的数据会变成默认值,Base若有默认构造则不报错,静默地产生错误结果。而如果Base没有默认构造,则会编译错误——从这个角度看,给基类去掉默认构造反而是种保护。

还有个必须提的点:赋值运算符不处理自赋值也是继承体系里的经典问题。虽然现代 C++ 里用 copy-and-swap 惯用法能天然规避,但如果你手写赋值并且在里面delete了旧资源,一定要在最开头判断if (this == &other) return *this;。

至于移动语义,规则类似:派生类默认生成的移动构造会调用基类的移动构造。但如果基类没有定义移动操作、只定义了拷贝操作,那派生类的"移动"实际上会退化成拷贝。这在性能敏感的代码里是个隐形陷阱,用-Wall -Wextra配合编译器的一些静态分析能帮忙发现。

5. 虚函数与多态:动态绑定是怎么落地的

5.1 虚表、vptr 和一次间接调用的开销

每个含虚函数的类,编译器会给它生成一张虚函数表,本质是个函数指针数组,按虚函数的声明顺序排列。每个对象里存一个指向该表的指针,通常放在对象最前面。

调用p->area()时,实际发生的事情是:

  1. 从p指向的内存里取出 vptr。
  2. 按area在虚表中的固定槽位索引,取出函数地址。
  3. 用this = p调用这个地址。

对比普通成员函数调用(编译期就能确定地址,直接 call),虚调用多了一次内存读取和一次间接跳转。绝大多数场景下这点开销可以忽略,但如果你要在一个百万次循环的热点路径里调用虚函数,而且每个对象的类型都一样,那这个开销确实会显现出来。优化思路不是"别用虚函数",而是把虚调用提到循环外面,或者改用模板+静态多态(CRTP)来消除间接跳转。

另一个代价是每个对象多一个指针,还有 vptr 的初始化时机问题。vptr 是在构造函数的初始化阶段设置的,而且每进入一层构造函数,vptr 就会被改写成当前层次对应的虚表。这正是下一条要讲的诡异行为的根源。

5.2 override 和 final:让编译器帮你抓错

override这个关键字不改变任何语义,它的唯一作用是让编译器检查这个函数是否真的重写了基类的虚函数。看看不加它会发生什么:

class Base { public: virtual void process(int value) const; }; class Derived : public Base { public: void process(int value); // 漏了 const,实际上没有重写,只是隐藏了 };

Derived::process少了个const,签名不同,所以它没有重写Base::process,而是新加了一个函数,同时隐藏了基类的版本。结果就是通过Derived对象直接调用走新版本,通过Base*调用就走基类版本,行为不一致,而且一句警告都不一定有。

加上override之后,编译器立刻报错:"Derived::process没有重写任何基类成员函数"。同理,final用来禁止进一步重写或者禁止继承。

class Derived : public Base { public: void process(int value) const override; // 编译期检查,签名不对直接报错 }; class Leaf final : public Derived { }; // 不允许再被继承

我的习惯是:所有虚函数重写都写 override,没有例外。这相当于免费获得了一层编译期保护。

有一个反直觉但确实成立的规则值得单独说:虚函数的多态调用只看静态类型,不看访问权限。

class Base { public: virtual void run() { std::cout << "Base\n"; } }; class Derived : public Base { private: void run() override { std::cout << "Derived\n"; } // 注意是 private }; int main() { Derived d; // d.run(); // 编译错误:private Base& b = d; b.run(); // 正常输出 Derived }

通过基类引用调用时,编译器检查的是Base::run的访问权限(public,合法),运行时通过虚表找到的是Derived::run并执行它。访问控制在编译期检查,动态绑定发生在运行期,两者互不干涉。这个特性有其用途,比如 NVI(非虚接口)惯用法就依赖它:基类提供 public 非虚函数,内部调用 protected 或 private 的虚函数,把"什么时候调用"和"调用时做什么"分开。

5.3 构造函数和析构函数里调虚函数,一定会"失效"

class Base { public: Base() { print(); } virtual void print() { std::cout << "Base::print\n"; } }; class Derived : public Base { public: void print() override { std::cout << "Derived::print\n"; } }; Derived d; // 输出的是 Base::print

原因是构造Derived时,先进入Base的构造函数。此时Derived的成员还没构造,vptr 指向的是Base的虚表,所以虚调用只会找到Base::print。析构过程同理,顺序反过来而已。

这个行为的官方说法叫"构造函数和析构函数中调用的虚函数不会分派到派生类"。它其实是合理的保护机制——如果真让你调到Derived::print,而它访问了尚未初始化的成员,那才是灾难。

实际编码时,如果基类构造函数里确实需要执行某种"初始化钩子",用普通非虚函数、或者干脆要求派生类在构造完成后显式调用一个init()函数。别在构造/析构里依赖动态绑定。

5.4 纯虚函数与抽象类:把契约和实现分开

virtual double area() const = 0;这样的纯虚函数把类变成抽象类,不能实例化。

class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; virtual double perimeter() const = 0; };

抽象类和普通类的区别不只在于"能不能 new"。抽象类定义的是接口契约:任何具体的Shape都必须能算面积和周长,至于怎么算,完全是各个派生类自己的事。这种设计让上层算法可以只依赖Shape&写一次:

double totalArea(const std::vector<std::unique_ptr<Shape>>& shapes) { double sum = 0; for (const auto& s : shapes) sum += s->area(); return sum; }

以后新增三角形、五边形、任意多边形,这个函数一个字都不用改。这才是多态真正的价值——它对修改封闭,对扩展开放。

纯虚函数也可以有函数体(C++ 允许virtual void f() = 0;之后再在类外定义void Shape::f() { ... }),派生类通过Shape::f()显式调用。这个用法主要用于给派生类提供一个默认实现,但强制它们必须自己声明。

设计抽象类时有两条经验。第一,基类接口尽量窄。够用就行,多一个虚函数就多一份子类必须承担的义务,后续想加也加不进去(会破坏所有已有子类)。第二,基类的析构函数永远 virtual,这条没有例外,前面说过原因。

6. 多重继承与虚继承:什么时候能用,什么时候不能

6.1 多重继承本身没问题,问题在语义

class D : public A, public B在语法上完全合法,语义上要求D同时是A和B。最经典的正当场景是接口组合:一个类既实现了可序列化接口,又实现了可比较接口。

class Serializable { public: virtual ~Serializable() = default; virtual std::string serialize() const = 0; }; class Comparable { public: virtual ~Comparable() = default; virtual int compare(const Comparable& other) const = 0; }; class Record : public Serializable, public Comparable { // 实现两个接口的方法 };

这种情况下两个基类都是纯抽象类,没有任何数据成员,也就不会出现数据布局冲突。这是多重继承最安全、也最常见的用法。

问题出在多个基类有同名成员时:

class A { public: void hello() {} }; class B { public: void hello() {} }; class C : public A, public B { }; C c; c.hello(); // 编译错误:二义性 c.A::hello(); // 显式限定,合法

C对象里实际上有两个hello,来自两个不同的基类子对象,编译器无法选择,只能让你自己指明。这里的教训是:多重继承里,基类之间的名字冲突是必然要面对的问题,尽量让基类接口名字带上前缀或者干脆避免多继承带数据的类。

6.2 菱形继承与虚继承的真实代价

菱形继承是多重继承最著名的坑:

class Animal { public: int age_ = 0; }; class Mammal : public Animal { }; class Winged : public Animal { }; class Bat : public Mammal, public Winged { };

Bat对象里会有两份Animal子对象,Bat b; b.age_ = 5;直接编译错误,因为编译器不知道改哪一份。用b.Mammal::age_和b.Winged::age_分别访问,能编译,但显然这不是你想要的——一只蝙蝠只有一个年龄。

解法是虚继承:

class Mammal : public virtual Animal { }; class Winged : public virtual Animal { }; class Bat : public Mammal, public Winged { };

此时Bat里只保留一份Animal子对象,b.age_也能正常访问。

但代价不小。虚基类的子对象位置不再是固定的,因为它被放在了对象布局的末尾(g++ 是这样实现的),而且是通过虚基类表偏移量来定位的。这意味着:

  • 虚基类的构造函数由最终派生类负责调用,中间的Mammal、Winged在构造时会把虚基类的构造交给最底层的类,这个机制相当绕。
  • 从Bat*转成Animal*不再是一个简单的地址运算,需要查表。
  • 对象的sizeof会变大,多了几个指针。
  • 编译错误信息会变得极其难读,尤其是模板参与进来的时候。

我的实际建议是:菱形继承在设计层面上大多数时候是可以避免的,别等到用虚继承去救。如果Bat只需要复用Mammal和Winged的行为而不需要它们的数据,把那些行为抽成接口,让Bat分别实现;如果确实需要共享同一份Animal数据,那可能说明Animal更适合作为一个被组合的成员,而不是被继承的基类。

6.3 组合优于继承的判定清单

这一条虽然老生常谈,但落到具体决策上还是需要清单。我在评审继承关系时通常这么过一遍:

判断问题是否
派生类对象能否完全替代基类对象?可以考虑继承用组合
派生类需要访问基类的 protected 数据成员吗?重新设计,尽量改成函数更倾向于组合
基类会有多个维度变化吗?用组合 + 策略模式可以考虑继承
只是想复用几个工具函数?用组合或自由函数可以考虑继承
基类是纯接口(无数据、无实现)?大胆用继承仔细斟酌

清单里的"派生类需要访问 protected 数据成员吗",答案是"是"的时候要警惕。因为这是继承被滥用的最强信号——你想复用的其实是实现细节,而不是接口契约。真正的多态应该建立在接口之上。

7. 实战:一个可扩展的图形计算程序

光看规则记不住,用起来才记得牢。下面这个例子把前面讲的构造析构顺序、虚函数、虚析构、纯虚接口、多重继承接口组合一次性串起来。

7.1 需求与接口设计

需求很朴素:支持圆形、矩形、正方形三种图形,能算面积和周长,能统一打印,能放进一个容器里统一处理。

设计决策逐条说明:

  • 抽出抽象基类Shape,定义area()、perimeter()、describe()三个纯虚函数。这样上层只需要Shape&。
  • 基类析构函数必须virtual,因为要放进unique_ptr<Shape>并通过基类指针销毁。
  • 加一个Printable接口(纯虚类)演示多重继承组合接口,用默认实现提供通用打印逻辑。
  • Square采用组合一个Rectangle的方式,而不是从Rectangle继承。理由在下面会说明。

7.2 完整可编译代码

#include <cmath> #include <iostream> #include <memory> #include <string> #include <vector> class Printable { public: virtual ~Printable() = default; virtual std::string label() const = 0; // 非虚接口:统一打印格式,具体内容交给虚函数 void print(std::ostream& os) const { os << "[" << label() << "] " << payload() << "\n"; } protected: virtual std::string payload() const = 0; }; class Shape : public Printable { public: explicit Shape(std::string name) : name_(std::move(name)) { std::cout << " Shape ctor: " << name_ << "\n"; } ~Shape() override { std::cout << " Shape dtor: " << name_ << "\n"; } virtual double area() const = 0; virtual double perimeter() const = 0; std::string label() const override { return name_; } protected: std::string payload() const override { return "area=" + std::to_string(area()) + " perimeter=" + std::to_string(perimeter()); } private: std::string name_; }; class Circle : public Shape { public: explicit Circle(double r) : Shape("Circle"), r_(r) { std::cout << " Circle ctor, r=" << r_ << "\n"; } double area() const override { return 3.141592653589793 * r_ * r_; } double perimeter() const override { return 2 * 3.141592653589793 * r_; } private: double r_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : Shape("Rectangle"), w_(w), h_(h) { std::cout << " Rectangle ctor, " << w_ << "x" << h_ << "\n"; } double area() const override { return w_ * h_; } double perimeter() const override { return 2 * (w_ + h_); } private: double w_; double h_; }; // 正方形:组合一个 Rectangle,而不是继承 class Square : public Shape { public: explicit Square(double side) : Shape("Square"), rect_(side, side) { std::cout << " Square ctor, side=" << side << "\n"; } double area() const override { return rect_.area(); } double perimeter() const override { return rect_.perimeter(); } private: Rectangle rect_; }; int main() { std::cout << "sizeof(Shape) = " << sizeof(Shape) << "\n"; std::cout << "sizeof(Circle) = " << sizeof(Circle) << "\n"; std::cout << "sizeof(Rectangle) = " << sizeof(Rectangle) << "\n"; std::cout << "==== 构造开始 ====\n"; std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>(1.0)); shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0)); shapes.push_back(std::make_unique<Square>(2.0)); std::cout << "==== 统一处理 ====\n"; double total = 0.0; for (const auto& s : shapes) { s->print(std::cout); total += s->area(); } std::cout << "total area = " << total << "\n"; std::cout << "==== 析构开始 ====\n"; return 0; }

7.3 编译、运行与结果解读

编译命令,用 VSCode 的话直接在集成终端敲:

g++ -std=c++17 -Wall -Wextra -Wreorder -O2 -o shapes shapes.cpp ./shapes

-Wreorder是专门用来抓初始化列表顺序不一致的,前面提过的坑靠它兜底。建议把它写进.vscode/tasks.json的args数组里,这样每次 Ctrl+Shift+B 编译都会带上。

运行输出大致是这样:

sizeof(Shape) = 16 sizeof(Circle) = 24 sizeof(Rectangle) = 32 ==== 构造开始 ==== Shape ctor: Circle Circle ctor, r=1 Shape ctor: Rectangle Rectangle ctor, 3x4 Shape ctor: Square Shape ctor: Rectangle Rectangle ctor, 2x1 Square ctor, side=2 ==== 统一处理 ==== [Circle] area=3.141593 perimeter=6.283185 [Rectangle] area=12.000000 perimeter=14.000000 [Square] area=4.000000 perimeter=8.000000 total area = 19.141593 ==== 析构开始 ==== Shape dtor: Circle Shape dtor: Rectangle Rectangle dtor? ...

几处值得对着输出验证的点:

第一,sizeof(Shape)是 16 而不是 8。原因有两层:Shape有两个虚基类(Printable和Shape自己引入了虚表),vptr 占 8 字节,std::string name_在 libstdc++ 里是 32 字节,所以实际会更大。不同标准库实现会给出不同数字,重要的是它比单纯的数据成员总和要大,多出来的就是虚表指针和多继承带来的额外指针。

第二,构造顺序严格是"基类 → 成员 → 自身"。看Square那一段:先Shape ctor: Square(虚基类/直接基类),再Shape ctor: Rectangle+Rectangle ctor(因为组合了一个Rectangle成员,它也要完整构造一遍),最后才是Square ctor。这说明组合是有成本的——Square内部藏着一个完整的Rectangle对象。

为什么Square要组合而不是继承Rectangle?因为继承会带来一个语义麻烦:Rectangle有setWidth和setHeight,如果Square继承它,那Square也就有了这两个方法,调用square.setWidth(5)之后它就不再是正方形了。这就是著名的"正方形不是矩形"问题,属于里氏替换原则的典型反例。用组合,Square对外只暴露area()和perimeter(),不可能被改成非正方形。

第三,析构顺序和构造完全相反。每个对象先析构自己的部分,成员再析构,最后基类。因为Shape有virtual ~Shape(),delete一个Shape*时会正确分派到具体类型的析构函数,然后自动往上调用Shape的析构,不会漏。

第四,print走的是 NVI 模式。Printable::print是非虚的,payload是虚的。Shape重写了payload来组装统一的输出格式,具体数值来自各自的area/perimeter。这样做的好处是输出格式只在一个地方定义,未来想改成 JSON 或者加时间戳,改print一处就够了。

8. 常见问题速查与踩坑记录

把这一章最常撞上的问题整理成一张表,方便对号入座。

现象根因处理方式
delete基类指针时只调了基类析构基类析构函数不是 virtual基类析构加virtual,或加= default
派生类里同名函数导致基类所有重载报"找不到匹配"名字隐藏,不同作用域不构成重载派生类里加using Base::func;
加了override反而编译报错签名没对上(漏 const、参数类型不同、返回类型不协变)逐字对照基类声明的签名
构造函数里调用虚函数没走派生类版本构造期 vptr 指向当前层次虚表别在构造/析构里依赖动态绑定
多重继承下Derived*转第二个基类指针,地址变了第二个基类子对象不在对象头部用static_cast或dynamic_cast,别用reinterpret_cast
菱形继承下访问公共基类成员报二义性有两份基类子对象用虚继承,或重新设计为组合
赋值运算符里基类数据没被赋值手写operator=时没调用Base::operator=显式调用Base::operator=(other)
派生类成员初始化顺序和预期不一致初始化顺序只看声明顺序,不看列表顺序按声明顺序书写,开-Wreorder
派生类"移动"实际是拷贝基类只定义了拷贝操作,未定义移动操作基类补齐移动构造和移动赋值

除了表里这些,还有几条我踩过之后印象特别深的经验。

第一条,关于基类的可继承性判断。一个类该不该被继承,不该由派生类说了算,而应该由基类作者显式声明。C++ 里没有sealed之外的控制手段,所以我在给团队定规范时,会要求:所有不是为继承设计的类,一律加final;所有为继承设计的类,必须有一个 virtual 析构函数和一个写清楚的注释说明派生类需要实现什么。这条约定拦住的问题比任何静态检查工具都多。

第二条,关于虚函数的粒度。我见过一个基类有 20 多个虚函数,每个派生类只需要实现其中 2 到 3 个,剩下的全部要有空实现,否则编译不过。这种设计基本没法维护。正确做法是把虚函数拆分到多个小接口里,每个派生类只实现自己需要的几个接口。这就是接口隔离原则在 C++ 里的落地方式:基类越窄,扩展越自由。

第三条,宁可用protected函数也不用protected数据。这条前面说过,但值得重复。数据一旦暴露给派生类,就等于永久放弃了修改它的自由。函数则是契约,只要签名不变,内部怎么改都行。

第四条,别迷信多重继承的"灵活"。真正需要多继承的场景,绝大多数都是"实现多个纯接口",而不是"继承多个有状态的基类"。我经手的项目里,凡是继承了两个以上带数据成员的基类的地方,后面几乎都出过问题——要么是布局相关的 bug,要么是构造顺序相关的 bug,要么是虚继承带来的性能问题。

最后再说一个很小的技巧。写继承体系的时候,可以在每个派生类的构造函数和析构函数里加一行带类名的日志(就像上面实战代码里那样),然后写一个小的冒烟测试,把整条构造析构链打印出来。这套东西在开发阶段跑一遍,能一眼看出构造顺序对不对、析构有没有漏。等确认无误之后,用宏把这些日志关掉,代码干干净净地进生产。我自己排查过好几次"对象提前析构"的诡异问题,最后都是靠这些日志定位的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询