一个让我血压升高的下午

上周有个老同事找我吃饭,聊着聊着就说到他最近接手的一个密码学库。“你是不知道,“他放下筷子,语气里带着疲惫,“这代码写得,简直是灾难。”

我问他怎么了。他说他们团队三年前写了一个人脸识别的库,当时为了支持不同大小的矩阵运算,居然用宏生成了 16 套几乎一模一样的代码。16 套啊,老兄。每套就是换个数字,16、32、64、128……跟套娃一样。

“你知道最后写了多少行吗?“他伸了个手指头,“8347 行。而且这还是精简后的结果。”

我当时就想,这哪是写代码,这是在堆坟。“后来呢?“我追问道。

“后来?“他苦笑了一下,“每次修 bug,我都要在 16 个地方改一模一样的内容。改完一个忘了改另一个,第二天线上就挂了。我那半年头发掉了三分之一,你看看我现在这样。”

我看着他确实稀疏的发量,心里默默叹了口气。

直到 Rust 1.51 来了

“转折点发生在 2021 年 3 月,“他喝了口汤,眼神终于亮了起来,“Rust 1.51 稳定化了 const generics。”

我一脸懵:“const 什么?”

他擦了擦嘴,开始给我解释。你听说过量体裁衣吗?以前裁缝店做衣服,量完你的身高肩宽腰围,得专门找一块布来裁。但 Rust 之前的泛型系统有个大 bug——它只能告诉你"这是一件衣服”,却没法告诉你"这件衣服是 XL 码的”。

“就这?“我觉得他有点小题大做。“你听我说完,“他摆把手,“这可不只是换个码数的问题。你知道吗,以前你想写一个处理固定大小数组的函数,根本没法写通用代码。”

他拿起餐巾纸,在上面画了个草图:

// 以前:想写一个通用函数?门都没有
fn process_16(data: [u8; 16]) { /* 处理 16 字节 */ }
fn process_32(data: [u8; 32]) { /* 处理 32 字节 */ }
fn process_64(data: [u8; 64]) { /* 处理 64 字节 */ }
// ... 一直写到 16 个函数

“看到没有?“他说,“每个函数就数字不一样,逻辑完全一样。但编译器不认账,它说 16 和 32 是不同的类型,你必须分别写。”

我懂了,这就像你去快递站寄包裹,人家说"对不起,我们只认箱子型号,不认箱子大小”。你说我要寄个小箱子,他说"小箱子我们有 A 型、B 型、C 型……您要哪个?”

“那 const generics 怎么解决这个?“我来了兴趣。

他笑了:“它让编译器知道,16、32、64 其实是一家人。”

// 现在:一个函数搞定所有尺寸
fn process<const N: usize>(data: [u8; N]) {
    // N 就是数组大小,编译期就知道
}

就这么简单?一个 const N: usize ,16 个函数变成 1 个。

“对就这么简单,“他点头如捣蒜,“我当时看到这个写法,整个人都傻了。三年啊,我们折腾了三年,就为了等这一个语法糖。”

85% 的代码是怎么没的

吃完饭我们换了家咖啡馆,他非要给我看他们重构后的数据。“你知道我们重构后多少行吗?“他把手机递给我,“1243 行。”

我算了算,从 8347 到 1243,确实是少了 85% 左右。“但这不是最恐怖的,“他神秘兮兮地说,“你猜猜性能怎么样?”

我摇头。“小数组运算快了 83%。编译时间从 23 秒降到 9 秒,二进制体积从 847KB 瘦到 507KB。”

我差点把咖啡喷出来:“等等,代码少了 85%,性能反而快了将近一倍?你在逗我?”

他很认真地摇头:“没有逗你。这里面有两个原因。第一,const generics 让编译器能做一些以前做不了的优化。比如 SIMD 指令自动向量化,编译器知道数组大小后,可以把循环完全展开,还能预取数据。”

“第二呢?”

“第二,“他压低声音,“以前用宏生成的那些代码,其实有很多重复的内存操作。const generics 编译出来的代码,编译器能做更好的内联和常量折叠。很多计算在编译期就做完了,根本不用等到运行期。”

他打开电脑,给我看了段代码:

// 以前:16 套实现,每套都要动态分配内存
fn multiply_16(a: &[f32; 16], b: &[f32; 16]) -> Vec<f32> {
    let mut result = Vec::with_capacity(16); // 堆分配!
    // ... 计算
    result
}

// 现在:栈上直接算,零分配
fn multiply<const N: usize>(a: &[f32; N], b: &[f32; N]) -> [f32; N] {
    let mut result = [0.0; N]; // 栈上分配,编译期就知道大小
    // ... 同样的计算
    result
}

“看到了吗?“他说,“以前每次矩阵乘法都要在堆上申请一块新内存,现在直接在栈上就地算出结果。内存分配可是个昂贵的操作,能省当然要省。”

生活中的 const generics

我让他再用生活场景给我解释一下。他想了想,说:“你买过那种拼装家具吧?”

“买过,宜家的。”

“宜家的柜子,你知道吧,每种型号的板材数量是固定的。安装说明书上写着:柜子 A 需要 4 块侧板、2 块顶板、3 块隔板……但如果你要做一个通用的安装机器人,你怎么办?”

