Coursera Programming Languages, Part C 华盛顿大学 Week 3

2023-03-07,,

整个系列课程的最后一小结!

介绍了之前在 interface 中所提到的 subtype 系统以及其与 ML 中 generics 的不同

introduction to subtyping

在之前的课堂中 (主要是 Part A),我们了解了 FP 中的静态类型,尤其是 ML 中的 type system

而 ML type system 的有效性建立在参数多态 (parametric polymorphism),即泛型 (generics) 上

所以,我们还需要学校 OOP 语言中的 type system

在 OOP 中,一切都是对象 (not entirely true),所以 OOP 中的 type system 的目的主要是防止 方法缺失 (method missing) 的错误

如果对象中有可以从外部访问的域 (fields),那么 type system 同样也要防止 域缺失 (field missing) 的错误

另外还有其他的错误,如调用的方法实参与形参数量不符

虽然 Java 与 C# 这些 OOP 语言中实装了泛型 generics,但是 OOP 中 type system 的有效性主要建立在 子类多态 (subtype polymorphism),即子类 (subtyping) 上

在这节课中,我们将会使用一个类 ML 的虚构语言来介绍 subtype

A Made-Up Language of Records

在我们的虚构语言中,我们有一个类 ML 格式的 Record:与 ML 不同的是,Record 中的域是可变的 (mutable)

我们虚构语言的格式将会是 ML 与 Java 的混合,以更清晰间接的解释 subtyping 的机制

表达 {f1=e1, f2=e2, ..., fn=en},中 fi 是域名,ei 是一个表达 (expression)

每个 ei 都将被 evaluate 为 vi,所以这个表达最终会被 evaluate 为 {f1=v1, f2=v2, ..., fn=vn}

表达 e.f 中,先将表达 e evaluate 为 v

v 是一个包含 f 域的 Record,则这个表达的结果是 f 域的内容。

注意:虚构语言的 type system 会保证 Record v 一定存在 f

表达 e.f = e2,这是一个 mutate 操作:先将 ee2 分别 evaluate 为 v1v2

之后,我们将 v1 Record 中的域 f 的内容更新为 v2

同样,type system 会保证 Record v1 一定存在 f

有了表达之后,接下来我们需要描述虚构语言的 type system (同样是类 ML 语言)

如果 e1 的类 (这里的类是 type 而不是 class)t1, e2 的类为 t2, ..., en 的类为 tn

Record {f1=e1, f2=e2, ..., fn=en} 的类即为 {f1:t1, f2:t2, ..., fn:tn}

如果 e 存在某个域的类型为 f : t,那么 e.f 的类即为 t (否则将不会 type-check)

如果 e 存在某个域的类型为 f : t 并且 e2 的类同样t,那么 e.f=e2 这一句表达的类为 t (否则将不会 type-check)

此外,函数,变量,算术表达以及函数的调用都遵循上面的格式与原则 (函数的 type 格式: args type -> return type)

Wanting Subtyping

目前的 type system 会阻止以下程序的运行

fun distToOrigin (p : {x:real, y:real}) =
Math.sqrt(p.x * p.x + p.y * p.y) val c : {x:real, y:real, color:string} = {x=3.0, y=4.0, color="green"}
val five = distToOrigin(c) # does not type-checked

由于函数 distToOrigin 的形参类型与实参类型并不一致,所以 type system 判定为出现了问题阻止其运行

但是实际上这个程序运行起来并不会出现任何问题:因为函数中所访问的域都是 \(c\) 中存在的,并不会触发 field missing 的错误

这就给了我们一个使我们现有的 type system 更加灵活的灵感:这直接引出了子类型 subtyping 的必要性

这个新的规则就是:如果某个表达所属的类型 (type) 为 {f1:t1, f2:t2, ..., fn:tn},那么它同样属于将任意域移除后产生的新类型

以上面的程序为例,既然变量 \(c\) 的类型是 {x:real. y:real, color:string},那么,它同样属于类型 {x:real, y:real} (即移除 color 域后产生的新类型)

这样,上面的程序就可以被新的 type system 所接受了

所谓子类型 (subtyping) 就是:对超类型元素进行操作的子程序、函数等程序元素,也可以操作相应的子类型 (百度定义)

Letting an expression that has one type also have another type that has less information in the idea of subtyping

可能有些反直觉:超类型所含有的信息,是子类型含有的信息的子集,也就是说,子类型所含有的信息量比超类型要多

