Dart 官方再解释为什么放弃了宏编程,并转向优化 build_runner ? 和 Kotlin 的区别又是什么?

近日,Dart 团队再次详细解释了为什么 Dart 放弃了宏编程,简单说就是:在现在的 Dart 语言上进行的宏编程支持,最终得到了"高不成低不就"的结果,所以官方最终放弃了这个支持。

首先就是配置 Macros 的问题 ,宏/代码生成器通常使用注解进行配置/触发,而注解配置需要是常量,但是在目前 Dart 语言体系里,常量计算是在整个程序编译完成之后才进行的,而 Macros 必须在编译开始阶段运行(因为它要生成代码供编译使用),比如:

  • 宏问编译器:"这个 @Config(timeout: A) 里的 A 是多少?"
  • 编译器说:"等我把程序编译完才能告诉你,因为 A 可能是从别的常量算出来的。"
  • 宏说:"你不告诉我,我就没法生成代码让你编译。"

这就是目前的 "undefined area" 问题,再举个直观例子:

  • 理想情况 :你写一个宏 @MyMacro(config: someValue),编译器在编译时,需要先算出 someValue 是什么,然后再把这个值传给宏去执行
  • 实际问题someValue 本身可能又是由另一个宏生成的,为了运行宏,必须先算出参数(常量),为了算出参数(常量),可能需要先运行宏

结果这就陷入了死循环,要解决这个问题,编译器需要非常复杂的逻辑来梳理依赖关系,这在现有的 Dart 编译器架构下成本剧增。

而 Dart 团队也尝试过,通过定义一种简单的 JSON 格式( macro_metadata.schema.json),不让编译器去分析复杂的 Dart 语法,而是直接扫描源码,把宏的配置提取成这个 JSON 数据,这个 JSON 数据不需要"编译",它就是纯文本,任何工具都能直接读取:

虽然 JSON 简单,但也意味着开发者不能在宏配置里用复杂的 Dart 代码(比如引用另一个变量),只能写死值,而且这又搞出了两套逻辑(一套是正常的 Dart 编译,一套是专门处理这个 JSON 的逻辑),所以可以看到,这个尝试太麻烦且不灵活,所以最后这个路径被放弃了。

其次就是架构分裂导致的"工作量爆炸" ,目前 Dart 的工具链其实是两套系统:

  • Analyzer:负责 IDE 里的报错、补全,也就是你在写代码时它在跑
  • CFE (Common Front End) :负责真正的编译,当你点 Run 时它在跑

而如果为了支持 Macros,这两套系统必须各自实现一遍 Macros 的逻辑 ,这意味着 Macro 代码会在开发过程中被跑两次,再加上还不能直接完全淘汰的旧版代码生成(Codegen),同一个生成逻辑可能要跑三次,这不仅是慢的问题,更是在一个单进程工具里强行塞入了一个复杂的分布式构建逻辑,导致系统极度臃肿且不可扩展。

实际上,为了编译速度,Dart 编译器(特别是处理大型项目时)通常不会每次都去读取所有依赖库的 AST ,它只读取摘要,而要实现完整的宏编程,需要读取完整的源代码,但问题在于:

如果让编译器把所有依赖的源码都吐给宏,编译速度会慢到无法接受 ,如果只给摘要,宏的功能又会变得很弱,无法分析函数体内部的逻辑。

例如你在 IDE 里写代码时,宏应该实时运行,比如你写完 @JsonSerializable,下一秒你就能在代码里点出 .toJson() 方法。

但是要在你打字的同时,实时分析代码、运行宏、生成新代码、再让分析器理解新代码,这对性能要求极高,目前的尝试结果是,这会让 IDE 变得非常卡顿,用户体验极差

当然,最重要的一点是,实现出来的能力的"高不成低不就" ,因为就算 Macros 做出来了,也没办法完全替代 build_runner ,因为宏的设计本身也存在局限性,每个"阶段"可以输出的代码类型有限,比如禁止基于类的字段输出类,在 Dart 设计的最后,发现限制了输入和输出只能是 Dart 代码:

意味着它无法读取 .sql.graphql.proto 或 YAML 配置文件来生成代码,到头来发现处理 JSON schema 或 Protobuf 时还是得用 build_runner

所以如果费了半天劲做出来 Macros,结果开发者还得同时维护 Macros 和 build_runner 两套系统,而且性能还差,这样反而得不偿失。