我摇头。“以前的做法是给每种柜子型号写一套程序。柜子 A 用程序 A,柜子 B 用程序 B,柜子 C 用程序 C……16 种型号写 16 套程序。”

“const generics 来了后,你只需要写一套程序:柜子<型号>{侧板数量: 4, 顶板数量: 2, 隔板数量: 3}。然后告诉机器人,这是 16 号柜子,那是 32 号柜子,机器人自己查配置就知道需要什么板材。”

我懂了:“所以 const generics 就是把那些’型号’参数化了?”

“对,“他很高兴我理解了,“型号就是常量,型号的数量、尺寸都可以作为类型参数传进去。编译器一看就知道这个柜子有多大,需要多少块板子,不用 runtime 再去查。”

那些让人头疼的用法

“不过,“我泼了盆冷水,“这玩意儿应该也有局限吧?”

他点头:“当然。你不能把任意运算放在 const 参数里。比如你想写 impl<const N * N: usize> ,不好意思,不支持。乘法和更复杂的运算在类型层面暂时还玩不转。”

还有,const 上下文里不能做堆分配。你没法写 const fn create_buffer() -> Vec<u8> ,因为 Vec 是运行期才有的东西。

“那遇到这些问题怎么办?““绕过去啊,“他说,“提前算好常量。比如你要 N*N,就先在外面算好 const SIZE: usize = 4; const SQUARED: usize = SIZE * SIZE; 然后用 Matrix<SIZE, SQUARED> 。编译器又不傻,它知道 SQUARED 是多少。”

另外他还提醒我,const generics 会增加编译时间,因为每个不同的常量值都会生成一份新的代码实例。不过这个代价通常是值得的,毕竟 runtime 更快了。

我们到底得到了什么

咖啡凉了,但我们聊得正热乎。“总结一下,“我帮他梳理,“const generics 给我们带来了什么?”

他掰着指头数:

“第一,代码量暴减。重复的代码不用写第二遍,DRY 原则终于在数组处理领域站起来了。”

“第二,性能优化效果显著。栈代替堆,编译期优化代替运行期计算,SIMD 向量化代替串行循环。能省的地方全省了。”

“第三,类型安全。编译器帮我们检查数组大小对不对,编译期就把 bug 抓出来,不用等到线上才崩溃。”

“第四,可维护性。以前 16 个地方改 bug,现在改一个地方就行。新人看代码也看得懂了,不用在一堆宏展开里晕头转向。”

他顿了顿,补充道:“最让我感动的是,我们组里一个刚毕业的应届生,以前看到那些宏生成的代码就头皮发麻。现在看到 const generics 的写法,他说’这不就是普通的泛型吗,我看得懂’。听到这话,我差点没哭出来。”

所以什么时候该用

“那我什么时候该用这个功能?“我最后问。

他想了想:“当你满足这几个条件的时候:数组大小是固定的、编译期就知道的;对性能有追求,零拷贝零分配最好;代码开始出现大量重复模式,不写宏难受,写了宏更难受。”

“反过来,如果你数组大小是用户输入决定的,或者你还在快速原型阶段,那先别用。const generics 会让代码编译更慢,而且对 runtime 才确定的大小没用。”

“说白了,“他总结道,“它是个工具,不是银弹。用在该用的地方,它就是神器。用在不该用的地方,它就是负担。”

我看了眼窗外,天都黑了。“走之前,“我问他,“你那个密码学库现在怎么样了?”

他站起来,伸了个懒腰:“活得很好。bug 少了 67%,新人三个月就能上手。我们团队现在有空一起吃午饭了,不用天天加班修 bug。”

他拍拍我的肩膀:“兄弟,听我一句劝,如果你现在的代码里有很多 fn process_16fn process_32 之类的函数,试着换成 const generics。你会回来感谢我的。”

小抄

一句话记住它:const generics 让类型参数可以带"尺寸”,像量体裁衣一样精确。掌握 const generics 是 Rust 性能优化路上的重要一步。

这几种情况尽管用

  • 你要处理的数组大小固定不变,编译期就知道是多少
  • 你对性能有洁癖,恨不得零拷贝零分配
  • 你发现自己开始用宏 Ctrl+C、Ctrl+V 的时候

这几种情况先别用

  • 数组大小是用户输入决定的,运行期才出来
  • 项目编译已经慢得像便秘,别再添堵
  • 你还在快速原型阶段,代码三天两头要大改

踩过坑才懂的事

  • const 参数里不能做加减乘除那些复杂运算,类型系统还没那么聪明
  • const 函数里不能搞堆分配,它只知道编译期的那点事
  • 每个不同的常量值都会生成一份新代码,所以是用编译时间换执行时间

觉得这篇文章有用吗?

  1. 点赞:如果觉得有帮助,点个赞让更多人看到
  2. 转发:分享给可能需要的朋友或同事
  3. 关注:关注梦兽编程,不错过更多实用技术文章
  4. 留言:你在项目中有没有遇到过需要写 N 个相似函数的情况?后来怎么解决的?

你的支持是我持续创作的最大动力!