The Subtyping Relation

接下来,我们将 subtyping 加入已有的 type system 中,并且尽量维持原有的 type system 不变

举例来讲,关于函数调用的规则仍然保持不变:在函数定义中的形参与实参的数量与类型一一对应。

我们向原有的 type system 中加入两条新内容:

关于 subtyping 的内容:t1 <: t2 表示 t1t2 的一个子类型 (subtype)
唯一一条新加入的类型规则:如果 e 属于类型 t1 并且 t1 <: t2,则 e 还属于类型 t2

很常见的误解是,当我们设计语言时,我们能随心所欲的设定类型系统的规则

我们要记住,type system 的目的是在程序运行之前就防止某些行为 \(X\) 的发生

可以发现,在加入 subtyping 这一新类型规则的前后,我们的 type system 都可以避免 field missing 这一错误的发生

这条规则保证了,如果 t1 <: t2,那么类型为 t1 的任何值都能够用在任何期待类型为 t2 的地方

而正是因为 t1 一定含有 t2 中的每一个域,最后使得 field missing 错误仍然不会发生

Depth Typing and The Problem With Java/C# Subtyping

接下来我们来看一段程序

class Point { ... }  // has fields double x, y
class ColorPoint extends Point { ... } // add field string color
...
void m1(Point[] pt_arr) {
pt_arr[0] = new Point(3, 4); // !
}
String m2(int x) {
ColorPoint[] cpt_arr = new ColorPoint[x];
for (int i = 0; i < x; ++i)
cpt_arr[i] = new ColorPoint(0, 0, "green");
m1(cpt_arr);
return cpt_arr[0].color; // arror : missing field "color"
}

