第25章:C++中的接口设计:纯虚类作为接口,接口继承与实现继承,NVI(非虚接口)模式
接口设计这件事,说白了就是定规矩。你定好一套契约,别人照着实现就行。我在项目中见过太多因为接口设计混乱导致的惨案——改一个接口,牵动十几个派生类,改完还编译不过。嗯,今天我们就来聊聊C++里怎么把接口设计得既优雅又抗揍。
纯虚类作为接口:最纯粹的契约
C++里没有Java那种专门的interface关键字。但我们有纯虚函数。一个类如果只包含纯虚函数,没有数据成员,没有非虚函数,那它就是最纯粹的接口。
class IShape {
public:
virtual ~IShape() = default; // 虚析构,必须的
virtual double area() const = 0; // 纯虚函数
virtual double perimeter() const = 0; // 纯虚函数
};
我个人习惯给接口类加个I前缀,一眼就能认出来。你看这个IShape,它只规定了“形状要有面积和周长”,至于怎么算,它不管。
核心要点:纯虚类作为接口时,不要放任何数据成员,不要放任何非虚函数实现。接口就是接口,别掺和实现细节。
接口继承 vs 实现继承:别搞混了
很多新手分不清这两种继承。我当年也踩过坑。简单说:
- 接口继承:继承的是“能力”和“契约”。派生类必须提供实现。
- 实现继承:继承的是“代码”和“状态”。基类已经写好了,派生类直接拿来用或者重写。
举个例子:
// 接口继承:IShape 只定义契约
class Circle : public IShape {
public:
double area() const override { return 3.14 * r_ * r_; }
double perimeter() const override { return 2 * 3.14 * r_; }
private:
double r_;
};
// 实现继承:Widget 提供了默认行为
class Widget {
public:
virtual void draw() { /* 默认绘制逻辑 */ }
};
class Button : public Widget {
public:
void draw() override { /* 按钮自己的绘制逻辑 */ }
};
你想想看,Circle继承IShape,它必须自己实现所有方法,这叫接口继承。Button继承Widget,它可以复用基类的draw,这叫实现继承。两者目的完全不同。
避坑指南:我曾经在一个项目中把实现继承当接口继承用,基类里放了数据成员,结果派生类之间互相干扰,调试了整整两天。记住:接口继承用纯虚类,实现继承用普通基类。别混着来。
NVI(非虚接口)模式:把接口和实现彻底分离
NVI模式,全称Non-Virtual Interface。它的核心思想是:接口函数不应该是虚函数,虚函数应该是私有的,由接口函数内部调用。
为什么要这么折腾?直接让用户调用虚函数不好吗?
不好。原因有三:
- 虚函数是公开的,用户可以直接调用,绕过了你设计的流程控制。
- 你没法在虚函数调用前后加一些通用逻辑(比如日志、锁、权限检查)。
- 基类和派生类的职责边界模糊了。
NVI模式长这样:
class Document {
public:
// 公开的非虚接口
void open() {
// 前置处理:检查权限、记录日志
std::cout << "Opening document..." << std::endl;
// 调用私有虚函数,由派生类实现
doOpen();
// 后置处理:更新状态、通知观察者
std::cout << "Document opened." << std::endl;
}
void save() {
std::cout << "Saving document..." << std::endl;
doSave();
std::cout << "Document saved." << std::endl;
}
virtual ~Document() = default;
private:
// 私有的虚函数,派生类可以重写
virtual void doOpen() = 0;
virtual void doSave() = 0;
};
class TextDocument : public Document {
private:
void doOpen() override {
std::cout << "Opening text file..." << std::endl;
}
void doSave() override {
std::cout << "Saving text file..." << std::endl;
}
};
你看,用户只能调用open()和save(),根本不知道有doOpen和doSave的存在。派生类只需要关注“怎么打开”和“怎么保存”,不用操心“打开前要检查权限”、“保存后要通知观察者”这些通用逻辑。
个人经验:我在做游戏引擎的序列化系统时,用了NVI模式。所有资源类的serialize()接口都是公开的非虚函数,内部调用私有的doSerialize()。这样我可以在序列化前后统一处理版本号、校验和,派生类只管写自己的数据。后来加加密功能,一行代码都没改派生类。
三种模式的对比
| 模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 纯虚类接口 | 定义纯粹的契约,如策略模式、回调接口 | 最轻量,无实现污染 | 无法添加通用逻辑 |
| 接口继承 | 需要多态行为,派生类各自实现 | 灵活,派生类自由发挥 | 容易滥用,导致接口膨胀 |
| NVI模式 | 需要统一的前置/后置处理 | 控制力强,职责清晰 | 代码量稍多,需要额外设计 |
知识体系结构图
下面这张图帮你理清今天讲的核心逻辑:
实际项目中的选择建议
说了这么多,到底怎么选?我个人的经验是:
- 如果你只是想要一个回调接口,用纯虚类,最轻量。
- 如果派生类之间有大量共享代码,用实现继承,别重复造轮子。
- 如果你需要严格控制调用流程,比如加锁、日志、权限检查,用NVI模式。
最后说一句:接口设计没有银弹。纯虚类、接口继承、NVI模式,各有各的适用场景。关键是你得想清楚:这个接口到底要解决什么问题?是定义契约?是复用代码?还是控制流程?想明白了,选哪个都不会错。
嗯,今天就聊到这里。接口设计这件事,越早想清楚,后面越省心。
公众号:蓝海资料掘金营,微信deep3321