设计一套可扩展的 C++ 系统时,你迟早会写出「只定义契约、不提供实现」的类:它规定「凡是我的子类都必须有area()和draw(),但怎么实现我不管」。这种类就是抽象类(abstract class),靠纯虚函数(pure virtual function)实现。它看起来简单,却藏着两个反直觉的坑(纯虚函数其实可以有实现、纯虚析构函数反而必须给实现),踩中任何一个都可能让你面对诡异的链接错误。这篇把机制、坑和设计规范一次讲透。
1. 引子:什么是抽象类
把虚函数写成= 0,它就变成了纯虚函数;只要类里含至少一个纯虚函数,这个类就是抽象类,不能直接实例化。
// demo1.cpp — 编译: g++ -std=c++17 -Wall -O2 demo1.cpp -o demo1#include<iostream>#include<string>classShape{// 抽象类public:virtualdoublearea()const=0;// 纯虚函数:没有 =0 的实现virtual~Shape()=default;};classCircle:publicShape{doubler_=1.0;public:doublearea()constoverride{return3.14159*r_*r_;}// 必须实现};intmain(){Circle c;std::cout<<"area="<<c.area()<<'\n';}area=3.14159如果反过来想new Shape或栈上Shape s;,编译器会直接拒绝。这正是抽象类存在的意义:它只描述「应该有什么能力」,本身不完整。
error: cannot declare variable 's' to be of abstract type 'Shape' because the following virtual functions are pure within 'Shape': 'virtual double Shape::area() const'2. 反直觉点一:纯虚函数可以有定义
很多教材说「纯虚函数没有实现」,严格说只对了一半。纯虚函数可以有函数体,只是定义必须写在类外;派生类既可以选择自己重写,也可以主动调用基类那份实现。这在「提供一个默认行为、但强制子类至少表态」时很有用。
// demo2.cpp — 编译: g++ -std=c++17 -Wall -O2 demo2.cpp -o demo2#include<iostream>#include<string>classAnimal{public:virtualstd::stringsound()const=0;// 纯虚virtual~Animal()=default;protected:std::stringfallback()const{return"[unknown]";}};// 纯虚函数的定义写在类外(这是合法的!)std::stringAnimal::sound()const{returnfallback();// 提供一个可被调用的默认实现}classDog:publicAnimal{public:std::stringsound()constoverride{return"Woof";}};classMystery:publicAnimal{public:// 不写自己的逻辑,主动调用基类的纯虚实现std::stringsound()constoverride{returnAnimal::sound();}};intmain(){Dog d;Mystery m;std::cout<<d.sound()<<'\n';std::cout<<m.sound()<<'\n';}Woof [unknown]Mystery::sound()通过Animal::sound()显式调用了基类那份纯虚实现:这在普通虚函数里做不到,却恰好是纯虚函数能做到的「反直觉」之处。
官方文档:abstract class (cppreference) —— 明确说明纯虚函数可以提供定义,且只能通过
Base::f()形式调用。
3. 反直觉点二:纯虚析构函数必须有定义
析构函数也可以是纯虚的,但纯虚析构函数必须提供定义,否则派生类析构时会链接失败。原因是:派生类析构函数执行完后,会隐式调用基类的析构函数;如果基类析构没有实体,链接器就找不到符号。
// demo3.cpp — 编译: g++ -std=c++17 -Wall -O2 demo3.cpp -o demo3#include<iostream>classBase{public:virtual~Base()=0;// 纯虚析构};Base::~Base(){std::cout<<"Base destroyed\n";}// 必须提供定义classDerived:publicBase{public:~Derived(){std::cout<<"Derived destroyed\n";}};intmain(){Derived d;// 离开作用域:先 ~Derived 再 ~Base}Derived destroyed Base destroyed构造与析构的调用链严格相反:构造时基类子对象先建、派生部分后建;析构时派生部分先拆、基类子对象后拆:
Derived d; ┌──────────────┐ │ Base 部分 │ ← ~Base() 最后调用(需要 Base::~Base 有定义) │ Derived 部分│ ← ~Derived() 先调用 └──────────────┘如果漏掉Base::~Base()的定义,会得到一个扑朔迷离的链接错误:
undefined reference to `Base::~Base()'注意:报错的不是Base自己,而是它的派生类Derived的析构:这常让人误以为是派生类写错了。
官方文档:virtual function (cppreference) —— 析构函数作为虚函数与纯虚函数时的语义说明。
4. 普通虚函数 vs 纯虚函数
一张表厘清两者的关键差异,避免混用:
| 项 | 普通虚函数virtual f() | 纯虚函数virtual f() = 0 |
|---|---|---|
| 派生类是否必须重写 | 否(可继承默认实现) | 是(否则派生类仍是抽象类) |
| 含它的类能否实例化 | 能 | 不能(抽象类) |
| 能否有函数体/定义 | 能(类内或类外) | 能,但定义必须写在类外 |
| 典型用途 | 提供可覆写的默认行为 | 定义接口、强制子类实现契约 |
下面这小段代码印证「普通虚函数基类可实例化、带默认行为」:
// demo4.cpp — 编译: g++ -std=c++17 -Wall -O2 demo4.cpp -o demo4#include<iostream>#include<string>classReader{public:virtualstd::stringsource()const{return"unknown";}// 普通虚函数virtual~Reader()=default;};classFileReader:publicReader{public:std::stringsource()constoverride{return"file";}};intmain(){Reader r;// 普通虚函数基类可实例化FileReader f;std::cout<<r.source()<<'\n';std::cout<<f.source()<<'\n';}unknown file5. 接口该怎么设计:Core Guidelines 的建议
抽象基类最典型的用途是当作接口(interface)。C++ Core Guidelines 对「接口类」有几个明确主张:
- 接口 = 纯虚函数 + 虚析构 + 无数据成员。不放数据成员,接口就只表达「能力」,不掺状态,派生类各管各的状态。
- 基类析构要么
public+virtual,要么protected+ 非virtual。前者允许通过基类指针删除对象(多态删除),后者表示「这个类只该被继承、不打算被多态删除」。 - 不要塞太多方法(ISP 接口隔离原则的体现):只放真正需要被多态调用的函数,能用更小接口就别搞「胖接口」。
推荐接口结构(ISP 视角):接口只暴露「真正需要被多态调用」的契约 Shape(抽象类 / 接口) ┌──────────────────────────┐ │ + area() = 0 (纯虚) │◄── 只描述必要能力 │ + draw() = 0 (纯虚) │ │ + ~Shape() virtual │ │ (无数据成员) │ └──────────────────────────┘ ▲ ▲ Circle Square + area() + area() + draw() + draw() - r_ - s_6. 完整示例:形状接口 + 两个实现 + 多态调用
把上面的规范串起来:抽象基类只放纯虚函数与虚析构、无数据成员;两个派生类各自持有状态并实现接口;用std::unique_ptr<Shape>管理生命周期,避免裸new/delete。
// demo5.cpp — 编译: g++ -std=c++17 -Wall -O2 demo5.cpp -o demo5#include<iostream>#include<memory>#include<vector>classShape{// 抽象基类 = 接口public:virtualdoublearea()const=0;virtualvoiddraw()const=0;virtual~Shape()=default;// public + virtual 析构};classCircle:publicShape{doubler_;public:explicitCircle(doubler):r_{r}{}doublearea()constoverride{return3.14159*r_*r_;}voiddraw()constoverride{std::cout<<"draw Circle r="<<r_<<'\n';}};classSquare:publicShape{doubles_;public:explicitSquare(doubles):s_{s}{}doublearea()constoverride{returns_*s_;}voiddraw()constoverride{std::cout<<"draw Square s="<<s_<<'\n';}};intmain(){std::vector<std::unique_ptr<Shape>>shapes;shapes.push_back(std::make_unique<Circle>(2.0));shapes.push_back(std::make_unique<Square>(3.0));for(autoconst&p:shapes){p->draw();std::cout<<"area="<<p->area()<<'\n';}}draw Circle r=2 area=12.5664 draw Square s=3 area=97. 延伸阅读
- abstract class (cppreference) —— 纯虚函数与抽象类实例化规则的权威说明。
- virtual function (cppreference) —— 虚函数、虚析构与覆盖规则的完整参考。
- C++ Core Guidelines C.35 —— 基类析构应 public+virtual 或 protected+非 virtual。
- C++ Core Guidelines C.129 —— 区分实现继承与接口继承。
本知识库内的相关篇目:
- 《多重继承与菱形继承:虚继承到底解决了什么》 —— 一个类继承两个基类
- 《对象切片(object slicing):多态失效的隐形杀手》 —— 把派生类对象按值赋给基类对象、按值传给基类参数、塞进 std::vector
- 《override 与 final:把多态错误拦在编译期》 —— 派生类想重写基类虚函数却写错签名,编译器一声不吭,运行时多态静默失效——这是最隐蔽的 bug 之一。
8. 一句话总结
纯虚函数= 0定义抽象类、强制派生类实现接口,但它「可以有类外定义、可供派生类主动调用」;纯虚析构函数恰恰相反:必须自己给定义,否则派生类析构链接失败。接口类请遵循「纯虚函数 + 虚析构 + 无数据成员」,按 Core Guidelines 把析构设为 public+virtual,并接口尽量精简。