那么有人要说了,为什么别的语言可以, Dart 就不行?难道不是 Dart 团队菜么?说是也是,但其实这也和语境和场景有关系,例如:

  • C++ 的模板元编程确实有完整的宏能力,但是 C++ 项目【编译速度】和【报错堆栈】大家应该有所体会,这对于 Dart 一个支持 hot reload 的场景来说,完全无法接受

  • Rust 的宏也非常强大,但是和 Dart 设计不一样:

    • Rust 的宏在编译器完全理解代码的含义(类型检查、语义分析)之前就展开了,宏看到的是一堆符号(Tokens),而不是"这是一个 String 类型的变量"
    • Rust 宏时序清晰:宏先运行 -> 生成 Rust 代码 -> 编译器再介入检查类型,这和 Dart 原本的设计不一样,因为宏生成的代码又会改变类型检查的结果,这属于 Dart 原本的设计问题

那有人要说 Kotlin 呢?实际上说,Kotlin 没有严格意义上的宏,这也是为了防止语言分裂成各种"方言",所以 Kotlin 不提供像 Rust 那样让用户随意修改语法的宏系统,但是 Kotlin 有两套机制来达到类似的效果,并且实现比 Dart 好很多:

  • 首先就是 KSP ,这也是 Dart 的 build_runner 的"理想形态" ,KSP 主要用来生成新文件,而不是修改现有文件,但是它速度快,KSP 直接利用 Kotlin 编译器的解析结果,不需要像 Dart build_runner 那样每次启动一个笨重的分析过程,所以体验好,用户感知也不强
  • Compiler Plugins,它可以直接修改编译器生成的中间代码 (IR),在编译器把代码变成机器码的过程中,修改了函数的签名和逻辑,但是也有个缺陷,它和 Kotlin 版本的绑定关系比较强,不通用版本

虽然 Compiler Plugins 具备元编程能力,但是还是和宏有本质区别,宏的核心行为是"在调用处展开 ",比如你写了一行代码,编译时它变成了十行,它发生在语法分析阶段

事实上在 Flutter 的 Engine 里你就可以看到很多 C++ 的宏定义,例如 _returnAsync ,Dart VM 在 C++ 层面使用了大量宏模版代码生成,这里 _returnAsync 就是通过下方的宏定义在多次地方进行动态生成和调用:

这里的生成和引用流程还挺长,就不展开了,下次可以单独一篇聊聊,但是最直观的现象就是,如果你不完整编译出来 Engine 产物,那么 Flutter Engine 在 CLion 等 IDE 上,是没办法执行例如点击跳转对应函数等行为。

但是编译速度这个大家应该感受过了。

而 Compiler Plugin 虽然也能修改代码,但它通常不是在语法层面上做"文本/AST 替换",而是在更深层的 IR (中间表示) 层Bytecode 层 做逻辑注入,比如 Jetpack Compose 的 @Composable 并不是把你的函数代码删了换成别的,而是像"切面(AOP)"一样,在函数内部静悄悄地塞入了状态管理逻辑。

因为 JetBrains 也知道,宏会让 IDE 很难做,如果一段代码在编译前长 A ,编译后变成完全不一样的 B,IDE 的"跳转定义"、"重构"、"代码补全"就会失效,或者需要极其复杂的逻辑去推断宏展开后的样子。

当然, Kotlin 在这些能力上的表现, Dart 确实欠缺很多,所以,2026 官方的目标就是优化出一个更好用的 build_runner ,实现出一个类似 Kotlin KSP 的效果。

所以 Augmentation 才是 Dart 的新方向,它允许通过 augment 关键字,在新文件里去"补充"旧文件的定义,所以 2026 可以期待一下,全新的 build_runner 是否能落地成功。

相关推荐
2501_9418053111 小时前
从微服务网关到统一安全治理的互联网工程语法实践与多语言探索
前端·python·算法
寧笙(Lycode)11 小时前
前端包管理工具——npm、yarn、pnpm详解
前端·npm·node.js
Android-Flutter11 小时前
android compose PullToRefreshAndLoadMore 下拉刷新 + 上拉加载更多 使用
android·kotlin
小夏卷编程11 小时前
vue2 实现数字滚动特效
前端·vue.js
文心快码BaiduComate11 小时前
嫌市面上的刷题App太丑,我让Comate帮我写了个“考证神器”
前端·产品
harrain11 小时前
html里引入使用svg的方法
前端·svg
遗憾随她而去.11 小时前
Webpack5 基础篇(二)
前端·webpack·node.js
Mintopia11 小时前
🧭 一、全栈能力的重心正在从“实现” → “指令 + 验证”转移
前端·人工智能·全栈
Mintopia11 小时前
2025,我的「Vibe Coding」时刻
前端·人工智能·aigc
似霰11 小时前
HIDL Hal 开发笔记4----Passthrough HALs 实例分析
android·framework·hal