动态语言长大以后,都会想要一点静态反馈

AI 摘要

  • Elixir 1.20 的重点不是让 Elixir 变成静态语言,而是在没有新增类型标注的前提下,对现有程序做类型推断和渐进检查。
  • 动态语言长大以后,常常会重新请回一些约束:TypeScript 之于 JavaScript,PEP 484 之于 Python,Sorbet 之于 Ruby,Hack 之于 PHP,Dialyzer 之于 Erlang。
  • 真正有趣的问题不是动态和静态谁赢了,而是语言怎样在不破坏原来手感的情况下,把错误更早地暴露出来。
  • Elixir 这次的 dynamic() 不只是传统意义上的“随便什么都行”。它会随着代码里的使用方式被逐步收窄,然后只在确定会错的地方报警。
  • 好的类型系统不是宗教,而是一种反馈系统:它把本来要到运行时、测试时、上线后才暴露的问题,尽量提前到编辑器和编译器里。

有一类编程语言,年轻时都讨厌仪式感。

Ruby、Python、JavaScript、PHP、Erlang、Elixir,气质各不相同,但它们都曾经靠一件事吸引人:先写起来。少一点声明,少一点模板,少一点为了让编译器满意而写的铺垫。你有一个想法,就把它变成函数、对象、消息、进程、脚本或者页面。语言先相信你,系统先让你跑起来。

这种自由很迷人。

直到代码库长大。

一个人写脚本时,动态语言像一把轻刀。十个人维护服务时,它像一张大家共享的白板。等到一百个人、十年历史、无数接口、老依赖、线上事故和新人 onboarding 全部堆在一起,白板上那些“大家都知道”的约定开始变成负债。

这个函数到底可能返回什么?

这里的 map 一定有这个 key 吗?

这个 nil 会不会漏进来?

这个分支是不是已经永远走不到了?

动态语言最初省掉的很多说明,后来会以另一种形式回来:文档、测试、lint、代码评审、运行时断言、IDE 插件、类型标注、静态分析。

所以我看到 Elixir 1.20 发布 时,第一反应不是“动态语言又认输了”。

更像是:一个语言开始优雅地变老。

一间黄昏里的工程工作室:流动的动态代码经过一座发光桥梁,变成右侧可检查的类型轨道。

Elixir 这次做了什么

Elixir 1.20 的官方标题很直接:now a gradually typed language。

但这句话容易被误读。

它不是说 Elixir 从今天起变成了 Haskell、Rust 或 OCaml。它也不是说所有 Elixir 程序员以后都要在函数上写满类型签名。恰恰相反,Elixir 团队把第一阶段做得很克制:不引入新的类型标注,先对每个 Elixir 程序做类型推断和渐进类型检查

官方说这个阶段要找两类东西:dead code,以及 verified bugs。

dead code 好理解,就是类型和控制流已经说明某些分支永远到不了。

verified bugs 更有意思。它指的是:如果这段代码真的执行到这里,运行时一定会失败。换句话说,类型系统不急着指出所有“可能不优雅”的地方,而是先挑那些确定会炸的地方。

这很关键。

一个动态语言要引入类型检查,最大的敌人往往不是技术难度,而是不信任。只要误报太多,开发者很快就会把它当噪音。噪音出现三次,人脑就开始自动过滤。过滤一旦发生,再精密的系统也没用。

Elixir 这次的策略,是先建立信任。

它的 dynamic() 类型尤其值得看。很多渐进类型系统里,类似 anyuntyped 的东西多少带着一点“这里先别管”的意思。类型信息到这里会变松,检查也会变弱。

Elixir 的 dynamic() 更像一个范围。

一个值刚进入函数时可能很模糊,但它在后面的代码里会被使用。你对它做 guard,你匹配它的形状,你访问它的字段,你把它送进某个函数。这些动作都会留下线索。Elixir 的类型系统会根据这些线索逐步收窄它。

官方举了一个很直观的例子:如果你写 data.a + data.b,系统可以推断 data 至少应该是一个带有 ab 字段的 map,而且两个字段都应该是数字。后面如果你又把整个 data 当数字去加,系统就能指出问题。

这不是把动态代码硬塞进静态模具里。

这是从代码自己的使用方式里恢复约束。

一个三段式反馈图:dynamic value 经过 observed use,变成 early warning。

这事以前发生过很多次

Elixir 不是第一个走到这里的动态语言。

某种意义上,过去十几年,动态语言社区一直在反复回答同一个问题:

我们怎样在不推翻原来语言的前提下,得到更早的错误反馈?

