许式伟

Go+ 类型系统(1):Go、Go+ 与 Rust 自动类型推导的对比

类型是现代语言中最重要的基础能力,哪怕对号称弱类型的脚本语言来说也是如此。我们都知道,程序=数据结构+算法。最早的时候,人们把类型看作表达数据结构的设施,而函数代表了算法。在过程式的语言(比如C语言)中这种分工还是非常明显的。但在面向对象兴起之后,类型的含义就扩展了很多,除了表达数据结构,它也包含了该数据结构相关的算法集(方法列表),数据结构和算法从松散耦合(数据结构是算法的输入输出)变成了紧耦合(算法是数据结构的一部分)。
尽管所有编程语言都有类型,但是它们的类型系统体现出来的编程哲学却很可能会迥然不同。Go 语言的类型系统设计是非常有特色的。所以接下来我会有连续多篇的内容谈论类型系统设计背后的编程哲学,一一进行剖析。
今天我想谈的是:类型自动推导能力。
可能可以认为,Go 语言是第一个推崇类型自动推导的静态类型语言(2009 年开源),一个经典的 := 操作符,让 Go 代码写起来颇有点动态脚本语言的味道。
这可以说是非常有卖点的特性,被很多新老语言所借鉴。其中,老牌的 C++ 最先响应,在 2011 年 C++11 标准中引入自动类型推导,为此还不惜改变了 auto 关键字的语义。Java 在 2018 年的 Java 10 中引入自动类型推导,而 C# 则是在 2020 年 C# 9.0 中引入。
上面是三个最主流的老牌静态类型语言的选择。当然值得一提的是,C 语言几乎是唯一的例外,它雷打不动的保持了特性的极度稳定不变,你强任你强。
那么新语言呢?2015 年发布 1.0 版本的 Rust,不只是一上来就有类型自动推导能力,而且试图把类型推导能力往前更进一大步。
我们知道,在 Go 语言中类型推导仅局限于单个语句(statement)内进行,你没办法先定义一个未知类型的变量,然后通过后续其他语句对它的使用来推导其类型。而 Rust 可以做到这一点。甚至在泛型的情形下你可以只告诉它部分信息:你可以定义一个向量(Vec),但是向量的元素(element)类型是什么,由编译器通过后续对该变量的使用来决定。
当然推导能力做得这么灵活好不好,我个人对此是存疑的,在 Go+ 中暂不考虑支持这样的特性。这当然并不是因为这个特性很难实现,恰恰相反,Go+ 编译器依赖  gox 包已经支持了该特性(见 pkg.NewAutoParam 函数)。在前面《Go+ 探究:如何 10 天实现工业级的 C 编译器》一文中,我们也已经介绍了该特性的一种应用场景(但是不是唯一的,事实上,gox 设计的 AutoParam 支持出现在函数的参数列表、返回值、全局/局部变量类型、数组/Slice的元素类型、Map的键/值类型、结构体的成员变量类型等等)。所以 Go+ 要引入该特性是轻松的,因为底层依赖的引擎已经支持。
另外,Go 语言的类型自动推导能力只作用于基础表达式,不能作用于数组、Slice、Map、结构体等复合类型。在这一点上,C#、Rust 以及 Go+ 都有进行改进。
例如,在 Go+ 中你可以这样定义 Slice 和 Map:
a := [1, 3, 5, 7]b := {"Hi": 1, "He": 3}

在 Rust 中的文法也很类似(当然 Map 在 Rust 中不是一等公民所以并没有类似 Go+ 的语法):
let a = [1, 3, 5, 7];

另一个类型自动推导能力增强的机会是闭包(或者叫 lambda 表达式)。我们知道,数据科学领域中最经典的 Map/Reduce 就深度依赖 lambda。由于 Go 缺乏对 lambda 表达式的函数原型进行推理,所以用 Go 实现 Map/Reduce 看起来不太优雅:
Map(arr, func(x float64) float64 {    return expr})

在这一点上,Java、C#、Rust 以及 Go+ 都做了类型推导能力的增强(难得大家意见这么一致啊)。例如在 Rust 中你可以这样做:
Map(arr, |x| expr);

而 Java、C# 和 Go+ 都类似,采用 => 或 -> 作为 lambda 文法的标识。以下是 Go+ 的写法(在 Java 中需要换为 -> 操作符):
Map(arr, x => expr)

