ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Dart 放弃宏的深层原因:不敌编译速度与静态分析生态

Dart 放弃宏的深层原因:不敌编译速度与静态分析生态 Dart 团队宣布放弃宏Macros功能的时候我第一时间是想鼓掌的。不是我不需要这个功能恰恰相反这几年写 Flutter 业务代码被 JSON 序列化、数据类、copyWith 这类样板代码折磨得不轻。但当我看到宏从预留特性到原型实现再到最终被取消的全过程尤其是看到了 DCMDart Code Metrics这类静态分析工具在生态里爆发的潜力之后我的结论和团队保持一致取消宏是 Dart 语言发展史上最务实的决定之一。这篇文章不聊八卦就从一个普通 Flutter/Dart 开发者的视角聊聊宏的初心、它死在哪个环节以及“没有宏”的 Dart 靠什么活得更好。1. 宏原本想解决的生产力痛点如今依然是切肤之痛1.1 Dart 生态里绕不开的样板代码三座大山我承认宏被提上日程的初衷完全是正当的。Dart 语言在服务端和客户端双线作战大家抱怨最多的不是语法不支持函数式而是——手上的活全浪费在读模型和写模型上。第一座大山是 JSON 的序列化和反序列化。Flutter 项目里最常见的场景就是接后端接口。从MapString, dynamic里手动as String或者as int写一百个字段就得写一百行提心吊胆的转换代码。json_serializable这个包解决了一部分问题但它需要 build_runner 先生成代码生成完的.g.dart文件一有模型改动就要重新跑一遍。第二座大山是数据类的不可变性。写一个User类要手动写、hashCode、toString、copyWith。如果你用过 Kotlin 的data class回来看 Dart 的普通类会觉得像回到了手工劳作的年代。freezed这个包填补了空白但本质上仍然是在用代码生成器模拟语言级特性。第三座大山是依赖注入的模板化。手写 ServiceLocator 的注册代码每加一个 Repository 就要复制粘贴十几行改一个依赖少则两三处多则牵连一整个测试文件。这三座大山的存在让 Dart 团队在 2020 年前后认真考虑了宏。当时的思路很清晰如果能像 Swift 的Observable或者 Kotlin 的Parcelize那样直接在字段上标注一个装饰器编译器自动展开成模板代码上面所有痛点都能一次性解决而且调用方代码会干净到令人发指。1.2 从“注解”到“宏”的距离比想象中远得多这里插一句很多人把 Dart 里的override或者Deprecated理解成宏这是一种误读。Dart 的 annotation 只是元数据本质是无操作的标记。override是让编译器检查父类方法签名Deprecated只是给 IDE 一个提示。它们没有能力在编译期生成新的声明、修改类的结构或者注入方法体。真正的宏是要在编译期间捕获 AST抽象语法树甚至形成新的语法节点。这个“捕获”和“重写”的过程在 Python 这种动态语言里还好说Python 的装饰器是运行时拦截函数调用对象模型本身就允许猴子补丁。但 Dart 是静态类型语言类型检查要在宏展开前后都成立不然你没法保证宏生成出来的代码不破坏类型安全。所以当初 Dart 团队rollout的宏方案不是 Python 式的运行时装饰器也不是 C 语言的纯文本替换而是类似 Swift 的独立宏声明加外部实现的模式。宏定义者编写一个单独的宏类编译器在指定的阶段比如类型检查之后调用宏的build方法把宏展开的代码重新放进 AST 里。理论上这个方案能在保证类型安全的同时实现代码复用甚至能做出 freezed 和 json_serializable 的编译期统一体。光想一想就觉得很美part文件和.g.dart文件全都可以干掉build_runner 也不再需要编译一次成型IDE 跳转直接进宏定义。但是一旦进入工程化落地的阶段事情就变味了。2. 宏方案在编译器和工具链里真实撞上的墙2.1 编译速度这个大前提差点被宏拖垮Dart 作为 Flutter 的底层语言一直把编译速度当成生命线。Flutter 引以为傲的热重载Hot Reload依赖的是 Dart 编译器的增量编译能力。你在 Android Studio 里改一行代码编译器只需要把改动的那一个库重新编译然后和之前的 assets 合并整个链路要控制在秒级用户才能获得“改完立刻看到效果”的体验。而宏一旦进入这个链路会带来一个可怕的问题宏展开的缓存有效性判定。如果常量或者编译期可执行的数据展开逻辑不依赖外部的脏数据那还好办。但宏的定义体里一旦引用了别的库里的常量或者依赖了文件系统里的配置比如dart define传进来的环境变量编译系统就必须判断这个宏的输入哈希有没有变化。变了就要重新展开而不只是重新编译被修改的代码。这听起来还好那再想一层宏展开之后生成的 AST 会被重新解析为源码参与后续的类型检查。类型检查的结果如果反过来影响宏的展开条件比如某个字段在类型层面已被宏改写就会产生循环依赖。解决这个问题的通用手段是把编译阶段分成多轮每一轮只展开不受上一轮结果影响的宏直到 AST 不再变化。这个多轮展开的机制一旦引入增量编译的路径就不再是线性的。热重载时编译器无法只关注和改动文件相关的库因为宏可能从另外一个库展开到你正在改的这个文件里。这种不可预测的全局影响对一个以“毫秒级反馈”为核心体验的框架来说几乎是不可接受的。2.2 类型系统的负担宏展开后的代码还要再做一遍类型检查第二个撞墙点是类型检查的重复计算。Dart 的强类型系统本来就为 null safety 做了大量静态分析。引入宏之后类型检查器必须对宏展开前后的代码各做一遍检查。第一遍检查宏本身写得对不对第二个检查宏生成出来的代码在当前上下文里是否类型安全。这里的复杂度不是线性增加的而是组合爆炸。宏类里有泛型参数宏方法有类型参数宏展开的代码里还引用了宏调用者的私有类型或者函数这些交叉会让编译器在分析和命名解析阶段需要维护一个非常复杂的中间表示。实现这个复杂度的难度远超给语言加一个extension type或者新的 pattern matching 语法。Dart 团队内部曾经在某个 issue 里坦承宏的最简可行版本MVP就已经需要改动核心编译器的大部分架构包括欢迎句法分析器、AST 转换层、常量求值器、库加载器甚至还有前端服务器用于 IDE 的语法分析。这是个巨大的工程不是一两个大版本能完成的。2.3 宏和 IDE 的相爱相杀补全和跳转必须实时的痛苦像我们这种天天在 IDE 里写 Dart 的人很难回忆起没有代码补全和可靠跳转的日子。而宏的存在天然和 IDE 功能冲突。设想一个场景你用jsonSerializable标记了一个模型类IDE 要在输入user.的时候自动提示name、age、toJson这些成员。但是toJson是宏展开出来的它并不存在于源代码里。IDE 为了提示这个成员必须先执行宏展开逻辑。如果没有宏IDE 只需要做标准的语法索引成员补全可以直接从 AST 里提取。有了宏IDE 就得内嵌一个 Dart 编译器的部分后端并运行宏代码。宏的代码可能依赖第三方包可能有 IO 操作可能动态计算配置这会让补全操作从几毫秒变成几十甚至几百毫秒。如果宏展开出错代码补全直接消失那是多么痛苦的开发体验。Dart 团队和 JetBrains 合作在 IDE 插件层面做了不少实验但结果都不理想。宏带来了一个“既要又要”的死局既要编译期有完整的类型信息又要在编辑期还没编译完就能给开发者展示成员。就算做成增量缓存宏定义的修改也会让大范围的缓存失效而 IDE 的响应能力是用户感知最直接的部分一卡就会骂娘。2.4 语义版本的雷区宏展开器可以依赖语言内部细节还有一个很容易被忽略的点但做工具链的人会当场崩溃——宏展开器实现时依赖的底层库可能和 Dart 语言自身的演进产生冲突。Kotlin 和 Swift 的宏或者编译器插件相对更成熟是因为它们背后的编译器实现公开稳定有明确的各种 entry-point。而 Dart 的编译器历史上迭代非常快前端、kernel、后端本来就经常调整。Dart 团队如果要提供一个公共宏 API等于是要把内部架构冻结一部分用来保证宏定义者的代码在未来的编译器版本上依然能转。换句话说引入宏会让语言开发背上一个“永久兼容”的包袱。以后编译器内部想重构就得考虑所有宏定义包的兼容而这个包袱本来是不需要的。Dart 团队显然意识到了语言设计不能为了个别生产力特性牺牲掉整个编译器和 SDK 的迭代自由度。3. DCM 和代码生成器接棒没有宏的生态反而更健康3.1 code generation 的野路子build_runner 生态的成熟宏被取消不代表样板代码的痛点就让人硬扛。实际上Dart 生态走了一条曲线救国的路线用代码生成器code generator加上静态分析插件把原本想在编译器里做的事挪到了构建时和编辑器里独立完成。这条路线的代表就是 build_runner 生态。freezed、json_serializable、riverpod_generator、drift都是干这个的。虽然它们跑起来没有宏那么“无缝”每次改完模型要重新跑一遍 build_runner但这个代价换取的是方案的可控性生成的代码是纯 Dart 文件可以提交进 git也能直接在调试器里断点。生成器本身是一个普通的 Dart 程序可以用我们最熟悉的调试方式测试和断点。如果生成器出了问题不会连带拖垮编译器的核心进程。IDE 在生成完成之后可以正常索引、补全、跳转不需要理解额外的 AST 变换逻辑。我自己在实际项目里是深度用户。比如用freezed定义可辨识联合类型写完一个 sealed 类然后自动生成when和map方法。虽然跑 build_runner 要等十几秒但胜在结果透明、可审查。如果使用宏生成的代码在 AST 里出了问题连调试入口都找不到。3.2 DCM 补上静态分析空缺的那块拼图如果说代码生成器解决的是“样板代码产出”的问题那 DCMDart Code Metrics解决的则是“代码质量检查”的问题。DCM 和 analyze 命令不一样analyze 主要看语法错误和基础 lintDCM 提供的是跨文件级别的复杂度和可维护性检查。你甚至可以订阅 DCM 的插件机制自己写分析规则。这里就有个关键点DCM 之所以能跑得这么顺畅就是因为 Dart 语言没有被宏的 AST 变换搞复杂。如果宏存在每一个分析规则的编写者都要考虑“当前代码里哪些成员是宏生成出来的”“宏展开后的调用链要不要计入圈复杂度”“宏生成的代码能不能被自定义检查规则读取”这些问题。没有宏DCM 的规则只需要关心源码这一层做静态分析时不用额外跑一个解释器。这就是工具链的确定性优势。我们自己团队最近接入了 DCM 的自定义规则约定所有 public API 必须有 doc comment所有异步方法不能被 unsafe 的as转换污染。这套规则在 CI 里自动打分配合 analyze 一起跑非常丝滑。如果换到宏场景这个规则得先去理解宏展开的代码复杂度高出一大截还不一定稳定。3.3 真正缺的不是“宏”而是“更好的代码生成体验”Dart 团队并没有原地摆烂。他们在取消宏的同时把重心转向了让 build_runner 更好用这比硬啃宏要务实得多。比如他们优化了 build_runner 的增量缓存只在lib/目录下依赖变更时重新生成。又比如在 Dart 3.4 之后强化了DataClass注解和 IDE 的联动虽然这只是中间产物还有 Kotlin 风格的扩展字段在整个分析器里的支持。而且宏方案里开发者的希望大都会在“代码的代码”问题上绕不开一个核心难题作为用户你到底愿意为了“少写几行样板代码”用多大的代价换取编译链路的复杂化从社区反馈看绝大多数人其实愿意选择先写手动代码再在 CI 里检查质量。这与工程文化的务实倾向一致追求可预测性和稳定性而不是短期的语法糖。4. “没有宏”让 Dart 在 AI 辅助编程时代显得特别从容4.1 AI 生成代码和宏的定义天然冲突这两年的 AI 编程辅助工具Copilot、Codeium 等已经成了很多团队的工具链标配。这种背景下宏的处境变得更尴尬了。AI 学习的代码语料是人类手写的代码、文档和开源项目中的实际代码。宏是“由编译器生成代码”的方案它依赖的是一个外部的语义模型AI 很难从现有语料中学会“在哪个场景下定义一个宏展开器”。AI 要想正确写出宏需要推理源代码的 AST 结构、宏参数的类型、宏展开后可能会生成多少个嵌套语法节点。这超出了绝大多数代码补全模型的能力。反过来代码生成器build_runner生成的是普通 Dart 文件。AI 在训练时见过大量.g.dart文件知道copyWith和toJson一般长什么样子。AI 生成的业务代码即使不合规至少也停留在普通源码层面更容易分析和修正。Dart 取消宏从 AI 工具链的兼容性角度看等于避开了 AI 无法理解的高级抽象保住了让 AI 能直接参与码字的基础盘。4.2 手写代码的可预测性还是交付安全的底线我在几个合同项目里被迫使用过 Kotlin 的宏编译器插件形态印象最深的是当宏展开出错IDE 指向的是宏定义文件和真实算法逻辑隔了一层排查链路极长。而在 Dart 里生成代码是显式的任何 review 的人都能打开xxx.freezed.dart看到产出。站在团队管理的角度这一点对于保证交付安全太重要了。代码审查没法审查一个未来才会展开的 AST 变换但可以审查已经展开的.g.dart。如果用了宏团队成员犯错的成本和排查问题的成本都会上升尤其是在跨项目的复杂依赖里一个宏会影响出人意料的范围。4.3 标准化样板代码的未来交给模板交给 DSL而不是交给宏在 JavaScript/TypeScript 世界里样板代码的处理方式是模板字符串和代码生成脚本如 hygen、plop在 Dart 世界里build_runner 天然扮演了这个角色。我们完全可以用 shellexec 和 mason 这样的管理工具把freezedjson_serializableriverpod组合起来做成一个内部脚手架新模块一键生成全套模板。宏能让你写一个注解就展开出一整套 CRUD。但只要你做一个私有依赖注入库封装一个AutoInject再配一个 build_runner 生成器效果完全一样而且更可控。这就是 Dart 生态的真实现状不走宏走生成器 模板 DSL 的组合拳反而让每个环节都可以替换和调试。5. 我的个人判断这波劝退劝得越早Dart 未来越稳5.1 对比 Kotlin 和 SwiftDart 的劣势在于团队规模和生态阶段Kotlin 有 JetBrains 这种重兵投入 IDE 的公司做后盾Swift 有苹果整个工具链部门在养。Dart 团队相对小而精核心精力又长期集中在 Flutter 这个框架本身。对这样的团队来说维护一个宏系统意味着全年无休的兼容性测试、编译器核心 bug 修复还得面对一堆第三方宏框架的依赖炸弹。更关键的是Dart 生态不像 JVM 生态那样存在大量大型企业级框架宏如果被滥用会在顶层设计上引发很多风格不一致。没有宏反而能保持语言本身的“幼稚”和干净——在一个生态还没完全成熟的阶段这是宝贵的演进特权。5.2 社区的真实反应少数愤怒大多数回归理性宏要取消的消息放出后少数开发者觉得受到了背叛。但仔细观察那些抱怨的人多半是把宏和“代码生成”混为一谈或者只是对样板代码烦躁并没有真的主导过宏的详细设计。反而是很多实际维护过大型 Flutter monorepo 的开发者对取消宏持肯定态度。因为他们经历过 build_runner 生成中断导致整个 CI 全红、代码生成版本和源码不匹配、以及pub get后不同机器生成的代码不一致等痛苦他们明白编译期的黑盒生成行为会带来更隐蔽的问题。而 DCM 等的实践恰恰把静态分析和代码生成的职责分开让各自领域的复杂度变得可控。5.3 最后的实操建议没有宏的日子怎么用组合拳提升效率如果你现在正焦虑“没有宏怎么办”我的建议是把重心放到下面这套组合方案上数据类建模freezed不可变 copyWith 可辨识联合JSON 序列化json_serializable配合explicitToJson: true状态管理模板riverpodriverpod_generator数据库和本地缓存drift代码生成器加迁移数据库代码质量门禁DCM 自定义规则 dart analyze 标准规则项目脚手架mason 自定义模板一键生成模块这套组合拳没有一项需要语言级魔法但每一样都可以在 CI 里独立验证也可以被别人审查。工程上的确定性和可控性往往比花哨的语法语义更能决定一个项目的长期健康度。Dart 选择放弃宏放弃的是在编译器层面引入一个长期复杂度的风险换来的却是工具链生态的稳定、AI 编程的兼容以及语言演进的自由度。从这个角度回看这确实是无比正确的决定。
返回列表