Skip to content

抽象类与接口:定好"做什么",不管"怎么做" ​

上一章咱们把虚函数和多态的机制完整过了一遍,知道了通过基类指针调用 speak() 时,编译器会怎么查 vtable、怎么在运行时找到真正的函数地址。但有一个问题咱们刻意绕过了:如果基类本身就不该被实例化呢?比如咱们定义了一个 Shape 类来表示"形状",但"形状"本身是个抽象概念,世界上不存在一个"既不是圆、也不是矩形、也不是任何具体形状"的 Shape 对象。它只是一个公共接口,真正有意义的都是它的派生类。

这就是抽象类要解决的问题。这一章咱们从纯虚函数讲起,搞清楚 C++ 中的抽象类机制,然后讨论接口设计——特别是 C++ 没有 interface 关键字这件事该怎么处理,以及接口隔离原则在实际工程中怎么落地。

您要是习惯从 Java 或 C# 转过来,可能会下意识地以为 C++ 的抽象类和 Java 的 abstract class 完全等价。大部分情况下确实如此,但 C++ 的纯虚函数有一个 Java 不具备的特性:纯虚函数可以拥有默认实现。这个差异后面会专门讲,先别急。

纯虚函数与抽象类的诞生 ​

咱们想让一个类变成"不可实例化"的抽象类,只需要在类中声明至少一个纯虚函数。语法很简单,在虚函数声明的末尾加上 = 0:

C++
class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;       // 纯虚函数
    virtual const char* name() const = 0;   // 纯虚函数
};

= 0 这个写法看起来有点怪,但它的语义很明确:这个函数在基类中没有实现,派生类必须提供自己的版本。包含至少一个纯虚函数的类就是抽象类,编译器会阻止咱们直接创建它的对象:

C++
Shape s;            // 编译错误!不能实例化抽象类
Shape* p = nullptr; // OK,指针和引用是可以的

咱们通过指针或引用来操作派生类对象完全没问题,这正是多态发挥作用的前提。派生类要想变成"具体类"(可以实例化的类),就必须把基类中的每一个纯虚函数都实现一遍,一个都不能落下:

展开代码收起代码共 25 行
C++
class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_(r) {}
    double area() const override { return 3.14159265 * radius_ * radius_; }
    const char* name() const override { return "Circle"; }
};

class Rectangle : public Shape {
    double width_, height_;
public:
    Rectangle(double w, double h) : width_(w), height_(h) {}
    double area() const override { return width_ * height_; }
    const char* name() const override { return "Rectangle"; }
};

// 通过基类引用统一操作
void print_area(const Shape& shape) {
    std::cout << shape.name() << ": " << shape.area() << std::endl;
}

Circle c(2.0);
Rectangle r(3.0, 4.0);
print_area(c);  // Circle: 12.5664
print_area(r);  // Rectangle: 12

如果派生类忘记实现某个纯虚函数,那么派生类自己也会变成抽象类。这时候咱们尝试实例化它,编译器会报错说 "cannot instantiate abstract class"。初学者经常被这个报错搞得一脸懵,原因往往就是漏掉了一个纯虚函数没有 override。好消息是编译器的错误信息里通常会列出哪些纯虚函数还没实现,照着补上就行。

抽象类的设计思路 ​

咱们把抽象类的设计思路记成一句话:基类定义"能做什么",派生类决定"怎么做"。

在咱们上面的 Shape 例子中,Shape 说"每个形状都能计算面积、都有名字",但具体怎么算、叫什么名字,交给 Circle 和 Rectangle 自己去定。这种分工非常清晰:基类是一份契约,派生类是签约方。任何派生类想要成为"可用的具体类型",就必须履行契约中规定的全部义务。

再看一个更贴近实际工程的例子。假设咱们正在开发一个日志系统,需要支持多种输出目标——控制台、文件、网络。咱们可以定义一个抽象的 ILogger 来统一接口,然后 ConsoleLogger 直接输出到 std::cout,FileLogger 追加写入到指定文件。上层业务代码只需要依赖 ILogger 接口,完全不需要知道底层是写到控制台还是写到文件:

C++
class ILogger {
public:
    virtual ~ILogger() = default;
    virtual void log_info(const std::string& msg) = 0;
    virtual void log_warning(const std::string& msg) = 0;
    virtual void log_error(const std::string& msg) = 0;
};

class ConsoleLogger : public ILogger {
public:
    void log_info(const std::string& msg) override {
        std::cout << "[INFO] " << msg << std::endl;
    }
    void log_warning(const std::string& msg) override {
        std::cout << "[WARN] " << msg << std::endl;
    }
    void log_error(const std::string& msg) override {
        std::cout << "[ERROR] " << msg << std::endl;
    }
};