直觉上来讲好像没什么问题:ColorPoint 类作为 Point 类的子类,其类型同样也属于 Point` 类的子类型 (注意类 class 与类型 type 的区别) 将 ColorPoint类型传入期待Point类型的函数m1``,理应是子类型规则 (subtyping rules) 的正常操作

但当我们仔细分析时却会发现这段程序会出现 field missing 错误

这是因为,这里我们面对的是数组:cpt_arr 数组的类型是 ColorPoint[] 类型而非 ColorPoint,同样的,m1 函数的形参类型是 Point[] 类型而非 Point

也就是说,t1 <: t2 时,t1[] <: t2[] 是一个伪命题

实际上,数组 (Array) 是一种特殊的 Record:其所有域的域名是由 \(0\) 开始连续的一段整数 (即下标 index),其所有域域值的类型相同

例如一个长度为 \(n\) 的 Point 数组的类型即为{0:Point, 1:Point, ..., n-1:Point},而相同长度的 ColorPoint 数组的类型为 {0:ColorPoint, 1:ColorPoint, ..., n-1:ColorPoint}

这样看就能很清楚的知道,Point[] 类型与 ColorPoint[] 类型并不符合上文所提到的子类型关系

事实上,Point[]ColorPoint[] 间的关系可以称作 "深子类型" (depth subtyping) 的一种变体 (当数组长度 \(n \neq 1\) 时完全符合深类型的定义)

正常的子类型关系被称作 "宽子类型" (width subtyping)

深子类型的定义是 (注意,深子类型的定义与子类型 subtyping 规则是相悖的):如果 ta :< tb,那么 {f1:t1, f2:t2, ..., fn:ta} :< {f1:t1, f2:t2, ..., tn:tb}

例如 circle : {center:{x:real, y:real}, rad} :< sphere : {center{x:real, y:real, z:real}, rad} 就是一个深子类型的例子

回到上面那一段 C 程序,我们可以发现,将 ColorPoint[] 类型传入形参类型为 Point[] 的操作并不是全无意义的

如果我们的 m1 函数并不涉及对数组的修改操作,而只涉及访问操作的话 (由于形参类型为 Point[],所以函数内只会访问 xy 域,而这两个域都是 ColorPoint 类型所拥有的),程序便不会出现错误

这就指向了深子类型的成立条件:以下三个条件中,唯有两个能够同时成立

可对域进行修改 (setting a field)
允许深子类型成立(letting depth subtyping change the type of a field)
type system 能够防止 field missing 错误的发生

那么 Java 与 C# 是如何处理这个问题的呢?

它们将目光放到了这一语句上 pt_arr[0] = new(3, 4);

运行时,若检查到 (run-time checks) pt_arr 数组实际上的类型是 ColorPoint[],便会抛出 ArrayStoreException 错误

运行检查遵循这个不变的原则 (invariant): ColorPoint[] 类型的数组只能存储 ColorPoint 类型或其子类型的数据,而不能存储其超类型的数据

也就是说,Java 允许了对域进行修改 (条件一),允许深子类型成立 (条件二),那么自然,Java 的 type system 是无法防止 field missing 错误发生的 (条件三)

为了弥补 type system 的缺漏,Java 加入了运行时检查 (run-time checks) 来确保上面的原则 (invariant) 被始终遵守

一般来说,运行时检查意味着 type system 能静态检查到的错误变少了,还要算上运行时检查付出的设计与时间成本

那么 Java 为什么要这样设计呢?

这是为了语言的灵活性 (flexibility) 做出的的让步:例如,若我们为 Point[] 类型数组定义了一个排序函数,那么如果允许深子类型的合法性,这个函数同样也可以作用于 ColorPoint[] 函数

于是,Java 与 C# 以不完善的 type system 与额外的运行时检查作为代价,使这类型的操作得以实现

在其他的语言中存在更好的解决方法,如

结合泛型 (generics) 与子类型 (subtyping),即限定多态 (bounded polymorphism)

或有判断函数是否会更改数组元素的机制 (如果函数自始至终只是访问而不修改数组中的元素,那么深子类型是可靠 sound 的,前文有提到)

Functional Subtyping

我们知道,函数也有类型,那么函数类型之间的子类型 (subtyping) 的具体关系又是什么样的呢

了解这一点能够帮助我们认识到在 OOP 语言中如何正确的重写 (override) 某个方法

当我们提到函数的子类型时,意思是某一种类型的函数可以完全替换另一种类型的函数

举例来说,函数 f 的形参是一个类型为 t1->t2 的函数 g,那么我们可不可以传入一个类型为 t3->t4的函数 h 作为替代?如果可以的话,那么 h 的类型就是 g 类型的子类型。这样,t1, t2, t3, t4 四者之间又有什么关系?

我们以下面这个计算高阶函数 (higher-order function) 作为例子,它计算某个二维点 p 与某个函数 f 作用于该点后产生的新点 p2 的距离

fun distMoved (f : {x:real, y:real}->{x:real, y:real}, p : {x:real, y:real}) =
let val p2 : {x:real, y:real} = f p
val dx : real = p2.x - p.x
val dy : real = p2.y - p.y
in Math.sqrt(dx*dx + dy*dy) end fun flip p = {x=~p.x, y=~p.y}
val d = distMoved(flip, {x=3.0, y=4.0})

distMoved 函数的类型是 (({x:real, y:real}->{x:real, y:real})*{x:real, y:real}) -> real

在这里,我们需要研究的是,有哪些类型非 {x:real, y:real}->{x:real, y:real} 的函数可以作为参数 distMoved

首先是 flipGreen 函数:

fun flipGreen p = {x=~p.x, y=~p.y, color="green"}

这个函数的类型是 {x:real, y:real}->{x:real, y:real, color:string}

将这个函数传入 distMoved 不会有任何问题:因为其返回值虽然加入了域 color, 但仍然有域 x 与域 y

总结一下,ta :< tb,则 t->ta :< t->tb成立,即某个函数的子类型的返回值的类型可以是该函数返回值类型的子类型 (还是看符号标记吧)

这里我们介绍一个术语 (jargon): covariant (共变的),意即函数返回值的子类型关系与函数本身的子类型关系的共变行为

现在我们再来研究另一个例子,这个例子中,函数返回值的类型相同,而参数却存在子类型关系

我们会看到,函数参数的子类型关系与函数本身的子类型关系是 不共变 (NOT covariant)

fun flipIfGreen p = if p.color = "green"
then {x=~p.x, y=~p.y}
else {x=p.x, y=p.y}
val d = distMoved(flipIfGreen, {x=3.0, y=4.0})

这个函数的类型是 {x:real, y:real, color:string} -> {x:real, y:real}

然而将这个函数传入 distMoved 函数将会出现错误:因为在 distMoved 函数中的 p 没有 color 域,所以 flipIfGreen 中的 if 语句将会出错

总结一下,ta :< tb,则 ta->t :< tb->t 不成立。本质上,它是用需要更多参数(color, x, y)的函数代替了需要更少参数的函数 (x, y)

那如果反过来,我们用更少参数(x)的函数替换更多参数(x, y)的函数呢?研究一下这个函数

fun flipX_Y0 p = {x=~p.x, y=0}
val d = distMoved(flipX_Y0, {x=3.0, y=4.0})

这个函数的类型是 {x:real} -> {x:real, y:real}

可以发现,这个函数传入 distMoved 也不会出现问题,因为 distMoved 向该函数中传入的参数始终都有 x 域与 y 域而这个函数甚至不需要有 y

总结一下,ta :< tb,则 tb->t :< ta->t 成立

这个现象的术语(jargon)叫做: contravariance (逆变的),意即函数参数的类型的子类型关系与其本身的子类型关系的逆变行为

结合上面这三个例子,我们可以看出函数子类型的成立条件:

函数的子类型关系与参数的子类型关系逆变,与返回值的子类型协变 (function subtyping can allow contravariance of arguments and covariance of results)

也就是说,若函数 \(a\) 的类型是函数 \(b\) 类型的子类型,那么函数 \(a\) 的参数类型是函数 \(b\) 参数类型的超类型,或(且) 函数 \(a\) 的返回值类型是函数 \(b\) 返回值类型的子类型

那么,最后总结一下:

t3 <: t1 (contravariance of arguments)且 t2 :< t4 (covariance of results),则 t1->t2 :< t3->t4 (function subtyping)

(注意子类型的反射性 reflexibility: t :< t,任何一个类型都是它本身的子类型)

Subtyping for OOP

有了上面的类 ML 虚拟语言对 subtyping 的探讨,接下来我们正式学习在 Java 或者 C# 这样的 OOP 语言中 subtyping 的机制。

一个对象可以看作是一个 Record:在这个 Record 中有各种域 (fields, 且是 mutable 的) 与方法 (methods,这也是 Record 与对象的不同之处之一)

对于对象中的方法,我们看作是 immutable 的:也就是,若对象中的某个方法 \(m\) 被某段代码实现,那么不存在任何方法使得 \(m\) 指向其他的代码。虽然子类(subclass)实例中 \(m\) 可以指向别的代码,但那并不是对域的 mutate

这样,对对象的可靠的子类型系统建立在对 records 与对函数的子类型系统上:

一个子类型中可以有新的域 (A subtype can have extra fields)
由于域是 mutable 的,所以子类型不能对域的类型进行修改 (Because fields are mutable, a subtype cannot have a different type for a field)
一个子类型中可以有新的方法 (A subtype can have extra methods)
由于方法是 immutable 的,所以子类型中可以有方法的子类型,也就是说子类型中的方法可以有共变的返回值与逆变的参数 (Because methods are immutable, a subtype can have a subtype for a method, which means the method in the subtype can have subtype for a method, which means the methods in the subtype can have contravariant argument types and a covariant result type)

这里我对规则 \(2\) 与规则 \(4\) 再次强调一下:由于我们探讨的是某个类型 \(A\) 与其子类型 \(B\) 中同名域/函数的类型关系,因此实际上就是在讨论深子类型 (depth subtyping)在这些域/函数中是否能成立

由于域是 mutable 的,根据深子类型的三选二法则(见上),我们选择放弃第二个条件(允许深子类型成立),这样第三个条件(type system 能够防止 field missing 错误发生)

而由于函数是 immutable 的,同样根据三选二法则,第一个条件已经不满足(可对域进行修改),那么相应的第二个条件与第三个条件自然得到满足。既然第二个条件得到满足 (即允许深子类型成立),那么自然可以将 \(B\) 中的函数 \(m\) 的类型设定为 \(A\) 中同名函数的子类型

在 Java 中,每一个类(class)与接口(interface)的类型名与其同名

例如类 Foo 的类型就即为 `Foo类型,并且Foo类型中包括了类Foo中定义中的所有域的类型与方法的类型 同样的,接口Bar的类型即为Bar类型,且Bar类型中包括了接口Bar`` 中定义的所有方法的类型

