<font size="3">html
本文内容来自MIT_6.031_sp18: Software Construction课程的Readings部分,采用CC BY-SA 4.0协议。java
因为咱们学校(哈工大)大二软件构造课程的大部分素材取自此,也是推荐的阅读材料之一,因而打算作一些翻译工做,本身学习的同时也能帮到一些懒得看英文的朋友。另外,该课程的阅读资料中有的练习题没有标准答案,所给出的“正确答案”为译者所写,有错误的地方还请指出。git
(更新:从第10章开始只翻译正确答案)程序员
<br />github
<br />web
审校:安全
V1.0 Sun Apr 8 13:29:19 CST 2018oracle
<br />app
本次课程的主题是接口:将抽象数据类型中的实现与抽象接口分离开,并在Java中运用interface
强制这种分离。
在此次课程后,你应该可以定义ADT的接口,并可以写出对应的实现类。
<br />
译者注:本次阅读少部分说法基于Javase8及之后的版本。参考:Java 8 Interface Changes – static method, default method
<br />
Java中的interface
(接口)是一种表示抽象数据类型的好方法。接口中是一连串的方法标识,可是没有方法体(定义)。若是想要写一个类来实现接口,咱们必须给类加上implements
关键字,而且在类内部提供接口中方法的定义。因此接口+实现类也是Java中定义抽象数据类型的一种方法。
这种作法的一个优势就是接口只为使用者提供“契约”(contract),而使用者只须要读懂这个接口便可使用该ADT,他也不须要依赖ADT特定的实现/表示,由于实例化的变量不能放在接口中(具体实现被分离在另外的类中)。
接口的另外一个优势就是它容许了一种抽象类型可以有多种实现/表示,即一个接口能够有多个实现类(译者注:一个类也能够同时实现多个接口)。而当一个类型只用一个类来实现时,咱们很难改变它的内部表示。例如以前阅读中的 MyString
这个例子,咱们对 MyString
实现了两种表示方法,可是这两个类就不能同时存在于一个程序中。
Java的静态检查会发现没有实现接口的错误,例如,若是程序员忘记实现接口中的某一个方法或者返回了一个错误的类型,编译器就会在编译期报错。不幸的是,编译器不会去检查咱们的方法是否遵循了接口中的文档注释。
关于定义接口的细节,请参考 Java Tutorials section on interfaces.
Java interfaces
思考下面这个Java接口和实现类,它们尝试实现一个不可变的集合类型:
/** Represents an immutable set of elements of type E. */ public interface Set<E> { /** make an empty set */ A public Set(); /** @return true if this set contains e as a member */ public boolean contains(E e); /** @return a set which is the union of this and that */ B public ArraySet<E> union(Set<E> that); } /** Implementation of Set<E>. */ public class ArraySet<E> implements Set<E> { /** make an empty set */ public ArraySet() { ... } /** @return a set which is the union of this and that */ public ArraySet<E> union(Set<E> that) { ... } /** add e to this set */ public void add(E e) { ... } }
下面关于 Set<E>
和 ArraySet<E>
的说法哪个是正确的?
A
标号处有问题,由于接口不能有构造方法。--> True
The line labeled B
is a problem because Set
mentions ArraySet
, but ArraySet
also mentions Set
, which is circular. --> False
B
标号处有问题,由于它没有实现“表示独立”。--> True
ArraySet
并无正确实现 Set
,由于它缺失了 contains()
方法。--> True
ArraySet
doesn’t correctly implement Set
because it includes a method that Set
doesn’t have. --> False
ArraySet
并无正确实现 Set
,由于 ArraySet
是可变的,可是 Set
是不可变的。--> True
<br />
回忆一下,咱们以前说过类型就是值的集合。Java中的 List
类型是经过接口定义的,若是咱们想一下List
全部的可能值,它们都不是List
对象:咱们不能经过接口实例化对象——这些值都是 ArrayList
对象, 或 LinkedList
对象,或者是其余List
实现类的对象。咱们说,一个子类型就是父类型的子集,正如 ArrayList
和 LinkedList
是List
的子类型同样。
“B是A的子类型”就意味着“每个B都是A”,换句话说,“每个B都知足了A的规格说明”。
这也意味着B的规格说明至少强于A的规格说明。当咱们声明一个接口的实现类时,编译器会尝试作这样的检查:它会检查类是否所有实现了接口中规定的函数,而且检查这些函数的标识是否对的上。
可是编译器不会检查咱们是否经过其余形式弱化了规格说明:例如强化了某个方法输入的前置条件,或弱化了接口对于用户的保证(后置条件)。若是你在Java中定义了一个子类型——咱们这里是实现接口——你必需要确保子类型的规格说明至少要比父类型强。
Immutable shapes
让咱们为矩形定义一个接口:
/** 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. */ public class ImmutableSquare { private final int side; /** Make a new side x side square. */ public ImmutableSquare(int side) { this.side = side; } /** @return the width of this square */ public int getWidth() { return side; } /** @return the height of this square */ public int getHeight() { return side; } }
ImmutableSquare.getWidth()
是否知足了 ImmutableRectangle.getWidth()
的规格说明? --> Yes
ImmutableSquare.getHeight()
是否知足了 ImmutableRectangle.getHeight()
的规格说明? -->Yes
ImmutableSquare
的规格说明是否知足了(至少强于) ImmutableRectangle
的规格说明? --> Yes
Mutable shapes
/** A mutable rectangle. */ public interface MutableRectangle { // ... same methods as above ... /** Set this rectangle's dimensions to width x height. */ public void setSize(int width, int height); }
如今每个正方形类型仍是矩形类型吗?
/** A mutable square. */ public class MutableSquare { private int side; // ... same constructor and methods as above ... // TODO implement setSize(..) }
对于下面的每个 MutableSquare.setSize(..)
实现,请判断它是否合理:
/** Set this square's dimensions to width x height. * Requires width = height. */ public void setSize(int width, int height) { ... }
--> No – stronger precondition
/** Set this square's dimensions to width x height. * @throws BadSizeException if width != height */ public void setSize(int width, int height) throws BadSizeException { ... }
--> Specifications are incomparable
/** If width = height, set this square's dimensions to width x height. * Otherwise, new dimensions are unspecified. */ public void setSize(int width, int height) { ... }
--> No – weaker postcondition
/** Set this square's dimensions to side x side. */ public void setSize(int side) { ... }
--> Specifications are incomparable
<br />
MyString
如今咱们再来看一看 MyString
这个例子,此次咱们使用接口来定义这个ADT,以便建立多种实现类:
/** 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); }
如今咱们先跳过 valueOf
这个方法,用咱们在“抽象数据类型”中学习到的知识去实现这个接口。
下面是咱们的第一种实现类:
public class SimpleMyString implements MyString { 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); } }
而下面是咱们优化过的实现类:
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.end + end); } }
valueOf
是静态方法,可是在这里就不是了。而这里也使用了指向实例内部表示的this
。@Override
的使用,这个词是通知编译器这个方法必须和其父类中的某个方法的标识彻底同样(覆盖)。可是因为实现接口时编译器会自动检查咱们的实现方法是否遵循了接口中的方法标识,这里的 @Override
更可能是一种文档注释,它告诉读者这里的方法是为了实现某个接口,读者应该去阅读这个接口中的规格说明。同时,若是你没有对实现类(子类型)的规格说明进行强化,这里就不须要再写一遍规格说明了。(DRY原则)substring(..)
这样的生产者服务的。它的参数是表示的域。咱们以前并不须要写出构造方法,由于Java会在没有构造方法时自动构建一个空的构造方法,可是这里咱们添加了一个接收 boolean b
的构造方法,因此就必须显式声明另外一个为生产者服务的构造方法了。那么使用者会如何用这个ADT呢?下面是一个例子:
MyString s = new FastMyString(true); System.out.println("The first character is: " + s.charAt(0));
这彷佛和咱们用Java的聚合类型时的代码很像,例如:
List<String> s = new ArrayList<String>(); ...
不幸的是,这种模式已经破坏了咱们辛苦构建的抽象层次 。使用者必须知道具体实现类的名字。由于Java接口中不能包含构造方法,它们必须经过调用实现类的构造方法来获取接口类型的对象,而接口中是不可能含有构造方法的规格说明的。另外,因为接口中没有对构造方法进行说明,因此咱们甚至没法保证不一样的实现类会提供一样的构造方法。
幸运的是,Java8之后容许为接口定义静态方法,因此咱们能够在接口MyString
中经过静态的工厂方法来实现建立者valueOf
:
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(true); } // ...
如今使用者能够在不破坏抽象层次的前提下使用ADT了:
MyString s = MyString.valueOf(true); System.out.println("The first character is: " + s.charAt(0));
将实现彻底英寸起来是一种“妥协”,由于有时候使用者会但愿有对具体实现的选择权利。这也是为何Java库中的ArrayList
和LinkedList
“暴露”给了用户,由于这两个实如今 get()
和 insert()
这样的操做中会有性能上的差异。
Code review
如今让咱们来审查如下 FastMyString
实现,下面是对这个实现的一些批评,你认为哪一些是对的?
应该把抽象函数注释出来 --> True
应该把表示不变量注释出来 --> True
表示域应该使用关键词 final
以便它们不能被从新改变索引 --> True
The private constructor should be public so clients can use it to construct their own arbitrary strings --> False
The charAt
specification should not expose that the rep contains individual characters --> False
charAt
应该对于大于字符串长度的 i
有更好的处理 --> True
<br />
Set<E>
Java中的聚合类型为“将接口和实现分离”提供了很好的例子。
如今咱们来思考一下java聚合类型中的Set
。Set
是一个用来表示有着有限元素E
的集合。这里是Set
的一个简化的接口:
/** A mutable set. * @param <E> type of elements in the set */ public interface Set<E> {
Set
是一个泛型类型(generic type):这种类型的规格说明中用一个占位符(之后会被做为参数输入)表示具体类型,而不是分开为不一样类型例如 Set<String>
, Set<Integer>
, 进行说明。咱们只须要设计实现一个 Set<E>
.
如今咱们分别实现/声明这个ADT的各个操做,从建立者开始:
// example creator operation /** Make an empty set. * @param <E> type of elements in the set * @return a new set instance, initially empty */ public static <E> Set<E> make() { ... }
这里的make
是做为一个静态工厂方法实现的。使用者会像这样调用它:Set<String> strings = Set.make();
,而编译器也会知道新的Set
会是一个包含String
对象元素的集合。(注意咱们将<E>
写在函数标识前面,由于make
是一个静态方法,而<E>
是它的泛型类型)。
// 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);
接下来咱们声明两个观察者。注意到规格说明中的提示,这里不该该提到具体某一个实现的细节或者它们的标识,而规格说明也应该适用于全部Set
ADT的实现。
// 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);
对于改造者的要求也和观察者同样,咱们依然要在接口抽象的层次书写规格说明。
阅读参考:
Collection interfaces & implementations
假设下面的代码都是逐次执行的,而且不能被编译的代码都会被注释掉。
这里的代码使用到了 Collections
中的两个方法,你可能须要阅读一些参考。请为下面的问题回答出最合理的答案。
Set<String> set = new HashSet<String>();
set
如今指向: --> 一个HashSet对象
set = Collections.unmodifiableSet(set);
set
如今指向: --> 一个实现了Set
接口的对象
set = Collections.singleton("glorp");
set
如今指向: --> 一个实现了Set
接口的对象
set = new Set<String>();
set
如今指向: --> 这一行不能被编译
List<String> list = set;
set
如今指向: --> 这一行不能被编译
<br />
假设如今咱们要实现上面的 Set<E>
接口。咱们既可使用一个非泛型的实现(用一个特定的类型替代E
),也可使用一个泛型实现(保留类型占位符)。
首先咱们来看看泛型接口的非泛型实现:
在 抽象函数 & 表示不变量 咱们实现了 CharSet
类型,它被用来表示字符的集合。其中 CharSet1
/2
/3
这三种实现类都是 Set
接口 的子类型,它们的声明以下:
public class CharSet implements Set<Character>
当在Set声明中提到 E
时,Charset
的实现将类型占位符E
替换为了Character
:
public interface Set<E> { // ... /** * Test for membership. * @param e an element * @return true iff this set contains e */ public boolean contains(E e); /** * Modifies this set by adding e to the set. * @param e element to add */ public void add(E e); // ... }
public class CharSet1 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(); } // ... }
CharSet1
/2
/3
的实现方法不适用于任意类型的元素,例如,因为它使用的是String
成员, Set<Integer>
这种集合就没法直接表示。
接着咱们再来看看泛型接口的泛型实现:
咱们也能够在实现 Set<E>
接口的时候不对E
选择一个特定的类型。在这种状况下,咱们会让使用者决定E
究竟是什么。例如,Java的 HashSet
就是这种实现,它的声明像这样:
public interface Set<E> { // ...
public class HashSet<E> implements Set<E> { // ...
一个泛型实现只能依靠接口规格说明中对类型占位符的要求,咱们会在之后的阅读中看到 HashSet
是如何依靠每个类型都要求实现的操做来实现它本身的,由于它没办法依赖于特定类型的操做。
<br />
在Java代码中,接口被用的很普遍(但也不是全部类都是接口的实现),这里列出来了几个使用接口的好处:
假设你有一个有理数的类型,它如今是以类来表示的:
public class Rational { ... }
如今你决定将 Rational
换成Java接口,同时定义了一个实现类IntFraction
:
public interface Rational { ... } public class IntFraction implements Rational { ... }
对于下面以前 Rational
类中的代码,请你断定它们对应的身份,以及应该出如今新的接口或者新的实现类中?
Interface + implementation 1
private int numerator; private int denominator;
这段代码是(选中全部正确答案):
它应该位于:
[ ] 接口
[x] 实现类
[ ] 都有
Interface + implementation 2
// denominator > 0 // numerator/denominator is in reduced form
这段代码是(选中全部正确答案):
它应该位于:
Interface + implementation 3
// AF(numerator, denominator) = numerator / denominator
这段代码是(选中全部正确答案):
它应该位于:
Interface + implementation 4
/** * @param that another Rational * @return a Rational equal to (this / that) */
这段代码是(选中全部正确答案):
它应该位于:
Interface + implementation 5
public boolean isZero()
这段代码是(选中全部正确答案):
它应该位于:
Interface + implementation 6
return numer == 0;
这段代码是(选中全部正确答案):
它应该位于:
<br />
有时候一个ADT的值域是一个很小的有限集,例如:
这样的类型每每会被用来组成更复杂的类型(例如DateTime
或者Latitude
),或者做为一个改某个方法的行为的参数使用(例如drawline
)。
当值域很小且有限时,将全部的值定义为被命名的常量是有意义的,这被称为枚举(enumeration)。JAVA用enum
使得枚举变得方便:
public enum Month { JANUARY, FEBRUARY, MARCH, ..., DECEMBER };
这个enum
定义类一种新的类型名,Month
,这和使用class
以及interface
定义新类型名时是同样的。它也定义了一个被命名的值的集合,因为这些值其实是public static final
,因此咱们将这个集合中的每一个值的每一个字母都大写。因此你能够这么写:
Month thisMonth = MARCH;
这种思想被称为枚举,由于你显式地列出了一个集合中的全部元素,而且JAVA为每一个元素都分配了数字做为表明它们的值。
在枚举类型最简单的使用场景中,你须要的惟一操做是比较两个值是否相等:
if (day.equals(SATURDAY) || day.equals(SUNDAY)) { System.out.println("It's the weekend"); }
你可能也会看到这样的代码,它使用==
而不是equals()
:
if (day == SATURDAY || day == SUNDAY) { System.out.println("It's the weekend"); }
若是使用String
类型来表示天数,那么这个代码是不安全的,由于==
检测两边的表达式是否引用的是同一个对象,对于任意的两个字符串“Saturday”
来讲,这是不必定的。这也是为何咱们老是在比较两个对象时使用equals()
的缘由。可是使用枚举类型的好处之一就是:实际上只有一个对象来表示枚举类型的每一个取值,且用户不可能建立更多的对象(没有构造者方法!)因此对于枚举类型来讲,==
和equals()
的效果是同样的。
在这个意义上,使用枚举就像使用原式的int
常量同样。JAVA甚至支持在switch
语句中使用枚举类型(switch
在其余状况下只容许使用原式的整型,而不能是对象):
switch (direction) { case NORTH: return "polar bears"; case SOUTH: return "penguins"; case EAST: return "elephants"; case WEST: return "llamas"; }
可是和int
值不一样的是,JAVA对枚举类型有更多的静态检查:
Month firstMonth = MONDAY; // static error: MONDAY has type DayOfWeek, not type Month
一个enum
声明中能够包含全部能在class
声明中经常使用字段和方法。因此你能够为这个ADT定义额外的操做,而且还定义你本身的表示(成员变量)。这里是一个声明了一个成员变量、一个观察者和一个生产者的枚举类型的例子:
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 nextSemester() { 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
类型也都有一些内置的(automatically-provided)操做,这些操做在Enum
中定义:
ordinal()
是某个值在枚举类型中的索引值,所以 JANUARY.ordinal()
返回 0.compareTo()
基于两个值的索引值来比较两个值.name()
返回字符串形式表示的当前枚举类型值,例如, JANUARY.name()
返回"JANUARY"
.toString()
和 name()
是同样的.阅读JAVA教程中的Enum Types (1页)和 Nested Classes(1页)
Semester
考虑这三种可选的方式来命名你将要注册的Semester:
startRegistrationFor("Fall", 2023);
String
类型常量:public static final String FALL = "Fall"; ... startRegistrationFor(FALL, 2023);
public enum Semester { IAP, SPRING, SUMMER, FALL }; ... startRegistrationFor(FALL, 2023);
下列关于每一个方案的优缺点叙述正确的是:
startRegistrationFor("FAll", 2023)
FALL = "Spring"
startRegistrationFor("Autumn", 2023)
.startRegistrationFor(new Semester("Autumn"), 2023)
.startRegistrationFor(JANUARY, 2023)
.<br />
如今咱们完成了对“抽象数据类型”中“Java中ADT实现”的理解:
ADT 角度 | Java实现 | 例子 |
---|---|---|
抽象数据类型 | 类 | String |
接口 + 类 | List and ArrayList |
|
枚举(Enum) | DayOfWeek |
|
建立者操做 | 构造方法 | ArrayList() |
静态(工厂)方法 | Collections.singletonList() , Arrays.asList() |
|
常量 | BigInteger.ZERO |
|
观察者操做 | 实例方法 | List.get() |
静态方法 | Collections.max() |
|
生产者操做 | 实例方法 | String.trim() |
静态方法 | Collections.unmodifiableList() |
|
改造者操做 | 实例方法 | List.add() |
静态方法 | Collections.copy() |
|
(成员)表示 | private /私有域 |
<br />
抽象数据类型是由它支持的操做集合所定义的,而Java中的结构可以帮助咱们形式化这种思想。
这可以使咱们的代码:
Java的枚举类型可以定义一种只有少部分不可变值的ADT。和之前使用特殊的整数或者字符串相比,枚举类型可以帮助咱们的代码:
</font>