跳转到内容

第 23 章 · 语言在互相抄什么:模式匹配、async、泛型、类型标注的趋同与分歧

本章回答什么:过去二十年,Python、JS/TS、C#、Go 从彼此那里学来了哪些特性,各自跟进时改成了什么样,又有哪些东西坚持不加。

一句话结论:面对同样的问题,答案会越来越像:模式匹配、async/await、泛型、类型标注、空值检查在四种语言里都以不同深度出现了;不抄的地方,几乎都能追到运行时的限制、向后兼容的包袱或设计者明说的取舍。

一个人 2015 年同时写过这四种语言,之后十年没碰,2026 年回来接手新项目,会注意到这些变化:

  • Python 代码里到处是类型标注,def total(items: list[Item]) -> Decimal 成了常态,持续集成里跑 mypy 或 pyright;分支多的地方用 3.10 的 match 代替一长串 if/elif。
  • JS 项目大多改用 TS 写,回调和 .then() 链被 async/await 取代,user?.address?.city ?? "未知" 一行就能安全地取深层属性。
  • C# 里随处可见 record、switch 表达式和 string?:新项目默认开启可空引用类型,可能为空的地方必须显式写问号。
  • Go 有了泛型,标准库多了 slices、maps 包;for v := range seq 可以遍历一个函数;2026 年 8 月的 1.27 又允许方法声明自己的类型参数。
  • Python 3.13 起有实验性的无 GIL 构建,3.14 把它转为官方支持,多个线程可以同时执行 Python 字节码,只是要另装这个构建。
  • 没变的同样显眼:Go 仍然逐层写 if err != nil,仍然没有 async;Python 的类型标注在运行时仍然不检查。

下面八个特性,每个都是一家先做出来,别家看到效果后跟进。跟进时没有一家是原样照搬的,都按自己的运行时和已有语法改了形状。

模式匹配(pattern matching)来自 ML 系语言。四种语言里 C# 最早:7.0(2017 年)加入类型模式 is Circle c,8(2019 年)加入 switch 表达式和属性、位置模式,9(2020 年)加入关系模式和 and/or/not 逻辑模式,11(2022 年)加入列表模式 [var first, .., var last]。Python 3.10(2021 年,PEP 634)加入 match 语句,类模式靠 __match_args__ 按位置解构。

差别在于穷尽检查放在哪里。C# 的 switch 是表达式,编译器对漏掉的情况给警告,但对开放的继承体系判断不了“只有这几种”,要等 C# 15 的 union 和 closed;Python 的 match 是语句,解释器只管按顺序试,漏没漏由类型检查器判断。TS 没有新语法,靠可辨识联合(discriminated union)加控制流收窄,TS 2.0(2016 年)起就能对 kind 字段做 switch,再用 never 查漏。JS 的模式匹配提案 2026 年仍在 TC39 第 1 阶段。Go 只有从 1.0 起就有的类型 switch,不能解构。详见第 15 章。

C# 5(2012 年)随 .NET 4.5 把 async/await 带进主流语言,思路来自 F# 的异步工作流。Python 3.5(2015 年,PEP 492)跟进,3.7(2018 年)起 async 和 await 才成为保留字,之前它们还能当变量名,就是为了不让旧代码一升级就报错。JS 在 ES2017 标准化了 async 函数。

三家关键字一样,底下不同:C# 编译器把方法改写成状态机,Task 一创建就开始执行;Python 调用 async def 函数只得到一个协程对象,要交给 asyncio 的事件循环或被 await 才会运行;JS 的事件循环内置在引擎和宿主里,Promise 创建时就开始执行。这些差别会在出错时显出来:C# 里忘了 await,任务照样在后台跑,抛出的异常可能没人看到;Python 里忘了 await,协程根本不执行,只留下一条“从未被等待”的运行时警告。Go 没有跟进,用运行时调度的 goroutine,让同步写法在等网络和通道时一般不占线程。详见第 19 章。

C# 2.0(2005 年)加入泛型时一并改了运行时,类型参数在运行时仍然存在,即具化(reification)。TS 在 1.0 之前的 0.9(2013 年)就有了泛型。Python 的泛型从 PEP 484(3.5,2015 年)开始,用 TypeVar 和 typing.List[T] 写;3.12(2023 年)的 PEP 695 改成 def first[T](xs: list[T]) -> T 这样的方括号语法。Go 等到 1.18(2022 年)才加入类型参数和用接口表达的约束,1.27(2026 年)又放开了方法自己的类型参数,但接口方法仍然不能有。

