软件构造 3-3 Abstract Data Type (ADT)

3.3 抽象数据类型( ADT )

一. ADT 与表示独立性

  抽象数据类型表示独立性: 如何设计良好的抽象数据结构,通过封装来避免客户端获取数据的内部表示(即“表示泄露”),避免潜在的 bug 在用户和开发者之间建立“防火墙”。

  • ADT特性:表示泄漏、抽象函数 AF 、表示不变量 RI
  • 基于数学的形式对 ADT 的这些核心特征进行描述并应用于设计中。

   AFRI 是给用户看的。

二. 抽象与用户定义类型

  抽象: 忽略底层的细节而在高层思考。

  模块化:将系统分为一个模块,每个模块可以单独的进行设计、实现、测试、推倒,并且在剩下的开发中进行复用。

  封装:在模块的外部建立起一道“围墙”,使它只对自己内部的行为负责,并且系统别处的 bug 不会影响到它内部的正确性。

  信息隐藏:将模块的实现细节隐藏,使未来更改模块内部时不必改变外部代码。

  功能分离:一个模块仅仅负责一个特性/功能,而不是将一个特性运用在很多模块上或一个模块拥有很多特性。

  数据抽象:由一组操作所刻画的数据类型。无法直接访问属性,即属性是 priavete 的。

  传统的类型定义:关注数据的具体表示。

  抽象类型:强调“作用于数据上的操作”,程序员和用户无需关心数据如何具体存储的,只需设计 使用操作即可。

1. 类型和操作的分类

  对于可变不可变数据类型:

  • 可变类型的对象:提供了可改变其内部数据的值的操作
  • 不变数据类型: 其操作不改变内部值,而是构造新的对象

  方法的类型可分为:
  注:下列式子中, t 代表着其他的类型, + 代表着该参数可能出现一次或多次,* 代表着这个参数可能出现零次或多次。

  • 构造器( creator ):从无到有,构造器可能实现为构造函数或静态函数,构造器实现的静态方法通常称为工厂方法t* → T 。如 String.valueOf(Object Obj)
  • 生产器( producer ):从接受旧类型的对象到创建新的对象,都是当前类型的。T+, t* → T 。如 Stringconcat()
  • 观察器( observer ):从旧类型到新类型。T+, t* → t 。如 Listsize() 操作
  • 变值器( mutator ):改变对象属性的方法,通常返回 void。注:如果返回值为 void,则必然意味着它改变了对象的某些内部状态,即必是变值器。变值器也可能返回非空类型。T+, t* → void | t | T 。如 Listadd()

  构造通常都是用构造函数实现的,例如 new HashSet() ,但是有的构造体是静态方法(类方法),例如 Arrays.asList()String.valueOf ,这样的静态方法也称为工厂方法

2. 抽象数据型

  int 不可变类型:

  • 构造器( creator ):0, 1, 2, …
  • 生产器( producer ):+, -, *, /
  • 观察器( observer ):==, !=, >, <
  • 变值器( mutator ):无

  List 可变类型,也是借口:

  • 构造器( creator ):ArrayListLinkedList 的构造函数,Collections.singletonList
  • 生产器( producer ):Collections.unmodifiableList
  • 观察器( observer ):size, get
  • 变值器( mutator ):add, remove, addAll, Collections.sort

  String 不可变类型:

  • 构造器( creator ):String 构造函数,valueOf 静态方法(工厂方法)
  • 生产器( producer ):concatsubstring, 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() ),且用操作满足用户需要的难度要低(如 Listsize() 操作)

4. 表示独立性

  表示独立性:用户使用 ADT无需考虑内部如何实现ADT 内部表示的变化不应影响外部 spec 和客户端,内部表示的改变将对外部的代码没有影响。例如: List 提供的操作独立于 List 表示为链表 LinkedList 还是数组 ArrayList

  如果一个操作完全在规格说明中定义了前置条件后置条件,使用者就知道他应该依赖什么,而你也可以安全的对内部实现进行更改(遵循规格说明)。

  除非 ADT 的操作指明了具体的前置条件和后置条件 ,否则不能改变 ADT 的内部表示—— spec 规定了用户和开发者之间的契约。


  如下图,两种实现形式:

在这里插入图片描述

在这里插入图片描述

  通过操作访问属性而不是直接访问属性。

5. 测试抽象类型

  对于抽象类型,需要测试 creators , producers , observermutators

  • 测试 creators , producers , mutators :调用 observers 来观察这些 operations 的结果是否满足 spec
  • 测试 observers :调用 creators , producersmutators 等方法产生或改变对象,来看结果是否正确。
  • 风险:如果被依赖的其他方法有错误,可能导致被测试方法的测试结果失效。

6. 不变性

  ADT 会保护/保留自己的不变量。

  不变量:是一种属性,在程序运行的任何时候总是 true。例如:immutability 就是一个典型的“不变量”。

  由 ADT 来负责其不变量,与用户端的任何行为无关。


  使用者可以直接访问内部的数据,称为表示暴露。类外部的代码可以直接修改类内部存储的数据。对于不可变类型表示暴露同时影响了不变量以及表示独立性。

  不可变类型除了使用 privatefinal 保护不变性以外,可以使用防御性复制使得在返回的时候复制一个新的对象而不会返回原对象的索引。例如使用 clone() (但标准库只有 5% 支持)。

  有时候防御式拷贝占用大量时间和内存,除非迫不得已,否则不要把希望寄托于客户端上, ADT 有责任保证自己的不变性,并避免“表示泄露”。

  所以最好的办法就是使用 immutable的类型,彻底避免表示泄露。