当 lambda 表达式的参数列表为空的时候,不同语言的表达形式有所差异。比如我们以 STEM 编程教育中经常遇到的键盘事件响应为例。如果用 Rust 要这样写:
OnKey(KeyA, || {    ... // 事件响应很少是单个语句结束,所以用了复合语句})

在 C# 中要这样写:
OnKey(KeyA, () => {    ...})

而在 Go+ 中,lambda 参数列表为空时参数列表的 () 可以省略:
OnKey(KeyA, => {    ...})

更进一步,由于 Go+ 支持命令行风格(对最外层的函数调用可以去掉括号),所以它可以进一步简化为:
OnKey KeyA, => {    ....}

总结来说,所有在 Go 类型自动推导能力基础上的的改进,都让静态类型语言进一步形式上脚本化了,而这我认为这是静态类型语言发展的最大趋势。
当然类型自动推导能力并非没有成本。
首先,它带来了原型(prototype)的模糊。对于局部的代码来说,这种模糊带来的损失是可控的,我们享受模糊带来的便捷的编码体验。
但是对于一个包(package)的接口(所有公开符号及其类型)来说,这种模糊会成为一个负担。例如,在 Go 语言中,你可能有时不能很容易看出一个全局变量(当然常量也有类似问题,但是出现概率相对少)的类型到底是什么(好在优秀的 IDE 通常会帮我们做类型推导并进行提示)。
如果你认为变量类型模糊还好的话,那么函数原型不清晰是一种工程上的灾难。好在,Go 没有给我们这样的机会。当然,在其他语言中,lambda 表达式的参数和返回值自动推理创造了这样的机会。例如,我们把 lambda 表达式赋值给某个公开的全局变量。
在 Go+ 中我们阻止了这样的做法。lambda 表达式仅用于函数参数、变量赋值(仅当变量类型已知时,所以不能有 a := lambda 这样的写法)、结构体成员赋值等可现场推理的场景。我们维持了 Go 的设计哲学:推理必须在单个语句内完成。
另外,类型自动推导能力有一些技术上的前置限制。这些限制有些可以克服,但是整体上对自动类型推导机制实现上的复杂性带来了考验。
其一,隐式类型转换。我们知道,C/C++ 有隐式类型转换。这带来了推理上的复杂性,理论上来说,我们应该考虑穷举所有可能的类型以找到最恰当的。而隐式类型转换带来的更大的类型搜索空间。
其二,函数重载。与隐式类型转换类似,函数重载带来了更大的类型搜索空间。本来我们一看到函数名字就可以查到函数原型,就可以有非常确定的推理结果。但是现在因为相同名字的函数可能有多个,这就给推理过程带来了多个推理的分支。
更要命的是,函数重载可能带来匹配度的概念。这发生在多个重载函数都满足需要的场景:到底要调用哪一个函数?匹配度最高的那一个。这就是匹配度的概念。
最典型的案例是 C++。因为它既有隐式类型转换,又有函数重载。到底调用哪一个?C++ 有它的匹配度算法。
如果有可能,最好当然是不要函数重载。
但我个人认为,函数重载几乎是不可避免的,尤其是在决定支持泛型之后。
函数重载和泛型是一对相互依存的特性。泛型的本质是对算法进行多种类型的泛化,重载的本质是算法对多种类型的特化。无论是泛化还是特化,都是为了算法语义一致性的封装。对 A 算法可能适合泛化,而对 B 算法则可能是适合特化,甚至有时可能既要泛化(针对大多数类型)又要特化(针对少数特殊的类型)。
事实上,在 Go 语义内建语义中大量采用了函数重载。这包括:
  • 操作符重载(比如整数相加、浮点数相加、字符串连接都用 + 操作符);
  • 内建函数(比如 len 函数不只是可以求数组长度,还可以求 Slice、Map、Chan、字符串的)。
这些都是类型特化的例子。他们没有任何代码复用,只不过希望一些算法需要有相同的语义抽象(整数相加和字符串相连真有那么强的相关性么,这是可能是一个哲学问题)。
Go 语言语法设计上大部分功能设计是我非常非常佩服的。但是在函数重载这件事情给自己特权是我个人不太喜欢的。在这一点上我比较喜欢 C++ 的哲学:我能够做的,也应该是你能够做到的。
所以 Go+ 选择了支持函数重载(当然我认为 Go 最后也会支持,在它的泛型成熟的时候)。详细介绍我们后面另外找机会进行展开。
让我们先回到类型自动推导的话题。
实际上,真正困难是发生在函数重载和类型自动推导组合的场景。这是两个相互矛盾的需求。一方面,函数重载希望通过输入参数的类型来推导选择哪个函数。而类型自动推导则希望根据选定的函数来推导变量的类型。
在 Go 和 Go+ 中,我们通过限制类型自动推导只在单个语句(statement)内完成,使得这两个功能不产生交叉。自动类型的变量不会出现在参数列表中(但 lambda 表达式是个例外)而只会依赖函数的返回值类型(此时选哪个函数已经确定),这就避免了相互等对方的类型确定的情况。
但是 Rust 这种存在自动类型变量(通过后续语句来推导而不是现场推导)概念的类型推导机制,就会遇到这样的障碍。解决障碍的方法只能是:多看几个自动类型变量的用例,只要用这个变量的地方足够多,最后搜索空间就可以锁定到一个精确的类型。如果最终还是有多个选择,那就只好引入匹配度,看最精准匹配的类型是什么。
其三,泛型。这很好理解,泛型和函数重载虽然机制不同,但是带来的结果一模一样:同一个函数调用有更大的类型搜索空间。而且泛型更要命的是,它的类型搜索空间理论上可以是无限的(当然因为具体一个程序中出现的实际类型数量是有限的,所以实际的搜索空间也是有限的)。
总结一下:类型自动推导机制,是现代语言的类型系统发展的重要趋势之一(我个人其实认为是排在第一位的趋势),它让静态类型语言和动态类型语言的边界变得更模糊。从工程上来说,强类型支撑着品质保障的有力防线,结合自动推导,又安全又便捷又高性能已经成为可能。所以新的脚本语言很难再有机会冒出来。
而 Go+ 语言的设计理念深度依赖类型自动推导,这是我们打低代码的重要基础。从使用的角度,我希望我们能够做到除了函数原型之外的其他所有地方,不要出现显式的类型定义,整个代码都是深度脚本化,易于理解和学习的。