ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++空对象模式实战:用Null Object消除空指针判空烦恼

C++空对象模式实战:用Null Object消除空指针判空烦恼 1. 当代码烂在空指针上每天都要面对的老大难先聊个真实场景。入职第三年的那个下午我接手了一个查询订单详情的接口。函数签名长这样OrderDetail* GetOrderDetail(int orderId);调用方拿到的可能是个nullptr代表订单不存在。于是每个调用点都得写成这样OrderDetail* detail GetOrderDetail(orderId); if (detail) { // 用detail里的数据渲染页面 } else { // 走订单不存在的兜底逻辑 }这段代码本身没毛病但它潜伏着一个特别容易踩的坑调用方忘了判空。忘了判空的结果轻则崩一个空指针异常重则带着脏数据往下走查错查到怀疑人生。更烦的是一个系统的调用方一多if (detail)这个判断就会散落在几十个地方每个地方的兜底逻辑还不一样——有的返回错误码有的给默认值有的干脆记录一条日志然后硬着头皮继续用空对象。这个问题的本质是把对象可能不存在这件事暴露给了所有调用方。而空对象模式Null Object Pattern想解决的恰恰就是把不存在封装进一个什么都不做的对象里让调用方永远拿到的都是一个可以安全调用的对象压根儿不需要判空。这个模式在Java、Ruby这种一切皆对象、动态分派低成本的语言里非常流行。但在C里它有一套完全不同的玩法和坑。这篇文章我会从实际项目经验出发把C环境下空对象模式的实现思路、适不适合用、怎么用才不掉进虚函数和生命周期的坑里一次性讲清楚。2. 空对象模式到底在消除什么先说透一件事空对象模式不是让代码少写几行它是改变了调用方的心智模型。2.1 没有空对象时调用方被迫做什么没有空对象时调用方的代码逻辑里必须出现一条隐性的分支路线void RenderOrderPage(int orderId) { OrderDetail* detail orderService-GetOrderDetail(orderId); if (detail ! nullptr) { RenderHeader(detail-GetTitle()); RenderItems(detail-GetItems()); RenderFooter(*detail); } else { RenderEmptyPage(); } }注意这个else分支。调用方本来只关心我要渲染一个订单页现在它被迫知道了订单可能不存在这件事还得自己决定不存在了该渲染什么。这不只是加了个分支而是把领域规则订单不存在时展示什么散落到了所有调用方手里。有十个调用方这条领域规则就可能被实现十遍而且实现得五花八门。更麻烦的是上面说的那种场景调用方忘了判空。一旦忘了判空就得靠空指针异常兜底而空指针异常把业务上不存在和程序出bug了混为一谈排查起来两行泪。2.2 有了空对象后调用方简化成什么样引入空对象后核心思路是让GetOrderDetail永远不返回nullptr。当订单不存在时它返回一个特殊的空订单对象。这个对象的接口和正常订单完全一致但所有方法的实现都是什么都不做或者返回中性值。void RenderOrderPage(int orderId) { const OrderDetail detail orderService-GetOrderDetail(orderId); RenderHeader(detail.GetTitle()); RenderItems(detail.GetItems()); RenderFooter(detail); }RenderOrderPage里干干净净没有if分支。它不知道也不用关心这个订单到底存不存在它就知道一件事情给我一个订单详情对象我把它渲染出来。而空订单对象的GetTitle()返回一个空字符串GetItems()返回空列表RenderFooter对这个对象做任何操作都是安全的。这就是空对象模式的核心价值把条件分支从调用方手里收走收进具体的实现类里。2.3 为什么C里的情况比其他语言特殊在Java里所有对象都是引用虚函数动态分派是语言默认行为搞一个空对象实现简直天经地义。但在C里有四个因素让这个模式变得微妙第一C里函数默认不是虚函数。要让空对象能真正无感替换真实对象必须设虚函数这意味着性能开销和类设计上的强制约束。第二C对象有明确的生存期。Java里你不小心返回了一个共享的空对象垃圾回收器帮你兜着C里你返回一个空对象的引用就得小心它是不是在某个局部作用域里已经被销毁了。第三C传值和传引用的语义差异巨大。传引用还能保持多态传值会把对象切片掉空对象的空行为也就切没了。第四C有std::optional、std::variant这些现代化的替代方案很多场景下不用空对象模式也能干净地处理不存在。这几个因素叠加下来C里空对象模式的适用范围和实现方式跟Java里那套照本宣科很不一样。下面几节我会逐个展开。3. 经典教科书实现以及它不够用的地方先看一个教科书级的实现理解一下基本盘。3.1 一个标准的虚函数版空对象假设我们要做一个日志系统接口长这样class ILogger { public: virtual ~ILogger() default; virtual void Info(const std::string message) 0; virtual void Warn(const std::string message) 0; virtual void Error(const std::string message) 0; }; class ConsoleLogger : public ILogger { public: void Info(const std::string message) override { std::cout [INFO] message std::endl; } void Warn(const std::string message) override { std::cout [WARN] message std::endl; } void Error(const std::string message) override { std::cerr [ERROR] message std::endl; } }; class NullLogger : public ILogger { public: void Info(const std::string) override { // 什么都不做 } void Warn(const std::string) override { // 什么都不做 } void Error(const std::string) override { // 什么都不做 } };使用方长这样class OrderService { public: void SetLogger(ILogger* logger) { logger_ logger ? logger : nullLogger_; } private: ILogger* logger_ nullLogger_; NullLogger nullLogger_; };这个实现把没有配置logger变成了一种合法状态业务代码里完全不需要判断logger_是不是空指针。这套东西在C项目里非常常见很多框架的内部就是拿空对象模式来消除可选依赖的判空逻辑的。3.2 虚函数表开销和 heap 分配的困境经典实现有几个绕不开的麻烦。首先为了让空对象替换真实对象ILogger里的方法都得是虚函数。虚函数调用是间接跳转对绝大多数场景来说开销可以忽略不计但如果是在一个每帧调用成千上万次的热路径上就需要注意了。C社区对虚函数的态度一向是能不用就不用这导致很多团队对空对象模式天然地不太感冒。其次上面这个实现里NullLogger是OrderService的一个成员对象。这在依赖可选的场景下没问题但如果空对象是全局共享的就得考虑线程安全和生存期。比如用单例ILogger GetNullLogger() { static NullLogger instance; return instance; }单例方式在多线程环境下没有初始化问题C11之后的magic static保证但有一个隐含的坑如果你的NullLogger内部有状态比如一个计数统计这个全局单例就会被所有线程共享状态就错乱了。所以空对象最好是无状态的一旦有状态全局共享就会出问题。3.3 子类继承空对象时的一个大坑教科书里不会专门讲这个坑但实际项目里特别容易踩。假设ConsoleLogger和NullLogger都从ILogger继承。有一天你给ILogger加了一个新方法class ILogger { public: virtual void Debug(const std::string message) 0; };你更新了ConsoleLogger但忘了更新NullLogger。结果编译不通过——NullLogger还是抽象类实例化失败。这时候你被迫把Debug加进去空实现。如果接口有十几个方法每加一个方法你都得同步维护两个类这就是空对象模式在接口膨胀时的维护成本。所以我在实际项目里通常有个不成文的规定空对象类实现的方法数量如果超过5个就要认真考虑是不是该用其他方案了。下面几节就是这些替代方案。4. 模板化空对象用策略模式把空的精髓榨出来纯虚函数接口版的空对象模式在Java里是最正统的但在C里我觉得更优雅的路线是模板。模板化之后我们不再需要为每一个接口都写一个什么都不做的实现类而是把空这个策略直接模板化。4.1 用模板消除重复的空对象实现直接看代码。假设我们有这样一个接口template typename T class Repository { public: virtual ~Repository() default; virtual std::optionalT FindById(int id) const 0; virtual std::vectorT FindAll() const 0; virtual void Save(const T entity) 0; };常规空对象写法是写一个NullRepository把所有方法空空地实现一遍。但它的实现内容其实就是返回一个空optional返回空vector什么都不做。既然这些行为是通用的我们就可以这样搞struct NullPolicy { static std::nullopt_t FindByIdResult() { return std::nullopt; } static std::vectorint FindAllResult() { return {}; } static void SaveEffect() {} };然后做一个通用的空仓库模板template typename T, typename Policy class NullRepository : public RepositoryT { public: std::optionalT FindById(int id) const override { return Policy::FindByIdResult(); } std::vectorT FindAll() const override { return Policy::FindAllResult(); } void Save(const T entity) override { Policy::SaveEffect(); } };这样对任意类型的RepositoryT只要定一个策略比如using NullProductRepository NullRepositoryProduct, NullPolicy;就能得到一个现成的空对象。加新接口时你只需要在策略里添一个对应方法或者用缺省策略兜底。4.2 CRTP方案的取舍模板化还有一个变体是CRTPCuriously Recurring Template Pattern。CRTP的好处是可以直接提供默认的空行为子类只需要override自己关心的那些方法template typename Derived class Nullable { public: void Info(const std::string) {} void Warn(const std::string) {} void Error(const std::string) {} void Debug(const std::string) {} // 新加的方法自动有默认空实现 }; class MyNullLogger : public NullableMyNullLogger { // 什么都不用写自动拥有所有空实现 };这里CRTP的主要价值在于接口扩展时空对象的维护成本几乎降到零。你在基类Nullable里加一个新的空实现方法所有继承它的空对象自动获得该方法的空实现。这个方案可以极大缓解上一节说的接口膨胀导致空对象类爆炸的问题。但代价是CRTP的写法对团队成员有一定门槛而且基类里的方法必须是static化的或者是通过Derived来间接调用的。如果你的空对象需要多态替换比如作为ILogger*传给某个函数CRTP还得额外加一层虚函数包装。这时候你要权衡是要纯编译期的零开销还是要运行时的动态多态。4.3 模板空对象的实际应用场景模板化空对象我实际用得最多的场景是数据访问层和外部服务依赖。比如一个项目里同时接了MySQL和Redis测试环境下可能不想真正连这两样东西。我一般会写一个NullUserRepository和NullCache让服务在没有配置任何存储的情况下也能正常启动、正常处理请求只是所有数据都会丢。这样本地起服务做联调的时候就不需要费劲去搭一套MySQL和Redis环境了。这个场景下模板化的优势特别明显UserRepository和OrderRepository各自有各自的接口但各自的空实现逻辑是一样的——FindById返回空FindAll返回空列表Save直接丢弃。写一个通用的模板空实现所有仓库都能复用。5. 虚函数空对象在真实项目中的应用怎么设计才顺手模板方案再优雅也绕不开一个问题如果你的架构已经大量依赖虚函数多态那空对象还是得走虚函数这条路线。而且很多场景——比如插件体系、策略注入、依赖反转——虚函数是C里最自然的选择。所以虚函数空对象怎么设计得顺手值得单独拿出来讲。5.1 接口设计直接决定空对象的可维护性虚函数空对象最容易出问题的点是接口设计。一个好的接口应该尽量让空实现变得自然。举个例子假设我们要设计一个短信发送服务class ISmsSender { public: virtual ~ISmsSender() default; virtual bool Send(const std::string phoneNumber, const std::string content) 0; virtual int GetRemainingQuota() const 0; };这个接口的空实现很顺滑class NullSmsSender : public ISmsSender { public: bool Send(const std::string, const std::string) override { return true; // 模拟发送成功 } int GetRemainingQuota() const override { return 0; } };但如果你把接口设计成这样class ISmsSender { public: virtual ~ISmsSender() default; virtual std::string Send(const std::string phoneNumber, const std::string content, std::string* errorMessage) 0; virtual std::mapstd::string, std::string GetMetrics() const 0; };空实现就开始尴尬了errorMessage要置空吗GetMetrics返回空map还是带几个键值接口越具体空实现的中性选择就越不显然。所以如果你打算用空对象模式接口设计时就要把空实现应该长什么样作为设计约束之一。5.2 空对象内部的空也有语义层次很多人以为空对象的方法实现就是什么都不做返回默认值。实际上空是有语义层次的。最浅的一层是静默空日志用空对象时Info方法真的什么都不做。再深一层是记录型空空对象内部保存一个标志位当有人调用它时记录下来方便测试断言某条日志是否被输出过。最深的一层是代理型空空对象内部持有真实对象平时不做事但可以从它身上切换到真实对象或者把调用转发给一组订阅者。拿测试举例我经常用这种记录型空对象class SpyLogger : public ILogger { public: void Info(const std::string message) override { messages_.push_back(message); } const std::vectorstd::string GetMessages() const { return messages_; } private: std::vectorstd::string messages_; };SpyLogger对外是空对象的样子可以被当作logger传入任何地方测试时还能检查它到底收到了什么消息。这比NullLogger多了一层记录能力但对外部调用方来说无感知。这种设计在业务里叫可观测空对象或测试替身本质上就是空对象模式的一个变种。5.3 延迟绑定与策略切换空对象作为开关空对象还有一类很有意思的应用就是作为系统功能的开关。举个例子一个推荐系统里我们对新用户和老用户可能要走完全不同的推荐策略。最简单的方式是写一堆if (user.IsNew())。可如果你把推荐器本身设计成策略对象空对象就能当关闭推荐的开关class IRecommendationEngine { public: virtual std::vectorProduct Recommend(int userId, int topN) 0; }; class RealRecommendationEngine : public IRecommendationEngine { public: std::vectorProduct Recommend(int userId, int topN) override { // 真实的协同过滤或向量检索 } }; class NullRecommendationEngine : public IRecommendationEngine { public: std::vectorProduct Recommend(int userId, int topN) override { return {}; // 不推荐任何东西 } };然后可以通过配置或者发布开关来切换std::unique_ptrIRecommendationEngine engine; if (config.Getbool(recommendation.enabled)) { engine std::make_uniqueRealRecommendationEngine(); } else { engine std::make_uniqueNullRecommendationEngine(); }这样做的好处是推荐引擎的调用方完全不需要知道推荐功能现在关着这个状态。它只是拿一个IRecommendationEngine调用Recommend得到一个结果结果为空列表它自然就不展示推荐位。功能下线不用改业务代码不用加开关判断把空对象塞进去就行。5.4 空对象与组合模式的配合还有一个很实用的场景是空对象和组合模式Composite Pattern的组合。比如文件系统里一个目录里有文件和子目录。组合模式里文件和目录都实现同一个接口IFileNodeclass IFileNode { public: virtual ~IFileNode() default; virtual std::string GetName() const 0; virtual long GetSize() const 0; virtual void Print(int indent) const 0; };但文件是叶子节点它的子节点列表天然为空。与其在文件类里维护一个空列表再实现一个GetChildren返回空不如直接用一个空对象来表示没有子节点class NullNode : public IFileNode { public: std::string GetName() const override { return ; } long GetSize() const override { return 0; } void Print(int indent) const override {} };然后文件和目录的类型树里叶子节点可以持有这个NullNode作为占位。Print函数遍历子节点时碰到NullNode自然什么都不输出。这套设计让递归遍历的代码完全没有边界判断写起来非常爽void PrintTree(const IFileNode node, int indent) { node.Print(indent); // 假设有这样一个获取子节点的方法 for (const auto child : node.GetChildren()) { PrintTree(*child, indent 2); } }因为没有nullptr就不存在子节点列表末尾出现空指针的可能性。空对象做组合模式的叶子占位在UI控件树里也是同样的玩法。6. 现代C的替代方案optional和variant什么时候更香我前面说了很多空对象模式的好话但如果你一门心思觉得所有返回指针的地方都得改成空对象那就从一个坑跳进另一个坑了。现代C的std::optional和std::variant在不少场景下是更合理的答案。6.1 optional语义清晰但不改变调用方的分支std::optional的核心价值是类型层面明确告诉你可能没有值std::optionalOrderDetail GetOrderDetail(int orderId);调用方必须解包才能拿到值auto detailOpt GetOrderDetail(orderId); if (detailOpt.has_value()) { OrderDetail detail *detailOpt; // 处理正常订单 } else { // 处理不存在 }这跟空对象模式差异明显optional把可能不存在这件事显式地暴露给调用方空对象则把这件事隐藏起来。什么时候该暴露、什么时候该隐藏我的判断标准是调用方如果需要对不存在这件事做出不同反应就用optional如果所有调用方对不存在的反应都一致或者根本不需要特殊反应就用空对象。比如订单详情接口有的调用方要展示页面有的调用方要做支付校验有的调用方要推送通知。订单不存在时它们各自要处理的东西不一样——展示页面要返回404支付校验要拒绝交易推送通知要跳过。这种情况下空对象反而不合适因为空对象只能给一个统一的默认行为没法区分调用方的差异化处理。std::optional反而更直观。6.2 variant把空变成一种显式状态std::variant的适用场景是返回结果有多种类型状态其中一种恰好是空。比如文件读取struct NotFound {}; struct FileContent { std::string content; }; std::variantNotFound, FileContent ReadFile(const std::string path);调用方用std::visit处理所有情况std::visit([](auto value) { using T std::decay_tdecltype(value); if constexpr (std::is_same_vT, FileContent) { std::cout value.content; } else { std::cout File not found.; } }, result);这里NotFound类型就像一个更丰富的空对象——它不只是空它还有一个类型名能传达文件不存在这个语义。相比空对象std::variant的优势是编译期穷举所有可能分支编译器帮你检查你是否处理了所有情况。空对象没有这种编译器层面的保护。6.3 什么时候坚持用空对象说了这两大现代替代方案的场景那空对象模式在C里还有不可替代的地盘吗有而且不少。第一依赖注入和可选依赖。一个系统有很多可插拔的组件有些组件在特定环境里不启用。用std::optionalstd::unique_ptrILogger写起来就别扭得要死空对象的无缝替换体验完胜。第二调用方不知道也不关心空状态。日志就是一个好例子——关闭日志和没日志对业务逻辑没有任何影响。调用方只需要我可以调用日志接口这个确定性空对象提供的就是这种确定性。第三组合对象的递归结构就像上一节说的文件系统例子。递归遍历时一个空节点和真实节点长一样代码结构就干净很多。第四性能敏感场景。std::optional在栈上存储值如果对象很大拷来拷去有代价。空对象返回引用零拷贝。不过这里说的是针对特定设计的优势不是绝对的。6.4 空对象模式在C里的判断清单把上面这些经验总结成一张决策表场景特征推荐方案理由调用方对不存在要有差异化反应std::optional显式暴露空状态让调用方自己决策所有调用方对不存在反应一致空对象模式隐藏空状态消除重复分支返回类型可能不止有/无两种状态std::variant类型系统穷举所有情况依赖注入、可选组件、开关策略空对象模式无缝替换调用方无感知递归结构、组合模式空对象模式消除边界判断接口方法多且变动频繁模板空对象或CRTP降低空实现维护成本这张表不是绝对的但它能帮你快速判断一个具体场景里空对象模式是不是好答案。现实中我见过太多为了模式而模式的代码宁可写一堆空实现也不肯用三行if的那才是真的本末倒置。7. C空对象实战改造一段真实场景前面讲了不少理论下面用一个我实际做过的重构案例完整走一遍决策、实现、验证的流程。7.1 初始代码与痛点项目是个网关服务里面有个功能进程用来检查请求的IP是否在封禁名单里std::shared_ptrIpBlacklist blacklist LoadBlacklist(); if (blacklist ! nullptr) { if (blacklist-Contains(requestIp)) { RejectRequest(); } }看起来不复杂是吧问题是LoadBlacklist()可能在配置缺失、文件不存在、加载失败时返回nullptr。每个调用点都得判空写的人一多就开始走样有的调用点忘了判空直接崩有的调用点把blacklist nullptr当成全部放行有的调用点当成全部拒绝。那段时期我统计了一下全项目里有17处地方在调用LoadBlacklist()每处的判空逻辑存在细微差别。这就是典型的空对象的语义被散落到多个调用方的场景。7.2 空对象设计我引入了一个EmptyIpBlacklistclass IIpBlacklist { public: virtual ~IIpBlacklist() default; virtual bool Contains(const std::string ip) const 0; }; class IpBlacklist : public IIpBlacklist { public: explicit IpBlacklist(std::unordered_setstd::string ips) : ips_(std::move(ips)) {} bool Contains(const std::string ip) const override { return ips_.count(ip) 0; } private: std::unordered_setstd::string ips_; }; class EmptyIpBlacklist : public IIpBlacklist { public: bool Contains(const std::string ip) const override { return false; // 空名单里没有任何IP永远返回不存在 } };然后把LoadBlacklist改成永远返回一个有效对象std::shared_ptrIIpBlacklist LoadBlacklist() { if (!config.fileExists) { return std::make_sharedEmptyIpBlacklist(); // 空名单 } auto ips ParseFile(config.path); if (ips.empty()) { return std::make_sharedEmptyIpBlacklist(); // 解析出来就是空 } return std::make_sharedIpBlacklist(std::move(ips)); }7.3 重构后代码长什么样所有调用点直接变成auto blacklist LoadBlacklist(); if (blacklist-Contains(requestIp)) { RejectRequest(); }没有判空没有nullptr没有要么放行要么拒绝的诡异分歧。行为统一到空名单不封禁任何IP这个领域规则上。重构完我还顺手清掉了一堆死分支原来有个调用点在blacklist nullptr时打日志说封禁名单未加载跳过检查现在这个日志不用打了原来有个调用点在blacklist nullptr时返回500拒绝全部请求这个分支也没了。整个系统的行为收敛到句子能做清楚的解释一个空的封禁名单就是不封禁任何人。7.4 这个案例里的经验和教训这个案例最后留下来几条有价值的经验。第一空对象的行为必须是领域自然的。封禁名单为空时不封禁任何人这个行为符合直觉。如果你的空对象返回true那是一颗定时炸弹——所有人都会忘记它存在还以为自己的请求被正确检查了。第二空对象的生命周期管理。上面用的是shared_ptr因此空对象是堆上分配的。如果你用引用返回务必确保空对象是静态的或者生命周期超过所有使用方const IIpBlacklist GetEmptyBlacklist() { static const EmptyIpBlacklist instance; return instance; }magic static保证了线程安全的初始化对象生命周期持续到程序结束安全。第三空对象不应该封装出错的状态。如果配置解析是因为文件损坏而不是文件不存在这就不是空对象该处理的。这种情况下应该抛异常或者返回std::optionalstd::shared_ptrIIpBlacklist。空对象只回答这个东西合法但无内容不回答加载过程出错了。把错误语义硬塞进空对象里会让排查问题变得异常痛苦。8. 性能与线程安全C空对象容易被忽略的两个死角理论讲完了实战也走了最后聊两个真刀真枪的项目里躲不掉的细节——性能和线程安全。这俩问题在Java里不太需要关心但C里一不小心就翻车。8.1 到处是虚函数性能到底会不会崩空对象模式天然依赖虚函数动态分派。很多人一听到虚函数就皱眉头觉得性能要崩。我做过一个测试在普通的生产环境机器上每秒调用一亿次空对象的虚函数相比直接调用非虚函数耗时差距大概在几纳秒到几十纳秒量级具体取决于缓存和分支预测情况。这个量级对绝大多数业务代码来说完全可以忽略。真正需要注意的是两个点。第一热路径上的重复虚调用。比如在游戏引擎的帧循环里每帧对每个实体都调用一次空对象方法。哪怕单次开销可以忽略成千上万个实体加起来也会吃掉不少时间。这种情况下的解法是把空对象的调用按类型提升比如用if constexpr或者模板在编译期消除虚调用。第二虚函数会阻碍编译器内联。如果你的空对象方法只有一句return false编译器实际上没法把它内联掉因为虚函数调用是间接的。对这种极端场景可以考虑用模板策略替代虚函数空对象。这也是我在第4节里为什么强调模板的原因——编译期多态能把开销完全消除。8.2 空对象单例的线程安全陷阱如果你用一个全局单例空对象class NullBlacklist : public IIpBlacklist { public: static const NullBlacklist Instance() { static const NullBlacklist instance; return instance; } bool Contains(const std::string) const override { return false; } };这个单例本身是线程安全的C11以后magic static的初始化是有线程同步保证的。但如果你的空对象内部有可变状态问题就来了。举个例子我在一个项目里见过有人给空对象加了个计数器统计空对象被调用了几次想用来做调试。这个计数器在多线程环境下就会数据竞争——两个线程同时counter加了半天数据还是错的还非要在空对象里加锁才能修。后来我把所有空对象的设计都改成const成员函数无状态非要做统计就额外包一层带锁的代理空对象自己保持纯。8.3 空对象的拷贝与移动语义C里还有个小细节空对象经常被以值的方式返回如果类里面有动态资源就得注意移动语义。但空对象一般没有资源所以拷贝默认就是浅拷贝没问题。不过有一种情况容易出事空对象内部的状态是一个标志位或者一个空容器如果你把空对象拷给真实对象可能会把我可是空的这个标志也拷过去导致后来的赋值逻辑混乱。比如class OrderDetail { public: virtual bool IsEmpty() const { return false; } virtual std::string GetTitle() const 0; }; class EmptyOrderDetail : public OrderDetail { public: bool IsEmpty() const override { return true; } std::string GetTitle() const override { return ; } };这种设计下如果你某处代码写了OrderDetail detail GetOrderDetail(42);detail的静态类型是OrderDetail对象切片会让IsEmpty()永远返回false整个空对象就崩了。所以在C里只要涉及多态一律要用指针或引用操作千万别用值语义去接。9. 落地到团队里的最后几条忠告代码层面的实现细节讲得差不多最后一节聊一点更贴近工程实践的东西——空对象模式在团队协作里怎么推得动、怎么避免后面被推翻。9.1 把空对象当成领域概念建模而不是防止崩溃的工具如果空对象的形态是因为怕空指针所以搞个空类那它就是一次性的补丁代码库里会越来越多。真正有效的做法是把空对象当成领域规则的一部分。封禁名单为空时不封禁任何人、推荐列表为空时不做推荐、日志为空时不落盘——这些听起来就是业务规则本身而不是技术手段。我建议在代码评审的时候问一句这个空对象的行为符合业务期望吗如果符合就可以合入如果只是为了不崩评审就应该打回让作者重新想想自己的接口设计是不是有问题。9.2 命名和注释要让人一眼看懂空对象的命名很重要。NullLogger、EmptyIpBlacklist、NoOpSmsSender都行但要能看出来它是什么的空形态。我个人偏好EmptyXxx前缀因为它传达这是一个合法的对象只是没有内容而不是这是一个bug的产物。类头注释也别懒至少写清楚这条// EmptyIpBlacklist 表示一个不含任何 IP 的封禁名单。 // 调用 Contains() 永远返回 false可安全用于配置缺失的场景。有这行注释后来接手的同事一眼就知道这玩意儿是干嘛的不会误改。9.3 过度使用空对象模式的识别与退路模式用得再好也会出现过度使用。判断标准很简单C代码库里的空对象类如果和真实实现的类一样多那八成是过度设计了。退路也简单。第一选择是换std::optional第二选择是合并空对象实现——比如多个空对象逻辑都是返回空vector或者返回false完全可以用模板统一掉第三选择是把空对象的生命周期内聚到某个管理类里避免到处new空对象实例。我个人在实际项目里的体会是空对象模式用得好的标志是它像一个安静的路标而不是一个热闹的工具箱。你几乎感觉不到它的存在但回头看代码到处都是它帮忙兜底的痕迹。回到开头那个订单详情的例子——如果所有调用方对订单不存在这件事的反应都是一样的空对象会是最省心的解法如果反应各不相同还是老实回到optional或者显式判空吧。判断清楚这两者的边界空对象模式在C里就能成为你工具箱里一把顺手又不扎手的改锥。
返回列表