lotuc
首发于lotuc
Programming Languages: Application and Interpretation【译15下】

Programming Languages: Application and Interpretation【译15下】

初翻: @lotuc

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


15.3.3 联合类型

假设我们要建立动物园动物的链表,动物有这些种类:犰狳、红尾蚺等。目前,我们必须创建新的数据类型:

(define-type Animal
  [armadillo (alive? : boolean)] ;犰狳
  [boa (length : number)]) ;蚺
“在德州,马路中间除了黄线和死掉的犰狳什么都没有。” —— Jim Hightower

然后创建它的链表:(listof Animal)。因此,Animal类型表示的是armadilloboa的“联合(或称联合体,union)”,不过要创建这种联合的唯一方式是每次都创建新类型:比如要创建动物和植物的联合,就需要:

(define-type LivingThings
  [animal (a : Animal)]
  [plant (p : Plant)])

这样实际的动物现在裹在了更深一“层”。这些类型被称为带标签的联合(tagged union)或可辨识的联合(discriminated union),因为我们需要显式引入类似animalplant的标签(或称辨识符(discriminator))来区分它们。相应地,结构体只能通过数据类型声明来定义;要创建只包含一种变体的数据结构,如

(define-type Constraints
  [eqCon (lhs : Term) (rhs : Term)])

来表示该数据结构,我们需要使用类型Constraints而不是eqCons,因为eqCons不是类型,只是能在运行时区分的类型变体。

无论哪种方式,联合类型的要点是表示析取或“或”。值的类型是联合中某个类型。值通常只能是联合中某个特定的类型,不过这取决于联合类型的精确定义、规范它们的规则等等。

15.3.3.1 作为类型的结构体

对此自然的反应可能是,为什么不移除这种限制?为什么不允许每个结构体独立存在,将类型定义为一些结构体的集合?毕竟,不管是C还是Racket,程序员都可以定义独立的结构体,无需使用标签构造函数将它们包裹在其它类型里!例如,Racket里可以写:

(struct armadillo (alive?))
(struct boa (length))

加个注释:

;; 动物是下面两者之一:
;; - (armadillo <boolean>)
;; - (boa <number>)

但是由于Racket不强制静态类型,这种比较不太清楚。然而,我们可以和 Typed Racket (内置与DrRacket中的静态类型Racket)相比较。下面是对应的静态类型代码:

#lang typed/racket
 
(struct: armadillo ([alive? : Boolean]))
(struct: boa ([length : Real])) ;; feet

无需引用armadillo就可以定义使用boa类型值的函数:

;; http://en.wikipedia.org/wiki/Boa_constrictor#Size_and_weight
(define: (big-one? [b : boa]) : Boolean
  (> (boa-length b) 8))

事实上,如果调用此函数时传入其它类型,如armadillo——(big-one? (armadillo true))——将发生静态错误。因为armadilloboa之间的关系等同与数和字符串之间的关系。

当然,我们仍可以定义这些类型的联合:

(define-type Animal (U armadillo boa))

在这之上定义函数:

(define: (safe-to-transport? [a : Animal]) : Boolean
  (cond
    [(boa? a) (not (big-one? a))]
    [(armadillo? a) (armadillo-alive? a)]))

之前我们有一种包含两个变体的类型,现在则有三种类型,其中两种类型恰巧能方便的通过联合定义第三种。

15.3.3.2 无标签联合

看起来我们好像还需要辨识标签,但并非如此。在支持联合类型的语言中,通常这样获取类型构造器optionof:将期望的返回类型和用于表示失败或者none的类型结合起来。例如,下面是(optionof number)的等价实现:

(define-type MaybeNumber (U Number Boolean))

同时,Boolean本身也可以是TrueFalse的联合,在Typed Racket中也确实如此。因此,选择(option)类型更为准确的模拟实现应该是:

(define-type MaybeNumber (U Number False))

更为一般的,可以定义:

(struct: none ())
(define-type (Maybeof T) (U T none))

