Luau 中的类型推断

自2006年以来,Roblox开发者一直使用Lua编程语言在Roblox上制作游戏和互动体验。Roblox开发者来自各行各业,经验水平各异,他们的创作同样丰富多彩。
其中一些作品堪称真正精妙的软件杰作,许多作品的代码行数甚至高达数万行。
虽然 Lua 是一门出色的编程语言,我们也非常喜欢它,但我们逐渐认识到与 Web 开发者社区相同的教训:使用动态类型编程语言编写大型应用程序非常困难!
我们创建 Luau 正是为了填补这一空白。
本质上,Luau首先是一个类型推断引擎,其次才是类型检查器。这一理念源自OCaml和TypeScript编译器等成功的先例。
然而,Lua 其实与 OCaml 或 JavaScript 都不尽相同。为了让 Luau 发挥出最大潜力,准确建模 Lua 的独特之处至关重要。
首先,让我们解释一些基础知识。
类型推断入门
类型推断几乎总是基于观察:某种类型 A 必须与另一种类型 B 相同。这虽是一种简化,但比人们想象的要接近事实。
让我们从一个非常简单的例子开始:
function id(x)
return x
end
local a = 5
local b = id(a)当我们检查 `id` 的类型时,会生成两个占位类型:`x` 的类型以及函数的返回类型。
这些占位符仅表明一件事:我们对它们一无所知。
随后我们分析return语句。我们观察到,无论该函数可能返回何种类型,它都与x的类型相同。由于x上没有其他约束,因此id的最终推断类型被推断为泛型函数(T) -> T。
(Luau 目前尚未提供泛型函数的语法,但其内部数据结构可以表示它们。)
当需要推断 b 的类型时,我们可以运用对 id 的了解。我们取 id 的类型,并将它的参数类型绑定到我们已有的具体参数类型上。由于函数的返回类型已经绑定到其参数类型上,因此 (T) -> T 被实例化为 (number) -> number。由此,我们可以推断出 b 的类型是 number。
Luau 进行的大部分类型检查工作都是这一思路的延伸。
多重返回
在几乎所有广泛使用的编程语言中,函数都只返回一个值。许多语言(尤其是函数式语言)提供了轻量级的元组,以使多重返回变得简单方便。
Lua 则反其道而行之,允许每个函数返回 0 个或多个值。这里没有使用元组机制,因为无法将整个元组绑定到一个单一名称上。
function take_five(a, b, c, d, e)
print(a, b, c, d, e)
end
function get_five()
return 1, 2, 3, 4, 5
end
-- a receives 1. The other return values are discarded.
local a = get_five()
-- foo, bar, and baz receive 1, 2, and 3, respectively.
-- Arguments 4 and 5 are discarded.
local foo, bar, baz = get_five()
-- the 5 return values from get_five are passed as
-- parameters to take_five
take_five(get_five())这带来了一些新的挑战。下面的 `compose` 的类型是什么?
function compose(f, g)
return function(...)
return f(g(...))
end
end在其他语言中,我们可以推断出g的返回类型必须与f的参数类型相同。而在Lua中,f必须接受与g返回的参数数量和类型完全一致的参数。
在 Luau 中,我们使用一种称为“类型包”(type pack)的结构来表示这一点。它与我们用来描述单个类型的数据结构非常相似,但可以表示零个或多个类型。
与类型类似,类型包支持占位符的概念,这些占位符随后可绑定到其他包上。类型包还具有其他特性:其长度可能是已知的或未知的;若是已知的,则可能具有固定大小或可变大小。
Luau 将函数建模为一对类型包:一个用于参数列表,另一个用于返回值。
如果我们定义语法 `A...` 来表示一个泛型类型包,那么我们可以为 `compose` 编写一个类型:
((B...) -> C..., (A...) -> B...) -> (A...) -> C...
(Luau 目前也不支持此语法。即将推出!)
表
在编写 Lua 代码时,表(Table)至关重要。它们集数组、哈希表和对象于一体。因此,从 Lua 代码中正确推断表的类型自然显得尤为重要。
在 Luau 中,我们将表分为 4 类:
- 结构确切已知的表
- 分段构建的表
- 作为函数参数传递的表状数据,以及
- Roblox API 数据类型
我们将它们分别称为密封表、非密封表、泛型表和原生类。
密封表
我们希望能够捕获的一个非常常见的错误是,在表属性赋值中属性名称拼写错误。
local some_table = {some_property=0}
-- oops. I got the name of the property wrong
some_table.sone_property = 55表通常默认是密封的。
未密封表
为了与惯用的 Lua 语言顺畅配合,我们需要某种方法来支持那些通过多条语句构建表的函数和模块。
local Counter = {}
Counter.value = 0
function Counter.increment()
Counter.value = Counter.value + 1
return Counter.value
end在这个例子中,如果我们只看第一行,推断出类型 {},然后在第二行报类型错误,那将是愚蠢的,因为我们期望 Counter 永远保持为空。
因此,我们采取的立场是:Counter 是一个未封装的表。Luau 能够轻松地知道该表的确切结构,但我们认为它仍可扩展。
我们不希望表的解封过于容易,因此应用了一些简单的启发式规则:
- 当表通过空表字面量初始化时,其类型即为未密封,且
- 每当我们在函数签名中遇到未密封表时,都会将其转换为密封表。
这为我们带来了相当不错的易用性。
function new_counter()
local Counter = {}
Counter.value = 0 -- OK. Counter is unsealed.
function Counter.increment()
Counter.value = Counter.value + 1
return Counter.value
end
return Counter
end
local c = new_counter()
c.value_ = 5 -- Not allowed. c is a sealed table here.泛型表
在处理未注释的函数参数时,通常很难确定以表形式使用的参数的确切结构:
local function print_point(p)
print(‘X =’, p.X, ‘Y =’, p.Y)
end我们知道p包含X和Y,但由于print函数可以输出任意内容,仅此而已。这些属性的类型可能各不相同,也可能存在任意数量的其他属性。它甚至未必是真正的表,也可能是Vector3这类Roblox API类型。
(稍后将详细介绍 Roblox API)
与 OCaml 及某些其他编程语言类似,Luau 的表参数具有行多态性。在 Luau 中,我们称之为泛型表。被推断为存在于泛型表上的字段,是对调用者施加的要求。只要满足必需的结构,其他属性也是允许的:
local a = print_point({X=3, Y=4})
local c = print_point({X=4, Y=3, Name='The Best Point'})
local b = print_point(Vector3.new(3, 4, 0))Roblox API
Roblox API 为我们雄心勃勃的开发者社区提供了大量强大的工具。该 API 由大量已映射到 Lua 的 C++ 类组成。
显然,Luau 需要了解这个 API。这种了解的一部分在于认识到 Roblox 类实例实际上并非 Lua 表。例如,内置的 `pairs()` 函数无法用于遍历 Roblox API 类型的属性。
迄今为止我们为 Luau 描述的类型系统是一个完全基于结构的类型系统。Lua(以及 Luau)认为表(table)仅仅是其所包含属性的集合。 Roblox API 并不符合这一模型。C++ 提供了一种名义类型系统,其中每个类都有其独特的“自我性”。两个类尽管结构完全相同,但仍然截然不同,这完全是典型的。实际上存在满足这一特性的 Roblox 类,而 Luau 需要正确地对它们进行建模。
我们通过为内置的 Roblox 类实例引入一种表状类型来解决这一问题。类类型与表类型的区别在于,即使它们支持完全相同的方法且类型完全一致,它们仍具有能够区分彼此的身份。它们还支持继承的概念,正如它们所建模的 C++ 类一样。
结论
从 Lua 这样的动态语言中提取静态类型面临诸多挑战。其中许多挑战虽是 Lua 特有的,但完全可以解决。我们认为这一方案效果相当不错!🙂
安迪·弗里森(Andy Friesen)是 Luau 类型检查器的技术负责人。他很高兴能从事视频游戏、开发者工具和编程语言这三个领域的交叉工作。
Roblox 公司及本博客均不认可或支持任何公司或服务。此外,对于本博客所含信息的准确性、可靠性或完整性,不作任何保证或承诺。