解耦!,这就是咱们最常说的解耦啊朋友们!这就是抽象类最核心的价值。抽象类把"接口定义"和"具体实现"彻底分离开来,使得咱们可以独立地修改其中一端而不影响另一端。未来新增一个 NetworkLogger 只需继承 ILogger 并实现三个方法,上层代码一行都不用改。

C++ 的接口:没有 interface 关键字的世界 ​

您要是写过 Java 或 C#,可能会觉得奇怪:C++ 怎么连个 interface 关键字都没有?确实没有,但 C++ 的抽象类机制完全能覆盖接口的语义。C++ 社区的惯例是:当一个类的所有成员函数都是纯虚函数,并且它没有非静态数据成员,咱们就称它为一个接口类(interface class)。

C++
// 标准的 C++ 接口类
class ISerializable {
public:
    virtual ~ISerializable() = default;
    virtual std::string serialize() const = 0;
    virtual bool deserialize(const std::string& data) = 0;
};

咱们注意几个细节。接口类的名字习惯以 I 开头(比如 ISerializable、IComparable、ILogger),这是一种广泛使用的命名约定,能让咱们一眼就认出"这是一个纯接口"。虚析构函数是必须的——只要咱们的类有可能通过基类指针被 delete,虚析构函数就是不可妥协的底线。= default 是 C++11 引入的写法,比手写一个空的析构函数体更简洁,也表达了"使用编译器生成的默认实现"的语义。

接口隔离原则——别让派生类实现它不需要的方法 ​

咱们聊到接口设计,就不得不提 SOLID 原则中的 I——接口隔离原则(Interface Segregation Principle, ISP)。它的核心思想是:不要强迫一个类去实现它用不到的方法。

举个反面例子。假设咱们定义了一个"全能"的设备接口,把连接管理、数据读写、串口配置全部混在一起:

C++
// 反面教材:臃肿的"胖接口"
class IDevice {
public:
    virtual ~IDevice() = default;
    virtual bool connect() = 0;
    virtual void disconnect() = 0;
    virtual int read() = 0;
    virtual void write(int value) = 0;
    virtual void flush() = 0;
    virtual void set_baudrate(int rate) = 0;
};

咱们要是实现一个只读的温度传感器,它根本不需要 write()、flush()、set_baudrate() 这些方法,但因为是继承自 IDevice,还是得全部实现一遍,哪怕只是写个空函数或者直接抛异常。更严重的问题是它会模糊类型之间的语义边界:一个只能读的传感器被迫宣称自己"会写",调用者很容易被误导。

咱们正确的做法是把大接口拆成若干个小而聚焦的接口,每个接口只描述一种能力:

C++
class IConnectable {
public:
    virtual ~IConnectable() = default;
    virtual bool connect() = 0;
    virtual void disconnect() = 0;
};

class IReadable {
public:
    virtual ~IReadable() = default;
    virtual int read() = 0;
};

class IWritable {
public:
    virtual ~IWritable() = default;
    virtual void write(int value) = 0;
    virtual void flush() = 0;
};

现在每种设备只需要继承自己真正需要的接口。咱们的一个只读温度传感器实现 IConnectable 和 IReadable 就够了,完全不用碰 IWritable;而一个全双工的串口驱动可以实现所有三个接口。这正是接口隔离原则想要达成的效果:每个类只暴露它真正支持的能力,不多也不少。

纯虚函数的默认实现——一个容易被忽略的高级技巧 ​

咱们接下来聊一个比较少被提及、但在某些场景下非常有用的特性:纯虚函数可以有函数体。

没错,您没看错。一个声明为 = 0 的纯虚函数,依然可以在类外部提供一个默认实现:

C++
class Base {
public:
    virtual ~Base() = default;
    virtual void on_error(const std::string& msg) = 0;  // 纯虚函数
};

// 纯虚函数的默认实现——类外部定义
void Base::on_error(const std::string& msg) {
    std::cerr << "[ERROR] " << msg << std::endl;
}

这看起来有点矛盾——既然是"纯虚"的,怎么还有实现?关键在于:= 0 影响的是类的抽象性质(有没有这个实现不影响类是否为抽象类),而函数体提供的是一个可选的默认行为。派生类仍然必须 override 这个函数,但咱们可以选择在 override 的版本中显式调用基类版本来复用通用逻辑:

C++
class Derived : public Base {
public:
    void on_error(const std::string& msg) override {
        Base::on_error(msg);    // 先复用基类的默认行为
        write_to_log_file(msg); // 再追加自己的处理
    }
};

这种技术咱们常在框架设计中见到:基类通过纯虚函数强制派生类"必须处理这个事件",同时又提供通用默认行为供按需复用。不过这种用法在日常业务代码中并不常见,这里点到为止,知道有这么回事就行。

实战演练——一个序列化器框架 ​

现在咱们把前面学的东西串起来,动手实现一个完整的小框架。场景是这样的:咱们需要一套序列化框架,支持把数据转成 JSON 或 XML 格式。通过定义一个 ISerializer 接口,上层代码完全不关心底层用的什么格式。

C++
#include <iostream>
#include <string>

/// @brief 序列化器接口——支持不同格式的数据输出
class ISerializer {
public:
    virtual ~ISerializer() = default;
    virtual void begin_object(const std::string& name) = 0;
    virtual void end_object() = 0;
    virtual void write_field(const std::string& key,
                             const std::string& value) = 0;
    virtual std::string result() const = 0;
};

然后咱们实现 JSON 和 XML 两种序列化器。它们的内部结构很不一样(JSON 需要处理花括号和引号,XML 需要处理标签对),但对外暴露的接口完全一致:

展开代码收起代码共 32 行
C++
class JSONSerializer : public ISerializer {
    std::string output_;
    int depth_ = 0;
public:
    void begin_object(const std::string& name) override {
        if (depth_ > 0) output_ += "\"" + name + "\": ";
        output_ += "{\n";
        ++depth_;
    }
    void end_object() override { --depth_; output_ += "}\n"; }
    void write_field(const std::string& key,
                     const std::string& value) override {
        output_ += "  \"" + key + "\": \"" + value + "\",\n";
    }
    std::string result() const override { return output_; }
};

class XMLSerializer : public ISerializer {
    std::string output_;
    std::string current_tag_;
public:
    void begin_object(const std::string& name) override {
        current_tag_ = name;
        output_ += "<" + name + ">\n";
    }
    void end_object() override { output_ += "</" + current_tag_ + ">\n"; }
    void write_field(const std::string& key,
                     const std::string& value) override {
        output_ += "  <" + key + ">" + value + "</" + key + ">\n";
    }
    std::string result() const override { return output_; }
};

咱们在上层函数里通过接口引用来使用序列化器,它对 JSON 和 XML 的细节一无所知:

C++
void serialize_sensor_data(ISerializer& serializer) {
    serializer.begin_object("sensor");
    serializer.write_field("id", "TEMP-001");
    serializer.write_field("value", "23.5");
    serializer.write_field("unit", "celsius");
    serializer.end_object();
}

int main() {
    JSONSerializer json;
    XMLSerializer xml;
    serialize_sensor_data(json);
    serialize_sensor_data(xml);
    std::cout << "=== JSON ===\n" << json.result() << "\n";
    std::cout << "=== XML ===\n" << xml.result() << std::endl;
    return 0;
}

编译运行,看看输出:

text
=== JSON ===
{
  "id": "TEMP-001",
  "value": "23.5",
  "unit": "celsius",
}

=== XML ===
<sensor>
  <id>TEMP-001</id>
  <value>23.5</value>
  <unit>celsius</unit>
</sensor>

serialize_sensor_data 函数只知道"有序列化器这个东西,我能往里写字段"。未来咱们需要支持 YAML、Protobuf 或者任何新格式,只需要新增一个 ISerializer 的实现类就行,上层代码一行都不用改。这正是抽象类和接口带来的扩展性。

练手时间 ​

练习一:设计 IComparable 接口 ​

请您定义一个 IComparable<T> 接口模板,包含一个纯虚函数 compare_to。然后实现一个 Student 类,按学号排序。

C++
template <typename T>
class IComparable {
public:
    virtual ~IComparable() = default;
    /// @returns <0 表示 this < other, 0 表示相等, >0 表示 this > other
    virtual int compare_to(const T& other) const = 0;
};

练习二:插件系统框架 ​

请您设计一个简单的插件框架:定义一个 IPlugin 接口(包含 name()、version()、initialize() 和 shutdown() 四个纯虚函数),然后实现两到三个具体的插件类。编写一个 PluginManager,用 std::vector<IPlugin*> 管理所有插件,并提供 load_all() 和 unload_all() 方法。这个练习能把抽象类、接口和运行时多态综合起来用一遍。

pdf-latest-70-g3de3d0a · 3de3d0a · 2026-09-27