3.3 抽象数据类型(
ADT
)
ADT
)
一. ADT
与表示独立性
抽象数据类型与表示独立性: 如何设计良好的抽象数据结构,通过封装来避免客户端获取数据的内部表示(即“表示泄露”),避免潜在的 bug
在用户和开发者之间建立“防火墙”。
ADT
的特性:表示泄漏、抽象函数AF
、表示不变量RI
- 基于数学的形式对
ADT
的这些核心特征进行描述并应用于设计中。
AF
与 RI
不是给用户看的。
二. 抽象与用户定义类型
抽象: 忽略底层的细节而在高层思考。
模块化:将系统分为一个模块,每个模块可以单独的进行设计、实现、测试、推倒,并且在剩下的开发中进行复用。
封装:在模块的外部建立起一道“围墙”,使它只对自己内部的行为负责,并且系统别处的 bug 不会影响到它内部的正确性。
信息隐藏:将模块的实现细节隐藏,使未来更改模块内部时不必改变外部代码。
功能分离:一个模块仅仅负责一个特性/功能,而不是将一个特性运用在很多模块上或一个模块拥有很多特性。
数据抽象:由一组操作所刻画的数据类型。无法直接访问属性,即属性是 priavete
的。
传统的类型定义:关注数据的具体表示。
抽象类型:强调“作用于数据上的操作”,程序员和用户无需关心数据如何具体存储的,只需设计 使用操作即可。
1. 类型和操作的分类
对于可变和不可变数据类型:
- 可变类型的对象:提供了可改变其内部数据的值的操作
- 不变数据类型: 其操作不改变内部值,而是构造新的对象
方法的类型可分为:
注:下列式子中, t
代表着其他的类型, +
代表着该参数可能出现一次或多次,*
代表着这个参数可能出现零次或多次。
- 构造器(
creator
):从无到有,构造器可能实现为构造函数或静态函数,构造器实现的静态方法通常称为工厂方法。t* → T
。如String.valueOf(Object Obj)
- 生产器(
producer
):从接受旧类型的对象到创建新的对象,都是当前类型的。T+, t* → T
。如String
的concat()
- 观察器(
observer
):从旧类型到新类型。T+, t* → t
。如List
的size()
操作 - 变值器(
mutator
):改变对象属性的方法,通常返回void
。注:如果返回值为void
,则必然意味着它改变了对象的某些内部状态,即必是变值器。变值器也可能返回非空类型。T+, t* → void | t | T
。如List
的add()
构造通常都是用构造函数实现的,例如 new HashSet()
,但是有的构造体是静态方法(类方法),例如 Arrays.asList()
和 String.valueOf
,这样的静态方法也称为工厂方法。
2. 抽象数据型
int
不可变类型:
- 构造器(
creator
):0
,1
,2
, … - 生产器(
producer
):+
,-
,*
,/
- 观察器(
observer
):==
,!=
,>
,<
- 变值器(
mutator
):无
List
可变类型,也是借口:
- 构造器(
creator
):ArrayList
和LinkedList
的构造函数,Collections.singletonList
- 生产器(
producer
):Collections.unmodifiableList
- 观察器(
observer
):size
,get
- 变值器(
mutator
):add
,remove
,addAll
,Collections.sort
String
不可变类型:
- 构造器(
creator
):String
构造函数,valueOf
静态方法(工厂方法) - 生产器(
producer
):concat
,substring
,toUpperCase
- 观察器(
observer
):length
,charAt
- 变值器(
mutator
):无
以下方法对应的类型:
方法 | 类型 |
---|---|
Integer.valueOf() | creator |
BigInteger.mod() | Producer |
List.addAll() | Mutator |
String.toUpperCase() | Producer |
Set.contains() | Observer |
Map.keySet() | Observer |
Collections.unmodifiableList() | Producer |
BufferedReader.readLine() | Mutator |
3. 设计抽象类型
抽象类型是通过它的操作定义的。
设计好的 ADT
,靠“经验法则”,提供一组操作,设计其行为规约 spec
。
- 设计简洁、一致的操作
- 要足以支持用户对数据所做的所有操作需要(如
List
必须需要get()
),且用操作满足用户需要的难度要低(如List
的size()
操作)
4. 表示独立性
表示独立性:用户使用 ADT
时无需考虑其内部如何实现,ADT
内部表示的变化不应影响外部 spec
和客户端,内部表示的改变将对外部的代码没有影响。例如: List
提供的操作独立于 List
表示为链表 LinkedList
还是数组 ArrayList
。
如果一个操作完全在规格说明中定义了前置条件和后置条件,使用者就知道他应该依赖什么,而你也可以安全的对内部实现进行更改(遵循规格说明)。
除非 ADT
的操作指明了具体的前置条件和后置条件 ,否则不能改变 ADT
的内部表示—— spec
规定了用户和开发者之间的契约。
如下图,两种实现形式:
通过操作访问属性而不是直接访问属性。
5. 测试抽象类型
对于抽象类型,需要测试 creators
, producers
, observer
和 mutators
- 测试
creators
,producers
,mutators
:调用observers
来观察这些operations
的结果是否满足spec
- 测试
observers
:调用creators
,producers
和mutators
等方法产生或改变对象,来看结果是否正确。 - 风险:如果被依赖的其他方法有错误,可能导致被测试方法的测试结果失效。
6. 不变性
ADT
会保护/保留自己的不变量。
不变量:是一种属性,在程序运行的任何时候总是 true
。例如:immutability
就是一个典型的“不变量”。
由 ADT
来负责其不变量,与用户端的任何行为无关。
使用者可以直接访问内部的数据,称为表示暴露。类外部的代码可以直接修改类内部存储的数据。对于不可变类型表示暴露同时影响了不变量以及表示独立性。
不可变类型除了使用 private
和 final
保护不变性以外,可以使用防御性复制使得在返回的时候复制一个新的对象而不会返回原对象的索引。例如使用 clone()
(但标准库只有 5%
支持)。
有时候防御式拷贝占用大量时间和内存,除非迫不得已,否则不要把希望寄托于客户端上, ADT
有责任保证自己的不变性,并避免“表示泄露”。
所以最好的办法就是使用 immutable
的类型,彻底避免表示泄露。
7. 表示不变性( RI
)和抽象函数( AF
)
有两个值域:
- 表示域(表示空间):包含的是值具体的实现实体。在简单的情况下,一个抽象类型只需要实现为单个的对象,但是更常见的情况是使用一个很多对象的网络。
- 抽象域(抽象空间):类型设计时支持使用的值。这些值是由表示域“抽象/想象”出来的,也是使用者关注的。
需要注意的是:
- 每一个抽象值都是由表示值映射而来。(满射)
- 一些抽象值是被多个表示值映射而来的。
- 不是所有的表示值都能映射到抽象域中。(未必单射)
于是有了两个概念:
- 抽象函数(
AF
):表示值到其对应抽象值的映射。AF:R → A
这种映射是满射,不一定是单射与双射。 - 表示不变性(
RI
):表示值到布尔值的映射:RI:R → boolean
对于表示值r
,当且仅当r
被AF
映射到了A
,RI(r)
为真。如下图的CharSet
(不容许有重复字符)
表示不变量和抽象函数都应该在表示声明后注释出来。
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
:对于同样的抽象类型有多种表示方法 - 表示域和抽象域不能决定
AF
和RI
:当我们确定表示域(表示值的空间)后,我们并不能决定哪一些表示值是合法的,以及如果它是合法的,它会被怎么解释/映射。对于同一个表示域,可以得到了不同的表示不变量。
同样的抽象域 A
和表示域 R
以及同样的表示不变量 RI
,可能有不同的解释方法/抽象函数 AF
。
一个 ADT
的实现不仅是选择表示域(规格说明)和抽象域(具体实现),同时也要决定哪一些表示值是合法的(表示不变量),合法表示会被怎么解释/映射(抽象函数)。
设计 ADT
:
- 选择
R
和A
- 如何解释合法的表示值——映射
AF
RI
——合法的表示值;
做出具体的解释:每个 rep value
如何映射到 abstract value
,而且要把这种选择和解释明确写到代码当中
8. 检查表示不变性
在每一个创建或者改变表示数据的操作后应该调用 private void checkRep()
检查不变性,换句话说,就是在使用创建者、生产者以及改造者之后。
Observer
方法可以不用,但建议也要检查,以防止你的“万一”
由于 null
的弊端,默认情况下不允许表示中的索引出现 null
值(包括数组或者列表中的元素)。
于是表示不变量中默认就会有 s != null
,这不需要专门在表示不变量的注释中进行说明。但实现检查表示不变量的 checkRep()
时,你应该显式的检查 s != null
。
通常来说,这种检查会是自动的,因为很多操作在内容是 null
时会自动抛出异常。所以有时候不需要使用assert s != null
。
实质上,不可变类型是指对象一旦被创建,其抽象值( A
空间的值)不会发生改变。对于使用者来说,其代表的值是不会变的,但是实现者可以在底层对表示域做一些改动,这些不会影响到抽象域的改动就称为友善改动(有益改变)。
9. AF
,RI
以及表示暴露安全性的注解
在描述抽象函数和表示不变性的时候:
RI
(表示不变性):说明什么区域是合法;说明是什么使得它合法 / 不合法。AF
(抽象函数):说明抽象域表示了什么;抽象函数的作用是规定合法的表示值会如何被解释到抽象域——说明从一个输入到一个输入是怎么对应的。- 表示暴露的安全性(
Safety from rep exposure
):要说明表示的每一部分——为什么不会发生表示暴露,特别是处理的表示的参数输入和返回部分(这也是表示暴露发生的位置)。
10. ADT
的规格说明
- 抽象类型的规格说明(包含操作的说明)应该只关注使用者可见的部分,这包括参数、返回值、可能抛出的异常。例如规格说明需要引用 T 的值时,它应该是抽象域的值而非表示域。
- 规格说明不应该谈论具体的表示/实现细节,例如表示域里面的值。它应该认为表示本身(私有区域)对于使用者是不可见的,就像是方法里面的局部变量对外部不可见。
- 注解表示不变量和抽象函数的时候使用的是"//"注释而非典型的
Javadoc
格式。
表示不变性的检查,对于 ADT
的操作,就是:
- 创建者和生产者必须对新的对象就建立不变量
- 改造者和观察者必须保持/保护这种不变量
- 无表示暴露
表示暴露使得情况更为复杂。
使用不变量完整的规则应该是:
结构归纳法. 如果一个抽象数据类型的不变量满足:
- 被创建者或生产者创建;
- 被改造者和观察者保持;
- 没有表示暴露。
那么这种类型的所有实例的不变量都是成立的。
11. 前置条件与不变量
用 ADT
不变量取代复杂的 Precondition
,相当于将复杂的 precondition
封装到了 ADT
内部。