由于由于none是新的、独特的类型,不会和其它类型混淆,因此该定义适用于所有类型。它提供给我们与选择类型相同的好处,且我们的值没有被埋入深一层的some结构体,而是立即可用。例如member,其Typed Racket中的类型是:

(All (a) (a (Listof a) -> (U False (Listof a))))

如果元素未找到,member返回false;否则,它将返回从该元素开始的链表(即,链表的第一个元素是期望的元素)。

> (member 2 (list 1 2 3))
'(2 3)

将其转换为使用Maybeof实现,可以写成:

(define: (t) (in-list? [e : t] [l : (Listof t)]) : (Maybeof (Listof t))
  (let ([v [member e l]])
    (if v
        v
        (none))))

如果元素未找到,它将返回值(none);如果找到了,仍然是返回链表:

> (in-list? 2 (list 1 2 3))
'(2 3)

这样就无需从some容器中取出链表。

15.3.3.3 辨识无标签联合

将值放入联合是一码事;我们还需要考虑如何以类型良好的方式将值从其中取出来。在我们的类ML类型系统中,我们使用程式化的符号——我们的语言中type-case,ML中的模式匹配——来标识和取出各部分。具体来说,对于代码:

(define (safe-to-transport? [a : Animal]) : boolean
  (type-case Animal a
             [armadillo (a?) a?]
             [boa (l) (not (big-one? l))]))

在整个表达式中a的类型保持一致。标识符a?l分别被绑定到布尔类型和数类型的值上,big-one?接收的就是这些类型,而不是armadilloboa。换句话说,big-one?函数的输入类型不可以是boa,因为根本没有这样的类型。

反之,使用联合类型的话,我们确实有boa类型。因此,我们遵守对值进行谓词操作将缩小其类型的原则。例如,在cond的子句

[(boa? a) (not (big-one? a))]

中,尽管a的初始类型为Animal,在通过boa?测试后,类型检查器会将其类型缩小到boa的分支,这样big-one?调用得以通过类型检查。反过来,其在条件表达式剩余部分的类型不是 boa——这里,只剩下armadillo一种可能。这给类型检查器提出了更高的要求,它需要能测试并识别特定模式(称为条件分割(if-splitting));缺了这种能力就无法使用联合类型编程;当然我们可以只识别类ML系统中能识别的模式,也就是模式匹配、type-case

15.3.3.4 改造为静态类型

毫不奇怪,Typed Racket使用联合类型。当将现有语言改造为静态类型时,它们尤其有用,因为现有语言(如脚本语言中)的程序没有用类ML类型系统的原则来定义。这种类型改造的通用的原则之一是尽可能多地静态捕获动态异常。当然,检查器最终会让一些程序无法通过检查【注释】,但如果它拒绝太多可以无错运行的程序,开发者不太可能采用它。由于这些程序是在没有考虑类型检查的情况下编写的,因此类型检查器需要以更为激进的方式接受该语言中被认为合理的习惯用法。

除非它实现了称为软类型(soft typing)的有趣想法:不拒绝任何程序,而是提供信息告知程序中无法通过类型检查之处。

考虑下面的JavaScript函数:

var slice = function (arr, start, stop) {
  var result = [];
  for (var i = 0; i <= stop - start; i++) {
      result[i] = arr[start + i];
  }
  return result;
}

它读入一个数组和两个索引,返回这两个索引之间的子数组。例如,slice([5, 7, 11, 13], 0, 2)求得[5, 7, 11]

在JavaScript中,开发人员在函数调用时可以自由的省略任意或者所有尾部参数。每个被省略的参数都被赋予特定值undefined,如何处理这种情形完全由函数决定。例如,slice的典型实现允许用户省略最后一个参数;下面的定义

var slice = function (arr, start, stop) {
  if (typeof stop == "undefined")
    stop = arr.length - 1;
  var result = [];
  for (var i = 0; i <= stop - start; i++) {
    result[i] = arr[start + i];
  }
  return result;
}

在未给定第三个参数时自动返回到数组结尾的子数组:因此slice([5, 7, 11, 13], 2)返回[11, 13]

