抽象数据类型 (ADT)
3-1节研究了“数据类型”及其特性 。
3-2节研究了方法和操作的“规约”及其特性。
本节:将数据和操作复合起来,构成ADT,学习 ADT的核心特征,以及如何设计“好的”ADT。
目录:
- 抽象和用户定义的类型
- ADT中的操作分类
- 抽象数据类型示例
- ADT的设计原则
- 表示独立性(Representation Independence)
- 在Java中实现ADT概念
- 测试ADT
- 不变量( Invariants )
- 抽象函数AF和表示不变量RI
- 有益的可变性
- 记录AF,RI和表示泄露
- ADT不变量取代前置条件
抽象和用户定义的类型
Abstraction and User-Defined Types
抽象数据类型是软件工程中一般原理的一个实例,它有很多名称:
- 抽象:用更简单,更高级的思想省略或隐藏低级细节。
- 模块化。 将系统划分为组件或模块,每个组件可以设计,实施,测试,推理和重用,与系统其余部分分开使用。
- 封装。围绕模块(硬壳或胶囊)构建墙,以便模块负责自身的内部行为,并且系统其他部分的错误不会损坏其完整性。
- 信息隐藏。 从系统其余部分隐藏模块实现的细节,以便稍后可以更改这些细节,而无需更改系统的其他部分。
- 关注点分离。 制作一个功能(或“关注”)是单个模块的责任,而不是跨越多个模块。
除了编程语言所提供的基本数据类型和对象数据类型,程序员可定义自己的数据类型。
传统的类型定义:关注数据的具体表示。 抽象类型:强调“作用于数据上的操作”,程序员和 client无需关心数据如何具体存储的,只需设计/使用操作即可。
ADT是由操作定义的,与其内部如何实现无关!
ADT中的操作分类
Mutable and immutable types
- 可变类型的对象:提供了可改变其内部数据的值的操作。
- 不变数据类型: 其操作不改变内部值,而是构造新的对象。
Classifying the operations of an abstract type
由上图可知,抽象类型的操作可分为四种:
- Creators(构造器):创建某个类型的新对象,可能实现为构造函数或静态函数(通常称为工厂方法)。
- Producers(生产器):从该类型的旧对象创建新对象。
- Observers(观察器):获取抽象类型的对象并返回不同类型的对象。
- Mutators(变值器):改变对象属性的方法 ,如果返回值为void,则必然意味着它改变了对象的某些内部状态,也可能返回非空类型 。
抽象数据类型示例
Integer.valueOf()
CreatorBigInteger.mod()
ProducerList.addAll()
MutatorString.toUpperCase()
ProducerSet.contains()
ObserverCollections.unmodifiableList()
ProducerBufferedReader.readLine()
Mutator
ADT的设计原则
设计好的ADT,靠“经验法 则”,提供一组操作,设计其行为规约 spec
- Rules of thumb 1:设计简洁、一致的操作。
- Rules of thumb 2:要足以支持client对数据所做的所有操作需要,且用操作满足client需要的难度要低。
- Rules of thumb 3:要么抽象、要么具体,不要混合 — 要么针对抽象设计,要么针对具体应用的设计。
表示独立性
表示独立性:client使用ADT时无需考虑其内部如何实现,ADT内部表示的变化不应影响外部spec和客户端。
除非ADT的操作指明了具体的pre和post-condition,否则不能改变ADT的内部表示——spec规定了 client和implementer之间的契约。
Example: different representations for strings
/** MyString represents an immutable sequence of characters. */
public class MyString {
Example of a creator operation ///
/** @param b a boolean value
* @return string representation of b, either "true" or "false" */
public static MyString valueOf(boolean b) { ... }
Examples of observer operations ///
/** @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) { ... }
Example of a producer operation ///
/** 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) { ... }
}
下面是如何声明内部表示的方法,作为类中的一个实例变量:
private char[] a;
通过选择这种表示形式,这些操作将以直接的方式实施:
public static MyString valueOf(boolean b) {
MyString s = new MyString();
s.a = b ? new char[] { 't', 'r', 'u', 'e' }
: new char[] { 'f', 'a', 'l', 's', 'e' };
return s;
}
public int length() {
return a.length;
}
public char charAt(int i) {
return a[i];
}
public MyString substring(int start, int end) {
MyString that = new MyString();
that.a = new char[end - start];
System.arraycopy(this.a, start, that.a, 0, end - start);
return that;
}
为了优化,我们可以将这个类的内部表示改为:
private char[] a;
private int start;
private int end;
有了这个新的表示,操作现在可以这样实现:
public static MyString valueOf(boolean b) {
MyString s = new MyString();
s.a = b ? new char[] { 't', 'r', 'u', 'e' }
: new char[] { 'f', 'a', 'l', 's', 'e' };
s.start = 0;
s.end = s.a.length;
return s;
}
public int length() {
return end - start;
}
public char charAt(int i) {
return a[start + i];
}
public MyString substring(int start, int end) {
MyString that = new MyString();
that.a = this.a;
that.start = this.start + start;
that.end = this.start + end;
return that;
}
由于MyString
的现有客户端仅依赖于其公共方法的规范,而不依赖其私有字段,因此我们可以在不检查和更改所有客户端代码的情况下进行更改。 这是代表独立性的力量。
总结
测试ADT
- 测试creators, producers, and mutators:
调用observers来观察这些 operations的结果是否满足spec; - 测试observers:
调用creators, producers, and mutators等方法产生或改变对象,来看结果是否正确。
以MyString
为例,测试策略如下:
// testing strategy for each operation of MyString:
//
// valueOf():
// true, false
// length():
// string len = 0, 1, n
// string = produced by valueOf(), produced by substring()
// charAt():
// string len = 1, n
// i = 0, middle, len-1
// string = produced by valueOf(), produced by substring()
// substring():
// string len = 0, 1, n
// start = 0, middle, len
// end = 0, middle, len
// end-start = 0, n
// string = produced by valueOf(), produced by substring()
不变量(Invariants)
一个好的抽象数据类型的最重要的属性是它保持不变量。不变量即在任何时候总是true 的变量。由ADT 来负责其不变量,与client端的任何行为无关 。
为什么需要不变量:保持程序的“正确性”,容易发现错误 。
Immutability as a type of Invariants
首先看这一个例子
/**
* This immutable data type represents a tweet from Twitter.
*/
public class Tweet {
public String author;
public String text;
public Date timestamp;
/**
* Make a Tweet.
* @param author Twitter user who wrote the tweet
* @param text text of the tweet
* @param timestamp date/time when the tweet was sent
*/
public Tweet(String author, String text, Date timestamp) {
this.author = author;
this.text = text;
this.timestamp = timestamp;
}
}
我们如何保证这些Tweet对象是不可变的,即一旦创建了推文,其作者,消息和日期永远不会改变?
对不可变性的第一个威胁来自客户实际上必须直接访问其领域的事实。
Tweet t = new Tweet("justinbieber",
"Thanks to all those beliebers out there inspiring me every day",
new Date());
t.author = "rbmllr";
这是表示泄露的一个简单例子,这意味着类外的代码可以直接修改表示。像这样的报道暴露不仅威胁到不变量,而且威胁到代表独立性。在不影响直接访问这些字段的所有客户端的情况下,我们无法更改Tweet的实施。
幸运的是,Java为我们提供了语言机制来处理这种表示泄露:
public class Tweet {
private final String author;
private final String text;
private final Date timestamp;
public Tweet(String author, String text, Date timestamp) {
this.author = author;
this.text = text;
this.timestamp = timestamp;
}
/** @return Twitter user who wrote the tweet */
public String getAuthor() {
return author;
}
/** @return text of the tweet */
public String getText() {
return text;
}
/** @return date/time when the tweet was sent */
public Date getTimestamp() {
return timestamp;
}
}
在private和public关键字表明哪些字段和方法可访问时,只在类内部还是可以从类外部访问。所述final关键字还保证该对象被构造后,该不可变型的字段将不被重新分配。
但这不是结局:表示仍然泄露!考虑这个完全合理的客户端代码,它使用Tweet:
/** @return a tweet that retweets t, one hour later*/
public static Tweet retweetLater(Tweet t) {
Date d = t.getTimestamp();
d.setHours(d.getHours()+1);
return new Tweet("rbmllr", t.getText(), d);
}
retweetLater
需要推文,并且应该用相同的消息(称为转推)返回另一条推文,但在一小时后发送。该retweetLater
方法可能是系统的一部分,可以自动回应Twitter名人所说的有趣事情。
这里有什么问题?该getTimestamp
调用返回Date
对推文引用的同一对象的引用t
。 t.timestamp
并且d
是同一个可变对象的别名。因此,当日期对象被突变时d.setHours()
,这t
也会影响日期,如快照图所示。
Tweet
不变性不变已被打破。问题是Tweet
泄露了对不变性依赖的可变对象的引用。我们以这种方式暴露了代表,Tweet不能再保证它的对象是不可变的。完美合理的客户端代码创造了一个微妙的错误。
我们可以通过使用防御性拷贝来修补这种风险:制作可变对象的副本以避免泄漏对代表的引用。代码如下:
public Date getTimestamp() {
return new Date(timestamp.getTime());
}
可变类型通常具有一个复制构造函数,它允许您创建一个复制现有实例值的新实例。在这种情况下,Date
拷贝构造函数使用1970年1月1日以来以毫秒为单位测量的时间戳值。作为另一个例子,StringBuilder
拷贝构造函数需要一个String
。复制可变对象的另一种方法是clone()
,某些类型但不是全部类型支持该方法。然而clone()
Java中的工作方式存在问题。有关更多信息,请参阅Josh Bloch,Effective Java,第11项。
所以我们做了一些防御性的复制,其返回值为getTimestamp
。但我们还没有完成!还有表示泄露。考虑这个(也是非常合理的)客户端代码:
/** @return a list of 24 inspiring tweets, one per hour today */
public static List<Tweet> tweetEveryHourToday () {
List<Tweet> list = new ArrayList<Tweet>();
Date date = new Date();
for (int i = 0; i < 24; i++) {
date.setHours(i);
list.add(new Tweet("rbmllr", "keep it up! you can do it", date));
}
return list;
}
此代码旨在在Date
一天中的24小时内推进单个对象,为每个小时创建一条推文。但请注意,Tweet的构造函数保存传入的引用,因此所有24个Tweet对象最终都以同一时间结束,如此快照图所示。
再次,Tweet的不变性已被违反。我们也可以通过明智的防御性复制来解决这个问题,这次是在构造函数中:
public Tweet(String author, String text, Date timestamp) {
this.author = author;
this.text = text;
this.timestamp = new Date(timestamp.getTime());
}
一般来说,应仔细检查所有ADT操作的参数类型和返回类型。如果任何类型都是可变的,请确保你的实现不返回对其表示的直接引用。这样做会造成表示泄露。
你可能反对说这看起来很浪费。为什么要制作所有这些日期的副本?为什么我们不能通过像这样仔细书写的规范来解决这个问题?
/**
* Make a Tweet.
* @param author Twitter user who wrote the tweet
* @param text text of the tweet
* @param timestamp date/time when the tweet was sent. Caller must never
* mutate this Date object again!
*/
public Tweet(String author, String text, Date timestamp) {
这种方法有时会在没有其他合理的选择时采用,例如,当可变对象太大而无法有效地复制时。但是,由此引发的潜在bug也将很多。除非迫不得已,否则不要把希望寄托于客户端上,ADT有责任保证自己的invariants,并避免表示泄露。
最好的办法就是使用immutable的类型,彻底避免表示泄露,例如java.time.ZonedDateTime
而不是 java.util.Date
。
保持不变性和避免表示泄漏,是ADT最重要的一个Invariant!
抽象函数AF和表示不变量RI
两个值的空间
R:表示值(rep 值)的空间,由实际实现实体的值组成。一般情况下ADT的表示比较简单,有些时候需要复杂表示。
A:抽象值构成的空间,client看到和使用的值。
ADT实现者关注表示空间R,用户关注抽象空间A 。
R->A的映射特点:
- 满射:每个抽象值被映射到一些rep值
- 未必单射:一些抽象值被映射到多个rep值
- 未必双射:并非所有的rep值都被映射。
抽象函数:R和A之间映射关系的函数
AF : R → A
表示不变量:将rep值映射到布尔值
RI : R → boolean
- 表示不变性RI:某个具体的“表示”是否是“合法的”
- 也可将RI看作:所有表示值的一个子集,包含了所有合法的表示值
- 也可将RI看作:一个条件,描述了什么是“合法”的表示值
表示不变量和抽象函数都应该记录在代码中,就在代表本身的声明旁边,以下图为例
AF和RI如下:
public class CharSet {
private String s;
// Rep invariant:
// s contains no repeated characters
// Abstraction function:
// AF(s) = {s[i] | 0 <= i < s.length()}
...
}
不同的内部表示,需要设计不同的AF和RI。选择某种特定的表示方式R,进而指定某个子集是“合法”的(RI),并为该子集中的每个值做出“解释”(AF)——即如何映射 到抽象空间中的值。比较下面两个例子:
它的AF和RI为:
public class CharSet {
private String s;
// Rep invariant:
// s[0] <= s[1] <= ... <= s[s.length()-1]
// Abstraction function:
// AF(s) = {s[i] | 0 <= i < s.length()}
...
}
它的AF和RI为:
public class CharSet {
private String s;
// Rep invariant:
// s.length() is even
// s[0] <= s[1] <= ... <= s[s.length()-1]
// Abstraction function:
// AF(s) = union of {s[2i],...,s[2i+1]} for 0 <= i < s.length()/2
...
}
即使是同样的R、同样的RI,也可能有不同的AF,即“解释不同”。
设计ADT:(1) 选择R和A;(2) RI — 合法的表示值; (3) 如何解释合法的表示值 —映射AF 做出具体的解释:每个rep value如何映射到abstract value 。而且要把这种选择和解释明确写到代码当中。
随时检查RI是否满足:你应该调用checkRep()在创建或改变不变量的每个操作(creators, producers, and mutators)结束时assert
表示不变量。
程序员之间的“潜规则”:数据都“非空”。
有益的可变性
对immutable的ADT来说,它在A空间的abstract value应是不变的。但其内部表示的R空间中的取值则可以是变化的。这种变化称为beneficent mutation (有益的可变性)。
举一个简单的例子。
public class RatNum {
private int numerator;
private int denominator;
// Rep invariant:
// denominator != 0
// Abstraction function:
// AF(numerator, denominator) = numerator/denominator
/**
* Make a new RatNum == (n / d).
* @param n numerator
* @param d denominator
* @throws ArithmeticException if d == 0
*/
public RatNum(int n, int d) throws ArithmeticException {
if (d == 0) throw new ArithmeticException();
numerator = n;
denominator = d;
checkRep();
}
...
}
/**
* @return a string representation of this rational number
*/
@Override
public String toString() {
int g = gcd(numerator, denominator);
numerator /= g;
denominator /= g;
if (denominator < 0) {
numerator = -numerator;
denominator = -denominator;
}
checkRep();
return (denominator > 1) ? (numerator + "/" + denominator)
: (numerator + "");
}
注意这个toString
实现重新分配了私有字段,numerator并且denominator对表示进行了变异 - 即使它是不可变类型的观察者方法!但是,关键的是,突变不会改变抽象的价值。将分子和分母除以相同的公因式g,或乘以-1都不会影响抽象函数的结果AF(numerator, denominator) = numerator/denominator。另一种思考的方式是AF是一个多对一的函数,并且代表值已经改变为另一个仍然映射到相同的抽象值。所以这种突变是无害的,或者是有益的。
记录AF,RI和表示泄露
在代码中用注释形式记录AF和RI是很好的习惯,另外需要记录的是表示泄漏的安全声明,你要给出理由,证明代码并未对外泄露其内部表示——自证清白。
以下是Tweet其表示不变量,抽象函数以及来自表示泄露的安全性的示例:
// Immutable type representing a tweet.
public class Tweet {
private final String author;
private final String text;
private final Date timestamp;
// Rep invariant:
// author is a Twitter username (a nonempty string of letters, digits, underscores)
// text.length <= 140
// Abstraction function:
// AF(author, text, timestamp) = a tweet posted by author, with content text,
// at time timestamp
// Safety from rep exposure:
// All fields are private;
// author and text are Strings, so are guaranteed immutable;
// timestamp is a mutable Date, so Tweet() constructor and getTimestamp()
// make defensive copies to avoid sharing the rep's Date object with clients.
// Operations (specs and method bodies omitted to save space)
public Tweet(String author, String text, Date timestamp) { ... }
public String getAuthor() { ... }
public String getText() { ... }
public Date getTimestamp() { ... }
}
请注意,我们没有任何显式的不变条件timestamp
(除了传统假设timestamp!=null
,我们对所有对象引用都有)。但是我们仍然需要timestamp
在表示泄露安全性论据中加入,因为整个类型的不变性取决于所有的字段保持不变。
例子2:
// Immutable type representing a rational number.
public class RatNum {
private final int numerator;
private final int denominator;
// Rep invariant:
// denominator > 0
// numerator/denominator is in reduced form, i.e. gcd(|numerator|,denominator) = 1
// Abstraction function:
// AF(numerator, denominator) = numerator/denominator
// Safety from rep exposure:
// All fields are private, and all types in the rep are immutable.
// Operations (specs and method bodies omitted to save space)
public RatNum(int n) { ... }
public RatNum(int n, int d) throws ArithmeticException { ... }
...
}
Summary
What an ADT spec may talk about
- ADT的规约里只能使用client可见的内容来撰写,包括参数、返回值、异常等。
- 如果规约里 需要提及“值”,只能使用A空间 中的“值”。
- ADT的规约里也不应谈及任何内部表示的细节,以及R空间中的任何值 。
- ADT的内部表示(私有属性)对外部都应严格不可见。
故在代码中以注释的形式写出AF和RI而不能在Javadoc文档中,防止被外部看到而破坏表示独立性/信息隐藏 。
How to establish invariants
- 在对象的初始状态不变量为true,在对象发生变化时,不变量也要为true 。
- 构造器和生产器在创建对象时要确保不变量为true 。
- 变值器和观察器在执 行时必须保持不变性。
- 表示泄漏的风险:一旦泄露,ADT内部表示可能会在程序的任何位置发生改变(而不是限制在ADT内部),从而无法确保ADT的不变量是否能够始终保持为true。
ADT不变量取代前置条件
设计良好的抽象数据类型的巨大优势在于它封装和执行了我们必须在前提条件下必须规定的属性。因此,可以用不变量取代方法的Precondition。例如:
/**
* @param set1 is a sorted set of characters with no repeats
* @param set2 is likewise
* @return characters that appear in one set but not the other,
* in sorted order with no repeats
*/
static String exclusiveOr(String set1, String set2);
可改为:
/** @return characters that appear in one set but not the other */
static SortedSet<Character> exclusiveOr(SortedSet<Character> set1, SortedSet<Character> set2);
好处如下:
- 因为所需条件(不重复排序)可以在一个地方,
SortedSet
类型中强制执行,并且由于Java静态检查起作用,阻止不满足此条件的值根本无法使用,在编译时出错。 - 它更容易理解,因为它更简单,并且名称
SortedSet
传达了程序员需要知道的内容。 - 它更容易进行更改,因为
SortedSet
现在可以更改表示量而不更改exclusiveOr
任何客户端。