第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。它的核心思想是:接口函数不应该是虚函数,虚函数应该是私有的,由接口函数内部调用。

为什么要这么折腾?直接让用户调用虚函数不好吗?

不好。原因有三:

  1. 虚函数是公开的,用户可以直接调用,绕过了你设计的流程控制。
  2. 你没法在虚函数调用前后加一些通用逻辑(比如日志、锁、权限检查)。
  3. 基类和派生类的职责边界模糊了。

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(),根本不知道有doOpendoSave的存在。派生类只需要关注“怎么打开”和“怎么保存”,不用操心“打开前要检查权限”、“保存后要通知观察者”这些通用逻辑。

个人经验:我在做游戏引擎的序列化系统时,用了NVI模式。所有资源类的serialize()接口都是公开的非虚函数,内部调用私有的doSerialize()。这样我可以在序列化前后统一处理版本号、校验和,派生类只管写自己的数据。后来加加密功能,一行代码都没改派生类。

三种模式的对比

模式 适用场景 优点 缺点
纯虚类接口 定义纯粹的契约,如策略模式、回调接口 最轻量,无实现污染 无法添加通用逻辑
接口继承 需要多态行为,派生类各自实现 灵活,派生类自由发挥 容易滥用,导致接口膨胀
NVI模式 需要统一的前置/后置处理 控制力强,职责清晰 代码量稍多,需要额外设计

知识体系结构图

下面这张图帮你理清今天讲的核心逻辑:

C++接口设计核心脉络 纯虚类作为接口 接口继承 vs 实现继承 NVI非虚接口模式 只包含纯虚函数 + 虚析构 无数据成员,无实现代码 继承能力契约,必须实现 继承代码状态,可复用 公开非虚接口 + 私有虚函数 前置/后置处理统一管理 核心原则:接口与实现分离,职责单一

实际项目中的选择建议

说了这么多,到底怎么选?我个人的经验是:

  • 如果你只是想要一个回调接口,用纯虚类,最轻量。
  • 如果派生类之间有大量共享代码,用实现继承,别重复造轮子。
  • 如果你需要严格控制调用流程,比如加锁、日志、权限检查,用NVI模式。

最后说一句:接口设计没有银弹。纯虚类、接口继承、NVI模式,各有各的适用场景。关键是你得想清楚:这个接口到底要解决什么问题?是定义契约?是复用代码?还是控制流程?想明白了,选哪个都不会错。

嗯,今天就聊到这里。接口设计这件事,越早想清楚,后面越省心。


公众号:蓝海资料掘金营,微信deep3321