在Typed JavaScript【注释】中,程序员可以通过为给定参数指定类型U Undefined来显式地指明函数可以接受更少的参数,此函数的类型如下:

∀ t : (Array[t] * Int * (Int U Undefined) -> Array[t])
由Arjun Guha等人在布朗(大学)创建。参见我们的网站

原则上,这意味着表达式stop - start存在发生类型错误的可能,因为stop可能不是数。然而,当用户省略该参数时,对stop的赋值正好将其设为数类型。换句话说,在所有控制路径上,减法发生前stop都将是数类型,因此该函数能通过类型检查。当然,这要求类型检查器能够对控制流(条件)和状态(赋值)进行推断来确保函数类型正确;而Typed JavaScript可以做到,也因此能允许这样的函数。

15.3.3.4 设计选择

拥有联合类型的语言中,通常有

  • 独立的结构体类型(通常用类表示),而不是带有变体的数据类型。
  • 用于表示特定类型的特殊(ad hoc)结构体集合。
  • 哨兵值(sentinel value)表示失败。

将这种风格的程序转换成满足类ML类型风格的非常费事。因此,许多改造过来的类型系统引入联合类型来减轻类型化过程的负担。

上述三个属性中,第一个相对中立,但是其它两个需要更多讨论。我们以反序依次解决它们。

  • 首先处理哨兵值。很多情况下,哨兵应该被替换为异常,但是在很多语言中,抛出异常的代价巨大。因此开发者倾向于区分真正的异常情况——不应该发生——和正常运行中的预期情况。检查元素是否属于链表发现不存在的情况显然属于后者(如果我们已经知道元素是否存在,这个谓词判断就无需进行)。在后一种情况下,使用哨兵是合理的。

然而,我们需要认识到,在C程序中,未能检测异常的哨兵值是错误——甚至安全缺陷——的常见原因。这点很容易解决。在C中,哨兵值和普通返回值类型相同(或者至少等同于类型相同),而且运行时也没有检查。因此哨兵可以被当作合法的值使用,且不会出现类型错误。这就导致哨兵值0可以被当作分配数据的地址来使用,从而导致系统崩溃。与之不同,我们的哨兵是真正意义上的新类型,无法用于任何计算。观察到前语言中没有任何函数的输入类型为none,可以推理出这点。

  • 先忽略这里贬义的“特殊”一词,对一组结构体进行不同的分组是否是个好主意?实际上,就算在遵循类ML规范的程序中,当程序员希望刻画一个大宇宙的子宇宙时,也会出现这种分组的情形。例如,ML程序员会使用下面的类型

(define-type SExp
[numSexp (n : number)]
[strSexp (s : string)]
[listSexp (l : (listof SExp))])

表示s-expression。如果有函数希望操作这些项的某个子集,比如数和数的链表,就必须创建新的类型,然后将值在两种类型之间转换,尽管这两个类型的内部表示完全相同。另一个例子,考虑CPS表达式的集合,这显然是所有可能表达式的一个子集,但如果不得不为其创建新的类型,我们将无法对其使用任何已有的表达式处理程序,比如解释器。

换种说法,联合类型似乎是我们之前见到的ML风格类型系统的合理变种。但是,即使在联合类型中仍有设计选择,它们都有其后果。例如,允许类型系统创建新联合类型吗?允许用户定义(和命名)联合吗?也就是说,允许表达式

(if (phase-of-the-moon)
    10
    true)

通过类型检查吗(将创建类型(U Number Boolean)),还是由于其引入了之前未命名并显式标识的类型而将其判定为类型错误?Typed Racket提供的是前者:它将创建真正的临时联合。对于给现有代码引入类型来说,这么做可能更好,因为它更加灵活。但对于写新代码来说,这是否是个好的设计还并不清楚,因为并非程序员期望内的联合会出现,而且无法避免。这给程序语言的设计空间提供了一个未被探索的角落。

15.3.4 名义类型系统与结构类型系统

我们最初的类型检查器中,如果两个类型具有相同的结构,则认为它们是相同的。事实上我们根本没有提供类型的命名机制,因此不清楚有何替代方案。

现在考虑Typed Racket。程序员可以写