实现差别直接影响能写什么:TS 和 Python 是擦除,运行时拿不到 T;C# 给每个值类型生成一份机器码,typeof(T) 能用;Go 按 GC 形状分组生成代码,再配一个字典。详见第 9 章。

C# 和 Go 生来就是静态类型,这里说的是动态语言补上类型。TS 2012 年公开发布,在 JS 之上加了一层编译期类型,检查完就擦掉。Python 3.0 的 PEP 3107 就有函数标注语法,3.5(2015 年)的 PEP 484 规定用它写类型,并加入 typing 模块,3.6(2016 年)的 PEP 526 让变量也能标注;3.14(2025 年)的 PEP 649 把标注改为用到时才求值,前向引用不必再写成字符串。

两家的共同点是“只检查,不执行”:标注不改变运行时行为,检查交给 tsc、mypy、pyright。不同在于 TS 必须先编译,类型错误可以挡住构建;Python 源码直接运行,类型检查是可以不跑的另一道工序。两家也都留了逃生口:标成 TS 的 any 或 Python 的 Any,就等于对这个值关掉检查。JS 本身也有类型标注提案,打算让引擎把标注当注释跳过,2026 年仍在 TC39 第 1 阶段。详见第 3 章。

C# 最早给值类型做了 int?(2.0),6(2015 年)加入 ?.,8(2019 年)加入可空引用类型,让 string 和 string? 在编译期有了区别。TS 2.0(2016 年)的 strictNullChecks 把 null 和 undefined 移出了普通类型。JS 在 ES2020 加入 ?. 和 ??。

两种做法常被混为一谈:?. 和 ?? 是运行时的安全访问,C# 8 的可空引用类型和 TS 的 strictNullChecks 是编译期追踪。C# 的追踪只给警告,运行时照样可能出现 null;TS 的追踪在生成的 JS 里不留痕迹。Python 没有 ?.,PEP 505 处于推迟(Deferred)状态,可空性写成 str | None 交给类型检查器。Go 两样都没有,靠零值和显式比较 nil。详见第 6 章。

“只装数据、按值比较、不能改”的类型被各家先后补上。Python 3.7(2018 年)的 dataclasses 用 @dataclass(frozen=True) 生成构造函数、相等比较和禁止赋值的检查;C# 9(2020 年)加入 record 和 with 表达式,10(2021 年)加入 record struct。注意 record 是引用类型,record struct 是值类型,而且 record struct 由位置参数生成的属性默认可写,要写成 readonly record struct 才不可变。JS 的 Records & Tuples 提案想加入深度不可变、按值比较的 #{} 和 #[],2025 年 4 月撤回,由仍在第 2 阶段的 Composites 提案接替;TS 只有编译期的 readonly 和 as const。

Go 没有专门语法:结构体本来就是值,赋值和传参都复制,字段全是可比较类型时能直接用 ==,但没有办法声明“这个字段不能改”。详见第 4 章和第 5 章。

Python 2.2(2001 年,PEP 255)借鉴 Icon 加入生成器,C# 2.0(2005 年)加入 yield return,JS 在 ES2015 标准化了迭代器协议和 function*,又在 ES2025 加入了 map、filter、take 这些迭代器辅助方法。三家都是消费者拉取:调用 next,生成器跑到下一个 yield 停下。

Go 1.23(2024 年)用另一种方式补上:for v := range f 可以作用于形如 func(yield func(V) bool) 的函数,迭代器把值推给循环体。没有生成器,也就不需要可暂停的函数帧;要拉取式时用 iter.Pull。详见第 13 章。

Python 1994 年就有了 lambda、map、filter,后来推导式成了更常用的写法。C# 2.0(2005 年)先有了用 delegate 关键字写的匿名方法,3.0(2007 年)加入 lambda、扩展方法和 LINQ,orders.Where(o => o.Paid).Select(o => o.Total) 成为惯用法;JS 的函数表达式在 ES3(1999 年)写进标准,ES5 给数组加了 map、filter、reduce,ES2015 又加入箭头函数,this 改为取外层的值。Go 从 1.0(2012 年)起有函数字面量和闭包,但标准库至今没有通用的 Map、Filter,slices 包只提供排序、查找、Collect 这类函数。