不同社区给过不同答案。

TypeScript 是最成功、也最容易被看见的答案。JavaScript 本身没有被改造成静态语言,TypeScript 选择站在旁边,做一个兼容 JavaScript 生态的超集。2012 年公开出现,2014 年 1.0 发布。微软当时讲得很清楚:目标是服务 large-scale JavaScript applications。换句话说,问题从来不是“小脚本需不需要类型”,而是“大型前端和 Node 代码库怎样活得久一点”。

TypeScript 的胜利,不只来自类型系统本身,也来自它尊重了 JavaScript 的事实地位。它没有要求世界重写。它说:你已经有 JS 了,我给你一条逐步变得更可维护的路。

Python 走得更温和。PEP 484 在 2014 年创建,面向 Python 3.5,引入 type hints。它反复强调:类型提示主要服务离线 type checker,Python 仍然是动态语言,类型提示不会变成强制要求。这个态度很 Python:给出标准词汇,让工具、IDE、mypy 和团队规范自己长起来。

所以 Python typing 的意义,不是突然让 Python 变成 Java。它更像给巨大生态补了一种共同语言。以前每个库、每个团队、每份文档都用自己的方式暗示类型;PEP 484 之后,大家至少能用同一套语法描述边界。

Ruby 的故事更拧巴一点。Ruby 的动态性非常深,元编程和 Rails 习惯让很多静态分析天然难做。Sorbet 的做法是渐进式:文件可以有不同 strictness,T.untyped 可以作为过渡,团队可以一块一块地把类型请进来。Sorbet 文档自己也承认这里有张力:渐进类型的好处是可以增量采用,代价是保证没有那么纯粹。

这句话很诚实,也很重要。渐进类型从来不是免费午餐。它是一种迁移策略。

PHP 也慢慢把约束请回了语言内部。PHP 7 增加 scalar type declarations 和 return type declarations,后来又继续补属性类型、union types 等能力。PHP 的路不如 TypeScript 那么“外置”,也不像 Python 那么“注释和工具优先”,它更像在原语言里逐步加护栏。

Hack 则更像另一条岔路:它从 PHP 的现实痛点出发,走向一个兼顾快速开发和静态约束的语言。Hack 官网自己的说法,就是调和动态语言的快速开发周期和静态类型带来的纪律。它回答的是 Facebook 那类大规模 PHP 系统的需求:不是“PHP 好不好”,而是“这么大的 PHP-like 系统怎样继续演进”。

Erlang 更早就有一个很特别的工具:Dialyzer。它基于 success typing,目标不是证明程序完全正确,而是找到那些类型分析上可以确认的问题。对 BEAM 世界来说,这条线尤其重要,因为 Elixir 今天并不是凭空长出类型意识。它旁边一直有 Erlang、Dialyzer、spec、pattern matching、OTP 这些历史包袱和资产。

这也是为什么 Elixir 1.20 很有意思。它没有简单复制 TypeScript、Python 或 Sorbet 的路线。

它选择先不让开发者写类型。

先让编译器从现有代码里读懂更多。

一张时间线插图:TypeScript、Hack、Python typing、Sorbet/Ruby、Elixir 1.20 依次出现。

动态语言不是讨厌类型

很多关于类型的争论,最后都会变成性格争论。

有人喜欢静态类型,因为它让边界清楚、重构安全、IDE 可靠。

有人喜欢动态语言,因为它写起来轻、表达力强、不需要一开始就把问题想得过度形式化。

吵到最后,很容易像两个阵营互相嫌弃:一边觉得对方鲁莽,一边觉得对方笨重。

但真实工程里,问题通常没有这么戏剧化。

动态语言不是讨厌类型。动态语言只是讨厌过早、过重、过机械的类型。

一个新想法刚冒出来时,你可能真的不知道数据最后会长什么样。你需要先写出形状,跑几次,看系统怎么动。这个阶段,强类型如果要求你提前定义所有边界,确实可能打断思考。

但当一个接口稳定下来,当一段逻辑每天服务线上用户,当十几个模块都依赖同一个返回结构,当新人需要理解“这里到底可能是什么”,类型就不再是束缚。

它变成公共语言。

一个好类型系统,不是让程序员证明自己聪明。

它是让团队少靠记忆。

这和测试很像。没人会说测试让程序员失去自由。真正糟糕的是测试写错地方,写成脆弱快照,写成维护成本,写成阻碍重构的水泥。好的测试会把重要假设固定下来,让后来的人敢改。

类型也一样。

差的类型系统让人填表。

好的类型系统让人早点听见系统在咳嗽。