(define-type NB1 (U Number Boolean))
(define-type NB2 (U Number Boolean))

然后写

(define: v : NB1 5)

假设还定义了函数

(define: (f [x : NB2]) : NB2 x)

然后用v调用f,即(f v):该调用应该通过类型检查吗?

有两种完全合理的解释。一种是说v被声明为类型NB1,与NB2名称不同,因此应该被当作不同类型,所以该调用应导致错误。这种系统被称为名义的nominal),因为类型的名字对于确定类型是否相等极为重要。

与之对应,另一种解释是说因为NB1NB2结构相同,因此开发者无法写出在这两种类型的值上表现的不同的程序来,所以它们应该被视为相同。【注释】这种类型系统被称为结构的structural),将允许上面的程序通过检查。(Typed Racket遵循结构类型的规范,理由同样是减少导入现有动态类型代码的负担,这些Racket代码通常是以结构解释为模型编写的。事实上,Typed Racket中(f v)不仅能通过类型检查,而且打印出的返回类型为NB1,无视f返回值的类型注解!)

如果特别小心,你会注意到被认为相同和实际相同之间是有区别的。这里不会涉及该问题,但请考虑编译器作者选择值的表示时其影响是啥,尤其在允许运行时获取值的静态类型的语言中。

名义和结构类型之间的区别在面向对象语言中是最常见的争议,后面将简要回顾这个问题。然而,这里的重点是要说明这些问题本质上并不关乎“对象”。任何允许命名类型的语言——出于程序员精神健康的需要,也就是所有的语言了——都要应付此问题:命名只是方便起见,还是说所选的名字是被认为是有意义的?选择前者导致结构类型,选择后者导致名义类型。

15.3.5 交叉类型

我们刚探索了联合类型,很自然的就会想到有没有交叉(intersection)类型呢。确实有。

如果联合类型指(该类型的)值属于这个联合中某个类型,交叉类型显然意味着该值属于交叉中的所有类型:合取,或“且”。这可能看起来很奇怪:值怎么可能属于多种类型呢?

用具体例子回答,考虑重载函数。例如,某些语言中+即可操作数,也能操作字符串;传入两个数它返回数,传入两个字符串它返回字符串。这种语言中,+的类型应该是什么呢?不是(number number -> number),因为那样它将不能用于字符串;同样的原因,也不是(string string -> string)。甚至它也不是

(U (number number -> number)
   (string string -> string))

因为+不仅仅是这些函数之一:实际上它(同时)是这两者。我们可以认为其类型是

((number U string) (number U string) -> (number U string))

这说明它的每个参数和返回值都只能是这两种类型之一,而不同时为两者。但是,这样做会导致精度损失。

思考题

这种类型以何种方式损失精度?

观察到,对于这个类型,所有函数调用的返回值类型均为(number U string)。因此,对于每个返回值都必须区分数和字符串,不然我们将得到类型错误。所以,尽管我们知道给定两个数参数将返回数结果,但这种信息在类型系统中丢失了。

更巧妙的是,这个类型允许独立的选择每个参数的类型。因此,根据该类型,(+ 3 "x")也是合法的(且其返回值类型为(number U string))。但我们描述的加法操作当然没有对这组参数定义过!

因此描述这种加法的更为合适的类型是

(^ (number number -> number)
   (string string -> string))

这里的让人联想到逻辑上的合取操作符。这允许函数用两个数或者两个字符串进行调用,其它的则不允许。使用两个数调用返回数类型;使用两个字符串调用返回字符串类型;除此之外没有其它合法调用了。这刚好对应于我们期望的重载行为(有时也称为特设多态(ad hoc polymorphism))。请注意这只能处理有限数量重载的情况。

15.3.6 递归类型

学过联合类型之后,值得讨论一下我们原来遇到过的递归数据类型表达式。如果接受变体作为类型构造器,我们可以将递归类型写作它们的联合吗?例如就BTnum来说,能否将它描述成等价于

((BTmt) U (BTnd number BTnum BTnum))