7. 表示不变性( RI )和抽象函数( AF

  有两个值域

  • 表示域(表示空间):包含的是值具体实现实体。在简单的情况下,一个抽象类型只需要实现为单个的对象,但是更常见的情况是使用一个很多对象的网络。
  • 抽象域(抽象空间):类型设计时支持使用的值。这些值是由表示域抽象/想象”出来的,也是使用者关注的。

  需要注意的是:

  • 每一个抽象值都是由表示值映射而来。(满射
  • 一些抽象值是被多个表示值映射而来的。
  • 不是所有的表示值都能映射到抽象域中。(未必单射

在这里插入图片描述

  于是有了两个概念:

  1. 抽象函数AF ):表示值到其对应抽象值的映射。AF:R → A
    这种映射是满射,不一定是单射与双射。
  2. 表示不变性RI ):表示值布尔值的映射:RI:R → boolean
    对于表示值 r ,当且仅当 rAF 映射到了 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()}
    ...
}

  抽象函数和表示不变量不只是由表示域抽象域决定

  • 抽象域不能独立决定 AFRI对于同样的抽象类型有多种表示方法
  • 表示域和抽象域不能决定 AFRI :当我们确定表示域(表示值的空间)后,我们并不能决定哪一些表示值是合法的,以及如果它是合法的,它会被怎么解释/映射。对于同一个表示域,可以得到了不同的表示不变量

  同样的抽象域 A表示域 R 以及同样的表示不变量 RI ,可能有不同的解释方法/抽象函数 AF

  一个 ADT 的实现不仅是选择表示域(规格说明)和抽象域(具体实现),同时也要决定哪一些表示值是合法的(表示不变量),合法表示会被怎么解释/映射(抽象函数)。


  设计 ADT

  • 选择 RA
  • 如何解释合法的表示值——映射 AF
  • RI ——合法的表示值;

  做出具体的解释:每个 rep value 如何映射到 abstract value ,而且要把这种选择和解释明确写到代码当中

8. 检查表示不变性

  在每一个创建或者改变表示数据的操作后应该调用 private void checkRep() 检查不变性,换句话说,就是在使用创建者生产者以及改造者之后。

  • Observer 方法可以不用,但建议也要检查,以防止你的“万一”

  由于 null 的弊端,默认情况下不允许表示中的索引出现 null 值(包括数组或者列表中的元素)。

  于是表示不变量中默认就会有 s != null ,这不需要专门在表示不变量的注释中进行说明。但实现检查表示不变量的 checkRep() 时,你应该显式的检查 s != null

  通常来说,这种检查会是自动的,因为很多操作在内容是 null 时会自动抛出异常。所以有时候不需要使用assert s != null


  实质上,不可变类型是指对象一旦被创建,其抽象值A 空间的值)不会发生改变。对于使用者来说,其代表的值是不会变的,但是实现者可以在底层对表示域做一些改动,这些不会影响到抽象域的改动就称为友善改动有益改变)。

9. AFRI 以及表示暴露安全性的注解

  在描述抽象函数和表示不变性的时候:

  • RI(表示不变性):说明什么区域合法;说明是什么使得它合法 / 不合法
  • AF(抽象函数):说明抽象域表示了什么;抽象函数的作用是规定合法的表示值会如何被解释到抽象域——说明从一个输入到一个输入是怎么对应的。
  • 表示暴露的安全性( Safety from rep exposure ):要说明表示的每一部分——为什么不会发生表示暴露,特别是处理的表示的参数输入返回部分(这也是表示暴露发生的位置)。

10. ADT 的规格说明

在这里插入图片描述

  • 抽象类型的规格说明(包含操作的说明)应该只关注使用者可见的部分,这包括参数返回值可能抛出的异常。例如规格说明需要引用 T 的值时,它应该是抽象域的值而非表示域
  • 规格说明应该谈论具体的表示/实现细节,例如表示域里面的值。它应该认为表示本身(私有区域)对于使用者是不可见的,就像是方法里面的局部变量对外部不可见。
  • 注解表示不变量抽象函数的时候使用的是"//"注释而非典型的 Javadoc 格式。

  表示不变性的检查,对于 ADT 的操作,就是:

  • 创建者生产者必须对新的对象就建立不变量
  • 改造者观察者必须保持/保护这种不变量
  • 无表示暴露

  表示暴露使得情况更为复杂。

  使用不变量完整的规则应该是:

  结构归纳法. 如果一个抽象数据类型的不变量满足:

  • 被创建者或生产者创建;
  • 被改造者和观察者保持;
  • 没有表示暴露。

  那么这种类型的所有实例的不变量都是成立的。

11. 前置条件与不变量

  用 ADT 不变量取代复杂的 Precondition ,相当于将复杂的 precondition 封装到了 ADT 内部。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值