差别在惰性:LINQ 和 ES2025 迭代器辅助方法是延迟执行的,数组的 map 和 Python 列表推导式立刻算出整个列表。详见第 7 章和第 13 章。

下表只记各语言第一次以语言或标准库形式提供该特性的版本,“没有”指 2026 年 10 月的正式版里没有。

特性 Python JS/TS C# Go
模式匹配 3.10(2021) JS 没有;TS 可辨识联合 2.0(2016) 7.0(2017) 没有(只有类型 switch)
async/await 3.5(2015) ES2017(2017) 5(2012) 没有
泛型 3.5(2015),新语法 3.12(2023) TS 0.9(2013) 2.0(2005) 1.18(2022)
类型标注 语法 3.0(2008),类型含义 3.5(2015) JS 没有;TS 0.8(2012) 1.0(2002),生来静态 1.0(2012),生来静态
空值安全 没有(交给类型检查器) ES2020(2020);TS 2.0(2016) 6(2015)?.,8(2019)可空引用 没有
记录类型 3.7(2018) 没有 9(2020) 没有(结构体本是值)
生成器 / 迭代器 2.2(2001) ES2015(2015) 2.0(2005) 1.23(2024)
匿名函数 / lambda 1.0(1994) 函数表达式 ES3(1999),箭头函数 ES2015(2015) 匿名方法 2.0(2005),lambda 3.0(2007) 1.0(2012)

表格只能看出先后,看不出“一家先做,别家跟进”的节奏。下面两张图把同一份数据放到时间轴上,每个特性一条带,菱形是某种语言加入它的版本:

语法类特性:迭代器、匿名函数、async/await、模式匹配

看每条带里菱形从左到右的顺序:迭代器和匿名函数的源头都在 2005 年之前;async/await 是 C# 先走一步,别家在三到五年内跟进;模式匹配里 TS 的可辨识联合和 C# 7.0 前后脚出现,之后 C# 和 Python 交替推进。

类型类特性:泛型、类型标注、空值安全、记录类型

这张图里菱形大多挤在 2012 年之后:动态语言补类型、静态语言补空值检查,几乎是同一个十年里发生的事。

Go 没有异常。 错误是普通的返回值,if err != nil 逐层返回,errors.Is、errors.As 检查包装链;panic/recover 只留给程序错误。详见第 10 章。

Go 没有 async/await。 goroutine 的栈可以增长,网络读写、通道和锁上的阻塞由运行时接管,只挂起 goroutine,同步写法在等待时一般不占操作系统线程,不需要把函数分成两种颜色。详见第 16 章和第 19 章。

Go 没有继承。 用结构体嵌入复用字段和方法,用隐式实现的接口做多态,类型不必声明自己实现了哪个接口。详见第 8 章。

Python 没有块级作用域。 if、for 里赋值的变量在块外照样可见,作用域只有模块、函数、类,加上推导式自己的一层;循环里创建的闭包读到的都是同一个变量,要靠默认参数或工厂函数绑定当时的值。详见第 7 章。

Python 运行时不检查类型标注。 标注写错程序照样运行,要靠 mypy、pyright 在运行前检查,或者在边界上用 isinstance 和校验库显式检查。详见第 3 章。

JS 只有一种普通数值类型 Number。 它是双精度浮点数,整数只在正负 2 的 53 次方以内是精确的,整除要用 Math.floor 或 Math.trunc 补。ES2020 加入的 BigInt 是任意精度整数,不是固定宽度整数,也不能和 Number 直接混算。详见第 7 章和附录 B。

C# 没有结构类型。 两个类型兼容,必须有一方在声明里写明继承或实现了另一方;想要“长得像就能用”只有 dynamic,代价是检查推迟到运行时。编译器在 foreach、await 这几处按方法名查找成员,算是有限的例外。详见第 3 章。

第一个原因是问题相同。网络服务要同时等成百上千个 I/O,每个连接一条线程撑不住,于是有了 async/await 或 goroutine;代码库长到几百万行、几百人维护,没有类型就不敢重构,于是 Python 和 JS 补了类型,Go 补了泛型;空引用一直是线上事故的常见来源,于是 C#、TS 把它变成了编译期能查的东西。类型信息还让编辑器的补全、跳转和批量重命名变得可靠,这也是动态语言补类型的一大动力。同样的压力落在四种语言上,解法自然会往一处靠。