的类型吗,其中BTmt是零参数的构造器,而BTnd是三参数的?不过,这三个参数的类型是什么?按上面所写的类型,BTnum要么是类型语言内建的(这不能令人满意),要么是未绑定的。也许我们要的是

BTnum = ((BTmt) U (BTnd number BTnum BTnum))

问题是这个方程没有明显解法(还记得ω吗?)。

这种情况我们讨论值的递归时就熟悉过。那时,我们发明了递归函数构造器(并展示了其实现)来规避这个问题。这里我们同样需要递归类型构造器。按惯例它被称为μ(希腊字母“缪”)。有了它,我们可以将上面的类型写做

μ BTnum : ((BTmt) U (BTnd number BTnum BTnum))

μ是绑定构造;它将BTnum绑定到后面写的整个类型上,包括对BTnum自身的递归绑定。实践中,整个递归类型就是我们希望得到的称为BTnum的类型:

BTnum = μ BTnum : ((BTmt) U (BTnd number BTnum BTnum))

尽管这看起来像是循环定义,但请注意,右侧的BTnum不依赖于等式左侧的那个:即,我们可以将其重写为

BTnum = μ T : ((BTmt) U (BTnd number T T))

换句话说,BTnum的这个定义可以被认为是语法糖,可以在程序的各个地方替换使用,无需担心无限回归的问题。

语义层面上,对μ绑定的类型的意义有两种截然不同的思考方式:它们可以被解释为同构递归(isorecursive)或等价递归(equirecursive)。然而其中区别很微妙,超出了本章范围。【注释】只需理解递归类型可以被视为等同于它的展开。例如,我们定义数的链表类型为

NumL = μ T : ((MtL) U (ConsL number T))

于是有

μ T : ((MtL) U (ConsL number T))
= (MtL) U (ConsL number (μ T : ((MtL) U (ConsL number T))))
= (MtL) U (ConsL number (MtL))
        U (ConsL number (ConsL number (μ T : ((MtL) U (ConsL number T)))))

以此类推(同构和等价递归之间的区别正是在相等性的概念上:是定义上的相等性还是同构意义上的)。每一步中,我们将参数T替换成整个类型。和值的递归一样,它的意思是需要时我们可以“获得另一个”ConsL构造。换种说法,链表的类型可以写成零或任意多元素的联合;这等价于包含零个、一个或任意个元素的类型;以此类推。任何数的链表都(恰好)符合这些类型。

Pierce的书中对此解释的非常好。

注意到,即使基于对于μ的这种非正式理解,我们已经可以给ω进而Ω提供类型。

练习题

描述ωΩ的类型。

15.3.7 子类型

假设我们有一个典型的二叉树定义;简单起见,我们假设值为数。为了说明问题,我们用Typed Racket写:

#lang typed/racket

(define-struct: mt ())
(define-struct: nd ([v : Number] [l : BT] [r : BT]))
(define-type BT (U mt nd))

考虑二叉树具体的值:

> (mt)
- : mt
#<mt>
> (nd 5 (mt) (mt))
- : nd
#<nd>

请注意,每个结构体构造器构造出自己对应类型的值,而不是BT类型的值。但是考虑(nd 5 (mt) (mt))nd的定义表明其子树必须为BT类型,但我们可以传给它mt类型的值。

显然,使用mtnd来定义BT并不是巧合。但是,它确实表明在进行类型检查时,不能只检查构造函数的相等性,至少我们目前所做的不够。相反,我们必须检查一种类型“适用于”另一种。这种行为被称为子类型化(subtyping)。

子类型化的本质是定义一种关系,通常用<:表示,将一对类型关联起来。在期待类型T的位置,如果放入类型S的值也成立,那么我们就称S <: T:换句话说,子类型化将可替代性的概念(即,任何期望类型T的值的地方,都可以被替换成类型为S的值)形式化。当这种关系成立时,S被称作子类型(subtype),T被称作超类型(supertype)。使用子集去解释这点是很有用的(通常也是准确的):如果S的值是T的子集,那么期望接受T值的表达式收到S值时不会出问题。

