Reading 12: 接口、泛型与枚举(Interfaces, Generics, Enums)
Reading 12: 接口、泛型与枚举(Interfaces, Generics, Enums)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。凡课程原文未涉及、为回答本讲延伸问题而补充的 Java 生态知识(
EnumSet/EnumMap、类型擦除与通配符细节、抽象类取舍),均单独放在标题中写明「补充说明(超出 6.031 原文的 Java 生态知识)」的小节里;原文中的 ADT 例子一律保留真实类名(MyString/SimpleMyString/FastMyString、Curve/ArrayCurve、ImmutableRectangle/ImmutableSquare/MutableRectangle/MutableSquare、Set<E>/SimpleSet<E>/CharSet、Month/Semester)。
概述
本讲回答的问题是:既然我们在 Reading 10(抽象数据类型)与 Reading 11(抽象函数与表示不变量)里已经学会「用一个类定义 ADT」,那么还有哪些语言机制可以定义 ADT?原文给出四条路:接口(interface)把 ADT 的规格与实现彻底分离,泛型类型参数(generic type parameter)让同一个 ADT 定义服务于一整族元素类型,枚举(enumeration)描述取值有限且不可变的小型 ADT,而全局函数 + 不透明类型(在 C 一类非面向对象语言里常见,原文用 FILE/fopen/fputs/fclose 举例)说明数据抽象本身并不依赖 class/interface 这些语言特性。贯穿全讲的核心原则是「面向接口编程」:客户端只依赖接口所声明的规格,实现类独占表示(rep)与抽象函数(AF)/表示不变量(RI),而子类型(subtyping)关系必须由规格的强弱决定——implements 关键字只保证签名兼容,绝不保证规格没有被削弱。
这直接服务于三大目标:Safe from bugs——接口让编译器替我们检查「是否实现了全部操作、签名是否正确、客户端是否只使用了规格允许的方法」,枚举让编译器拒绝取值集合之外的值、也拒绝把两个不同的枚举类型混用;Easy to understand——接口是客户端唯一需要读的文件,规格集中在接口里,不带任何实现细节(原文的 FastMyString 代码之所以难读,正是因为它把 ADT 层规格与实现细节混在一起);Ready for change——新增一个实现类即可替换整个表示,客户端代码一行不改,而泛型让同一份实现服务于 Set<String>、Set<Integer> 等一整族类型。
核心概念与设计原则详解
接口与实现分离(Interface / Implementation Separation)
- 定义与目的:接口是一串没有方法体的方法签名,它只表达「这个 ADT 有哪些操作、这些操作的契约是什么」;实现类在
implements子句里声明接口,并为接口中每个方法提供方法体。这样做的目的是建立一道抽象屏障(abstraction barrier):Java 接口里连实例字段都不能出现,客户端根本无从依赖表示(rep),因此不可能产生「意外的表示依赖」这一类最难修的 bug。 - 直观解释(”它是什么?”):接口像餐厅的菜单,实现类像后厨。菜单上写的是「你能点什么、会得到什么」,而不是「厨师用哪口锅、先把哪个调料下锅」。原文的
MyString就是菜单:length()、charAt(i)、substring(start, end);SimpleMyString与FastMyString是两个后厨,做法完全不同却端出同样的菜。 - 关键规则与最佳实践:
- 接口只放公开方法签名与规格(Javadoc),不放任何实例字段,也不放方法体(Java 8 之前;Java 8 起有了
default与static两个例外,见下一个概念)。 - 接口不能被
new:原文明确指出new List<String>()是静态错误,因为接口没有 rep;要得到List,必须实例化某个提供 rep 的类,例如new ArrayList<String>()。 - 接口不能声明构造器,所以「创建者操作(creator)」要么由实现类的构造器承担,要么由接口的静态工厂方法承担(
List.of()、MyString.valueOf(boolean))。 - 实现接口的类必须提供接口里全部方法的方法体(否则是编译错误),并且规格至少与接口一样强;用
@Override标注,它既让编译器检查签名匹配,又告诉读者「规格在接口里,不必在这里重复」(重复写规格违反 DRY)。 - 同一个 ADT 可以有多个实现并存于同一个程序:在 Reading 10 的
MyString例子里,两种表示法无法同时存在于一个程序中;改成「接口 +SimpleMyString/FastMyString两个实现类」后就可以。 - 编译器能替我们抓「少写方法」「返回类型写错」这类错误,但抓不到「后置条件被削弱」「前置条件被加强」这类规格层面的违约——原文强调那必须由人审。
- 原文的
Curve/ArrayCurve例子暴露了一个反向陷阱:把实现类名写进接口(如让Curve的join返回ArrayCurve)既造成循环依赖,又不是 representation-independent;同时ArrayCurve若少实现contains()就会直接编译失败,这说明签名层面的检查确实有效。
- 接口只放公开方法签名与规格(Javadoc),不放任何实例字段,也不放方法体(Java 8 之前;Java 8 起有了
/**
* MyString represents an immutable sequence of characters.
*/
public interface MyString {
// We'll skip this creator operation for now
// /**
// * @param b a boolean value
// * @return string representation of b, either "true" or "false"
// */
// public static MyString valueOf(boolean b) { ... }
/**
* @return number of characters in this string
*/
public int length();
/**
* @param i character position (requires 0 <= i < string length)
* @return character at position i
*/
public char charAt(int i);
/**
* Get the substring between start (inclusive) and end (exclusive).
* @param start starting index
* @param end ending index. Requires 0 <= start <= end <= string length.
* @return string consisting of charAt(start)...charAt(end-1)
*/
public MyString substring(int start, int end);
}
两个实现类提供完全不同的表示,却满足同一个接口规格——这正是接口的价值所在(代码取自 sp21 原文):
class SimpleMyString implements MyString { // 也可以放在独立的 SimpleMyString.java 中
private char[] a;
/**
* Create a string representation of b, either "true" or "false".
* @param b a boolean value
*/
public SimpleMyString(boolean b) {
a = b ? new char[] { 't', 'r', 'u', 'e' }
: new char[] { 'f', 'a', 'l', 's', 'e' };
}
// private constructor, used internally by producer operations
private SimpleMyString(char[] a) {
this.a = a;
}
@Override public int length() { return a.length; }
@Override public char charAt(int i) { return a[i]; }
@Override public MyString substring(int start, int end) {
char[] subArray = new char[end - start];
System.arraycopy(this.a, start, subArray, 0, end - start);
return new SimpleMyString(subArray);
}
}
class FastMyString implements MyString { // 原定义为 public class FastMyString implements MyString
private char[] a;
private int start;
private int end;
/**
* Create a string representation of b, either "true" or "false".
* @param b a boolean value
*/
public FastMyString(boolean b) {
a = b ? new char[] { 't', 'r', 'u', 'e' }
: new char[] { 'f', 'a', 'l', 's', 'e' };
start = 0;
end = a.length;
}
// private constructor, used internally by producer operations.
private FastMyString(char[] a, int start, int end) {
this.a = a;
this.start = start;
this.end = end;
}
@Override public int length() { return end - start; }
@Override public char charAt(int i) { return a[start + i]; }
@Override public MyString substring(int start, int end) {
return new FastMyString(this.a, this.start + start, this.start + end);
}
}
FastMyString 的表示是「整串字符 + start/end 两个下标」——于是 true 这个值的 rep 是 char[]{'t','r','u','e'} 加上 start=0、end=4,而 substring 只改两个整数、连字符数组都不复制,这就是「同一 ADT 的两种实现可以有不同的性能特征」。注意原文的 rep 字段写成 private char[] a; private int start; private int end;(没有 final),而原文紧接着的 code review 练习正是追问「rep 字段是否应该声明为 final 使其不能被重新赋值」——答案是应当(Reading 08 不可变性与安全从表示暴露的要求)。
原文在讨论「隐藏实现类」时还用到 Curve/ArrayCurve:
/** Represents an immutable curve in the plane. */
public interface Curve {
/** @return true if the point (x,y) lies on this curve */
public boolean contains(double x, double y);
/**
* @return a curve formed by connecting this with that
* 注意:返回类型必须是 Curve,不能写成 ArrayCurve,否则接口就不再 representation-independent。
*/
public Curve join(Curve that);
}
/** Implementation of Curve. */
class ArrayCurve implements Curve { // 也可以是独立的 ArrayCurve.java
/** make a one-point curve */
public ArrayCurve(double x, double y) { /* ... */ }
@Override public boolean contains(double x, double y) { /* ... */ return false; }
@Override public Curve join(Curve that) { /* ... */ return this; }
/** extend this curve with a segment to the point (x,y) */
public void add(double x, double y) { /* ... */ }
}
Java 接口的语法与规则(Rules of Java Interfaces:default 与 static 方法)
- 定义与目的:Java 接口默认只有签名;Java 8 引入了两个例外——
default实例方法(带方法体,实现类可以继承它,也可以覆盖它)与static方法(属于接口自身,通过接口名调用)。default方法的目的是「在不破坏已有实现类的前提下给接口增加新操作」;static方法的目的是「把创建者操作(工厂方法)放进接口,从而隐藏实现类的名字」。 - 直观解释(”它是什么?”):接口本来是纯粹的「合同」,
default方法相当于在合同里附上一段「标准做法」:不想自己写的人可以直接沿用,想自己写的人可以覆盖。static方法则像菜单背面的「订餐电话」——它属于菜单,而不属于任何一道菜,所以MyString.valueOf(true)与List.of("glorp")都不需要任何实例。 - 关键规则与最佳实践:
- 接口不能有实例字段:Java 接口里声明的字段自动成为
public static final常量,所以「在接口里放 rep」这件事在语法上就被禁止了——这是接口天然无表示泄漏的根据。 - 接口不能有构造器,因此创建者操作只能来自实现类构造器(
ArrayList()、LinkedList())或接口的静态方法(List.of()、MyString.valueOf(boolean))。 default方法会带实现,因此它必须被当作规格的一部分来谨慎设计:一旦发布就很难撤回;只在确实希望所有实现共享同一行为时才使用。JDK 自身大量使用这一机制,例如Collection.removeIf与List.replaceAll都是default方法。- 静态方法不能被实现类「继承后覆盖」,必须通过接口名调用:
MyString.valueOf(true),而不是new FastMyString(true)。 - 一个类可以
implements多个接口(原文的动机例子:一个下拉列表控件既是 widget 又是 list),但只能extends一个类。 - 接口继承接口用
extends,且只能加强规格或新增操作:原文的真实 JDK 签名是interface SortedSet<E> extends Set<E>,它不提供任何Set方法的实现,只是加强规格(例如承诺元素有序)并新增操作。
- 接口不能有实例字段:Java 接口里声明的字段自动成为
import java.util.ArrayList;
import java.util.List;
class InterfaceMethodsDemo {
static void demo() {
// static 方法属于接口自身:List.of() 是 List 接口里的静态方法
List<String> names = List.of("glorp", "fleeb");
List<String> mutable = new ArrayList<>(names);
// default 方法:接口提供的共享实现,实现类无需自己写
mutable.removeIf(s -> s.startsWith("g")); // Collection 的 default 方法
mutable.replaceAll(String::toUpperCase); // List 的 default 方法
System.out.println(mutable); // [FLEEB]
// 静态错误:接口没有 rep,不能被 new(原文举的就是 new List<String>())
// List<String> bad = new List<String>();
}
}
抽象屏障与工厂方法(Abstraction Barrier and Factory Methods)
- 定义与目的:用接口定义 ADT 后,客户端仍可能写
MyString s = new FastMyString(true);——这行代码虽然类型正确,却打破了抽象屏障:客户端必须知道表示类的名字。而接口里从来没有承诺过「每个实现都提供同样的构造器」:SimpleMyString与FastMyString的构造器参数就完全不同,构造器的规格也不会出现在接口里。工厂方法解决这个问题。 - 直观解释(”它是什么?”):这像租房时只和中介签合同,而不是直接认识房东。房东可以换(实现类可以换),你的合同(客户端代码)不用重签。原文用一句话概括这个模式的价值:「客户端可以在不打破抽象屏障的前提下使用 ADT」。
- 关键规则与最佳实践:
- 优先让客户端调用接口上的工厂方法(
MyString.valueOf(..)),而不是实现类构造器。原文的MyString.valueOf就是接口里的静态方法:public static MyString valueOf(boolean b) { return new FastMyString(b); }。 - 静态方法必须自己声明类型参数:原文特别指出这是一个「晦涩的 Java 语法要求」——
make这样的静态方法要在签名开头独立声明类型参数,写成public static <F> Set<F> make();原文故意用F而不是E,就是为了强调「这是另一个类型参数」。实例方法则不需要这个额外的<...>,因为它们总是使用外围接口声明的public interface Set<E>中的那个E。 - 静态工厂的返回类型就是接口类型,因而可以把实现类完全藏起来:原文的练习要求给
Set加上自己的创建者操作empty(),正确签名是public static <E> Set<E> empty(),正确实现体是return new HashSet<>();(而不是return new Set<>();,后者是静态错误,也不是return this;,静态方法没有this)。 - 完全隐藏实现在工程上是一种取舍:有时客户端确实需要按性能挑实现——这正是 Java 库同时暴露
ArrayList与LinkedList的原因,因为它们的get()与插入操作性能不同。 - 也可以用静态工厂方法(如
List.of()、Collections.unmodifiableList())替代构造器来承担创建者/生产者角色;原始 ADT 概念总表中「静态方法」这一栏正是它们。
- 优先让客户端调用接口上的工厂方法(
/**
* 原文的 MyString 接口加上创建者操作:静态工厂方法。
* 客户端从此只看见 MyString,看不见 FastMyString。
*/
public interface MyString {
/**
* @param b a boolean value
* @return string representation of b, either "true" or "false"
*/
public static MyString valueOf(boolean b) {
return new FastMyString(b);
}
/** @return number of characters in this string */
public int length();
/**
* @param i character position (requires 0 <= i < string length)
* @return character at position i
*/
public char charAt(int i);
/**
* Get the substring between start (inclusive) and end (exclusive).
* @param start starting index
* @param end ending index. Requires 0 <= start <= end <= string length.
* @return string consisting of charAt(start)...charAt(end-1)
*/
public MyString substring(int start, int end);
}
class MyStringClient {
static void demo() {
MyString s = MyString.valueOf(true); // 不打破抽象屏障
System.out.println("The first character is: " + s.charAt(0)); // 't'
System.out.println("The whole string is: " + s.substring(0, s.length()));
}
}
子类型(Subtyping)与 Liskov 替换原则(Liskov Substitution Principle, LSP)
- 定义与目的:类型就是「一组值 + 一组操作」。子类型是超类型的子集:「B 是 A 的子类型」意思是「每一个 B 都是一个 A」,用规格的语言说就是「每一个 B 都满足 A 的规格」。因此 B 的规格必须至少与 A 一样强。LSP 就是这条要求的另一种说法:子类型的对象必须能够在任何期望超类型的地方替换超类型,而不破坏该超类型向客户端承诺的任何性质。
- 直观解释(”它是什么?”):把超类型想成一份「能力承诺书」:凡是被标注为
ImmutableRectangle的东西,都保证「调用getWidth()我会给你宽度」。子类型只有真的做到全部承诺,才有资格自称超类型。原文用「每个正方形都是矩形吗?」这个问题把这个判断变成可操作的练习:不可变时答案是「是」,可变时答案就变成了「不是」。 - 关键规则与最佳实践:
- 规格的强弱规则:子类型可以加强后置条件、可以削弱前置条件(对调用者更宽松)、可以抛出更具体的异常;不可以加强前置条件、削弱后置条件,也不可以「规格不可比」。
implements(类实现接口,如真实 JDK 签名class ArrayList<E> implements List<E>)与extends(接口继承接口,如interface SortedSet<E> extends Set<E>)都建立子类型关系;接口extends接口只能加强规格或新增操作。- 编译器只检查签名兼容(是否实现了所有方法、返回类型是否匹配),不检查规格的强弱——「后置条件是否被削弱」必须由人审。
ImmutableSquare implements ImmutableRectangle是合法子类型:getWidth()与getHeight()都完整满足接口规格,整个ImmutableSquare也满足ImmutableRectangle规格。MutableSquare implements MutableRectangle却不合法:MutableRectangle.setSize(width, height)承诺任意宽高都可用,而正方形必须保持宽高相等,于是MutableSquare只能加强前置条件(要求width == height)或削弱后置条件(非正方形时行为未指定)——两者都违反「子类型规格至少一样强」。- 运行时强制类型转换(
(List<String>) obj)与instanceof分派是危险信号:它们通常意味着你在用运行时判断补救一个本该由规格层次表达的差别——即设计上「不是真正的子类型」。 - 静态类型 vs 动态类型要分清:原文的
List<String> list = new ArrayList<>(List.of("abc")); Object obj = list;中,list的静态类型是List<String>、动态类型是ArrayList;obj的静态类型是Object,动态类型仍是ArrayList。因此obj.size()是静态错误(Object没有size),而obj.toString()通过动态分派返回"abc";list = obj;是静态错误,list = (List<String>) obj;才合法(且obj = "abc";合法、list = "abc";是静态错误)。
/** An immutable rectangle. */
public interface ImmutableRectangle {
/** @return the width of this rectangle */
public int getWidth();
/** @return the height of this rectangle */
public int getHeight();
}
/** An immutable square: 每个正方形都真的是矩形,规格被完整满足。 */
class ImmutableSquare implements ImmutableRectangle {
private final int side;
/** Make a new side x side square. */
public ImmutableSquare(int side) { this.side = side; }
/** @return the width of this square */
@Override public int getWidth() { return side; }
/** @return the height of this square */
@Override public int getHeight() { return side; }
}
可变版本则相反。原文给出的三种候选 setSize 规格,只有「加强后置条件」那一类才合法:
/** A mutable rectangle. */
public interface MutableRectangle {
public int getWidth();
public int getHeight();
/** Set this rectangle's dimensions to width x height. */
public void setSize(int width, int height);
}
// A mutable square. 原文写作:implements MutableRectangle /* hopefully? */
class MutableSquare implements MutableRectangle {
private int side;
public MutableSquare(int side) { this.side = side; }
@Override public int getWidth() { return side; }
@Override public int getHeight() { return side; }
/**
* Set this square's dimensions to width x height.
* Requires width = height. // ← 违法:加强了前置条件
*/
@Override public void setSize(int width, int height) {
if (width != height) {
throw new IllegalArgumentException("width != height");
}
this.side = width;
}
}
原文对三种候选规格的判断结论如下,读者应当能独立复现这套推理:
Requires width = height.→ 加强了前置条件,不合法。@throws BadSizeException if width != height→ 同样是加强了前置条件(把原本合法的调用变成异常),不合法。- 「若
width = height则设为width x height;否则新尺寸未指定」→ 削弱了后置条件(对客户端的保证变少),不合法。 - 只有真正的加强后置条件(例如「保证设置成功后
getWidth() == width」这类更强的保证)才是合法的强化。
子类化(Subclassing)与动态分派(Dynamic Dispatch)
- 定义与目的:子类化(
class B extends A)定义一个类作为另一个类的扩展:子类自动继承父类的实例方法与方法体、可以覆盖它们、同时继承父类的 rep,并能新增自己的方法与字段。它与「实现接口」的关键差别就在于继承方法体与 rep。动态分派(dynamic dispatch)是 Java 决定「调用哪一份实现」的规则:看对象的动态类型,而不是引用的静态类型。 - 直观解释(”它是什么?”):实现接口像「签合同」——只继承义务,不继承家产;子类化像「继承家业」——你不仅拿到义务,还拿到房子和家具的摆放方式(rep),于是你的装修就会影响所有继承人。动态分派则像「按实际来的人提供服务」:无论你手里拿的是谁的会员卡(静态类型),服务都按你本人(动态类型)来。
- 关键规则与最佳实践:
- 子类化应当蕴含子类型:若
SpottedTurtle是Turtle的子类(Python 的class SpottedTurtle(Turtle),Java 对应class SpottedTurtle extends Turtle),它的规格也必须至少与Turtle一样强,因为 Java 允许把子类对象用在任何期待父类的地方。 - 继承 rep 带来三个风险:父类与所有子类之间的表示暴露(rep exposure)、父类与子类之间的表示依赖(rep dependence),以及父类与子类互相破坏对方的表示不变量。设计一个可安全子类化的父类,意味着它必须同时提供两份契约:一份给客户端,一份给子类——这些麻烦在接口上根本不会出现。
- 每个类都自动是
Object的子类,因而继承了toString()、equals()、hashCode();调试时常常需要覆盖toString()——FastMyString的默认输出是"FastMyString@504bae78"(只有类名与内存地址,毫无用处)。 - 覆盖方法时总是写
@Override:编译器会检查签名是否真的与父类/接口匹配,读者也知道规格在哪里。 - 记住:引用类型不影响分派结果——
Object obj = new FastMyString(true); obj.toString()得到"true",而不是Object的默认实现。
- 子类化应当蕴含子类型:若
public class FastMyString implements MyString {
// rep 与构造器见上文
@Override
public String toString() {
String s = "";
for (int i = 0; i < this.length(); ++i) {
s += this.charAt(i);
}
return s;
}
}
class DispatchDemo {
static void demo() {
FastMyString fms = new FastMyString(true); // 这个值代表 4 字符串 "true"
System.out.println(fms.toString()); // "true"
Object obj = new FastMyString(true);
System.out.println(obj.toString()); // 动态分派:仍然是 "true"
// System.out.println(obj.length()); // 静态错误:Object 没有 length()
}
}
泛型类型参数(Generic Type Parameters)
- 定义与目的:泛型类型是「规格里含有一个占位类型、稍后再填入」的类型。
Set<E>就是「某个其他类型 E 的有限集合」这一整族 ADT 的规格,而不必为Set<String>、Set<Integer>各写一份。它同时解决两个问题:代码复用(一份实现服务所有元素类型)与静态类型安全(Set<String>里塞不进整数,取出元素也不需要强转)。原文还指出List<E>是泛型接口、HashMap<K, V>是泛型类,而String<E>在 Java API 中根本不存在。 - 直观解释(”它是什么?”):泛型像带空格的标签模板:「这是装 ___ 的盒子」。你可以印出「装字符串的盒子」「装整数的盒子」,但每种盒子的标签一旦填好,装错东西就会被编译器当场抓住。原文用
Set<E>演示了这一点:Set<String> strings = Set.make();之后,编译器就知道这是一个字符串集合。 - 关键规则与最佳实践:
- 泛型接口的写法是
public interface Set<E>;泛型实现类保留占位符:public class HashSet<E> implements Set<E>(这就是 JDK 的做法);非泛型实现类把它换成具体类型:public class CharSet implements Set<Character>。 - 静态方法必须自己声明类型参数:原文的
public static <F> Set<F> make()故意用F而不是E,以强调这是另一个类型参数;实例方法不需要额外声明,因为它们总是用外围接口的E。 - 泛型实现只能依赖接口规格里显式承诺的性质:
HashSet<E>可以依赖E是Object(因而有hashCode与equals,这正是它做哈希所必需的),但不能调用只在String上才有的方法,因为E可能是任何类型。 - 非泛型实现(如用
String做 rep 的CharSet)通常不适合表示任意元素类型的集合:一个Stringrep 无法表示Set<Integer>,除非重新设计 RI 与 AF 来应付多位数——这正说明「换一个 rep 就要换一套 AF/RI」。 - 规格必须停留在抽象层:
contains(E e)、add(E e)的 Javadoc 说的是「元素」这一抽象概念,绝不能提「数组的哪个下标」之类表示细节;这些规格应当适用于SetADT 的任何合法实现。 - 接口的操作可以故意欠定(underdetermined),实现可以加强后置条件但不能削弱:原文的
Set.pick()只承诺「返回集合中的某个元素,空集时抛NoSuchElementException」,于是SimpleSet可以说「返回最近加入且尚未移除的元素」甚至「返回最小元素」;但把@return写成「返回elementList末尾的元素」就暴露了 rep、也不再 representation-independent。 - 泛型也让「同一份代码服务一整族类型」成为可能:原文的练习要求把
IntervalSet(问题集 2 的接口)泛型化,此时原先写new RepMapIntervalSet()的地方要改成new RepMapIntervalSet<String>(),也可以直接用菱形写法<>省掉重复的类型参数。
- 泛型接口的写法是
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
/**
* A mutable set.
* @param <E> type of elements in the set
*/
public interface Set<E> {
// example creator operation
/**
* Make an empty set.
* @param <F> type of elements in the set
* @return a new set instance, initially empty
*/
public static <F> Set<F> make() {
return new SimpleSet<F>();
}
// example observer operations
/**
* Get size of the set.
* @return the number of elements in this set
*/
public int size();
/**
* Test for membership.
* @param e an element
* @return true iff this set contains e
*/
public boolean contains(E e);
// example mutator operations
/**
* Modifies this set by adding e to the set.
* @param e element to add
*/
public void add(E e);
/**
* Modifies this set by removing e, if found.
* If e is not found in the set, has no effect.
* @param e element to remove
*/
public void remove(E e);
}
泛型接口,泛型实现:下面是原文的 SimpleSet<E>,它把元素放在 List<E> 里,完全不关心 E 究竟是什么;SimpleSet.pick() 还演示了「实现加强接口的后置条件」:
/** A generic implementation that keeps its elements in a list. */
public class SimpleSet<E> implements Set<E> {
private final List<E> elementList = new ArrayList<>();
@Override public int size() { return elementList.size(); }
@Override public boolean contains(E e) { return elementList.contains(e); }
@Override public void add(E e) { if (!contains(e)) { elementList.add(e); } }
@Override public void remove(E e) { elementList.remove(e); }
/**
* Picks an element from the set.
* @return the element most recently added but not yet removed
* @throws NoSuchElementException if set is empty
*/
public E pick() throws NoSuchElementException {
if (elementList.isEmpty()) {
throw new NoSuchElementException("pick on empty set");
}
return elementList.get(elementList.size() - 1);
}
}
泛型接口,非泛型实现:原文用 CharSet 演示「把 E 换成具体类型」;注意它的 rep 是一个 String,因此只适合字符集合:
/** Represents a mutable set of characters; rep is a String.
* 原文只列出 contains/add 与 "// ...";这里补全 size/remove 使其完整可编译。 */
public class CharSet implements Set<Character> {
private String s = "";
@Override
public boolean contains(Character e) {
checkRep();
return s.indexOf(e) != -1;
}
@Override
public void add(Character e) {
if (!contains(e)) s += e;
checkRep();
}
@Override
public int size() { return s.length(); }
@Override
public void remove(Character e) {
int i = s.indexOf(e);
if (i != -1) s = s.substring(0, i) + s.substring(i + 1);
checkRep();
}
/** Rep invariant: s has no repeated characters. */
private void checkRep() {
for (int i = 0; i < s.length(); ++i) {
assert s.indexOf(s.charAt(i)) == i : "rep invariant violated: repeated character";
}
}
}
class SetDemo {
static void demo() {
Set<String> strings = Set.make(); // 编译器推断 Set<String>
strings.add("glorp");
strings.add("glorp");
System.out.println(strings.size()); // 1
// Set<Integer> numbers = Set.make(); // 同一份泛型实现服务另一种元素类型
// strings.add(42); // 静态错误:int 不能转换为 String
Set<Character> chars = new CharSet(); // 非泛型实现:E 已被替换为 Character
chars.add('a');
System.out.println(chars.contains('a')); // true
}
}
原文还给出 JDK 的真实签名作为对照:class ArrayList<E> implements List<E>(泛型类实现泛型接口)、interface SortedSet<E> extends Set<E>(泛型接口继承泛型接口)、class HashSet<E> implements Set<E>(泛型实现,其内部依赖每个元素正确实现 Object.equals 与 Object.hashCode)。
补充示例(该 ADT 名字与语义出自 Reading 15 相等性,不是 Reading 12 的原文示例):下面用 Map<E, Integer> 实现一个「多重集」,用来复习「泛型实现 + AF/RI + 静态工厂 + 不暴露 rep」的组合。Bag<E> 是 Reading 15 用来讨论可变类型相等性的 ADT,其原文操作是 add(e)/remove(e) 返回新的 Bag,本笔记为呼应讨论把它写成变异式接口(add/remove 返回 void):
import java.util.HashMap;
import java.util.Map;
/**
* 补充示例:A mutable bag (multiset),同一元素可以出现多次。
* @param <E> type of elements in the bag
*/
public interface Bag<E> {
/** @param e element to add; modifies this bag by adding one occurrence of e */
public void add(E e);
/** @param e element to remove; if e is not found, has no effect */
public void remove(E e);
/** @return the total number of occurrences of all elements in this bag */
public int size();
/** @param e an element @return true iff this bag contains at least one occurrence of e */
public boolean contains(E e);
}
/**
* Rep: counts maps each element to the number of times it occurs.
* Rep invariant: every value in counts is strictly greater than 0.
* Abstraction function: AF(counts) = 每个元素 e 出现 counts.get(e) 次的多重集。
* Safety from rep exposure: counts 是 private final,从不返回或别名给客户端。
*/
class MapBag<E> implements Bag<E> {
private final Map<E, Integer> counts = new HashMap<>();
@Override
public void add(E e) {
counts.put(e, counts.getOrDefault(e, 0) + 1);
checkRep();
}
@Override
public void remove(E e) {
Integer n = counts.get(e);
if (n == null) { return; }
if (n == 1) { counts.remove(e); } else { counts.put(e, n - 1); }
checkRep();
}
@Override
public int size() {
int total = 0;
for (int n : counts.values()) { total += n; }
return total;
}
@Override
public boolean contains(E e) { return counts.containsKey(e); }
private void checkRep() {
for (int n : counts.values()) {
assert n > 0 : "rep invariant violated: a count is not positive";
}
}
}
/** 静态工厂:客户端只看到接口,看不到 MapBag。 */
final class Bags {
private Bags() { } // 不可实例化的工具类
/** @param <F> type of elements in the bag @return a new, empty bag */
public static <F> Bag<F> empty() { return new MapBag<F>(); }
}
泛型的实现限制:类型擦除与通配符——补充说明(超出 6.031 原文的 Java 生态知识)
- 定义与目的:Java 的泛型是通过类型擦除(erasure)实现的:类型参数只在编译期存在,编译后
Set<E>变成Set、E变成Object(或它的上界),运行时看不到类型实参。这样做是为了与 Java 5 之前的旧代码保持二进制兼容,代价是若干语法限制。通配符? extends E/? super E是「协变/逆变」的写法,用于让方法参数或返回值接受「一族相关类型」,在保留静态类型安全的同时提高 API 的灵活性。 - 直观解释(”它是什么?”):擦除像请柬模板上的「+1 来宾」——印刷模板上只有「来宾」这一栏(
Object),具体是谁只在你填写时(编译期)被检查;到了现场(运行时)没人再核对那一栏。通配符则像收货规则:「凡是E的子类都收」。? extends E是只读视角(你只能从中取出E),? super E是只写视角(你只能往里放入E)。 - 关键规则与最佳实践:
- 不能创建泛型数组:
new E[10]是编译错误;要么用Object[]加显式转换(会产生 unchecked 警告),要么改用ArrayList<E>这样的泛型集合。 - 不能
new E()、不能对类型参数做instanceof(e instanceof E非法)、不能定义类型为E的static字段、不能按泛型实参重载(contains(List<String>)与contains(List<Integer>)擦除后签名冲突)。 - 由于擦除,
List<String>与List<Integer>的getClass()返回同一个类;把List<String>强转成List<Integer>不会在转换处抛异常,而是在之后取元素时才抛——这就是泛型时代仍不能随意强转的原因。 - 通配符的使用惯例(PECS:Producer Extends, Consumer Super):只从结构中读取元素时用
? extends E,只写入元素时用? super E。 - 擦除还解释了为什么某些 API 必须显式传入
Class对象(例如EnumMap需要Direction.class):运行时拿不到类型实参,只能靠一个显式的类对象来弥补。 - 这些限制不影响「接口 + 泛型定义 ADT」的建模能力:
Set<E>依然服务于一整族元素类型,只是实现内部要用集合或Object[]规避new E[]。
- 不能创建泛型数组:
/** 补充说明:擦除带来的常见限制。 */
class ErasureDemo<E> {
// 不能写 new E[10];只能用 Object[] 加转换,并且会得到 unchecked 警告
@SuppressWarnings("unchecked")
private E[] elements = (E[]) new Object[10];
private int n = 0;
/** @param e an element; requires n < elements.length */
void add(E e) { elements[n++] = e; }
/** @param index an index @return the element at index */
E get(int index) { return elements[index]; }
static void demo() {
ErasureDemo<String> a = new ErasureDemo<>();
ErasureDemo<Integer> b = new ErasureDemo<>();
// 运行时看不到类型实参:两者的类对象完全相同
System.out.println(a.getClass() == b.getClass()); // true
Object obj = a;
@SuppressWarnings("unchecked")
ErasureDemo<Integer> lie = (ErasureDemo<Integer>) obj; // 转换处不报错
// lie.add(42); // 之后才可能出现 ClassCastException
}
/** 补充说明:通配符——只读取元素的“生产者”视角,可接受 List<? extends Number>。 */
static double sumOf(java.util.List<? extends Number> numbers) {
double sum = 0;
for (Number x : numbers) { sum += x.doubleValue(); }
return sum;
}
}
sp22 原版 TypeScript 与 Java 在子类型判定方式上有一处根本差异,必须对照:TypeScript 的接口是结构化类型(structural typing),只要一个类提供了接口要求的所有操作,即使它从未写过 implements,编译器也认为它是该接口的子类型(sp22 原文还专门用「结构子类型化」一节讨论它);Java 是名义类型(nominal typing),必须在 implements/extends 子句里显式声明,否则编译器完全不认。
// sp22 原版 TypeScript 写法:结构子类型化(structural subtyping)
interface MyString {
length(): number;
charAt(i: number): string;
substring(start: number, end: number): MyString;
}
// 注意:这个类**没有**写 implements MyString
class AccidentalString {
private a: string;
public constructor(s: string) { this.a = s; }
public length(): number { return this.a.length; }
public charAt(i: number): string { return this.a.charAt(i); }
public substring(start: number, end: number): MyString {
return new AccidentalString(this.a.substring(start, end));
}
}
// 合法:TypeScript 只看结构,AccidentalString 是 MyString 的结构子类型
const s: MyString = new AccidentalString("good morning");
// Java 对应写法:名义子类型化(nominal subtyping)
// 必须显式声明 implements;少写这一句就是静态错误,而不是“结构兼容”。
public class AccidentalString implements MyString {
private final String a;
public AccidentalString(String s) { this.a = s; }
@Override public int length() { return a.length(); }
@Override public char charAt(int i) { return a.charAt(i); }
@Override public MyString substring(int start, int end) {
return new AccidentalString(a.substring(start, end));
}
}
结构化类型很方便,但会在类型安全上开一个洞:它允许 B 成为 A 的子类型,即使 B 的规格与 A 不兼容。sp22 原文用可变性举例——Array 是 ReadonlyArray 的结构子类型,所以可以写 const readonlyArr: ReadonlyArray<number> = [1, 2, 3]; 把数组当作不可变值使用(反方向 const arr: Array<number> = readonlyArr; 是静态错误,因为 ReadonlyArray 不提供变异操作);但如果同时保留了可变别名 arr,一句 arr.push(4) 就把 ReadonlyArray 的不可变性彻底破坏了——这说明 Array 只是结构上的子类型,不是真正的(规格意义上的)子类型。补充说明:Java 中没有结构子类型化,所以这一具体漏洞不会以同样形式出现,但有个高度相似的陷阱——Collections.unmodifiableList(list)(原文在集合接口那一节用过它)只返回一个不可修改的视图,原 list 一旦仍被任何别名持有并可改写,视图的内容就会跟着变;想真正不可变,应使用 List.of(...)/List.copyOf(...) 或丢弃全部可变别名(这与 Reading 08 不可变性的要求一致)。
枚举(Enumerations)
- 定义与目的:有些 ADT 只有很小、有限、不可变的一组取值:一年十二个月、一周七天、罗盘四个方向、线段端帽的 butt/round/square。把每个取值定义为命名常量,就叫枚举(enumeration)。Java 的
enum不只是「一堆常量」——它定义了一个新的类型名(和 class、interface 一样),并且是真正的类:可以有字段、构造器、方法,因而也能有自己的 rep、抽象函数与表示不变量。 - 直观解释(”它是什么?”):枚举像一副只有固定几张的牌。你可以说「方块 K」,但你不能自己印一张新牌(客户端没有可用构造器);也正因为只有一副牌、每张牌在内存里只有一个对象,判断两张牌是不是同一张,用「是不是同一张牌」(
==)就足够了。 - 关键规则与最佳实践:
public enum Month { JANUARY, FEBRUARY, MARCH, ..., DECEMBER };定义了类型名Month与一组命名值;这些值实质上是public static final常量,所以按全大写命名。原文的另一个例子是public enum Semester { IAP, SPRING, SUMMER, FALL };。- 枚举值天生不可变且唯一:客户端没有构造器可用,无法制造新实例;因此
==与equals()等价,而且==更 fail-fast——它在编译期检查两侧是同一个枚举类型,而equals()要到运行时才发现类型不同。 - 与之对照,如果
day用String表示,那么day == SATURDAY就非常不安全:==判断的是两个表达式是否引用内存中同一个对象,而两个内容相同的"Saturday"未必是同一个对象,所以对象比较必须用equals();枚举没有这个问题。 - 枚举可以用在
switch中(switch本来只接受整型、其包装类型与String,不接受其他对象),这让「按取值分派」写起来清晰又安全;原文的Month.startOfNextSemester()与switch (direction) { case NORTH: ... }都是实例。 - 枚举比
int常量有更多静态检查:Month firstMonth = MONDAY;是静态错误,因为MONDAY的类型是DayOfWeek而不是Month。 - 枚举变量可以为
null(它仍是对象引用),必须像其他对象类型一样防范空引用。 - 枚举可以有 rep、观察者与生产者:
private Month(int daysInMonth)私有构造器为每个常量初始化字段;此外还有一个自动的、不可见的ordinal字段(取值 0、1、…)。 - 自动提供的操作(由
Enum定义):ordinal()返回值在枚举中的下标(JANUARY.ordinal()是 0)、compareTo()按 ordinal 比较两个值、name()返回常量名字符串(JANUARY.name()是"JANUARY")、toString()与name()行为相同。补充说明:Java 语言规范还自动提供静态方法values()(按声明顺序返回全部取值)与valueOf(String)(按名字取常量,名字非法时抛IllegalArgumentException)。 - 枚举可以自成文件
Month.java,也可以作为另一个类型的从属声明:原文指出若某个Date类型需要它,写成Date内部的public enum Month,外部客户端用Date.Month引用。 - 原文的结论很直接:相比「特殊整数值」或「特殊字符串」这些老办法,枚举让代码更安全(静态检查拒绝集合外的值与混用类型)、更好懂(命名常量不神秘,命名类型比
int/String更能自我说明)、更易改(可以按枚举类型名搜索出所有使用点,IDE 还能自动重构)。
/** 一个真正的枚举类:有 rep、有观察者、有生产者(sp21 原文第 1540 行起)。 */
public enum Month {
// the values of the enumeration, written as calls to the private constructor below
JANUARY(31),
FEBRUARY(28),
MARCH(31),
APRIL(30),
MAY(31),
JUNE(30),
JULY(31),
AUGUST(31),
SEPTEMBER(30),
OCTOBER(31),
NOVEMBER(30),
DECEMBER(31);
// rep
private final int daysInMonth;
// enums also have an automatic, invisible rep field:
// private final int ordinal;
// which takes on values 0, 1, ... for each value in the enumeration.
// rep invariant:
// daysInMonth is the number of days in this month in a non-leap year
// abstraction function:
// AF(ordinal, daysInMonth) = the (ordinal+1)th month of the Gregorian calendar
// safety from rep exposure:
// all fields are private, final, and have immutable types
// Make a Month value. Not visible to clients, only used to initialize the constants above.
private Month(int daysInMonth) {
this.daysInMonth = daysInMonth;
}
/**
* @param isLeapYear true iff the year under consideration is a leap year
* @return number of days in this month in a normal year (if !isLeapYear)
* or leap year (if isLeapYear)
*/
public int getDaysInMonth(boolean isLeapYear) {
if (this == FEBRUARY && isLeapYear) {
return daysInMonth + 1;
} else {
return daysInMonth;
}
}
/**
* @return first month of the semester after this month
*/
public Month startOfNextSemester() {
switch (this) {
case JANUARY:
return FEBRUARY;
case FEBRUARY: // cases with no break or return
case MARCH: // fall through to the next case
case APRIL:
case MAY:
return JUNE;
case JUNE:
case JULY:
case AUGUST:
return SEPTEMBER;
case SEPTEMBER:
case OCTOBER:
case NOVEMBER:
case DECEMBER:
return JANUARY;
default:
throw new RuntimeException("can't get here");
}
}
}
/** 原文的另一个枚举:用于注册选学期。 */
enum Semester { IAP, SPRING, SUMMER, FALL }
/** 罗盘方向;原文用它演示 switch 与枚举值。 */
enum Direction { NORTH, SOUTH, EAST, WEST }
class EnumDemo {
/** @param day a day of the week @return true iff it is a weekend day */
static boolean isWeekend(java.time.DayOfWeek day) {
// 每个枚举值在内存中只有一个对象,所以 == 与 equals 等价,而且更 fail-fast
return day == java.time.DayOfWeek.SATURDAY || day == java.time.DayOfWeek.SUNDAY;
}
/** @param direction a compass point @return the animal you would meet there */
static String animalAt(Direction direction) {
switch (direction) {
case NORTH: return "polar bears";
case SOUTH: return "penguins";
case EAST: return "elephants";
case WEST: return "llamas";
default: throw new AssertionError("unreachable");
}
}
static void demo() {
System.out.println(Month.JANUARY.ordinal()); // 0
System.out.println(Month.JANUARY.name()); // "JANUARY"
System.out.println(Month.FEBRUARY.getDaysInMonth(true)); // 29
System.out.println(isWeekend(java.time.DayOfWeek.SATURDAY)); // true
System.out.println(animalAt(Direction.SOUTH)); // "penguins"
// Month firstMonth = java.time.DayOfWeek.MONDAY; // 静态错误:类型不同
}
}
原文还用「报名选学期」的例子比较了三种方案:直接传字符串字面量 startRegistrationFor("Fall", 2023)(客户端拼错 "FAll" 也得不到静态错误,不能 fail fast)、用 public static final String FALL = "Fall" 命名常量(常量可被重新赋值,也拦不住客户端传 "Autumn")、用 public enum Semester { IAP, SPRING, SUMMER, FALL }(客户端无法新增学期值,也无法把 Month.JANUARY 传进来——那是静态错误)。
EnumSet 与 EnumMap——补充说明(超出 6.031 原文的 Java 生态知识)
- 定义与目的:
EnumSet<E extends Enum<E>>与EnumMap<K extends Enum<K>, V>是 Java 标准库为枚举专门设计的集合与映射。6.031 原文完全没有提到它们——原文关于枚举只讲了enum本身(Month、Semester)以及它相比字符串/整数常量的优势。之所以值得补充,是因为枚举的取值既然只有固定几个、还自带ordinal编号,就没有必要为它们付出通用哈希容器的代价。 - 直观解释(”它是什么?”):
EnumSet内部是按 ordinal 索引的位向量(一个long可存 64 个取值),EnumMap内部是按 ordinal 索引的数组。这就像点名册上只有固定的 4 个名字:你只需要 4 个勾选框,而不是一个能写任何名字的大本子再去找哪一页写了谁。 - 关键规则与最佳实践:
- 集合用法:
EnumSet.of(a, b, ...)、EnumSet.allOf(Semester.class)、EnumSet.noneOf(Semester.class)、EnumSet.range(a, b)、EnumSet.complementOf(set);遍历顺序固定为枚举的声明顺序,测试与调试因此可复现。 - 映射用法:构造时必须传入枚举类型对象
new EnumMap<>(Direction.class)(类型擦除导致运行时无法从EnumMap<Direction, V>推断出Direction),键按 ordinal 顺序迭代,且不接受null键。 - 相对
HashSet<String>的优势:不必为每个取值保存字符串对象、不必计算字符串哈希、也不会被任意字符串污染或混入另一个枚举类型;EnumSet/EnumMap的类型安全由编译器保证。 - 相对
boolean[]的优势:EnumSet是一个真正的Set,带规格、带类型名,不会因为枚举声明顺序调整而静默错位。 - 性能优势不代表可以牺牲封装:把
EnumSet当字段时仍不要把它本身返回给客户端(那是表示暴露),应返回Collections.unmodifiableSet(...)或副本。 EnumSet/EnumMap都不是线程安全的,并发环境下需要外部同步(见 Reading 21 并发与 Reading 23 互斥)。- 回到原文的结论:「取值集合小而固定 → 用枚举」这一步是课程内容;「枚举的集合用
EnumSet而不是HashSet」这一步是本补充说明。
- 集合用法:
// 补充说明:以下 API 超出 6.031 原文范围。
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Set;
/** 原文的 Semester 枚举 + 补充的 EnumSet 用法。 */
enum Semester { IAP, SPRING, SUMMER, FALL }
class RegistrationOffice {
/** EnumSet:位向量实现,类型安全,迭代顺序固定为声明顺序。 */
private final EnumSet<Semester> offered =
EnumSet.of(Semester.IAP, Semester.SPRING, Semester.FALL);
/**
* @param semester the semester to register for
* @param year the calendar year
* @throws IllegalArgumentException if semester is not offered this year
*/
public void startRegistrationFor(Semester semester, int year) {
if (!offered.contains(semester)) {
throw new IllegalArgumentException("semester not offered: " + semester);
}
System.out.println("Registering for " + semester + " " + year);
// 对比:客户端无法传 "FAll" 或 Month.JANUARY —— 都是静态错误
}
/** @return an unmodifiable view of the semesters offered this year */
public Set<Semester> semestersOffered() {
return Collections.unmodifiableSet(offered); // 不暴露 rep
}
}
/** EnumMap:为枚举键优化的映射,内部就是按 ordinal 索引的数组。 */
class DirectionSurvey {
/** 构造时必须显式传入枚举类型对象(类型擦除)。 */
private final EnumMap<Direction, Integer> counts = new EnumMap<>(Direction.class);
DirectionSurvey() {
for (Direction d : Direction.values()) { // values():按声明顺序的全部取值
counts.put(d, 0);
}
}
/** @param d the direction observed; modifies this survey by counting one more observation */
void record(Direction d) {
counts.put(d, counts.get(d) + 1);
}
/** @param d a direction @return the number of observations recorded in direction d */
int count(Direction d) {
return counts.get(d); // 每个枚举值都已建键,不会返回 null
}
}
抽象类与接口的取舍(Abstract Class vs Interface)——补充说明(6.031 原文没有单独一节讲抽象类)
- 定义与目的:抽象类(
abstract class)不能实例化,可以包含抽象方法也可以包含具体实现与字段;接口只有规格、没有 rep。当多个类型需要共享同一份实现或同一份字段时用抽象类;当只需要共享类型(规格)、并且希望一个类能同时是多个抽象类型的成员时用接口。Java 8 引入default方法后两者能力接近,但接口仍不能持有实例字段、不能有构造器。 - 直观解释(”它是什么?”):接口像职业资格证:一个人可以同时是会计师和律师(多实现),但证件本身不发工资、不给你办公室。抽象类像家族企业:你继承它的资产与员工(rep 与方法体),但一个孩子只能继承一家的家业(单继承),而且你的经营方式会牵动整个家族(表示依赖)。
- 关键规则与最佳实践:
- 优先用接口:sp21 Reading 12 的「子类化」一节说明了继承 rep 会带来表示暴露、表示依赖、父子互相破坏 RI 三个问题;课程在 sp21 问题集 2 中给出明确态度——「
IntervalSetTest是一个抽象类。抽象类与子类化有它们的用处,但一般应被避免。」换句话说,抽象类只在你确实要共享实现时才值得付出继承 rep 的代价。 - 抽象类的判断信号:多个实现要共享同一段方法体或同一批字段,而且这种共享是表示层面的共享;问题集 2 的
IntervalSetTest正是这种用法:所有测试方法共享,唯一留给子类填空的是protected abstract IntervalSet<String> emptyInstance();。 - 接口的判断信号:你需要多重类型(一个类同时是 widget 与 list)、或者只关心规格而不希望客户端受到任何表示影响——这是本讲的主线用法。
- 判断表的其余维度:接口不能有实例字段、不能有构造器;抽象类可以两者都有,还可以有
protected成员。接口支持多实现,抽象类只支持单继承(但可以「继承一个抽象类 + 实现多个接口」)。 - 若接口的
default方法开始承担大量实现,就该反问「这其实是不是想共享 rep?」——若是,考虑抽象类,或把共享实现抽成 helper 类,让实现类组合它而不是继承它(组合不会带来表示依赖)。 - 原文也提醒:接口继承(
SortedSet<E> extends Set<E>)只会加强规格或新增操作,不会带来任何实现;想共享实现只能靠抽象类或default方法。
- 优先用接口:sp21 Reading 12 的「子类化」一节说明了继承 rep 会带来表示暴露、表示依赖、父子互相破坏 RI 三个问题;课程在 sp21 问题集 2 中给出明确态度——「
| 维度 | 抽象类(abstract class) | 接口(interface) |
|---|---|---|
| 实例字段/rep | 可以有(含 private 字段) | 不能有(字段自动是 public static final 常量) |
| 构造器 | 可以有,子类用 super(..) 调用 | 不能有 |
| 方法实现 | 普通方法 + 抽象方法 | 只有 default 与 static 方法可有方法体 |
| 继承数量 | 单继承(extends 一个类) | 多实现(implements 多个接口) |
| 建立的关系 | 子类化 + 子类型(继承 rep) | 纯子类型(不继承 rep) |
| 规格的位置 | 通常分散在类与其子类中 | 集中在接口里,客户端只读它 |
| 主要风险 | 表示暴露、表示依赖、父子互相破坏 RI | 几乎没有(接口没有 rep) |
/** 问题集 2 的真实结构:IntervalSet 是接口,两个实现类各有自己的 rep。 */
public interface IntervalSet<L> {
/** @param label a label @param start start of the interval @param end end of the interval */
public void insert(L label, long start, long end);
/** @param label a label @return true iff this set contains label */
public boolean contains(L label);
/** @return a new, empty interval set; 客户端不必知道实现类是哪一个 */
public static <L> IntervalSet<L> empty() {
return new RepMapIntervalSet<L>();
}
}
/** 一个实现:用 Map 做 rep(ps2 指定了它的表示)。 */
class RepMapIntervalSet<L> implements IntervalSet<L> {
private final java.util.Map<L, long[]> intervals = new java.util.HashMap<>();
@Override public void insert(L label, long start, long end) {
intervals.put(label, new long[] { start, end });
}
@Override public boolean contains(L label) { return intervals.containsKey(label); }
}
/** 抽象类的合适用法:共享同一套测试逻辑,子类只填一个“空实例”钩子。 */
abstract class IntervalSetTest {
/** @return a new empty instance of the implementation under test */
protected abstract IntervalSet<String> emptyInstance();
/** 共享的测试逻辑:对任何实现都成立。 */
public void testInitialLabelsEmpty() {
IntervalSet<String> set = emptyInstance();
assert !set.contains("labelA");
}
}
/** 子类只填空,从而把同一套测试跑在不同实现上。 */
class RepMapIntervalSetTest extends IntervalSetTest {
@Override protected IntervalSet<String> emptyInstance() {
return new RepMapIntervalSet<String>();
}
}
用接口与实现类定义 ADT(Defining an ADT with an Interface and Implementation Classes)
- 定义与目的:这是本讲的主线结论:ADT = 接口(操作集合 + 规格) + 一个或多个实现类(表示 + AF + RI + 方法体)。接口回答「这个类型能做什么、承诺了什么」,实现类回答「用什么数据表示、代码怎么写」,两者之间只通过规格相连。
- 直观解释(”它是什么?”):接口是 ADT 的「合同文本」,实现类是「按合同施工的施工队」。
MyString接口说「你能取长度、按位置取字符、取子串」,FastMyString说「我用char[]加起止下标施工,substring不用复制字符」;Set接口说「你能加入、查询、删除」,HashSet说「我用哈希表施工,查找平均 O(1)」。客户端拿着合同办事,不关心哪家施工队。 - 关键规则与最佳实践:
- 接口里写操作与规格:
length()、charAt(int i)、substring(int start, int end);绝不提「数组」「下标」等表示细节,否则接口就不再 representation-independent(原文的Curve如果让join返回ArrayCurve,就同时犯了「依赖实现类」与「不 representation-independent」两个错误)。 - 实现类里写 rep、AF、RI、
checkRep()、toString(),并用@Override标注每个实现方法(原文的FastMyString/SimpleMyString都这样写)。 - 客户端应当只依赖接口类型:
MyString s = MyString.valueOf(true);、Set<String> strings = Set.make();,而不再提FastMyString、SimpleSet;更好的做法是用静态工厂连实现类名都藏掉。 - 当规格故意欠定时,接口应保持宽松,实现可以自行加强(但不能削弱):
Set.pick()只承诺返回集合中某个元素,SimpleSet可以承诺「返回最近加入且尚未移除的元素」。 - 同一个 ADT 的多个实现可以有显著不同的性能与互信度取舍:一个简单到显然正确的实现,和一个更快但更可能含 bug 的精致实现可以并存,让应用按「被 bug 咬到有多痛」来选择;原文的
ArrayList与LinkedList就是库层面的例子。 - 原文的 ADT 概念总表(Java 版)值得记住:ADT 可以由单一类(
String)、「接口 + 类」(List/ArrayList)、或enum(DayOfWeek)实现;创建者可以是构造器ArrayList()、静态工厂方法List.of()、常量BigInteger.ZERO;观察者可以是实例方法List.get()或静态方法Collections.max();生产者可以是String.trim()或Collections.unmodifiableList();变异者可以是List.add()或Collections.copy();而表示永远是private字段。
- 接口里写操作与规格:
/** 接口定义 ADT 的操作集合;规格里不能出现任何表示细节。 */
public interface LabeledSet {
/** @param label a label @return true iff this set contains label */
public boolean contains(String label);
/** @param label a label; modifies this set by adding label if not already present */
public void add(String label);
/** @param label a label; modifies this set by removing label if present, else no effect */
public void remove(String label);
/** @return the number of distinct labels in this set */
public int size();
}
/** 实现一:哈希表,查找平均 O(1),元素无序。 */
class HashSetLabeledSet implements LabeledSet {
private final java.util.Set<String> labels = new java.util.HashSet<>();
@Override public boolean contains(String label) { return labels.contains(label); }
@Override public void add(String label) { labels.add(label); }
@Override public void remove(String label) { labels.remove(label); }
@Override public int size() { return labels.size(); }
}
/** 实现二:有序树,单次操作 O(log n),但可直接产出有序序列。 */
class TreeSetLabeledSet implements LabeledSet {
private final java.util.Set<String> labels = new java.util.TreeSet<>();
@Override public boolean contains(String label) { return labels.contains(label); }
@Override public void add(String label) { labels.add(label); }
@Override public void remove(String label) { labels.remove(label); }
@Override public int size() { return labels.size(); }
/** @return the labels in lexicographic order */
public java.util.List<String> sortedLabels() {
return new java.util.ArrayList<>(labels);
}
}
class LabeledSetDemo {
static void demo() {
// 客户端只依赖接口:换实现只需改这一行
LabeledSet set = new HashSetLabeledSet();
set.add("glorp");
set.add("glorp");
System.out.println(set.size()); // 1
// Java 集合类的对应事实:接口 + 多个实现
java.util.Set<String> hashed = new java.util.HashSet<>(); // 无序,平均 O(1)
java.util.Set<String> sorted = new java.util.TreeSet<>(); // 有序,O(log n)
java.util.Set<String> insertionOrder = new java.util.LinkedHashSet<>(); // 保持插入顺序
hashed.add("b"); sorted.add("b"); insertionOrder.add("b");
System.out.println(hashed.size() + sorted.size() + insertionOrder.size());
}
}
原文最后提醒:数据抽象不依赖语言特性。C 语言里没有类、方法、字段,甚至没有 private,但表示独立性依然可以实现——FILE 类型配合 fopen、fputs、fclose 就是「不透明类型 + 一组全局函数」的 ADT:客户端没有任何办法窥视 FILE 内部,只能把 FILE 交给这些操作使用(fputs 甚至把文件放在第二个参数)。这说明 ADT 是一种设计模式,而不是某个语言的语法糖。
代码示例与对比分析
场景 1:客户端必须知道具体实现类的名字才能创建对象,抽象屏障被打破
❌ 错误代码
// 错误:客户端直接依赖具体表示类 FastMyString / SimpleMyString
class MyStringClient {
static void demo() {
// 客户端必须知道表示类的名字,还必须知道构造器参数代表什么含义
FastMyString s = new FastMyString(true); // "true"
System.out.println("The first character is: " + s.charAt(0));
// 另一个客户端挑了另一个表示类,两段代码无法互换
SimpleMyString t = new SimpleMyString(false); // "false"
System.out.println("length = " + t.length());
// 更糟的是:两个实现的构造器签名根本不同,
// 接口里也没有任何地方承诺“所有实现都提供同样的构造器”
}
}
【错误代码的问题】
- 抽象屏障被打破:客户端代码里出现了
FastMyString这个名字,从此任何「换实现」的改动都要改所有客户端,Ready for change 直接失效。 - 没有静态保证:接口里根本没有构造器,因此「所有实现都提供同样的构造器」这件事没有任何编译期保证——原文的
FastMyString(boolean)与SimpleMyString(char[])(私有构造器)就是反例。 - 客户端可能意外依赖实现细节:一旦客户端拿到具体类型,就很容易调用实现类独有的方法(例如
ArrayCurve.add(x, y)),此后这些细节都变成事实上的公开接口。 - 测试与替换变难:无法在不改客户端的前提下,用一个更简单(更可能正确)的实现替换性能更好的实现来做对照测试。
✅ 正确代码
/** 客户端只依赖接口 MyString;创建者操作是接口上的静态工厂方法。 */
class MyStringClient {
static void demo() {
MyString s = MyString.valueOf(true); // 只出现接口名
System.out.println("The first character is: " + s.charAt(0)); // 't'
System.out.println("The whole string is: "
+ s.substring(0, s.length())); // "true"
// 客户端无法写 new FastMyString(..),因为它只见过 MyString 这个类型
}
}
【为什么这样更好】 客户端只引用接口类型与接口的静态工厂方法,实现类名 FastMyString 只出现在接口内部(return new FastMyString(b);)。此后要把默认实现换成 SimpleMyString 或第三种表示,只需改接口里的那一行工厂代码,所有客户端零改动;同时接口里没有字段,客户端也无从依赖任何表示细节。原文的对应写法是「在 MyString 接口里加 public static MyString valueOf(boolean b)」,sp22 原版则在 TypeScript 里用工厂函数 function makeMyString(s:string):MyString 达成同一效果。
【代码对比解说】 两种写法的类型检查都不弱:错误代码也能编译、也能运行。差别在于依赖的方向。错误写法让客户端依赖「具体表示类」,正确写法让客户端依赖「规格」。这正是「接口是客户端唯一需要读的东西」这条原则的落地方式:客户端读 MyString 就知道全部契约,且无法越过契约去触碰实现。原文还提醒这是一种取舍:完全隐藏实现意味着客户端无法按性能挑实现;Java 库因此同时暴露 ArrayList 与 LinkedList,让需要选择性能的客户端仍有选择权——但即便如此,规格仍写在 List 接口里。
【设计原则透视】 这一组对比直接对应 抽象屏障(abstraction barrier)与 表示独立性(representation independence):接口不含 rep,所以「接口 + 静态工厂」是唯一能同时满足「客户端只看规格」和「实现可替换」的设计。与 Reading 11(抽象函数与表示不变量)呼应:AF/RI 只存在于实现类里,客户端看不到;与 Reading 10(抽象数据类型)呼应:ADT 由操作定义,接口正是「操作集合」的代码化。静态工厂方法还额外解决了「接口不能有构造器」这一语言限制。
场景 2:一个可变的「正方形也是矩形」违反了 Liskov 替换原则
❌ 错误代码
/** A mutable rectangle. */
interface MutableRectangle {
int getWidth();
int getHeight();
/** Set this rectangle's dimensions to width x height. */
void setSize(int width, int height);
}
// A mutable square:原文写作 implements MutableRectangle /* hopefully? */
class MutableSquare implements MutableRectangle {
private int side;
MutableSquare(int side) { this.side = side; }
@Override public int getWidth() { return side; }
@Override public int getHeight() { return side; }
/**
* Set this square's dimensions to width x height.
* Requires width = height. // ← 更强的前置条件:违反 LSP
*/
@Override public void setSize(int width, int height) {
if (width != height) {
throw new IllegalArgumentException("width != height");
}
this.side = width;
}
}
class RectangleClient {
/** 把所有矩形放大一倍——这段代码在 MutableSquare 上会崩溃。 */
static void doubleSize(MutableRectangle r) {
r.setSize(r.getWidth() * 2, r.getHeight() * 2); // 可能违反正方形的前置条件
}
/** 只能靠 instanceof + 强转“补救”,这就是设计坏味道。 */
static void doubleSizeSafely(MutableRectangle r) {
if (r instanceof MutableSquare) {
MutableSquare s = (MutableSquare) r; // 运行时强转:危险信号
s.setSize(s.getWidth() * 2);
} else {
r.setSize(r.getWidth() * 2, r.getHeight() * 2);
}
}
}
【错误代码的问题】
- 违反 LSP:
MutableSquare的前置条件比MutableRectangle更强(setSize只接受width == height),于是「凡是MutableRectangle都能任意设置宽高」这一承诺被破坏,替换后客户端逻辑出错。 - 强迫客户端写运行时分派:
instanceof+ 强转是「本来就不该是子类型」的典型症状;每逢新增一个「特殊矩形」都要再改一次这段分派逻辑,Ready for change 变差。 - 错误延迟到运行时:前置条件被加强时编译器不会报错(它只检查签名),错误只会在某次
setSize(3, 5)调用时以异常或错误结果暴露——不符合 fail-fast。 - 破坏规格的单一事实来源:接口说「设成 width x height」,实现却偷偷附加了条件,读接口的人无法预知;这也会让后续依赖规格的逻辑(如 Reading 15 的
equals)出错。
✅ 正确代码
/** An immutable rectangle: 不可变类型没有 setSize,因此不存在“改尺寸”的分歧。 */
public interface ImmutableRectangle {
/** @return the width of this rectangle */
public int getWidth();
/** @return the height of this rectangle */
public int getHeight();
}
/** An immutable square: 每个正方形都真的是矩形,规格被完整满足。 */
class ImmutableSquare implements ImmutableRectangle {
private final int side;
/** Make a new side x side square. */
public ImmutableSquare(int side) { this.side = side; }
/** @return the width of this square */
@Override public int getWidth() { return side; }
/** @return the height of this square */
@Override public int getHeight() { return side; }
}
class RectangleClient {
/** 任何 ImmutableRectangle 都能安全参与这段计算,无需任何 instanceof。 */
static int area(ImmutableRectangle r) {
return r.getWidth() * r.getHeight();
}
}
/**
* 若确实需要可变的矩形与正方形,就不要让正方形冒充矩形:
* 定义两个彼此独立的类型,各自提供符合自身规格的操作。
*/
interface MutableRectangle2 {
int getWidth();
int getHeight();
/** Set this rectangle's dimensions to width x height. */
void setSize(int width, int height);
}
interface MutableSquare2 {
int getSide();
/** Set this square's side length to side. */
void setSide(int side);
}
【为什么这样更好】 不可变版本里,ImmutableSquare 的每个方法的后置条件都与 ImmutableRectangle 完全一致(规格相同,因而至少一样强),所以它确实是合法子类型:任何期望「能取宽高」的地方都能用正方形,而且不需要任何运行时判断。原文的三个判断练习也印证了这一点:getWidth() 与 getHeight() 都满足接口规格,因此整个 ImmutableSquare 满足 ImmutableRectangle 规格。当确实需要可变性时,正确做法是承认它们不是同一个抽象类型,各写各的规格,而不是用 instanceof 掩盖设计缺陷。
【代码对比解说】 关键差别不在于「正方形数学上是矩形」,而在于可变性让规格发生冲突:矩形承诺「可以任意设置宽高」,正方形承诺「宽恒等于高」,两者不可能同时成立。所以「正方形是不是矩形」这个问题的答案是「取决于是否可变」——这正是 LSP 与规格强弱规则的用武之地。原文为此给出了四种候选 setSize 规格,其中三种被判为不合法:加强前置条件(Requires width = height)、加强前置条件(抛出 BadSizeException)、削弱后置条件(非正方形时行为未指定)。第二种写法还展示了替代方案:把「共享的行为」放在接口(getWidth/getHeight),把「冲突的行为」拆到不同接口。
【设计原则透视】 这组对比把 LSP 与 Reading 07(设计规格)中「前置条件不能加强、后置条件不能削弱」的规则直接连了起来:implements 只保证签名,规格的强弱必须由人保证。instanceof + 强转则对应 Reading 15(相等性)里同样危险的「按运行时类型分支处理」模式——它是抽象边界失效的信号。它还说明 Reading 08(不可变性)为什么是消除 LSP 冲突的利器:不可变类型没有变异者,因而不存在「子类无法满足变异者规格」的问题。
场景 3:用 String 常量代替 enum,并用 HashSet<String> 而不是枚举集合
❌ 错误代码
import java.util.HashSet;
import java.util.Set;
/** 错误:用字符串常量模拟“学期”这一有限取值集合(原文的三种方案之一)。 */
class RegistrationOffice {
public static final String IAP = "IAP";
public static final String SPRING = "Spring";
public static final String SUMMER = "Summer";
public static final String FALL = "Fall";
private final Set<String> offered = new HashSet<>();
RegistrationOffice() {
offered.add(IAP);
offered.add(SPRING);
offered.add(FALL);
}
/** @param semester the semester name @param year the calendar year */
public void startRegistrationFor(String semester, int year) {
if (!offered.contains(semester)) {
throw new IllegalArgumentException("semester not offered: " + semester);
}
System.out.println("Registering for " + semester + " " + year);
}
}
class BadClient {
static void demo() {
RegistrationOffice office = new RegistrationOffice();
office.startRegistrationFor("FAll", 2023); // 拼错也照样编译通过,运行时才炸
office.startRegistrationFor("Autumn", 2023); // 客户端“发明”了一个新学期
Set<String> weird = new HashSet<>();
weird.add("Fall");
weird.add("Fall "); // 多一个空格就是另一个元素
System.out.println(weird.size()); // 2
}
}
【错误代码的问题】
- 不能 fail fast:
"FAll"、"Autumn"这类错误字符串是合法的String,编译器无法拒绝;错误只能在运行时通过IllegalArgumentException暴露,甚至可能悄悄写入数据库。原文明确指出字符串字面量方案「不能 fail fast」。 - 命名常量并不安全:
public static final String FALL = "Fall"虽然名字固定,却拦不住客户端直接传"Autumn";若常量没写final,它还能被重新赋值(原文把这个当作该方案的缺点之一)。 - 表示与性能错配:
HashSet<String>需要为每个字符串计算哈希、保存字符串对象;任何字符串都能被放进集合,需要额外检查来维持「集合只含合法学期」这一不变量。 - 重构困难:要找出「所有使用学期的地方」只能用字符串搜索,
"Fall"与代码里其他用途的"fall"无法区分,IDE 重构也无从下手。
✅ 正确代码
import java.util.EnumSet;
import java.util.Set;
import java.util.Collections;
/** 正确:取值集合小而固定,用枚举而不是字符串常量(原文的第三种方案)。 */
enum Semester { IAP, SPRING, SUMMER, FALL }
class RegistrationOffice {
/**
* EnumSet 用法属于「补充说明(超出 6.031 原文的 Java 生态知识)」;
* 原文只讲 enum 本身。若严格只用原文知识,可改用 Set<Semester>。
*/
private final EnumSet<Semester> offered =
EnumSet.of(Semester.IAP, Semester.SPRING, Semester.FALL);
/**
* @param semester the semester to register for
* @param year the calendar year
* @throws IllegalArgumentException if semester is not offered this year
*/
public void startRegistrationFor(Semester semester, int year) {
if (!offered.contains(semester)) {
throw new IllegalArgumentException("semester not offered: " + semester);
}
System.out.println("Registering for " + semester + " " + year); // FALL
}
/** @return an unmodifiable view of the semesters offered this year */
public Set<Semester> semestersOffered() {
return Collections.unmodifiableSet(offered); // 不暴露 rep
}
}
class GoodClient {
static void demo() {
RegistrationOffice office = new RegistrationOffice();
office.startRegistrationFor(Semester.FALL, 2023); // IDE 可补全,不可能拼错
// office.startRegistrationFor(Month.JANUARY, 2023); // 静态错误:类型不匹配
// office.startRegistrationFor("Fall", 2023); // 静态错误:String 不是 Semester
// 枚举的相等性:每值唯一,所以 == 与 equals() 等价,而且更 fail-fast
Semester s = Semester.FALL;
System.out.println(s == Semester.FALL); // true
System.out.println(Month.JANUARY.ordinal()); // 0
System.out.println(Month.JANUARY.name()); // "JANUARY"
}
}
【为什么这样更好】 枚举把「取值集合」变成类型系统的一部分:客户端不可能传入 "FAll"(那不是 Semester),也不可能传入 Month.JANUARY(类型不同,静态错误),更不可能发明一个新学期(枚举没有客户端可见的构造器)。原文对枚举方案的总结正是这三点:静态检查使客户端不能使用有限集合之外的值、也不能混淆两个不同的枚举类型;命名常量与命名类型比 int/String 更能自我说明;使用某个枚举的代码可以按枚举类型名搜索出来,改动时有据可依。附带的好处是,若采用 EnumSet/EnumMap(补充说明),还能得到位向量/数组级性能、固定的迭代顺序与编译期类型安全,而不是 HashSet<String> 的字符串哈希开销。
【代码对比解说】 两者都能「表示四个学期」,但承担的保证完全不同。字符串方案把「合法性」放在运行时检查里(offered.contains(..) 就是那个补丁),而枚举方案把合法性放进类型里,运行时检查只剩「本学期是否开设」这一真正的业务条件。此外 values()/valueOf() 让「枚举全部取值」「按名字解析」都有标准答案,而字符串方案只能靠手工维护的数组或反射。
【设计原则透视】 这一组直接对应原文枚举一节的总结(Safe from bugs / Easy to understand / Ready for change 三条全部成立),并与 Reading 15(相等性)相关:枚举值唯一,所以 == 与 equals() 等价且更 fail-fast;而字符串必须用 equals(),因为 == 判的是「是否同一个对象」。EnumSet 那部分只是 Java 生态的延伸(已在代码注释与上一小节中标注为补充说明),不能当作 6.031 原文内容。
场景 4:不使用泛型,容器只能装 Object,取出时必须运行时强转
❌ 错误代码
import java.util.ArrayList;
import java.util.List;
/** 错误:非泛型容器,元素类型完全不受检查。 */
class ObjectSet {
private final List<Object> elements = new ArrayList<>();
public void add(Object e) { elements.add(e); }
public int size() { return elements.size(); }
public boolean contains(Object e) { return elements.contains(e); }
/** @param i an index @return the element at index i, as an Object */
public Object pick(int i) { return elements.get(i); }
}
class BadSetClient {
static void demo() {
ObjectSet set = new ObjectSet();
set.add("glorp");
set.add(42); // 编译通过:整数混进了“字符串集合”
set.add(new int[] { 1, 2 }); // 什么都装得下
String s = (String) set.pick(1); // 运行时 ClassCastException!
System.out.println(s.length());
}
}
【错误代码的问题】
- 类型错误延迟到运行时:
set.add(42)违反使用者意图却完全合法;(String) set.pick(1)才在运行时抛ClassCastException,异常位置离真正的错误源(add那一行)很远,调试成本高。 - 每个调用点都重复强转:客户端必须牢记每个位置装的是什么类型,强转代码散布各处,一旦类型变化就要全局修改。
- 规格无法表达:「这是一个字符串集合」这件事在类型上无法表达,只能写在注释里;签名
add(Object)对客户端毫无约束力。 - 与泛型集合互操作时会出现 unchecked 警告:把
Object塞进List<String>需要强制转换,编译器只能给出警告而非错误,类型安全从此靠运气。
✅ 正确代码
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
/**
* 正确:用原文的泛型 Set<E> 把“元素类型”变成规格的一部分。
* A mutable set.
* @param <E> type of elements in the set
*/
public interface Set<E> {
/**
* Make an empty set.
* @param <F> type of elements in the set
* @return a new set instance, initially empty
*/
public static <F> Set<F> make() { return new SimpleSet<F>(); }
/** @return the number of elements in this set */
public int size();
/** @param e an element @return true iff this set contains e */
public boolean contains(E e);
/** @param e element to add; modifies this set by adding e to the set */
public void add(E e);
/** @param e element to remove; if e is not found in the set, has no effect */
public void remove(E e);
}
/**
* 泛型实现:元素存在 List<E> 里,完全不关心 E 是什么。
* Rep invariant: elementList has no repeated elements.
* Abstraction function: AF(elementList) = the set of elements in elementList.
* Safety from rep exposure: elementList 是 private final,从不返回给客户端。
*/
class SimpleSet<E> implements Set<E> {
private final List<E> elementList = new ArrayList<>();
@Override public int size() { return elementList.size(); }
@Override public boolean contains(E e) { return elementList.contains(e); }
@Override public void add(E e) { if (!contains(e)) { elementList.add(e); } }
@Override public void remove(E e) { elementList.remove(e); }
/**
* Picks an element from the set.
* @return the element most recently added but not yet removed
* @throws NoSuchElementException if set is empty
*/
public E pick() throws NoSuchElementException {
if (elementList.isEmpty()) { throw new NoSuchElementException("empty set"); }
return elementList.get(elementList.size() - 1);
}
}
class GoodSetClient {
static void demo() {
Set<String> strings = Set.make(); // 编译器推断 Set<String>
strings.add("glorp");
strings.add("glorp");
// strings.add(42); // 静态错误:int 不能转换为 String
System.out.println(strings.size()); // 1
// 取出元素不需要强转:泛型接口的签名已经给出类型
List<String> copy = new ArrayList<>();
copy.add(strings.contains("glorp") ? "glorp" : "none");
System.out.println(copy.get(0)); // glorp
}
}
【为什么这样更好】 泛型把「元素类型」提升为类型参数,于是「Set<String> 里不能放整数」成为编译期保证,客户端不需要任何强转,也就不可能出现 ClassCastException(这正是泛型引入的首要动机:在集合 API 里消灭强转)。同一份 SimpleSet<E> 实现同时服务于 Set<String>、Set<Integer> 等一整族类型,无需为每种元素类型重写。而 java.util.HashSet<E> implements Set<E> 正是 JDK 用泛型实现 Set 的真实方式。
【代码对比解说】 两种写法的差异可以从两个维度看。维度一,谁来检查:Object 版本把检查推给运行时(强转),泛型版本把检查留在编译期(类型参数),并且错误信息直接指向出错的那一行 add(42)。维度二,规格在哪里:Object 版本的规格只能靠注释描述「这个容器里装的是字符串」,泛型版本的规格写进了签名 Set<String>,是机器可读的。代价是泛型带来若干限制(类型擦除、不能 new E[]、不能 e instanceof E),但这些限制只影响实现内部,不影响客户端看到的抽象。
【设计原则透视】 这组对比把本讲三条主线一次串起:接口定义了 ADT 的操作与规格(Set<E> 只谈「元素」这一抽象概念,绝不提数组或链表);泛型让规格带占位符类型,从而适配整族元素类型并保持静态类型安全;子类型与 LSP 则要求 SimpleSet<E> 的每个方法规格至少与 Set<E> 一样强——例如 remove 在元素不存在时必须「无副作用」地返回而不许抛异常(那会加强前置条件),而 pick() 把后置条件从「某个元素」加强为「最近加入的元素」则是合法的。checkRep() 与 AF/RI 注释继续承担 Reading 11 的职责,assert 则呼应 Reading 09(避免调试)中 fail-fast 的要求。
与其他设计原则的关联
- 与 Reading 06(规格说明 Specifications)、Reading 07(设计规格 Designing Specifications):接口就是规格的容器,本讲的一切判断(子类型是否合法、
default方法能否加、实现能否加强pick()的后置条件)都归结为「前置条件不能加强、后置条件不能削弱」。接口把「规格写在哪里」这个问题的答案固定下来:写在接口里,而不是散落在各实现中。 - 与 Reading 08(不可变性 Immutability):不可变类型天然规避了本讲最尖锐的一类 LSP 冲突(
MutableSquare implements MutableRectangle)——没有变异者,就没有「子类无法满足变异者规格」的问题;sp22 的ReadonlyArray/Array结构子类型漏洞也提醒我们,真正不可变必须做到「没有可变别名」,这也是 Java 中Collections.unmodifiableList只提供视图、而List.of/List.copyOf更可靠的原因。 - 与 Reading 10(抽象数据类型 Abstract Data Types):本讲把 Reading 10 的「用类定义 ADT」升级为「用接口 + 实现类定义 ADT」,并首次让同一 ADT 的多种表示(
SimpleMyString与FastMyString)并存于同一程序;ADT 概念与 Java 实现方式的总表也在本讲给出。 - 与 Reading 11(抽象函数与表示不变量 Abstraction Functions & Rep Invariants):AF/RI/
checkRep()从此只属于实现类;接口里既不能有字段也不该有表示细节,所以「表示泄漏」在接口层面被结构性地排除(但实现类里仍要自己守 RI,例如CharSet的 rep 是「String中没有重复字符」)。 - 与 Reading 09(避免调试 Avoiding Debugging)与 Reading 13(调试 Debugging):枚举与泛型都是「把错误提前到编译期」的工具——这正是 fail-fast 的核心手段;当 bug 仍然发生、需要切片时,接口带来的清晰边界也让「哪些代码可能影响这个值」更容易判断。
- 与 Reading 15(相等性 Equality):本讲的枚举
==与equals()等价(每值唯一),而普通可变类型必须区分行为相等与观察相等;子类型改写equals语义会破坏「凡是超类型都能安全放入集合」的保证。Reading 15 还引入了Bag<E>这个可变多重集 ADT(本笔记只在明确标注的补充示例中使用它)。 - 与 Reading 16(map/filter/reduce):泛型接口是那一讲的基础设施——
List<E>、Set<E>、Optional<E>都靠类型参数让高阶函数保持静态类型安全,函数作为参数时接口又成了「函数类型」(见 Reading 20 回调中的单方法接口)。 - 与 Reading 17(递归数据类型 Recursive Data Types):递归 ADT(如语法树、图)通常需要接口与多个实现类配合,本讲的子类型与动态分派是它们的组织方式,而泛型让树可以携带任意元素或结果类型。
- 与 Reading 21(并发 Concurrency)、Reading 23(互斥 Mutual Exclusion)、Reading 24(队列 Queues):接口让「同一个抽象类型的线程安全实现与非线程安全实现」可以并存互换(这本身就是本讲「多个实现 + 性能取舍」的实例),而本讲涉及的集合类(
HashSet、EnumSet、EnumMap)都不是线程安全的,需要外部同步。 - 与 Reading 05(版本控制):枚举带来的可搜索类型名让「修改取值集合」变成一次可审计的全局重构,这与版本控制中的可追踪变更相辅相成。
关键要点
- 接口只写规格,实现类只写表示:接口里不出现字段、不出现表示细节(原文的诊断:
Curve.join若返回ArrayCurve就既不 representation-independent 又造成循环依赖);客户端只依赖接口类型,创建者操作优先用接口的静态工厂方法(MyString.valueOf(boolean)),这样换实现时客户端零改动。 - 子类型由规格定义,不由关键字定义:
implements/extends只保证签名兼容;子类型必须「前置条件不更强、后置条件不更弱」。ImmutableSquare implements ImmutableRectangle合法,MutableSquare implements MutableRectangle却会加强setSize的前置条件而违法,此时应拆成两个独立类型,而不是用instanceof+ 强转打补丁。 - 泛型是「一份实现服务一族类型」的机制:写
interface Set<E>、class HashSet<E> implements Set<E>(泛型实现)或class CharSet implements Set<Character>(非泛型实现);静态方法必须自己声明类型参数(public static <F> Set<F> make(),原文故意用F强调它是不同的类型参数),泛型实现只能依赖接口规格里显式承诺的性质(如Object的equals/hashCode)。 - 取值集合小而固定时一律用
enum而不是字符串/整数常量:枚举是真正的类,可以有 rep、私有构造器与方法;每个值唯一因而==与equals()等价且更 fail-fast,也支持switch与ordinal()/compareTo()/name()/toString();用EnumSet/EnumMap(补充说明,超出 6.031 原文)替代HashSet<String>/HashMap<String, V>可获得位向量/数组级性能、固定迭代顺序与编译期类型安全。 - 优先用接口,抽象类只在确实要共享实现或字段时才用:继承 rep 会带来表示暴露、表示依赖与父子互相破坏 RI(sp21 问题集 2 甚至明确说抽象类与子类化「一般应被避免」);Java 8 的
default/static方法缩小了两者的差距,但接口仍不能有实例字段与构造器。
常见陷阱与注意事项
- 在接口里声明实例字段来「顺便共享」表示 → 违反接口无 rep 的规则(字段自动成为
public static final常量),一旦误以为它能当 rep 用就会写出无法编译或语义错误的代码,而真正的表示必须留在实现类里(如FastMyString的char[] a与start/end)。 - 让客户端写
MyString s = new FastMyString(true);或Set<String> set = new HashSet<>();→ 抽象屏障被打破,而接口里没有构造器规格,FastMyString(boolean)与SimpleMyString(char[])这样的构造器根本不可能被接口统一承诺,换实现必须改所有客户端,客户端还会进一步依赖实现类独有的方法。 - 让可变子类「假装」实现超类的变异者操作(
MutableSquare implements MutableRectangle并给setSize加width == height前置条件)→ 违反 LSP,客户端对超类型编程时会在运行时崩溃或得到错误结果,编译器不会预警;原文的另外两种候选规格(抛出BadSizeException、削弱后置条件)同样不合法,正确做法是拆成两个独立类型或用不可变类型。 - 用
instanceof+ 强制类型转换来「兼容」不同子类型 → 这是抽象层次设计失败的信号:每新增一个子类型都要改这段分派代码,强转错误只会在运行时以ClassCastException暴露;应把差异上移到接口规格或拆分为不同抽象类型。 - 用
String/int常量 +HashSet<String>/HashMap<String, V>表示有限取值集合 → 客户端可以传拼错的字符串或任意整数而得不到静态错误(不能 fail fast),命名常量还能被重新赋值,方法签名无信息量,重构只能靠字符串搜索;应改用enum(配合补充说明中的EnumSet/EnumMap,注意EnumMap构造时必须传Direction.class,因为擦除导致无法推断枚举类型)。 - 以为泛型在运行时会保留类型实参、于是把
List<String>强转成List<Integer>、或写new E[10]/e instanceof E→ 由于类型擦除,前者不会在转换处报错而是在稍后取元素时才抛异常,后两者直接是编译错误;泛型容器的元素类型检查只发生在编译期,运行时仍可能因 unchecked 强转出现ClassCastException(补充说明范畴)。
思考题(带答案)
问题 1:原文的 ImmutableSquare implements ImmutableRectangle 被判定为合法子类型,而 MutableSquare implements MutableRectangle 被判定为不合法。请用「前置条件/后置条件」与 LSP 的语言解释这两者的差别;并说明原文给出的四种候选 MutableSquare.setSize 规格各自属于哪一种情况、为什么都不合法(或哪一种才合法)。
答案:子类型关系由规格决定:「B 是 A 的子类型」意味着「每一个 B 都满足 A 的规格」,也就是 B 的规格至少与 A 一样强,因而 B 的对象可以替换 A 的对象而不破坏 A 向客户端承诺的任何性质(这就是 Liskov 替换原则)。在不可变版本里,ImmutableRectangle 只承诺 getWidth()/getHeight() 返回宽高,而 ImmutableSquare 用 final int side 实现这两个观察者,后置条件完全一致(规格相同,因而至少一样强),所以它是合法子类型。到了可变版本,MutableRectangle.setSize(width, height) 承诺任意宽高都可以设置,而正方形的规格要求宽恒等于高,二者无法同时成立。原文的四种候选规格分别是:①「Requires width = height」——加强了前置条件(把原本合法的 setSize(3, 5) 变成违约),不合法;②「@throws BadSizeException if width != height」——同样是加强前置条件(原本合法的调用变成抛异常),不合法;③「若 width = height 则设为 width x height,否则新尺寸未指定」——削弱了后置条件(对客户端的保证变少),不合法;④ 只有真正的加强后置条件(例如承诺设置成功后宽高必然等于参数,或不引入任何新前置条件)才是合法的强化。因此当可变性使规格冲突时,正确做法是承认它们是两个抽象类型:把共享的观察者留在只读接口(ImmutableRectangle)里,把冲突的变异者分别放进各自的类型,而不是靠 instanceof + 强转掩盖设计缺陷。
问题 2:某团队用 String 保存「一学期的四种取值」(startRegistrationFor("Fall", 2023)),并用 HashSet<String> 记录本学期开设的学期。请指出至少三个具体后果,说明改用原文的 enum Semester { IAP, SPRING, SUMMER, FALL } 后分别如何被消除;并解释为什么 EnumSet/EnumMap 更适合枚举(注意:EnumSet/EnumMap 超出 6.031 原文范围,属于 Java 生态补充知识)。
答案:第一,不能 fail fast:startRegistrationFor("FAll", 2023) 或 "Autumn" 都是合法字符串,编译器无从拒绝,错误只能留到运行时(或更糟,写进数据库),而改成 Semester 后这两个调用都是静态错误,IDE 还能补全——这正是原文比较三种方案时的第一个结论。第二,命名常量方案也不安全:public static final String FALL = "Fall" 拦不住客户端传 "Autumn",且常量若未声明 final 还能被重新赋值;枚举值则由语言保证唯一且不可构造新值。第三,签名无信息量且容易写反:两个标量参数可以互相写错而不被检出;用 Semester 后参数类型自带含义,而且把 Month.JANUARY 传进来也是静态错误(类型不同,这正体现「枚举比 int 常量有更多静态检查」)。第四,表示与不变量不匹配:HashSet<String> 可以装任意字符串(包括 "Fall " 这种多一个空格的变体),需要额外运行时校验来维持「只含合法学期」;枚举方案把合法性放进类型里。关于 EnumSet/EnumMap(补充说明):它们内部是位向量与按 ordinal 索引的数组,add/contains/remove 是常数时间且常数因子极小,内存占用与枚举取值个数成位,迭代顺序固定为声明顺序(测试与调试可复现),并且类型安全(EnumSet<Semester> 装不进 Month);而 HashSet<String> 要为每个取值保存字符串对象、计算字符串哈希、还可能被任意字符串污染。需要注意的是:「取值集合小而固定 → 用枚举」是 6.031 原文内容,而「枚举的集合用 EnumSet 而不是 HashSet」是本笔记的补充说明,EnumMap 的构造还必须显式传入 Semester.class,因为类型擦除使运行时无法推断键的枚举类型。
问题 3:说明 sp22 原版 TypeScript 的「结构子类型化(structural subtyping)」与 Java 的「名义子类型化(nominal subtyping)」的差别;并用 sp22 原文的 Array/ReadonlyArray 例子说明结构子类型化为什么会在类型安全上「开一个洞」,以及在 Java 中遇到类似情形应当如何应对。
答案:在 TypeScript 中,只要类型 B 提供了 A 所要求的全部操作(同样的公开方法与公开实例变量,且类型兼容),TypeScript 就认为 B 是 A 的子类型——B 的声明里完全不必提到 A(不需要 implements/extends),这叫结构子类型化。Java 则是名义子类型化:子类型关系必须由声明显式建立(类 implements 接口、接口 extends 接口、类 extends 类),否则编译器根本不承认两者有关系,即使方法签名逐一对得上——所以本讲的 MyString 实现类必须写 implements MyString,不存在「碰巧结构相同就被当作子类型」的情况。sp22 的例子是:ReadonlyArray 拥有 Array 的全部观察者与生产者但去掉变异者,于是 Array 是 ReadonlyArray 的结构子类型,因此 const readonlyArr: ReadonlyArray<number> = [1, 2, 3]; 合法,反方向 const arr: Array<number> = readonlyArr; 则是静态错误(ReadonlyArray 不提供变异操作)。但「洞」在于:如果同时还保留可变别名(const arr: Array<number> = [1,2,3]; const readonlyArr: ReadonlyArray<number> = arr;),一句 arr.push(4) 就能改掉 readonlyArr 看到的内容——Array 虽然结构上是 ReadonlyArray 的子类型,却不是真正的(规格意义上的)子类型,因为它的契约不提供不可变性。补充说明:Java 没有结构子类型化,这一具体漏洞不会以同样形式出现,但有个相似陷阱——原文用过的 Collections.unmodifiableList(list) 只返回一个不可修改的视图,原 list 若仍被别名持有并可改写,视图内容就会跟着变;要真正不可变,应使用 List.of(...)/List.copyOf(...) 生成独立副本,并丢弃全部可变别名(与 Reading 08 的要求一致)。