Java 与 C# 中的子类型 (subtyping) 关系仅仅会在明确声明的子类 (subclass) 关系某个类明确声明实现的接口 (interface)伴随出现

为了能够防止 field missingmethod missing 的错误并且贴合子类型的规则,子类 (subclass) 的规则比子类型 (subtyping) 更加严格 (restrictive),具体来说是

子类可以添加新的域但是不能移除已有的域 (A subclass can add fields but not remove them)
子类可以添加新的方法但是不能移除已有的方法 (A subclass can add methods but not remove them)
子类可以重写方法,并且重写的方法的返回值类型可以是原方法的返回值类型的子类型 (A subclass can override a method with a covariant return type)

(在 C++ 中重写方法的限制更加苛刻:参数表与返回值的类型均不能改变)
当某个类声明实现某个接口时,在实现某个方法时其返回值类型可以是接口声明的返回值类型的子类型 (A class can implement more methods than an interface requires or implement a required method with a covariant return type)

再次强调,类 (class)与类型 (type) 是两个不同的概念!!

一个类 (class) 定义了对象的行为,子类继承 (inherit) 父类的行为,并且通过延展 (extension)重写 (override)对继承来的行为进行修饰 (modify)
一个类型 (type) 是对对象的域以及其能够回应的消息的描述