子类型化对类型系统有着深远影响。我们必须审视每一种类型,并理解它和子类型化之间的相互作用。对于基本类型,这通常比较明显:数、字符串等不相交的类型,彼此无关。(存在一些语言,使用某基本类型表示其它的基本类型——例如,某些脚本语言中,数只不过是特殊写法的字符串,还有些语言中,布尔值就是数——这些语言中,基本类型之间也可能存在子类型关系,但是这并不常见。)但是,我们必须考虑子类型化和每个复合类型构造器之间的关系。

事实上,甚至我们关于类型的表述也需要改变。假设我们有个类型为T的表达式。通常我们会说它产生类型为T的值。现在,我们需要小心的说,它产出最多为T的值,因为它可能只产出T的某个子类型的值。因此,每个对类型的引用都隐含地涉及可能的子类型引用。为避免烦恼我会控制不这么做,但要小心,忽略这种隐含的解释可能导致推理错误。

15.3.7.1 联合

我们来讨论联合和子类型化会发生什么相互作用。显然,每个子联合是整个联合的子类型。在我们所用的例子中,显然每个mt值都是BT值;这同样适用于nd。因而,

mt <: BT
nd <: BT

于是,(mt)也是BT类型的,因此表达式(nd 5 (mt) (mt))类型正确,就是nd——因此也是BT类型。一般来说,

S <: (S U T)
T <: (S U T)

(我们写了两个看上去差不多的的规则,这是为了明确说明子类型处在联合中的哪“一边”并不重要)。它的意思是,S的值可以被认为是S U T的值,因为任何S U T类型的表达式都确实可以包含S类型的值。

15.3.7.2 交叉

既然到了这里,我们也简要的讨论一下交叉。正如你可能想象的那样,交叉的行为是对偶的:

(S ∧ T) <: S
(S ∧ T) <: T

为了说明这点,使用子集的解释:如果值即是S也是T,显然,它可以是两者中的任意一个。

思考题

为什么下面两条假设成立?
  1. (S U T) <: S
  2. T <: (S ∧ T)

第一条不成立是因为类型T的值是(S U T)中完全合法的值。例如,数是类型(string U number)的一员。然而,数不可以在需要类型为string的时候被使用。

至于第二条,类型T的值一般来说不是类型S的值。任何希望类型(S ∧ T)消费者希望其能够既作为T也作为S,而后一点无法保证。例如对前面重载的+来说,如果T(number number -> number),那么该类型的函数无法对字符串进行处理。

15.3.7.3 函数

我们还讨论过一种复合类型:函数。【注释】我们需要决定子类型关系中,任何一个类型为函数时的规则。通常我们认为函数和其它类型不相交,因此我们只需考虑函数类型作函数类型子类型的情况:也既,何时式子

(S1 -> T1) <: (S2 -> T2)

成立?方便起见,我们称类型(S1 -> T1)f1(S2 -> T2)f2。问题就变成了,如果表达式的期望类型为f2,何种情况下给其传递f1类型的函数是安全的?使用子集合解释来考虑这个问题比较容易。

我们还讨论过参数化数据类型。在本书中,对它们子类型化的探索作为练习留给读者。

考虑f2类型的使用。它返回值的类型为T2。因此,函数调用所在的上下文会对T2类型的值满意。显然,如果T1T2相同,那么这里f2的使用也能通过类型检查;类似的,如果T1T2值的一个子集,也是可以的。唯一的问题是,如果T1的值比T2多,该上下文将可能遭遇非期望的值,从而导致未定义行为。换句话说,我们需要T1 <: T2。注意这里包含的“方向”与整个函数类型中的方向相同;这被称为协变(covariance,两者在相同的方向上变化)。这也许正是你所期望的。

出于同样的原因,你可能认为参数位置也出现协变:即S1 <: S2。这也符合预期,但它是错的。让我们看看为什么。

