本网站内容使用人工智能(AI)或机器翻译技术翻译,可能存在错误。

Skip to content

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包含XY,但由于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 公司及本博客均不认可或支持任何公司或服务。此外,对于本博客所含信息的准确性、可靠性或完整性,不作任何保证或承诺。