由于在某些语言中,定义一个新的类就相当于引入了一个新的类型,这两个概念常常被混淆

Covariant self/this

在 OOP 语言中有一个不可忽视的存在,在对象中指向对象本身的"域":Java/C++ 中的 this、Ruby 中的 self 等等

self 指向的对象是所谓的当前对象,因此 self 的类型与当前对象的类型一致

下面我们来看看这个例子

class A {
int m() { return 0; } // In this method, the type of self is A
};
class B : A {
int m(int x) { return x; } // In this overrided method, the type of self is B
};

还记得我们用 Racket 实现 OOP 的时候吗? (用 FP 语言实现 OOP 中的对象以及 dynamic dispatch 机制)

我们将 self 作为一个明确的参数传入每个方法中 (功能上,self确实可以视为一个始终指向当前对象的隐藏参数,但在用 FP 语言实现时必须指明出来)

这样,上面的代码实际上将会变成这样

class A {
int m(A this) { return 0; } // In this method, the type of self is A
};
class B : A {
int x;
int m(B this) { return x; } // In this overrided method, the type of self is B
};

发现问题了吗?按照这个说法,我们的方法将会违反前面深子类型的相关规则

按理来讲,类 \(B\) 中的方法 \(m\) 的类型必须是类 \(A\) 中的方法的类型的子类型,这意味着两个方法定义中的参数类型应该是逆变(contravariant)的

然而,若我们将 this 作为参数添加进去,参数类型却不是逆变而是共变关系

其实,在 OOP 语言中,this 或者说 self 是被特殊处理的:它的类型与对象的类型是共变 (covariant) 的

也就是说,this 或者 self 与普通参数不同在于:对于普通参数,caller 可以随意传入符合类型的任何值,而对于 this 或者 self ,caller 只能传入当前对象本身

这就保证了传入的 this 或者说 self 参数的类型总是被调用方法所期望的类型的子类型 (如果 self 所调用方法的定义在其所属类的父类中,那么父类方法所期望的 self 的类型即是当前对象类型的超类型)

Generics Versus Subtyping

我们已经学习了子类型多态 (subtyping polymorphism) 与参数多态 (parametric polymorphism,即泛型 generics)

接下来我们来比较下这两种多态

What are generics good for?

先来看看泛型 (参数多态) 的两种最常见的应用

    高阶复合函数 compose
val compose : ('b -> 'c) * ('a -> 'b) -> ('a -> 'c)
    作用于某类型的集合/容器的函数
val length : 'a list -> int
val map : ('a -> 'b) * 'a list -> 'b list
val swap : ('a * 'b) -> ('b * 'a)

可以发现这些应用都具有这样一个特点:如果没有了泛型,代码的复用率将会大大降低

例如,如果没有泛型,对于每一对不同类型的 pair 都需要重新编写一个 swap 函数

Subtyping is a bad substitute for generics

如果某个语言不支持多态,那么程序员有时会用子类型 subtyping 来代替

然而(哈哈哈哈)

Doing so is like painting with a hammer instead of a paintbrush

技术上可行,但是很明显十分蹩脚

看看下面这个 Java 例子

class LamePair {
Object x;
Object y;
LamePair(Object _x, Object _y) { x = _x, y = _y; }
LamePair swap() { return new LamePair(y, x); }
...
} String s = (String)(new LamePair("hi", 4).y); // error caught only at run-time

可以看到,为了实现泛型,pair 中的两个域都是 Object 类