调用f2类型的函数,需要提供类型为S2的值作参数。假设我们将函数替换为类型f1的。如果S1 <: S2,这意味着新函数仅能接受S1类型的值——这是一个严格子集。这意味着对于某些值——在S2中但不在S1中的值——函数调用会提供它们为参数,而换入的函数在它们之上并无定义,这导致未定义的行为。为避免此,需要假定相反的方向:即替代函数应该至少能接收原函数能够接收的那些值。因此我们需要S2 <: S1,我们说该位置是逆变(contravariant)的:它和子类型化方向相反。

综合这两个发现,我们得到函数(对于方法也一样)子类型化的规则:

(S2 <: S1) and (T1 <: T2) => (S1 -> T1) <: (S2 -> T2)

15.3.7.4 实现子类型

当然,这些规则假定我们已经修改了类型检查器遵循子类型化的要求。子类型化的本质规则是,如果有表达式e,其类型为S,且S <: T,那么e也具有类型T。虽然这听起来很直观,但它也有问题,原因有二:

  • 到目前为止,我们所有的类型规则都是语法驱动的,这使我们可以编写递归下降的类型检查器。但现在有可一条适用于所有表达式的规则,我们不知道何时应用这条规则了。
  • 可能存在很多级别的子类型。这使得何时“停止”子类型化不再是个显而易见的问题。特别是,原来类型检查会求出表达式的类型,现在表达式可以有很多可能的类型;如果我们返回了“错误”的类型,可能会导致类型错误(因为它不是上下文期望的类型),尽管这时候可能存在其它的类型能够满足上下文需求。

这两个问题指出的是,我们这里给出的关于子类型化的描述根本上来说是声明性的:我们描述了它是怎样的,但是没有将这种说明转换成算法。对于每个实际的静态类型语言,将其转换成子类型算法——实现类型检查器的实际算法(理想情况下,该类型检查器仅让所有声明机制下被认为是有效的程序通过类型检测,也即,既可靠又完备)——或多或少是个有趣的问题。

15.3.8 对象类型

正如我们前面提到的,对象的类型通常分为两个阵营:名义的和结构的。名义类型大多数程序员通过Java都熟悉了,所以这里不多讨论。对象的结构类型是说,对象的类型本身就是一个结构化的对象,由字段的名字及它们的类型组成。例如,有两个方法——add1sub1——的对象,其类型将是:

{add1 : (number -> number), sub1 : (number -> number)}

(为方便引用,我们称这个类型为addsub。)类型检查的做法也很容易预计:对于字段的访问,我们只需确保字段存在,并将解引用表达式类型求为该字段的声明类型;对于方法调用,我们不仅需要确保对应成员存在,还要确保其类型是函数。到目前为止,一切都很简单。

对象类型会因为很多原因而变复杂:

很多书都专注于此问题。尽管有点过时,但是Abadi和Carelli的《A Theory of
Objects(对象理论)》仍然很重要。Bruce的《Foundationos of Object-Oriented Languages: Types and Semantics(面向对象语言基础:类型和语义)》更为现代,阐述也更温和。Pierce的书则漂亮的覆盖了所有必要的理论。
  • 自引用。self的类型是什么?它必须和整个对象的类型相同,因为任何可以从“外部”施加到对象上的操作也可以通过self在“内部”施加。这意味着对象是递归类型。
  • 访问控制:私有(private)、公共(public)和其它限制。这导致对象“外部”和“内部”类型之间的区别。
  • 继承:不仅需要为父对象指定类型,还需要考虑继承路径上哪些东西可见,这和“外部”可见的东西又有区别。
  • 多重继承和子类型之间的相互作用。
  • 像Java这样的语言中,类和接口之间的关系存在运行时成本。
  • 赋值。
  • 类型转换。
  • 横生枝节。

等等。其中的一些问题会因为名义类型而简化,因为给定类型名我们就可以确定其行为的所有信息(类型声明实际变成了一个字典,从中可以查询关于对象的描述),这也是赞成名义类型的一个论据。

请注意,Java的方法不是构建名义类型系统的唯一方法。之前讨论过,Java的类系统不必要地限制了程序员的表达能力;相应地,Java的名义类型不必要地将类型(接口描述)和实现混为一谈。因此,名义类型系统可以比Java做的好得多。例如,Scala在这个方面就做出了重要的改变。

