使用 Teal 进行类型编程
1. 欢迎学习 Teal!
在本教程中,我们将介绍一些基础内容,帮助你快速开始使用 Teal 对你的 Lua 代码进行类型检查。Teal 是 Lua 的一种带类型的方言。
2. 为什么要使用类型
如果你已经了解类型检查的重要性,可以跳过这一部分。:)
你的程序中的数据是有类型的:Lua 是一种高级语言,因此存储在 Lua 虚拟机内存中的每一个数据都带有一个类型:数字、字符串、函数、布尔值、用户数据、线程、nil 或表。
程序的核心就是对各种类型数据的操作。当程序按照预期运行时,它是正确的,而这一切依赖于将正确类型的数据相互匹配,比如拼图:你可以把一个数字乘以另一个数字,但不能把数字乘以布尔值;你可以调用一个函数,但不能调用字符串,等等。
然而,Lua 的变量并不知道类型。你可以随时将任何值赋给任何变量,如果你犯了错误,错误匹配了类型,程序会在运行时崩溃,或者更糟糕的情况是,它会默默地出现异常行为。
Teal 的变量知道类型:每个变量都有一个指定的类型,并且会一直保持该类型。这样,Teal 编译器可以在程序运行之前,帮助你发现一类常见的错误。
当然,它不能捕捉到程序中所有可能的错误,但可以帮助你避免一些诸如表字段拼写错误、遗漏参数等问题。Teal 还会让你对程序中处理的数据类型更加明确:当不够明确时,编译器会询问你并要求你通过 类型来记录。它还会不断检查这种“文档”是否已经过时。使用类型编程就像和机器一起进行配对编程。
3. 你的第一个 Teal 程序
让我们从一个简单的例子开始,声明一个类型安全的函数。假设这个例子叫做 add.tl
:
local function add(a: number, b: number): number
return a + b
end
local s = add(1,2)
print(s)
我们也可以在 Teal 中编写模块,并在 Lua 中加载它们。让我们创建第一个模块:
local addsub = {}
function addsub.add(a: number, b: number): number
return a + b
end
function addsub.sub(a: number, b: number): number
return a - b
end
return addsub
4. Teal 中的类型
Teal 是 Lua 的一种方言。本教程假设你已经了解 Lua,因此我们将重点介绍 Teal 为 Lua 添加的内容,主要是类型声明。
Teal 中的类型比 Lua 更加具体,因为 Lua 表格(table)能代表的数据结构非常广泛,并没有一个类型的概念加以约束。以下是 Teal 中的基本类型:
any
nil
boolean
integer
number
string
thread
(协程)
注意:integer
是 number
的一个子类型,它的精度未定义,取决于 Lua 虚拟机。
你还可以使用类型构造器声明更多类型。以下是几个例子:
- 数组 -
{number}
,{{number}}
- 元组 -
{number, integer, string}
- 映射 -
{string:boolean}
- 函数 -
function(number, string): {number}, string
最后,还有一些必须通过名称声明和引用的类型:
- 枚举 (enum)
- 记录 (record)
- 用户数据 (userdata)
- 数组记录 (arrayrecord)
以下是每种类型的声明示例:
-- 一个枚举:一组可接受的字符串
local enum State
"open"
"closed"
end
-- 一个记录:具有已知字段集的表
local record Point
x: number
y: number
end
-- 一个用户数据记录:用作用户数据的记录
local record File
userdata
status: function(): State
close: function(File): boolean, string
end
-- 一个数组记录:既是记录又是数组
local record TreeNode<T>
{TreeNode<T>}
item: T
end
5. 局部变量
Teal 中的变量有类型。因此,当你使用 local
关键字声明变量时,需要提供足够的信息以确定类型。在 Teal 中,声明变量时不给出类型是无效的:
local x -- 错误!不知道这个变量的类型是什么?
然而,有两种方式可以为变量赋予类型:
- 通过声明
- 通过初始化
声明时,在变量名后加上冒号和类型。当同时声明多个变量时,每个变量都应该有自己的类型:
local s: string
local r, g, b: number, number, number
如果在创建变量时初始化它,就不需要写类型:
local s = "hello"
local r, g, b = 0, 128, 128
local ok = true
如 果你用 nil
初始化变量但不给出类型,就无法提供有用的信息(你不希望变量在程序的整个生命周期中都保持 nil
,对吧?),因此你需要显式声明类型:
local n: number = nil
这与省略 = nil
的做法类似,但它为 Teal 提供了所需的信息。Teal 中的每个类型都接受 nil
作为有效值,尽管像在 Lua 中一样,在某些操作中使用它会导致运行时错误,因此要留意这一点!
6. 数 组
Teal 中最简单的结构化类型是数组。数组是 Lua 表,其中所有键都是数字,所有值都是相同类型的。实际上,它是一个 Lua 序列,具有与 Lua 序列相同的语义,比如 #
操作符和 table
标准库的使用。
数组使用花括号表示,可以通过声明或初始化来表示:
local values: {number}
local names = {"John", "Paul", "George", "Ringo"}
注意,values
被初始化为 nil
。要将其初始化为空表,需要显式地这么做:
local prices: {number} = {}
由于初始化空表用于构造数组的情况非常常见,Teal 提供了一个简单的推断逻辑,支持为没有声明的空表确定类型。代码中第一次为空表赋值的地方决定了它的类型。因此,以下代码是可以工作的:
local lengths = {}
for i, n in ipairs(names) do
table.insert(lengths, #n) -- 这使得 lengths 表成为 {number}
end
注意,这甚至适用于库调用。如果你对不兼容的类型进行赋值,tl 编译器会告诉你在程序的哪个地方它最初认为空表是一个不兼容的类型。
还要注意,我们在上面的例子中并不需要声明 i
和 n
的类型:for
语句可以从 ipairs
调用返回的迭代器函数的返回类型中推断出它们的类型。将 {string}
传递给 ipairs
意味着 ipairs
循环的迭代变量将是 number
和 string
。关于自定义用户编写的迭代器的示例,请参见下面的函数部分。
请注意,数组的所有项都应该是相同类型的。如果你需要处理异构数组,你将不得不使用强制转换运算符 as
将元素强制转换为所需的类型。请记住,当你使用 as
时,Teal 将接受你使用的任何类型,这意味着它也可以隐藏数据的不正确使用:
local sizes: {number} = {34, 36, 38}
sizes[#sizes + 1] = true as number -- 这不会执行真正的转换!它只会让 Teal 不再抱怨!
local sum = 0
for i = 1, #sizes do
sum = sum + sizes[i] -- 将在运行时崩溃!
end
7. 元组
在 Lua 中,另一种常见的表用法是元组:表中包含一个有序的元素集,每个元素的类型都已知,并且分配给其整数键。
-- 包含姓名和年龄的类型为 {string, integer} 的元组
local p1 = { "Anna", 15 }
local p2 = { "Bob", 37 }
local p3 = { "Chris", 65 }
当使用常量数字索引元组时,其类型可 以正确推断,超出范围的索引会产生错误。
local age_of_p1: number = p1[2] -- 没有类型错误
local nonsense = p1[3] -- 错误!索引 3 超出元组 {1: string, 2: integer} 的范围
当使用 number
变量索引元组时,Teal 会尽力通过将元组中的所有类型进行联合类型(遵循下面详细说明的联合限制)。
local my_number = math.random(1, 2)
local x = p1[my_number] -- => x 是 string | number 联合
if x is string then
print("Name is " .. x .. "!")
else
print("Age is " .. x)
end
元组还可以帮助你跟踪意外添加比预期更多的元素(只要它们的长度是显式注释的,而不是推断的)。
local p4: {string, integer} = { "Delilah", 32, false } -- 错误!预期最大长度为 2,得到了 3
在使用元组和数组时需要记住的一点是类型推断,以及何时需要或不需要它。如果表中的所有元素都是相同类型,那么表将被推断为数组,如果任何类型不同,则被推断为元组。因此,如果你想要一个联合类型的数组而不是元组,请明确注释:
local array_of_union: {string | number} = {1, 2, "hello", "hi"}
如果你想要一个所有元素都是相同类型的元组,也要进行注释:
local tuple_of_nums: {number, number} = {1, 2}
8. 映射
映射是另一种非常常见的表类型:表中的所有键都是某一类型,所有值都是另一类型,键和值的类型可以相同或不同。映射使用花括号和冒号表示:
local populations: {string:number}
local complicated: {Object:{string:{Point}}} = {}
local modes = { -- 这是 {boolean:number}
[false] = 127,
[true] = 230,
}
9. 记录
记录是 Teal 中支持的第三大表类型。它们代表了 Lua 代码中的另一种常见模式,Lua 为这种模式提供了特殊语法(点和冒号表示法用于索引):带有一组已知字段的表,每个字段对应一个特定的值类型。
要声明记录变量,首先需要创建一个记录类型。类型描述了该记录可以包含的有效字段集(键为字符串、值为特定类型)。你可以使用 local type
声明类型,也可以使用 global type
声明全局类型。
local type Point = record
x: number
y: number
end
记录类型是常量:不能重新赋值,声明时必须用类型初始化。
你还可以在记录定义后使用 Lua 的常规冒号或点语法声明记录函数,只要它在同一作用域中:
function Point.new(x: number, y: number): Point
local self: Point = setmetatable({}, { __index = Point })
self.x = x or 0
self.y = y or 0
return self
end
function Point:move(dx: number, dy: number)
self.x = self.x + dx
self.y = self.y + dy
end
当你使用这些函数时,不用担心:如果你搞错了冒号或点,tl 会检测并提示你!
如果你想在一个后续的作用域中定义函数(例如,该函数是由模块的用户定义的回调函数),你可以在记录中声明函数字段的类型,然后在任何地方进行赋值:
local record Obj
location: Point
draw: function(Obj)
end
一个记录也可以有数组部分,形成一个“数组记录”。以下是一个数组记录的例子。你可以同时将它作为一个记录使用(通过名称访问其字段),也可以将其作为一个数组使用(通过数字索引访问其元素)。
local record Node
{Node}
weight: number
name: string
end
请注意,上例中的递归定义:Node
类型的记录可以通过数组部分组织成树结构。
最后,记录可以包含嵌套的记录类型定义。这在将模块导出为记录时非常有用,因此在模块中创建的类型可以被客户端代码使用。
local record http
record Response
status_code: number
end
get: function(string): Response
end
return http
你可以使用常规的点表示法引用嵌套类型,并在所需的模块中使用它们:
local http = require("http")
local x: http.Response = http.get("http://example.com")
print(x.status_code)
你可以将记录字段标记为 const
,以防止它在初始化后被修改:
local record Point
const x: number
const y: number
end
local point: Point = {x = 100, y = 200}
point.x = 456 -- 错误!无法修改 const 字段
你可以使用 embed
关键字将另一个记录的所有字段包含到当前记录中:
local record Point
x: number
y: number
end
local record Circle
embed Point
radius: number
end
local c: Circle = {x = 100, y = 200, radius = 50}
10. 泛型
Teal 支持简单的泛型,足够处理操作抽象数据类型的集合和算法。
你可以在任何类型的地方使用类型变量,并且可以在函数和记录中声明它们。以下是一个泛型函数的例子:
local function keys<K,V>(xs: {K:V}):{K}
local ks = {}
for k, v in pairs(xs) do
table.insert(ks, k)
end
return ks
end
local s = keys({ a = 1, b = 2 }) -- s 是 {string}
我们在尖 括号中声明类型变量,并将它们用作类型。泛型记录的声明和使用方式如下:
local type Tree = record<X>
{Tree<X>}
item: X
end
local t: Tree<number> = {
item = 1,
{ item = 2 },
{ item = 3, { item = 4 } },
}