由于任何类都是 Object 类的子类,所以任意类的对象都可以作为 pair 中域的值

但是在从 pair 中提取数据时将会出现问题:我们所提取的数据总是一个 Object 类,而不能准确的知道数据的类型

为了准确的获取数据的类型,我们只能借助 downcast 操作(即强制转换并进行评估例如 (String)e

downcast 一是借助的运行时检查 (run-time checks),不能有效的对 type system 进行利用

二是可能导致运行的错误。如上面程序中的最后一句,只有在运行时才会查出错误

总的来说,尝试用子类代替泛型,最后都将走向 dynamic typing 的方法

由于任何对象都可以储存在 Object 类的域中,所以只能依靠程序员进行动态检查,type system 无法获取数据实际的类型

What is subtyping good for?

那么子类型的优势区间在哪里呢?当对有额外信息的数据进行复用时,采用子类型是一个很好的选择

在 ML (不支持子类型) 中,这样的代码将不会 type-checked

fun distToOrigin1 {x=x, y=y} =
Math.sqrt(x*x + y*y)
val five = distToOrigin1 {x=3.0, y=4.0, color="red"}

而在支持 subtyping 的语言中,distToOrigin1 函数既能作用于 Color 类的对象,也能作用于 ColorPoint 类的对象

在图形用户界面 (graphical user interface) 的编写中,子类型的使用非常频繁

Bounded Polymorphism

既然参数多态与子类型多态都有其各自的优势区间,Java 与 C# 等 OOP 语言选择同时实现这两个机制

同时实现这两个机制会带来一些"并发症"(complication)如更难定义的静态重载与子类型,在这里不详细讨论

我们重点探讨这将会带来的好处:除了这两个多态机制各自的优势区间,这两种多态机制结合起来将能使代码的复用性 (reusage) 与表达性 (expressiveness) 更进一步

核心的 idea 被称为 bounded generic types(有界泛类型),将子类型多态的 "T 的子类型" 与泛型的 "泛用于所有类型的 'a" 结合,催生了有界泛类型的 idea:"泛用于所有类型的 'a 类型是 T 类型的子类型"

下面我们来看看这个 Java 的例子

class Pt {
double x, y;
double distance(Pt pt) { return Math.sqrt((x-pt.x)*(x-pt.x)+(y-pt.y)*(y-pt.y)); }
Pt(double _x, double _y) { x = _x, y = _y; }
}

接下来是一个静态的函数,形参有 pts(一个存储 point 类元素的 list),center(point 类,代表圆心),radius(double 类,代表半径)

该函数会返回一个新的 list,这个 list 将会存储 pts 中所有在圆内的点

static List<Pt> inCircle(List<Pt> pts, Pt center, double radius) {
List<Pt> result = new ArrayList<Pt>();
for(Pt pt : pts)
if (pt.distance(center) <= radius)
result.add(pt);
return result;
}

这个函数对 List<Pt> 类型的 list 十分有效

如果 ColorPt类型 是 Pt类型的子类型 (ColorPt 类继承了 Pt 类,并添加了 color 域与一些相关的方法)

我们能不能将 List<ColorPt> 类型的 list 传入该函数,实现函数的复用呢?

首先,由于 List 的域是 mutable 的,所以深子类型不满足,List<ColorPt> 类型并不能看作是 List<Pt> 类型的子类型

就算可以传入 List<ColorPt> 类型的 list,我们所期待的函数返回的 list 也应该是 List<ColorPt> 类型

(注意,虽然上面的代码若传入 List<ColorPt> 类型的 list 后,通过 downcast 返回的也将是 List<ColorPt>类型的 list,但是我们需要找到一个方法使其能在 type system 中表达出来)

Java 的 bounded polymorphism 可以满足我们的要求

static <T extends Pt> List<T> inCircle(List<T> pts, Pt center, double radius) {
List<T> result = new ArrayList<T>();
for (T pt : pts)
if (pt.distance(center) <= radius)
result.add(pt);
return result;
}

在这个方法里,类型 T 是一个泛型,但同时该泛型又是 Pt 类型的子类型

这样在函数体内对对象 distance 方法的调用一定是有效的。Wonderful!

Coursera Programming Languages, Part C 华盛顿大学 Week 3的相关教程结束。

《Coursera Programming Languages, Part C 华盛顿大学 Week 3.doc》

下载本文的Word格式文档,以方便收藏与打印。