> LeekScript 教程
本文以你已经了解表达式、变量和函数为前提,只是想为解决问题多提供一件工具。和大多数动态类型语言一样,LeekScript 没有原生的类型标注工具,所以每个人往往都会有自己的一套写法。
回顾一下:对象就是语言可以操作的东西,可以赋给变量、作为参数传递、作为函数的返回值,也可以用在表达式里。在 LeekScript 中,我们可以使用以下这些基本对象:
你可以看到,这些对象的分组并不是随意的。每个对象都属于一个家族,我们把它叫作类型。
每个对象都有一个类型。为了能用类型来推理,我们需要一种把它们写出来的方式。在本文中,我们使用这样的记法:object : Type,类型名以大写字母开头。你也可以自由使用别的记法。下面是最基本的类型列表,并附上几个可能的值:
null : Null true, false : Bool 1, -273 : Int 1.0, 2,34 : Float "a", "abc" : String
这里用的名字是随意取的,并不是 Leek Wars 社区通用的约定。也可以把 Int 和 Float 中的对象归入同一个类别 Num,因为操作它们时很少需要加以区分。
注意,LeekScript 不支持类型标注。如果你想把类型记下来,只能写在注释里。
// object : Type object; object; // : Type
这种记法用来表示一个对象可能是任意类型。写法相同,只是名字以小写字母开头。object : a 表示我们不知道 object 是什么类型,它可以是任何东西。如果定义 otherObject : b,我们同样不知道它的类型,它甚至可能和 object 类型相同。不过,如果定义 sameObject : a,我们掌握的信息就多了一点:我们仍然不知道 sameObject 的确切类型,但知道它和 object 的类型相同。例如,如果 object : Num,那么 sameObject 也是 Num 类型。而 otherObject 可能是 Num 类型,也可能是 String 或 Bool 等类型……
这些类型稍微复杂一些。你可能一直在想,为什么前面没有介绍数组和函数。可以这么说,它们里面包含了别的类型。例如 [1] 包含 Num;function(x) { return "" + x; } 接受任意东西(a),并把它以字符串(String)的形式返回。
标注这些类型时,我们先写一个具体类型,后面跟上其他具体类型或类型变量。例如,数字数组可以这样标注:array : Array Num;字符串数组则是:array : Array String。前面那个接受任意对象并返回其字符串表示的函数,可以标注为:toString : Function a String。可见,参数里可以有多个类型。关联数组是另一个例子:比如一个记录各个格子是否在对手视线之外的关联数组,可以标注为:safeCells : Assoc Cell Bool。
以后标注数组或函数时,我们会使用更简洁的语法:
array : [Num] toString : a -> String safeCells : {Cell : Bool}
对于不接受参数的函数,我们使用 ()。原则上也可以把它用在函数的返回值上,不过在 LeekScript 中,任何不返回值的函数都会隐式返回 null。 例如:rand : () -> Float、say : String -> () 或 say : String -> Null。
如果要标注一个可能有多种类型、但所有可能类型都已知的对象,我们使用记法 a | b | ...。当然,备选项可以不止两个。另一种写法是 Sum a b ...。getNearestEnemy 的返回值就是这种类型的一个例子:如果没有活着的敌人,或者敌人都位于无法到达的格子上,这个函数就会返回 null 而不是一个 ID:getNearestEnemy : () -> ID | Null。
如果要标注一个包含多个不同类型对象的对象,我们使用 (a, b, ...),对应于 Product a b ...。这种类型一般叫作元组(tuple),或 n 元组。它主要用于标注接受多个参数的函数。在 LeekScript 2 中,应该可以同时返回多个对象。目前,我们只能用 [a | b | ...] 类型的数组凑合,或者巧妙地利用闭包。一个接受多个参数的函数的例子:lineOfSight : (Cell, Cell) -> Bool。
标注 safeCells 时,我们用了 Cell。LeekScript 中并没有这个类型,它实际上是一个 Int。不过用 Cell 能更清楚地说明 safeCells 里装的是什么。当然, safeCells : {Int : Bool} 这种标注也同样成立。假设有一个数组,存放着各根韭葱的 ID 和它们在地图上的位置,那么 {ID : Cell} 比 {Int : Int} 好用得多。同理,可以认为 Num = Int | Float。
标注复杂类型时,可以用括号来避免歧义。对于函数,从求值的角度看,优先级在左边。因此,一个接受函数作为参数的函数写作 (a -> b) -> (),而一个返回函数的函数写作 () -> (a -> b),可以简写为 () -> a -> b。
文档在标注函数类型时使用了另一种记法。下面是几个例子,分别用文档的记法和本文的记法写出:
getCellsToUseChip(Nombre chip, Nombre leek) : TableauDeNombres cells arrayFilter(Tableau array, Fonction callback) : Tableau newArray getLife(Nombre leek) : Nombre life say(Chaîne message)
getCellsToUseChip : (Chip, ID) -> [Cell] arrayFilter : ([a], a -> Bool) -> [a] getLife : ID -> Num say : String -> ()
到目前为止,我们只看了对象的类型。但我们真正关心的是如何操作这些对象,为程序得到有用的结果。因此,我们也可以标注构成程序的各个表达式的类型。看到 1 + 1 时,你会立刻想到它的结果是一个数字:1 + 1 : Num。回顾一下:单独一个对象也是一个表达式(1 : Num),只是它已经无法再进一步求值(化简)了。 标注表达式的结果可能很有用,但当表达式变得复杂,或者其中有错误时,我们的标注就可能出错。为了确保类型正确,我们要把表达式拆开,给每个部分一个显而易见的类型,然后在需要的地方代入这些类型,从而得出表达式化简后的类型。 例如,在 1 + 1 中有三个部分:
可以肯定,传给 (+) 的对象都是所需的类型。如果把代入的步骤一一写出来,可以写成这样:
注意,倒数第二步其实没什么意义,再往前一步也可以跳过。这里把它们列出来,是为了清楚地展示参数是如何消失的。
再看一个稍微复杂一点的表达式,它获取所有能用来攻击敌人的格子(保留重复项):
arrayFlatten(arrayMap(getAliveEnemies(), getCellsToUseWeapon), 1) arrayFlatten : (a, 1) -> [a] arrayMap : ([a], a -> b) -> [b] getAliveEnemies : () -> [ID] getCellsToUseWeapon : ID -> [Cell]
getAliveEnemies() : [ID] arrayMap(_, getCellToUseWeapons) : ([ID],) -> Cell arrayMap(getAliveEnemies(), getCellToUseWeapons) : Cell arrayFlatten(arrayMap(getAliveEnemies(), getCellToUseWeapons), 1) : [Cell]
arrayFlatten 的行为有点特殊。默认情况下,只给它一个参数时,它会无视层级,把所有层全部压平。可惜这种用法很少派上用场(笔者就从没用过),而且会让程序的行为更难理解。因此,我们只使用一层拼接,并在类型中明确写出来。如果想拼接两层,只需写成 arrayFlatten : ([a], 2) -> [a])。
检查我们写的东西是否正确,是编译器的工作。而运用类型能帮上忙的地方,是在我们构造新表达式时给出指引。只要知道手头有哪些东西、它们是什么类型,以及想要得到什么类型,就能大大缩小寻找答案的范围。例如,如果想把一个数字列表归约成一个数字,标准库提供了以下函数,类型如下:
average : [Num] -> Num sum : [Num] -> Num
count : [a] -> Num
arrayMax : [a] -> a arrayMin : [a] -> a pop : [a] -> a shift : [a] -> a
不过,我们很少能直接找到一个正好满足需求的函数。根据手头的数据,可以在中间加一个过渡环节。假设输入类型为 a、输出类型为 b,一个好的出发点是:先列出所有以 a 为输入、或以 b 为输出的函数和运算符。接下来只要找出类型能对上的函数对;如果一个都对不上,就再加一层间接,继续往下找。根据可用的输入和输出数量,某些类型可能只在特定的间接层次里才派得上用场。
下面是在准备本文之前不久遇到的一个需要求解的类型:
getLeekNamed : (String, ID) -> ID | Null function getLeekNamed(name, potentialID) { /* ... */ }
目标是:如果该 ID 对应的名字与参数中的名字相同,就返回这个 ID,否则返回 null(不返回任何值)。之后还要改造这个函数,让它能作用于一个 ID 列表((String, [ID]) -> ID | Null)。这样,先解决一个类型经过简化的表达式,再加上操作参数化类型的机制(这里就是遍历数组),就能相对轻松地解决真正的问题。
前面把运算符 (+) 标注为只作用于 Num。可是你很可能也把它用在了别的类型上,比如 String 和 []。如果把数字想象成用一进制表示(这对小数不太说得通,但就当它成立吧),那么加法就变成了拼接,和字符串、数组的情况一样。这种拼接操作可以抽象成一个类 Concatenable。有了这个新记法,就可以把它的类型推广为 (+) : Concatenable a => (a, a) -> a。还有其他运算符和函数也是这样工作的,尤其是比较运算符,它们要求类型可以排序(Ord);还有相等运算符,它们要求类型能够判断值是否相等(Eq)。
看了 (+) 的类型之后,你可能会问:为什么可以写出 1 + " Rue du Néant" 或 null + [1, 2] 这样的东西,又该怎样在类型中表示它?我们不会去表示它。当类型不匹配时,要么报错,要么对象在执行拼接之前被自动转换为字符串。
Impossible de charger les données du jeu.
Vérifiez votre connexion et réessayez.