C++面向接口编程:从抽象基类到依赖注入的工程实践

📅 2026/7/15 20:39:04
C++面向接口编程:从抽象基类到依赖注入的工程实践
1. 项目概述从“拧螺丝”到“画蓝图”的思维跃迁干了十多年C从桌面应用到后台服务从嵌入式设备到游戏引擎我踩过最大的坑往往不是某个语法特性没用好而是代码结构从一开始就“长歪了”。新手和老手写出的C代码最本质的区别往往不在于用了多少奇技淫巧而在于看待代码的视角你是盯着眼前这个“螺丝”怎么拧面向实现还是在规划整个“建筑”的框架和连接方式面向接口。今天要聊的“面向接口编程而不是面向实现编程”就是这道分水岭。它不是一个新潮的概念而是贯穿软件工程生命周期的核心设计哲学尤其在C这种兼具高性能与复杂性的语言里理解并实践它是写出可维护、可扩展、经得起时间考验的代码的关键。简单来说面向接口编程Interface-based Programming就是让你的代码依赖抽象蓝图、契约而不是具体实现砖块、螺丝。在C的语境下这个“接口”通常指的就是包含纯虚函数的抽象基类Abstract Base Class, ABC。它定义了一组行为契约告诉使用者“我能做什么”但不规定“我具体怎么做”。而面向实现编程则是直接依赖具体的类代码里充满了对具体数据成员和实现细节的硬编码。举个例子你要写一个日志系统。面向实现的做法可能是直接#include “FileLogger.h”然后FileLogger logger; logger.WriteToFile(...);。一旦哪天需要把日志输出到网络或者控制台你就得把代码里所有用到FileLogger的地方翻个底朝天。而面向接口的做法是先定义一个ILogger接口类里面有virtual void Log(const std::string message) 0;。你的业务代码只和ILogger*或std::unique_ptrILogger打交道。至于背后是FileLogger、NetworkLogger还是ConsoleLogger在程序启动时通过配置或工厂模式“注入”进去就行了核心业务逻辑一行都不用改。这听起来像是增加了前期设计的复杂度但它的回报是巨大的降低模块间的耦合度让系统像乐高积木一样易于替换和组合提升代码的可测试性你可以轻松地用模拟对象Mock来隔离测试增强架构的清晰度接口就是最好的文档清晰地划定了模块的职责边界。无论你是正在被“屎山”代码折磨的维护者还是准备设计一个新系统的架构师亦或是想在面试中脱颖而出、应对那些“设计模式”、“软件设计原则”八股文的求职者深入理解并掌握面向接口编程的具体措施都是你C功力进阶的必修课。接下来我们就抛开那些空泛的理论直接深入到C的语法土壤里看看怎么把这张“蓝图”画出来、用起来并避开实践中的那些坑。2. 核心优势解析为什么你的C代码需要“接口”这根定海神针在深入具体措施之前我们必须彻底搞清楚费这么大劲搞“面向接口”到底能带来什么实实在在的好处。这些好处不是纸上谈兵而是每一次需求变更、每一次功能扩展、每一次深夜调试时能让你少加几天班、少掉几根头发的真金白银。2.1 降低耦合度从“钢筋水泥”到“插拔模块”耦合度是衡量模块间依赖紧密程度的指标。高耦合的代码就像用钢筋水泥把两个模块浇铸在一起动一个则另一个必然受损。面向实现就是高耦合的温床。假设你有一个OrderProcessor类它直接包含了SqlDatabase类的实例并调用了sqlDb.ExecuteQuery(...)。这意味着OrderProcessor牢牢地绑死在了特定的数据库实现上。如果明天要换用NoSqlDatabase或者为了单元测试需要换成一个内存数据库你几乎需要重写OrderProcessor。面向接口如何解决我们定义一个IDatabase接口包含virtual bool Execute(const std::string query) 0;。OrderProcessor只持有IDatabase*指针。现在OrderProcessor和具体的数据库实现解耦了。它们之间唯一的联系就是那份IDatabase契约。你可以随时创建一个MySqlDatabase或MockDatabase来实现这个接口并在运行时或编译时替换给OrderProcessor使用。系统的各个部分变成了独立的、可插拔的模块维护和升级的复杂度呈指数级下降。实操心得判断耦合度的一个简单方法——看你的头文件包含。如果一个业务类的头文件里#include了一堆具体实现类的头文件特别是那些涉及第三方库或平台特定API的耦合度就已经很高了。理想情况下业务类的头文件应该只包含接口类头文件和标准库组件。2.2 增强可扩展性拥抱变化而非推倒重来需求变更是软件开发的常态。一个设计良好的系统应该对扩展开放对修改封闭开闭原则。面向接口编程是实践这一原则的基石。因为客户端代码依赖的是稳定的接口所以当需要增加新功能时你只需要添加新的实现类而无需修改已有的、依赖接口的客户端代码。继续用日志的例子。最初系统只需要文件日志你实现了FileLogger。后来产品经理要求增加将错误日志实时发送到监控平台的功能。在面向接口的设计下你不需要去修改任何调用日志的代码。你只需要新增一个MonitoringLogger类继承自ILogger接口并在其Log函数中实现网络发送逻辑。然后在应用程序的初始化阶段将ILogger的实例换成MonitoringLogger或者更高级的组合多个Logger的CompositeLogger。所有原本调用ILogger::Log的地方自动就拥有了新功能。这种扩展方式平滑、安全且不会引入回归缺陷。2.3 提升可测试性告别“牵一发而动全身”的集成测试可测试性尤其是单元测试的便利性是衡量代码质量的关键。面向实现的代码很难进行单元测试因为你无法将被测类与其依赖的具体环境数据库、网络、文件系统隔离开。测试一个OrderProcessor你就得真的搭建一个数据库这变成了集成测试速度慢、环境复杂、不稳定。面向接口编程天然支持测试替身Test Doubles。我们可以为IDatabase接口创建一个MockDatabase类在测试中将其注入到OrderProcessor。在这个MockDatabase中我们可以预设Execute函数的返回值或者记录它被调用的次数和参数从而对OrderProcessor的业务逻辑进行孤立、快速、可靠的验证。现代测试框架如 Google Test可以很方便地配合模拟框架如 Google Mock来创建和使用这些Mock对象这使得编写单元测试从一件痛苦的事情变成一种高效的开发习惯。2.4 提高代码复用性打造你的核心资产库接口定义了一种通用的能力。一个依赖于ISerializable接口的序列化函数可以序列化任何实现了该接口的对象。一个依赖于IComparable接口的排序算法可以对任何实现了该接口的数据集合进行排序。这种复用是架构层面的复用比复制粘贴代码片段的复用级别高得多。当你构建起一个基于清晰接口的库时这些接口和它们的通用客户端代码就成为了团队的核心资产。新项目可以直接复用这些经过考验的接口和逻辑只需要根据新需求提供新的实现即可。这极大地加速了开发进程并保证了不同项目间设计的一致性。2.5 改善团队协作与代码清晰度契约优于文档接口是一份清晰的、编译器可检查的契约。它明确地告诉协作者“我这个模块需要什么”作为参数传入的接口以及“我这个模块能提供什么”作为返回值或成员暴露的接口。这比任何书面文档都更准确、更及时。新成员阅读代码时通过查看接口定义就能快速理解模块的职责和边界而不必深陷于复杂的实现细节中。在大型团队中接口可以成为并行开发的桥梁。架构师或核心开发者定义好关键接口后不同团队可以分别实现这些接口或者开发依赖这些接口的上层模块只要契约不变开发工作就可以并行无阻地进行。3. C中实现面向接口编程的核心措施理解了“为什么”接下来就是关键的“怎么做”。在C中实现面向接口编程有一系列具体、可操作的技术手段。它们从最基本的语言特性延伸到设计模式共同构建起一套完整的实践体系。3.1 基石纯虚函数与抽象基类这是C中定义接口最直接的方式。一个包含至少一个纯虚函数的类就是抽象基类它不能实例化只能被继承。// ILogger.h - 接口定义 class ILogger { public: virtual ~ILogger() default; // 虚析构函数至关重要 // 纯虚函数定义核心契约 virtual void Log(LogLevel level, const std::string message) 0; virtual void Flush() 0; // 可以提供非虚的接口函数可选 void LogInfo(const std::string msg) { Log(LogLevel::Info, msg); } void LogError(const std::string msg) { Log(LogLevel::Error, msg); } // 可以包含纯虚函数外的其他非虚函数或静态方法 static std::string GetCurrentTimeStamp(); }; // FileLogger.h - 具体实现 #include “ILogger.h” #include fstream class FileLogger : public ILogger { public: explicit FileLogger(const std::string filePath); ~FileLogger() override; // C11 开始推荐使用 override 关键字 void Log(LogLevel level, const std::string message) override; void Flush() override; private: std::ofstream logFile_; // ... 其他实现细节 };关键点与避坑指南虚析构函数Virtual Destructor这是生命线如果接口可能通过基类指针被删除delete或者被放入智能指针中管理那么基类必须拥有虚析构函数。否则通过基类指针删除派生类对象是未定义行为会导致资源泄漏。virtual ~ILogger() default;是最简洁现代的写法。override关键字C11起在派生类中重写虚函数时务必使用override关键字。这能让编译器帮你检查函数签名是否完全匹配基类的虚函数避免因为手误如参数类型、const修饰符不同导致创建了新的虚函数而非重写这是一种极其隐蔽的Bug。接口纯度接口类应尽可能只定义行为契约。避免在接口中定义数据成员尤其是非静态的。如果多个实现需要共享一些数据可以考虑定义一个独立的配置结构体通过接口的构造函数或初始化函数传入。默认实现非纯虚函数接口中可以提供纯虚函数的默认实现通过 0后仍提供函数体但需谨慎使用。这通常用于提供一种“可选”的扩展点但会使得接口变得复杂。更常见的做法是像上面例子一样提供一些基于核心纯虚函数的非虚辅助函数如LogInfo。3.2 依赖注入将“依赖”从代码内部移到外部依赖注入是实践面向接口编程的关键设计模式。它的核心思想是一个类不应该自己创建它所依赖的对象而应该由外部通常是调用者或专门的框架提供给它。三种常见注入方式构造函数注入最常用、最推荐的方式。依赖通过构造函数参数传入并在类内部保存起来。这保证了对象在构建完成后就处于完全可用的状态依赖不可变。class OrderProcessor { public: // 通过构造函数注入 ILogger 依赖 explicit OrderProcessor(std::unique_ptrILogger logger) : logger_(std::move(logger)) { if (!logger_) { throw std::invalid_argument(“Logger cannot be null”); } } void Process(const Order order) { logger_-LogInfo(“Start processing order: ” order.id); // ... 处理逻辑 } private: std::unique_ptrILogger logger_; // 持有接口的智能指针 }; // 使用 auto processor std::make_uniqueOrderProcessor( std::make_uniqueFileLogger(“app.log”) ); // 测试时 auto mockLogger std::make_uniqueMockLogger(); auto processorUnderTest std::make_uniqueOrderProcessor(std::move(mockLogger));Setter方法注入通过公共的setter方法设置依赖。适用于依赖是可选的或在对象生命周期内可能变化的情况。但要注意线程安全性和对象状态的一致性。class ReportGenerator { public: void SetFormatter(std::shared_ptrIReportFormatter formatter) { formatter_ std::move(formatter); } void Generate() { if (!formatter_) { throw std::runtime_error(“Formatter not set!”); } // 使用 formatter_... } private: std::shared_ptrIReportFormatter formatter_; };接口注入依赖项本身作为一个接口需要实现类来实现一个特定的“setter”方法。这种方式在C中较少见更常见于一些特定的框架。实操心得优先使用构造函数注入。它强制要求依赖在对象构造时就必须明确使得对象的依赖关系清晰可见并且天然保证了对象在完整状态下被使用。使用std::unique_ptr管理接口所有权可以明确表达“独占”语义避免意外的共享。如果确实需要共享再考虑std::shared_ptr。3.3 工厂模式封装对象创建的复杂性当创建实现类的逻辑比较复杂例如需要读取配置、组装多个子部件或者你希望完全对客户端隐藏具体的实现类时工厂模式就派上用场了。工厂负责根据某种条件如配置字符串、枚举值创建并返回对应的接口指针。// LoggerFactory.h #include “ILogger.h” #include memory #include string class LoggerFactory { public: enum class LoggerType { File, Console, Network, Composite }; static std::unique_ptrILogger CreateLogger(LoggerType type, const std::string param “”) { switch (type) { case LoggerType::File: return std::make_uniqueFileLogger(param); case LoggerType::Console: return std::make_uniqueConsoleLogger(); case LoggerType::Network: return std::make_uniqueNetworkLogger(param); case LoggerType::Composite: { auto composite std::make_uniqueCompositeLogger(); composite-AddLogger(std::make_uniqueFileLogger(“app.log”)); composite-AddLogger(std::make_uniqueConsoleLogger()); return composite; } default: throw std::invalid_argument(“Unknown logger type”); } } // 也可以从配置文件读取 static std::unique_ptrILogger CreateLoggerFromConfig(const std::string configPath); };工厂模式将对象的创建与使用分离使得客户端代码 (OrderProcessor) 完全不需要知道FileLogger或NetworkLogger的存在它只需要和ILogger以及LoggerFactory打交道。这进一步降低了耦合并且将变化的创建逻辑集中到了一处便于管理。3.4 使用智能指针管理生命周期在面向接口编程中对象多态地通过基类指针被引用。手动管理这些指针的生命周期new/delete极易出错导致内存泄漏或悬空指针。现代C必须使用智能指针。std::unique_ptrInterface表示独占所有权。当依赖关系是明确的、一对一的并且生命周期由持有者管理时使用。这是构造函数注入中最常见的类型。它不能被复制但可以移动。std::shared_ptrInterface表示共享所有权。当多个对象需要共享同一个接口实例并且其生命周期不确定由谁结束时使用。要小心循环引用问题必要时使用std::weak_ptr。std::weak_ptrInterface弱引用用于打破shared_ptr的循环引用或观察一个可能已被销毁的对象。重要规则接口类的析构函数必须是虚的这样通过基类unique_ptr或shared_ptr删除对象时才能正确调用到派生类的析构函数。3.5 模块化与物理设计头文件分离良好的物理设计文件组织能强化逻辑设计。接口和实现应该严格分离。接口头文件(ILogger.h)只包含抽象基类的声明。它应该尽可能“轻”不包含任何第三方库或平台特定的头文件。理想情况下它只包含标准库中声明所需的部分如string、memory。这保证了所有依赖此接口的模块在编译时只需处理最少的、稳定的信息编译速度更快耦合度更低。实现头文件(FileLogger.h)包含具体实现类的声明。这里可以包含实现所需的特定头文件。实现源文件(FileLogger.cpp)包含具体的实现逻辑。这样一个只需要使用日志功能的业务模块其源文件只需要#include “ILogger.h”而不会因为间接包含了FileLogger.h而被迫引入fstream等可能变化或平台相关的依赖。这是降低编译依赖和耦合度的关键实践。4. 从理论到实践一个完整的设计案例拆解让我们通过一个更复杂的案例——一个简单的图形绘制引擎——来串联上述所有措施。假设我们需要支持绘制多种形状圆形、矩形并能将绘制结果输出到不同设备屏幕、打印机。4.1 定义核心接口首先定义两个核心接口IShape形状和IDrawingDevice绘制设备。// IShape.h #pragma once #include memory #include “IDrawingDevice.h” // 前向声明可能不够因为要用到IDrawingDevice class IDrawingDevice; // 前向声明 class IShape { public: virtual ~IShape() default; // 绘制自身到指定的设备 virtual void Draw(IDrawingDevice device) const 0; // 计算面积 virtual double Area() const 0; // 克隆自身原型模式 virtual std::unique_ptrIShape Clone() const 0; }; // IDrawingDevice.h #pragma once #include string struct Point { double x; double y; }; struct Color { uint8_t r, g, b, a; }; class IDrawingDevice { public: virtual ~IDrawingDevice() default; virtual void DrawLine(const Point from, const Point to, const Color color, int thickness) 0; virtual void DrawCircle(const Point center, double radius, const Color fillColor, const Color borderColor) 0; virtual void DrawRectangle(const Point topLeft, double width, double height, const Color fillColor, const Color borderColor) 0; virtual void BeginDraw() 0; virtual void EndDraw() 0; virtual std::string GetDeviceInfo() const 0; };4.2 实现具体类然后我们实现具体的形状和设备。// Circle.h / Circle.cpp #include “IShape.h” #include “IDrawingDevice.h” class Circle : public IShape { public: Circle(Point center, double radius, Color fill, Color border) : center_(center), radius_(radius), fillColor_(fill), borderColor_(border) {} void Draw(IDrawingDevice device) const override { device.DrawCircle(center_, radius_, fillColor_, borderColor_); } double Area() const override { return 3.14159 * radius_ * radius_; } std::unique_ptrIShape Clone() const override { return std::make_uniqueCircle(*this); } private: Point center_; double radius_; Color fillColor_, borderColor_; }; // ScreenDevice.h / ScreenDevice.cpp (模拟) #include “IDrawingDevice.h” #include iostream class ScreenDevice : public IDrawingDevice { public: void BeginDraw() override { std::cout “[Screen] Begin Drawing\n”; } void EndDraw() override { std::cout “[Screen] End Drawing\n”; } void DrawCircle(const Point center, double radius, const Color fill, const Color border) override { std::cout “[Screen] Draw Circle at (“ center.x “,” center.y “) with radius “ radius “\n”; } // ... 实现其他 DrawLine, DrawRectangle std::string GetDeviceInfo() const override { return “Screen Device (Simulated)”; } };4.3 构建高层模块现在我们可以构建一个不依赖任何具体形状或设备的绘图文档类。// DrawingDocument.h #include vector #include memory #include “IShape.h” #include “IDrawingDevice.h” class DrawingDocument { public: // 注入绘制设备 explicit DrawingDocument(std::unique_ptrIDrawingDevice device) : device_(std::move(device)) {} void AddShape(std::unique_ptrIShape shape) { shapes_.push_back(std::move(shape)); } void Render() const { if (!device_) return; device_-BeginDraw(); std::cout “Rendering on: ” device_-GetDeviceInfo() “\n”; for (const auto shape : shapes_) { shape-Draw(*device_); // 多态调用这里不知道shape的具体类型 } device_-EndDraw(); } double CalculateTotalArea() const { double total 0.0; for (const auto shape : shapes_) { total shape-Area(); // 多态调用 } return total; } private: std::vectorstd::unique_ptrIShape shapes_; std::unique_ptrIDrawingDevice device_; };4.4 组装与运行最后在程序入口如main函数或一个专门的工厂/组装模块中我们将所有具体实现“装配”起来。// main.cpp #include “DrawingDocument.h” #include “Circle.h” #include “Rectangle.h” // 假设已实现 #include “ScreenDevice.h” #include “PrinterDevice.h” // 假设已实现 int main() { // 1. 创建具体的设备依赖注入 auto screen std::make_uniqueScreenDevice(); // auto printer std::make_uniquePrinterDevice(“PDF”); // 2. 创建文档并注入设备 DrawingDocument doc(std::move(screen)); // 3. 添加具体的形状 doc.AddShape(std::make_uniqueCircle(Point{10, 20}, 5.0, Color{255,0,0,255}, Color{0,0,0,255})); doc.AddShape(std::make_uniqueRectangle(Point{30, 40}, 10.0, 8.0, Color{0,255,0,128}, Color{0,0,0,255})); // 4. 进行操作全部通过接口 doc.Render(); std::cout “Total area: ” doc.CalculateTotalArea() “\n”; // 5. 扩展性体现要换打印机输出只需修改上面第1行。 // 要添加三角形只需实现 Triangle 类继承 IShape然后 AddShape 即可。 // DrawingDocument 的代码一行都不用改 return 0; }这个案例清晰地展示了面向接口编程的强大之处DrawingDocument完全不知道Circle、ScreenDevice的存在它只与IShape和IDrawingDevice对话。添加新形状或新设备对现有核心逻辑零修改。这就是“对扩展开放对修改关闭”。5. 进阶技巧、常见陷阱与性能考量掌握了基本措施后我们还需要关注一些进阶实践和容易踩的坑。5.1 接口设计与单一职责原则接口不是越大越好。一个臃肿的接口违反了单一职责原则SRP会导致实现类被迫实现许多它不需要的方法要么抛异常要么留空或者客户端依赖了不必要的功能。好的接口应该聚焦于一个特定的、内聚的角色。反面例子class IMonster : public IGameEntity, public IRenderable, public IAudible, public ISerializable { // ... 几十个方法 };改进拆分成多个精细的接口。一个Monster类可以实现IRenderable、IAudible等多个接口。客户端代码根据需要使用对应的接口指针。class Monster : public IGameEntity, public IRenderable, public IAudible { // ... }; void RenderScene(const std::vectorstd::unique_ptrIRenderable renderables); void PlaySounds(const std::vectorIAudible* audibles);5.2 多重继承与菱形继承问题C支持一个类继承多个接口抽象基类这是实现类似其他语言中“接口”功能的关键。但要警惕菱形继承钻石问题。class IWorker { public: virtual void Work() 0; }; class IEater { public: virtual void Eat() 0; }; class Human : public IWorker, public IEater { // 多接口继承安全且常见 void Work() override { /*...*/ } void Eat() override { /*...*/ } };如果IWorker和IEater都从一个共同的基类ILivingBeing虚继承而来而Human又同时继承它们就会形成菱形结构需要小心处理虚基类。在纯接口继承中通常所有基类都是只有纯虚函数的类没有数据成员这时即使不使用虚继承问题也不大但为了清晰和避免潜在歧义可以将公共的纯虚接口单独提取或者使用虚继承。5.3 默认参数与虚函数在虚函数中使用默认参数是一个危险的行为。C的默认参数是静态绑定的即在编译时根据调用该函数的指针或引用的静态类型决定而不是动态类型。class IPrinter { public: virtual void Print(const std::string msg, bool addNewLine true) 0; }; class FancyPrinter : public IPrinter { public: // 注意这里重新指定了默认参数但通常不建议这么做 void Print(const std::string msg, bool addNewLine false) override { std::cout “*** ” msg (addNewLine ? “\n” : “”); } }; IPrinter* printer new FancyPrinter(); printer-Print(“Hello”); // 糟糕这里调用的是 IPrinter::Print 的默认参数 true // 但执行的是 FancyPrinter::Print 的函数体。 // 最终行为输出 “*** Hello” 但没有换行逻辑混乱避坑指南避免在接口的虚函数中使用默认参数。如果需要有默认行为可以通过重载非虚的辅助函数来实现或者将默认值作为实现类的职责。5.4 性能考量虚函数开销与内存布局虚函数调用通过虚函数表vtable实现比普通函数调用多一次间接寻址会有轻微的性能开销。在绝大多数应用场景下这点开销微不足道远低于其带来的设计收益。不要过早优化。只有在性能分析Profiling明确显示虚函数调用是热点瓶颈时才需要考虑替代方案如模板策略模式编译期多态。// 编译期多态示例 (策略模式与模板结合) template typename LoggerPolicy class OrderProcessorT { LoggerPolicy logger_; // 这里不是指针是具体类型 public: void Process(const Order order) { logger_.Log(“Processing ” order.id); // ... } }; // 使用时 OrderProcessorTFileLogger processorWithFile; OrderProcessorTNetworkLogger processorWithNetwork;这种方式没有运行时多态开销但缺点是类型在编译期就固定了失去了运行时动态替换的能力。两者需要权衡。5.5 测试策略Mock对象的创建使用Google Mock等框架可以极大地简化Mock对象的创建。#include “gmock/gmock.h” class MockDatabase : public IDatabase { public: MOCK_METHOD(bool, Execute, (const std::string query), (override)); MOCK_METHOD(ResultSet, Query, (const std::string query), (override)); }; TEST(OrderProcessorTest, ProcessOrderSucceeds) { MockDatabase mockDb; OrderProcessor processor(std::make_uniqueMockDatabaseWrapper(mockDb)); // 需要包装一下智能指针 EXPECT_CALL(mockDb, Execute(“INSERT INTO orders ...”)) .WillOnce(testing::Return(true)); // 设定预期行为 Order testOrder CreateTestOrder(); bool result processor.Process(testOrder); EXPECT_TRUE(result); // 也可以使用 .Times(), .InSequence() 等来定义复杂的调用预期 }6. 总结与个人实践体会面向接口编程不是银弹但它是在中大型C项目中管理复杂度、应对变化的最有力武器之一。它迫使你在编码前期进行更多的思考——思考模块的边界、职责和协作方式而这部分思考的投入会在项目的中后期以数十倍、数百倍的形式回报你体现在更少的Bug、更快的特性迭代和更轻松的新人 onboarding 上。从我个人的经验来看实践面向接口编程最大的障碍往往不是技术而是习惯和思维定式。刚开始可能会觉得“多此一举”定义一个接口还要写一堆实现类不如直接写一个具体类来得快。但当你经历过一次因为一个底层库的升级比如从libcurl换到Boost.Beast而导致上百个文件需要修改的噩梦后你就会深刻理解“依赖抽象”的价值。我的建议是从一个小的、相对独立的模块开始尝试。比如你的下一个工具类、下一个数据处理模块试着先花10分钟画个草图定义一两个核心接口。你会发现代码会自然地变得更清晰、更易于测试。随着经验的积累这种设计思维会成为你的本能。最终你会发现自己不是在“写C代码”而是在“用C构建一个清晰、灵活、健壮的系统”。这大概就是工程师和码农之间那道看不见的鸿沟吧。