打开网易新闻 查看精彩图片

全球超过500万C++开发者中,能把单元测试覆盖率做到60%以上的不到15%。不是不想测,是代码里IO、状态、定时器缠成一团,牵一发而动全身。

函数式编程圈有个流传多年的解法,但C++程序员总觉得那是Haskell(一种纯函数式编程语言)玩家的玩具。直到有人把贪吃蛇游戏拆成两段:一段纯得像个数学公式,另一段脏得理直气壮。

纯函数凭什么能"指挥"副作用

纯函数凭什么能"指挥"副作用

纯函数的定义很苛刻:同样的输入永远给出同样的输出,执行过程不碰外部世界。但一个真正的程序总得读写文件、发网络请求、刷新屏幕——这矛盾怎么破?

答案藏在"描述"与"执行"的分离里。纯函数不亲自打印日志,而是返回一条数据说"这里该打印了";不直接改状态,而是返回"新状态应该是这样"。副作用被推迟到调用方——那个不追求纯度的外壳层——去落地。

这就像餐厅后厨与前台的分工。厨师只管按配方做菜(纯函数),从不直接接触顾客;服务员把菜端出去、收反馈、处理投诉(副作用)。配方本身干净可复现,服务环节灵活应变。

这个架构被命名为"函数式核心-命令式外壳"(functional core–imperative shell)。核心层全是业务决策,外壳层全是脏活累活。

贪吃蛇里的代码解剖

来看一段真实的方向校验逻辑。游戏规则很简单:不能直接180度掉头,按"上"的时候如果正在往下走,这次按键无效。

传统写法里,这个函数可能直接读取当前状态、直接写日志、直接触发音效——测试时需要模拟整个游戏引擎。而核心-外壳分离后,代码长成这样:

核心层只回答一个问题:这个方向改变合法吗?它返回一个结构体,包含两个字段:是否接受这次改变,以及可选的日志内容。没有打印,没有状态修改,没有定时器。

外壳层拿到这个结果,决定要不要更新游戏状态、要不要调用日志系统、要不要播放"无效操作"的提示音。业务规则与执行细节彻底解耦。

关键的设计约束在这里:核心层自包含,外壳层依赖核心,但核心对外壳一无所知。这意味着你可以用单测覆盖核心,不需要链接任何IO库,不需要启动线程池,甚至不需要文件系统。

为什么C++程序员现在才认真看这个

函数式概念1980年代就有了,但C++的编译器直到C++11才给lambda表达式开绿灯,C++14才完善了返回类型推导。更关键的是,早期教程总爱用Haskell或Elm举例,满屏的monad(一种用于处理副作用的抽象结构)和functor(一种支持映射操作的数据结构),劝退效果极佳。

现在的转变来自两个压力。一是测试成本的飙升:一个中等规模的金融交易系统,回归测试套件跑完全量可能需要48小时,而核心层如果能做到毫秒级单测,迭代速度完全不在一个量级。二是并发的噩梦:纯函数天然线程安全,没有数据竞争,不需要锁。

Google的Abseil库、Facebook的Folly库,近年都增加了对不可变数据结构和纯函数工具链的支持。这不是审美偏好,是工程债务逼出来的。

但迁移现有代码是另一回事。一个典型的陷阱是"假分离"——核心层里偷偷埋了全局状态查询,外壳层里又塞了业务判断。审查代码时有个简单标准:核心层的函数签名里,如果出现文件路径、网络地址、数据库连接池,就是架构腐败的信号。

另一个坑是性能焦虑。返回描述数据而不是直接执行,意味着额外的内存分配和拷贝。现代C++的移动语义和返回值优化能缓解大部分问题,但极端场景下仍需权衡。游戏引擎的渲染循环、高频交易系统的订单簿,可能需要在热点路径保留传统写法。

这架构能走多远

这架构能走多远

核心-外壳模式不是银弹。它的边界是:核心层必须能完整表达业务决策,不能半推半就。如果"是否接受订单"取决于数据库里的实时库存,而这个查询本身有副作用,你就得把库存快照作为参数传进核心,而不是让核心自己去查。

这倒逼出一种数据流向:外壳层在调用核心之前,把所有需要的外部状态捞上来,打包成值类型塞进去。核心返回的新状态,再由外壳层写回外部世界。IO的总量没有减少,但发生的位置被严格管控。

有团队把这种模式推到极致:核心业务逻辑用Haskell写,编译成C++可调用的库,外壳层用传统的C++工程实践包裹。代价是团队需要双语能力,收益是核心层的bug率下降了一个数量级。

更务实的做法是渐进式改造。从最容易隔离的模块开始——配置校验、价格计算、权限判定——先把这些挪进纯函数,建立单测保护网,再逐步向外扩张。每推进一块,回归测试的时间就缩短一截。

一个有趣的观察是,这套架构与微服务的边界划分形成呼应。服务内部的纯逻辑 vs 服务间的网络调用,本质也是核心与外壳的变体。只是分布式场景下,"描述副作用"变成了发消息,"执行副作用"变成了消费消息。

当你下次面对一个几千行的类,成员变量里混着业务状态、缓存、锁、文件句柄,不妨试着画一条线:哪些代码在回答"应该做什么",哪些代码在回答"怎么做到"。前者往左迁,后者往右留。迁移的难度往往比想象中低,因为纯函数不依赖框架,不依赖运行时,甚至不依赖特定的语言版本。

那个贪吃蛇的例子最终跑在生产环境,核心层的单元测试有200多个,执行时间0.3秒;外壳层的集成测试只有十几个,但覆盖了所有IO路径。作者说最爽的时刻,是发现一个新bug时,能在一秒内定位到核心还是外壳——前者改完立即验证,后者才需要启动完整环境。