第二个原因是人和想法在流动。Anders Hejlsberg 先后主持了 C# 和 TS,两门语言的泛型、async、空值检查思路一脉相承;Python 的 PEP 484 开篇就说它深受 mypy 启发,讲模式匹配设计理由的 PEP 635 逐一对照了 Haskell、Scala、Rust、C# 等语言的写法;JS 的生成器借鉴了 Python,Python 的 PEP 505 又以 C#、JS 的 ?. 作为参照。提案互相引用,前人的坑也就跟着被绕开了:Python 的 match 一开始就带守卫子句和类模式,用不着重走 C# 从 7 到 11 一点点加的路。

运行时限制。 C# 的泛型能具化,是因为微软在 .NET 2.0 一并升级了运行时和 IL 格式;TS 的类型只能擦除,因为它的输出是普通 JS,浏览器和 Node 不认识类型,也不会为 TS 改引擎。Python 不在运行时检查标注也是同一类原因:每次调用都核对类型,会让本来就不快的解释器再慢一截。

向后兼容。 C# 8 的可空引用类型只给警告,不给错误,因为它要面对十七年来默认可以为 null 的代码;Python 把 async 变成保留字花了两个版本;JS 不能删掉 var 和 ==,因为网页上的旧脚本必须照常运行。新特性只能叠加,不能替换。

设计哲学。 Go 的官方 FAQ 写明了两条:把异常和 try/catch/finally 这种控制结构绑在一起会让代码绕来绕去,还会诱使人把打不开文件这类普通错误也当成异常;类型之间的关系本可以自动推出,不必事先声明继承层次。所以他们宁可多写几行 if err != nil,让接口隐式满足。Go 拖到 1.18 才加泛型,也是因为一直没找到编译速度、二进制大小和运行速度都能接受的折中。

下面只列 2026 年 10 月已经进入预览版、已被接受或已排进正式版的东西。

  • C# 15 的 union 和 closed:在 .NET 11 的预览版里,union Pet(Cat, Dog, Bird) 让编译器知道“只有这几种”,switch 写全了就不再需要 _ 分支;预计随 2026 年 11 月的 .NET 11 正式发布。出处:微软文档“What’s new in C# 15”。
  • Python 的无 GIL 构建:PEP 703 在 3.13 引入实验性构建,PEP 779 在 3.14 把它转为官方支持,但默认安装的仍是带 GIL 的构建,何时成为默认还没有定。出处:Python 3.14 的“What’s New”。
  • Python 的 lazy import:PEP 810 已经定稿,随 3.15(2026 年 10 月)发布。lazy import json 把模块的加载推迟到第一次使用。
  • JS 的延迟模块求值:TC39 的 Deferring Module Evaluation 提案(import defer)处于第 3 阶段,和 Python 的 lazy import 解决的是同一个启动速度问题。出处:tc39/proposals 仓库。
  • JS 的 using 声明:显式资源管理提案已进入第 4 阶段,预计收进 ES2027,作用对应 Python 的 with、C# 的 using 和 Go 的 defer;TS 5.2 起已能编译它。出处:tc39/proposals 的已完成提案列表。

装饰器提案在第 2.7 阶段,模式匹配和类型标注提案在第 1 阶段,Composites 在第 2 阶段,都还没到第 3 阶段,不列入;Go 的和类型提案仍在讨论,也没有排进任何版本。

  • 模式匹配、async/await、泛型、类型标注、空值检查、记录类型、迭代器、lambda 这八项,四种语言大多都有了,但实现方式各不相同:同样是泛型,有擦除、具化、按形状分组三种实现。
  • 先做的一方常常背上兼容包袱,后来者能直接借鉴整套设计;C# 的模式匹配分多个版本陆续补齐,Python 3.10 一次就带上了守卫和类模式。
  • 坚持不抄的地方都有替代:Go 用错误值、goroutine、嵌入,Python 用类型检查器,JS 用 BigInt 补整数,C# 用接口代替结构类型。
  • 判断一个“即将到来”的特性,看它的正式状态:预览版、已定稿的 PEP、TC39 第 3 阶段以上才值得为它做计划。