对这些问题进行充分论述需要更多的篇幅。这里我们只讨论一个有趣的问题。还记得我们说过,子类型化迫使我们考虑每种类型构造器吗?有了对象的结构类型,我们就必须多考虑一种:对象类型构造器。因此我们必须了解它与子类型化之间的相互作用。

在开始之前,先来确保我们理解对象类型到底意味着什么。考虑上面的addsub类型,其中列出了两个方法。什么对象的类型可以是它?显然,恰好拥有这两个方法、且方法的类型符合的对象符合条件。同样明显的是,如果某个对象只包含这两个方法中的一个而不含另一个,不管它还包含有其它什么,都不符合条件。但其中短语“不管它还包含其它什么”是最先要考虑的。如果对象表示的是算术包,除了这两个方法之外,它还包含+*呢(所有方法的类型也都正确)?这种情况下的对象当然能提供上面两个方法,因此该算术包确实具有类型addsub。不过将其作为类型addsub使用时,其它方法不可用。

下面我们写下这个包的完整类型,称之为as+*

{add1  : (number -> number),
 sub1  : (number -> number),
 +     : (number number -> number),
 *     : (number number -> number)}

前面论证的是,类型as+*的对象也允许被声明为类型addsub,这意味着它可以放入任何期望addsub类型值的上下文。换句话说,我们刚才的意思其实是as+* <: addsub

{add1  : (number -> number),           {add1 : (number -> number),
 sub1  : (number -> number),        <:  sub1 : (number -> number)}
 +     : (number number -> number),
 *     : (number number -> number)}

这可能乍一看令人困惑:我们说过子类型化遵从集合包含关系,因此我们期望小的集合在左侧而大的集合在右侧。可这里,好像“大的类型”(至少在字符数量的意义上是)在左侧而“小的类型”在右侧。

要理解为什么这是正确的,需要建立这样的直觉:“越大”的类型包含的值越少。左侧的每个对象都含有四个方法,而且其中包含了右侧的那两个方法。但是,有很多对象有右侧的两个方法,但是不包含左侧那另外两个方法。如果我将类型看作对可接受值形状的约束的话,“更大”的类型给定了更多的约束,因此会导致更少的值。于是,尽管类型的大小关系可能看上去不对,但是它们所包含的值的集合的大小关系是正确的。

更一般地,这表明从对象中删除字段就能获得超类型。这被称为宽度子类型化(width subtyping),因为子类型“更宽”,而我们通过调整对象“宽度”来移动到更上层的类型。即使在Java的名义类型世界中也能看到这点:当沿着继承链上溯时,类中的方法和字段越来越少,直到Object——所有类的超类型——包含得最少。因此对于Java中的任意类类型CC <: Object

有时,缩小(narrowing)和拓宽(widening)的使用方式会让人疑惑,它看上去好像用反了一样。拓宽是指从子类型转到超类型,因为它是从一个“较窄”(较小)的集合到一个“较宽”(较大)的集合。这些术语是独立演化而来的,很不幸,并不一致。

正如你可能预计的那样,还有一种重要的子类型化形式,是关于给定成员内部的。就是说,任何特定的成员都可以归入相应位置的超类型。出于显而易见的原因,这种形式的子类型化被称为深度子类型化(depth subtyping)。

练习题

构造两个深度子类型化的例子。其中一个,给定字段为对象类型,使用宽度子类型化去取该字段的子类型。另一个例子中,给定字段为函数类型。

Java中限制了深度子类型化,它倾向于类型在对象层次结构中保持不变,因为这对传统的赋值操作来说是安全的。

宽度和深度子类型化的结合包含了对象子类型化中大部分最有趣的情形。然而,仅实现这两种子类型化的类型系统不可避免地会招致程序员恼火。其它方便的(而且数学上必须的)规则还包括:改变名称排列顺序的能力、反身性(每个类型是其自己的子类型,因为将子类型关系解释为更方便)和传递性。像Typed JavaScript这样的语言使用了所有这些特性为程序员提供最大的灵活性。

文章被以下专栏收录