为什么老语言会重新需要约束

语言社区一开始常常奖励表达力。

这很自然。一个新语言要活下来,必须先让少数人爱上它。它要足够顺手,足够有性格,足够能把某类问题做得比别人漂亮。

Elixir 的早期魅力,显然不在类型系统。

它的魅力在 BEAM,在轻量进程,在容错,在 pattern matching,在 Phoenix,在“用比较少的代码写出能抗压的并发系统”。你不是因为它像 Java 才爱上它。你是因为它不太像 Java。

但语言一旦成功,用户结构就会变化。

早期用户愿意忍受不确定性,因为他们在换取表达力和速度。后来用户不一样。他们带着公司系统、生产事故、长期维护、团队协作、合规要求和招聘问题进来。他们不会只问“这个语言酷不酷”。他们会问:

线上服务出错前能不能提前发现?

大规模重构有没有工具支持?

新人能不能从编辑器里看到函数边界?

库作者能不能把 API 说清楚?

代码十年以后还能不能被理解?

这些问题不会因为一个语言最初是动态的就消失。

所以历史会反复发生:语言先靠自由赢得早期市场,再靠反馈系统支撑长期维护。

TypeScript 不是 JavaScript 的背叛。

PEP 484 不是 Python 的背叛。

Sorbet 不是 Ruby 的背叛。

Elixir 1.20 也不是 Elixir 的背叛。

它们都是语言社区在承认一件事:自由解决不了所有规模问题。

Elixir 的克制,反而是重点

Elixir 这次最值得夸的地方,是它没有急着把类型签名端上来。

这听起来反直觉。一个语言都宣布自己逐渐类型化了,为什么不先给用户类型语法?

因为语法一旦出现,社区注意力会立刻转向风格争论。

应该怎么写?

多复杂的类型算过分?

库要不要强制标?

旧代码怎么办?

教程怎么改?

Elixir 团队先绕开了这些问题。他们先问:在用户什么都不写的情况下,编译器能不能给出有价值的反馈?

这个顺序很聪明。

如果一个类型系统必须依赖大量标注才能显示价值,它在动态语言社区里会很难推广。因为最难的从来不是写第一百个类型,而是让人愿意写第一个。

Elixir 1.20 的策略是:先把价值送到用户面前。

你不用改写代码。

你不用学习新语法。

你先看到它能发现一些真的会失败的问题。

等信任建立起来,再讨论 typed structs、type signatures、recursive types、parametric types。

这很像好的产品迁移。不要先让用户交迁移成本,再承诺未来会有价值。先在旧工作流里塞进一点真实收益,让人相信这条路值得走。

类型系统是一种组织记忆

我最近越来越觉得,类型系统和组织记忆有点像。

一个 bug 第一次出现时,它是事故。

第二次出现时,它是经验。

第三次还靠人眼去看,它就是流程问题。

类型系统能做的事,是把一部分经验固化成机器能检查的边界。这个函数不能拿 map 当数字。这个分支不可能拿到 nil 之外的东西。这个 key 不存在。这个返回值已经不是调用方以为的形状。

它不替代人的判断。

它只是把那些不该每次都靠人重新记住的判断,变成反馈。

这也是为什么“动态 vs 静态”的争论经常问错了问题。

真正的问题不是一个语言要不要类型。

真正的问题是:错误应该在哪里被发现?

如果错误只在用户那里被发现,太晚。

如果错误只在测试里被发现,还不错,但成本可能高。

如果错误在编辑器、编译器、CI 的早期阶段就被发现,团队就少付一次上下文切换。

静态反馈的价值,不是让程序变得哲学上更纯。

它是让错误更便宜。

优雅地变老

所以,Elixir 1.20 让我喜欢的不是“终于有类型了”。

我喜欢的是它没有否定自己。

它没有说:过去那套动态体验错了。

它也没有假装:规模不会改变语言需求。

它只是把一个更成熟的问题摆出来:当一个动态语言继续长大,它怎样在不牺牲原有手感的前提下,让系统更早地告诉开发者哪里不对?

这比“动态还是静态”有意思多了。

年轻语言常常靠自由吸引人。

成熟语言要学会给自由装反馈。

Elixir 1.20 不是一个终点。官方也说,类型签名、递归类型、参数化类型等后续工作还在路上。但这个第一步已经很有方向感:先少打扰开发者,先从现有代码里恢复信息,先找确定会错的问题,先建立信任。

如果说好的语言会陪程序员一起成长,那么它最后一定要学会一件事:

不只让人写得快。

还要让人错得早。

资料

关联阅读