多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++桥接模式变体全解析:从虚函数到类型擦除

C++桥接模式变体全解析:从虚函数到类型擦除 我早些年刚接触设计模式时照着Gof那本书把桥接模式的C例子敲了一遍当时只觉得“无非就是一个抽象类持有另一个抽象类派生出两组子类互相组合”。直到后来在真实项目里做跨平台UI、做渲染引擎抽象层我才意识到桥接模式的重点从来不是那几张类图而是“抽象和实现如何解耦以及用什么样的机制来绑定两端”。这个问题在C里尤其复杂因为C同时支持面向对象、泛型、函数式编程每一种范式都能表达桥接意图也就自然催生出桥接模式的诸多变体。这篇文章我想从一个老C开发的角度把桥接模式在C中的各种变体形态梳理一遍。从经典的虚函数桥接到模板版本的静态桥接再到基于std::function和类型擦除的现代变体每一类都会给出可运行的示例代码、适用场景和工程上的取舍。如果你已经掌握了桥接模式的基础概念但不确定在自己的项目里应该用哪一种写法这篇内容应该能帮你建立一套比较完整的决策思路如果你是刚学C不久刚开始接触设计模式也可以通过本文理解为什么同一个设计意图会有这么多不同的实现方式。当然也有可能你只是想看看那些写法背后的“坑”那可以直接跳到最后一章那里记录了不少实际踩过的问题。1. 先理解桥接模式再谈“变体”1.1 桥接模式到底在解决什么问题桥接模式Bridge Pattern的核心意图是让“抽象部分”和“实现部分”可以独立变化。很多人会觉得这和策略模式很像但两者关注的维度不一样策略模式关心的是一组算法之间的替换而桥接模式关心的是抽象接口与底层实现的解耦。用一个生活化的例子来解释。假设你有一个遥控器抽象部分它可以控制不同品牌的电视实现部分。如果你把遥控器和电视绑定死在一起每来一个新品牌的电视你就要重新设计一种遥控器这样类数量就容易爆炸。桥接模式的思路是遥控器内部只持有一份“电视接口”具体某个品牌的电视自己实现这个接口遥控器只面对接口操作。这样遥控器可以复用电视品牌也可以独立扩展。这就是“抽象与实现分离”。在C里最经典的桥接模式实现方式就是抽象类中持有一个指向实现类接口的指针或引用通过虚函数动态调用具体实现。但经典写法只是起点C另一大特性——模板——可以在编译期完成同样的解耦运行时开销为零C11之后的std::function可以把“实现”抽象成一个可调用对象类型擦除技术则能把多态封装在一个非常轻量的值类型容器里。这些都属于桥接模式的变体。1.2 “变体”的底层逻辑绑定时机与表达媒介要理解桥接模式的变体是怎么来的可以先抓住两个维度一个是“绑定时机”一个是“表达媒介”。绑定时机决定了抽象部分在什么时候知道自己的具体实现。虚函数版本是在运行时通过虚表找到实现模板版本是在编译期通过类型替换直接确定实现std::function版本则是把实现封装成可调用对象再在运行时调用只是这个可调用对象本身不一定包含继承关系。绑定时机直接影响性能特征和灵活性运行期绑定灵活但多了一次间接调用编译期绑定性能好但实现类型必须在编译时确定。表达媒介则决定了“实现”长什么样。虚函数变体里实现是一组继承自接口的子类对象。模板变体里实现就是一个类型甚至可以不要求它继承任何接口只要它有对应的成员函数即可。std::function变体里实现是一个可调用实体可以是lambda、函数指针、仿函数。类型擦除变体里实现被包装在一个不透明的容器内部对外抛出的是一个不太关心具体类型的接口。这两个维度组合起来就能解释我们在工程中看到的几乎所有桥接模式变体。接下来的内容我会从这四个最常见的形态入手逐个拆解。2. C里的桥接模式变体形态2.1 经典多态变体虚函数还是那个熟悉的桥先复现一下最标准的经典实现。图形绘制是个常见的例子我们要绘制不同形状抽象部分每个形状需要调用不同的绘制引擎实现部分比如屏幕绘制、SVG绘制。形状不该依赖具体的绘制引擎绘制引擎也不该因为新增形状而频繁改动。#include iostream #include memory #include string // 实现部分接口 class DrawAPI { public: virtual ~DrawAPI() default; virtual void drawCircle(double x, double y, double radius) 0; }; // 具体实现A屏幕绘制 class ScreenDrawer : public DrawAPI { public: void drawCircle(double x, double y, double radius) override { std::cout [Screen] circle at ( x , y ), radius: radius std::endl; } }; // 具体实现BSVG绘制 class SvgDrawer : public DrawAPI { public: void drawCircle(double x, double y, double radius) override { std::cout circle cx\ x \ cy\ y \ r\ radius \ / std::endl; } }; // 抽象部分 class Shape { public: explicit Shape(std::unique_ptrDrawAPI api) : api_(std::move(api)) {} virtual ~Shape() default; virtual void draw() 0; protected: std::unique_ptrDrawAPI api_; }; class Circle : public Shape { public: Circle(double x, double y, double r, std::unique_ptrDrawAPI api) : Shape(std::move(api)), x_(x), y_(y), r_(r) {} void draw() override { api_-drawCircle(x_, y_, r_); } private: double x_, y_, r_; };这样写的好处是Shape和DrawAPI各自可以独立扩展。新增一个Rectangle不需要动Drawing新增一个OpenGL绘制器所有Shape都能复用。坏处则是虚函数调用带来了少量开销同时整个类体系被两套继承结构架起来如果项目本身不太喜欢继承风格这套写法就会显得有点重。基于这个经典形态我平时还会做两个工程化变通。第一把裸指针换成unique_ptr明确所有权避免手工delete。第二把纯虚函数接口的析构函数显式写成virtual保证子类析构时能正确释放。这三个字看起来无关紧要但在多态删除时漏掉virtual析构函数就是内存泄漏现场。2.2 模板变体把桥搭在编译期如果实现部分在编译期就可以确定那完全可以用模板省掉虚函数走静态多态的路线。“抽象部分”仍然是模板类持有“实现部分”的对象但实现类型作为一个模板参数传进来。#include iostream struct ScreenDrawer { void drawCircle(double x, double y, double radius) { std::cout [Screen] circle at ( x , y ), radius: radius std::endl; } }; struct SvgDrawer { void drawCircle(double x, double y, double radius) { std::cout circle cx\ x \ cy\ y \ r\ radius \ / std::endl; } }; template typename Drawer class Circle { public: Circle(double x, double y, double r, Drawer drawer {}) : x_(x), y_(y), r_(r), drawer_(std::move(drawer)) {} void draw() const { drawer_.drawCircle(x_, y_, r_); } private: double x_, y_, r_; Drawer drawer_; };使用的时候编译期就把实现绑定了int main() { CircleScreenDrawer screenCircle(10.0, 20.0, 5.0); CircleSvgDrawer svgCircle(30.0, 40.0, 8.0); screenCircle.draw(); svgCircle.draw(); return 0; }这种变体的最大优势是零虚函数开销而且代码可读性并不差。Drawer不需要继承任何公共接口只要提供drawCircle成员函数即可这在C社区里被称为“鸭子类型”约束也叫弱约束接口。如果想要更强的约束C20里可以直接用Concept去限制Drawer类型编译期就能给出漂亮的错误提示。但模板变体也有明显代价实现类型的决策被推迟到实例化点一旦想运行期动态切换实现就非常不便。还是同一个Circle类如果程序启动后要根据配置文件决定用屏幕还是SVG输出模板变体就玩不转因为类型必须在编译期定死。如果硬要运行期切换通常会把模板包一层再套一个variant或者类型擦除这就又回到了运行期多态的路线。所以选择模板变体的前提是实现的组合集合在编译期完全能确定。2.3 std::function变体接口不再需要继承C11以后std::function给了桥接模式另一个非常优雅的表达方式。抽象部分不再持有一个“接口对象”而是直接持有一个可调用对象。这个可调用对象就是实现部分它可以是lambda表达式、函数指针、仿函数只要签名匹配就行。#include iostream #include functional class Circle { public: using DrawCallback std::functionvoid(double, double, double); Circle(double x, double y, double r, DrawCallback cb) : x_(x), y_(y), r_(r), drawCb_(std::move(cb)) {} void draw() const { if (drawCb_) { drawCb_(x_, y_, r_); } } private: double x_, y_, r_; DrawCallback drawCb_; };调用端可以传各种可调用对象int main() { auto screenDrawer [](double x, double y, double r) { std::cout [Screen] circle at ( x , y ), radius: r std::endl; }; Circle screenCircle(10.0, 20.0, 5.0, screenDrawer); Circle svgCircle(30.0, 40.0, 8.0, [](double x, double y, double r) { std::cout circle cx\ x \ cy\ y \ r\ r \ / std::endl; }); screenCircle.draw(); svgCircle.draw(); return 0; }这种写法的优势很明显不需要为“实现”声明任何接口类不需要构造一个具体的实现对象一个lambda就够了非常适合把跨模块的回调包装成桥接尤其是在插件化架构里外部模块向核心模块注册一个回调函数核心模块不关心谁提供的、怎么实现的只负责在合适的时机调用。不过std::function也有它的短板。第一std::function本身可能触发堆内存分配如果lambda捕获的变量比较多在性能敏感路径上会出现开销。第二std::function不支持常规的拷贝语义的约束检查传进来一个空std::function也不会在构造时报错直到调用时才发现问题所以最好在构造或调用前做空判断。第三可调用对象没有统一的“生命周期管理”概念如果lambda捕获了指针调用时就必须保证捕获对象的生命周期有效这一点反而比虚函数版本更难维护。因此如果实现部分是一组有状态的、长期存活的服务对象经典虚函数桥接通常比std::function更合适如果实现只是一个独立算法或短小回调std::function变体的表达更加轻巧。2.4 类型擦除变体把多态装进一个小盒子里类型擦除变体是前两种写法的混合产物。它既借用模板让调用方可以传入任意类型又在内部通过一层虚拟接口把具体类型封装起来对外暴露的是一个值类型容器。最典型的例子是std::function本身它就是通用类型擦除不过我们也可以自己实现一个专门针对“绘制实现”的小型擦除容器。#include iostream #include memory #include utility // 桥接实现类型擦除容器 class DrawEngine { public: // 模板构造函数接受任意“可绘制”对象 template typename T DrawEngine(T drawer) : self_(std::make_uniqueModelT(std::move(drawer))) {} // 对外成员函数转发给内部具体对象 void drawCircle(double x, double y, double r) { self_-drawCircle(x, y, r); } private: // 内部接口 struct Concept { virtual ~Concept() default; virtual void drawCircle(double x, double y, double r) 0; }; // 具体包装 template typename T struct Model : Concept { explicit Model(T drawer) : drawer_(std::move(drawer)) {} void drawCircle(double x, double y, double r) override { drawer_.drawCircle(x, y, r); } T drawer_; }; std::unique_ptrConcept self_; };使用的时候调用方仍然可以用屏幕绘制器、SVG绘制器或者任意自定义类型来构造DrawEngine根本不需要它们继承自某个DrawAPI。#include string struct ConsoleDrawer { void drawCircle(double x, double y, double r) { std::cout console draw( x , y , r )\n; } }; int main() { DrawEngine engine ConsoleDrawer{}; engine.drawCircle(1.0, 2.0, 3.0); DrawEngine engine2 [](double x, double y, double r) { std::cout lambda draw( x , y , r )\n; }; // 这里如果让DrawEngine支持lambda则需要在模板构造函数中区分 // 是否具有成员函数。实际工程中常用std::function替代或增加特化。 return 0; }严格说上面这个例子和std::function变体异曲同工只是一个面向成员函数一个面向调用运算符。类型擦除的真正价值在于抽象部分持有的对象从“接口指针”变成了“值类型”。由于值类型可以安全地拷贝、移动、放进容器抽象部分在传递和组合时都更方便。比如你需要一个std::vector 来保存一组不同实现虚函数版本的unique_ptr 也能做但类型擦除版本允许你用普通的值语义写起来更自然也更符合现代C的偏好。这个变体要小心的地方是内部仍有一次虚拟间接调用性能上和经典虚函数版本差不多。而且模板构造函数和拷贝构造函数容易混淆如果你既想实现模板构造又要保留常规拷贝构造必须用if constexpr或者委托构造小心设计否则容易产生编译歧义。3. 不同变体的选型与实战落地3.1 选型决策性能、扩展性、代码可读性怎么权衡面对这么多变体不少人的第一反应是“哪个更高级就选哪个”。这种思路会出问题。变体之间不是升级关系而是在不同约束条件下做权衡。我在项目中一般按下表来决策维度经典虚函数变体模板变体std::function变体类型擦除变体实现选择时机运行期编译期运行期运行期运行时开销虚函数间接调用无额外开销一次可调用对象调用虚函数间接调用是否需要实现继承接口需要不需要不需要不需要接口表达直观性较强较强较弱签名即接口较强是否便于运行期切换容易困难容易容易代码体积影响小较大模板实例化小小是否适合值语义容器一般一般可以非常适合入门门槛低中中高如果项目的核心目标是稳定、易读、团队成员普遍熟悉面向对象设计那经典虚函数变体就足够过度设计反而不划算。如果实现集合在编译期固定、且处于性能敏感路径模板变体会是更优选择。如果抽象部分是模块边界调用方希望以极低的学习成本注册自定义行为std::function变体最合适。如果整个抽象对象需要作为值四处传递甚至放进容器类型擦除变体能带来最好的使用体验。还有一点我得特别强调设计模式的作用是让结构清晰而不是给某些类图硬凑一个模式名。我在代码评审里见过有人为了“用上桥接模式”强行引入两套继承体系结果接口粒度太碎调用链变得很难追踪。真正该做的是先摸清变化点在哪儿确认抽象和实现确实有两个方向的独立变化再来从上面几种变体里选一个。3.2 一个跨平台日志系统的桥接变体实战为了讲清楚变体组合怎么用我拿一个实际项目里的例子来说明跨平台日志系统。抽象部分面向业务层提供logger接口支持info、warn、error级别实现部分则对应不同输出后端比如文件日志、控制台日志、网络上报。这个项目里的核心矛盾是业务层希望用同一套日志接口同时后端需要能在配置中指定而且文件日志和后端平台相关可能是Windows的调试输出也可能是Linux的syslog。我最终采用了“模板变体std::function变体”的组合方案。#include functional #include string // 输出后端的统一回调签名 // - 参数1日志级别 // - 参数2日志消息 using LogSink std::functionvoid(int, const std::string); class Logger { public: explicit Logger(LogSink sink) : sink_(std::move(sink)) {} void info(const std::string msg) { sink_(0, msg); } void warn(const std::string msg) { sink_(1, msg); } void error(const std::string msg) { sink_(2, msg); } private: LogSink sink_; };业务层完全不知道后端是文件、控制台还是网络它只看到Logger这个抽象。后端的创建则通过一个工厂来完成LogSink createConsoleSink() { return [](int level, const std::string msg) { std::cout [console][level level ] msg std::endl; }; } LogSink createFileSink(const std::string path) { return [path](int level, const std::string msg) { std::ofstream file(path, std::ios::app); if (file.is_open()) { file [file][level level ] msg std::endl; } }; }如果还想同时输出到文件和控制台也就是多路输出可以用一个组合sink把多个std::function包起来LogSink combineSinks(std::initializer_listLogSink sinks) { return [sinks](int level, const std::string msg) { for (const auto sink : sinks) { if (sink) { sink(level, msg); } } }; }这个场景里std::function变体帮我们轻松实现了后端聚合。换作经典虚函数版本我需要多设计一个MultiSink子类而std::function只需要一次lambda组合。但我也要为这种灵活性付出代价每次写日志都会调用std::function如果日志落在高频循环里性能会紧张而且Lambda捕获path后路径字符串被拷贝到了闭包内部如果日志后端经常动态重建会带来额外的分配开销。针对高频路径我一般会在Logger内部再加一层缓存或批量批量接口把每次日志独立写文件变成攒批输出来降低回调层面压力。从整体来看这个案例说明了几件事第一桥接模式的抽象部分不一定要是类继承体系有时一个轻量封装类就够了第二后端实现不一定是对象一个闭包就够了第三如果后端模式十分简单就不要为了“必须继承某个接口”去造一个DrawAPI层次的抽象基类。变体的价值就在于让实现更贴合真实场景。4. 工程里最容易踩的坑与排查思路4.1 虚函数桥接的常见坑虚函数桥接最经典的问题就是忘记把析构函数声明为virtual。如果DrawAPI作为接口没有virtual析构函数那么当Shape的unique_ptr释放DrawAPI对象时只会删除基类指针不会进入子类的析构函数。如果一个ScreenDrawer子类持有系统资源比如OpenGL纹理ID、Windows句柄这段资源就不会被释放内存泄漏和资源泄漏的结果就是项目上线一段时间后开始异常。排查这类问题最直接的方法是开启AddressSanitizer或者Valgrind。但如果你在Windows上用Visual Studio也可以直接用调试模式的CRT堆检查运行一小段反复构造销毁对象的压力代码观察进程内存是否有明显增长。日常写代码时更好的办法是强制约束接口类在抽象基类上声明virtual ~DrawAPI() default;。这句话看起来简单但带来的收益非常可观。C编译器并不会提醒你所以我会在团队规范里直接要求所有被多态继承的基类必须有virtual或protected析构函数。第二个常见坑是接管了指针所有权却没有明确所有权归属。早期经典写法中Shape接收一个DrawAPI*没有说由谁负责销毁。如果外部创建、Shape销毁则要明确Shape在析构函数中delete api_如果外部既创建又销毁Shape就变成了弱引用析构函数不能delete。这个问题用裸指针表达十分含糊。我更推荐直接用unique_ptr表示独占所有权让编译器帮助管理生命周期。通过阅读函数签名就能判断所有权unique_ptr表示转让shared_ptr表示共享裸指针表示不拥有。第三个坑是循环依赖。实现部分需要回调抽象部分抽象部分又持有实现部分的指针两个头文件互相include编译直接死循环。解决思路是让某个方向使用前向声明和指针不要两边都包含完整定义。常见做法是抽象部分持有实现部分的指针实现部分持有抽象部分的回调接口而不是具体的抽象类这又回到了组件划分层面。代码组织不好的话桥接模式反而会把依赖绕成圈。4.2 模板桥接的编译期问题模板变体最大的痛点是编译报错可读性差。尤其当模板参数不符合要求时编译器可能报出一长串从std::make_unique到某个成员函数不存在的一大堆信息非常劝退新人。遇到这种情况把问题分成几类来看第一类是缺少成员函数。比如传入的T没有drawCircle成员编译器提示“no matching function for call to ...”定位起来还算容易。第二类是成员函数签名不匹配比如drawCircle要求两个参数传了三个错误信息也很明确。第三类是编译错误信息冗长旧式编译器会展开所有实例化的上下文。前几年我在老项目里用模板桥接变体时经常被几百行的错误日志搞到心态崩溃。解决方向有两个。第一个是C20的Concept约束可以直接给模板参数加requires要求template typename Drawer requires requires(Drawer d, double x, double y, double r) { d.drawCircle(x, y, r); } class Circle { ... };这样当调用方传错类型时错误信息会直接提示“constraints not satisfied”比一长串未定义函数友好太多。第二个方向是给模板类包一层非模板的外壳把模板实例化细节藏起来这个思路其实就是类型擦除。如果项目还没有C20条件优先用类型擦除封装模板把复杂模板错误挡在内部。模板桥接的另一个问题是代码膨胀。每种不同实参类型都会实例化一份完整类代码如果实现类型非常多二进制体积会长大。在嵌入式C项目里这个现象尤其明显。我见过有人把几十个后端全部模板实例化链接出来的固件体积直接翻倍。解决方案是保留一层窄接口或std::function作为非模板基类让模板类只负责在构造时类型擦除之后运行期只走虚函数或std::function调用有效控制二进制体积。4.3 std::function桥接的性能陷阱std::function在工程中非常常见但它并非零成本。默认情况下std::function可以存储小型可调用对象而不触发堆分配但具体尺寸由实现定义。若lambda捕获了大体积状态比如捕获了一个std::string或者一个大数组std::function内部的小缓冲区放不下就会触发堆分配在性能敏感路径上产生明显延迟。排查的方法是在关键循环里对每次日志输出计时看看有没有毫秒级别的波动。优化手法有几个一是使用移动捕获C14的广义lambda捕获把大对象移动进闭包而不是拷贝减少分配二是前面提到的批量接口减少回调频率三是如果确定不需要通用std::function可以写一个针对特定签名的轻量类型擦除容器内部提供足够大的inline buffer。最后一个手段比较费工但如果你的项目处于超高频交易或实时音频领域这个投入是值得的。还要注意std::function的空调用问题。如果传了一个空std::function运行时调用会抛出std::bad_function_call异常。很多线上崩溃就是这种问题某个后端注册失败返回了空回调抽象部分没检查就调用。我个人的习惯是在Logger这类封装内部每次使用回调前都做空判断或者在构造函数里就把空回调替换成一段安全兜底逻辑宁可把日志丢弃也不让它触发异常。最后再聊几句我在实际工程里的体会桥接模式在C里能玩出很多花这恰恰说明C这门语言的设计张力很大。经典虚函数版本最稳适合多数团队成员阅读模板版本最快适合编译期确定的场景std::function版本最灵活适合模块边界和回调注册类型擦除版本则兼顾了灵活性和使用体验但实现复杂度偏高。我在实际项目里的选择策略是默认用经典虚函数版本代码评审中确认性能瓶颈指向这个虚函数调用时再改成模板或std::function变体从来不会为了炫技而提前优化。最后分享一个小技巧无论选哪种变体把“抽象部分”和“实现部分”的接口定义分别放在不同头文件里控制好头文件的依赖方向。这比具体选择哪种变体更重要。只有当两端在未来真的需要独立演进时桥接模式的威力才会体现出来如果只是当初觉得“这样设计比较优雅”后面却没有任何一端需要扩展那这个模式反倒成了维护负担。做工程能用简单代码解决问题就不要把结构做复杂这大概是我这几年最深的体会。
返回列表