3-3抽象数据类型(ADT)
一、抽象和用户定义类型
抽象意味着什么
抽象数据类型是软件工程中的一个通用原理的实例,它的名称很多:
-抽象:用更简单、更高级的概念省略或隐藏低层次的细节。
-模块化。将系统划分为组件或模块,每一个系统都可以被设计、实现、测试、推理,并与系统的其余部分分开使用。
-封装。围绕模块(硬壳或胶囊)构建墙壁,以便模块负责其自身的内部行为,并且系统的其他部分中的缺陷不能损坏其完整性。
-信息隐藏。从系统的其余部分隐藏模块的实现细节,以便在不改变系统其余部分的情况下可以更改这些细节。-分离关注点。制作一个特性(或“关注”)单个模块的责任,而不是将它分散在多个模块上。
用户定义类型
一种编程语言带有内置的类型(如整数、布尔值、字符串等)和内置的程序,例如,用于输入和输出。
用户可以定义自己的数据类型和过程-用户定义类型
除了编程语言所提供的基本数据类型和对象数据类型,程序员可定义 自己的数据类型
数据抽象
数据抽象:由一组操作所刻画的数据类型(而非…)
-一个数字是你可以添加和乘法的东西;
-一个字符串是你可以串接并取子串的东西;
传统的类型定义:关注数据的具体表示
抽象类型:强调“作用于数据上的操作”,程序员和 client无需关心数据如何具体存储的,只需设计/使用操作即可。
例
抽象数据类型布尔具有以下操作:
- true: Bool
- false: Bool
- and: Bool × Bool → Bool
- or: Bool × Bool → Bool
- not: Bool → Bool
有许多可能的方法可以实现Bool,并且仍然能够满足操作的规范,例如:
- 作为一个 bit,其中1表示真,0表示假
- 作为一个int值,其中5表示真,8表示假
- 作为字符串对象的引用,其中“false”表示“true”和“true”表示“false”
- 作为一个int值>1,素数表示真,复合数表示假
抽象类型是由其操作定义的
记住--抽象数据类型是由它的操作定义的。
-类型T的操作集,连同它们的规格,充分地描述了T的意思
当我们讨论列表类型时,我们指的不是一个链表或一个数组或任何其他特定的数据结构来表示一个列表。而是指一组不透明值——可能具有列表类型的可能对象——满足列表的所有操作的规格: get(), size()等
ADT是由操作定义的,与其内部如何实现无关
抽象类型的值是不透明的,因为客户端不能检查存储在其中的数据,除非操作允许。
它不仅隐藏了单个函数的实现,而且隐藏了一组相关函数(类型的操作)和它们共享的数据(存储在类型的值内的私有字段)
二、分类类型与操作
易变和不可变类型
类型,无论是内置的还是用户定义的,都可以归类为可变的或不可变的
- 可变类型的对象:提供了可改变其 内部数据的值的操作
- Date是可变的,因为您可以调用setMonth() ,并用getMonth() 操作观察更改
- 不变数据类型:其操作不改变内部值,而是构造新的对象
- 有时,一种类型将以两种形式提供,一种是可变的和不可变的形式。例如,StringBuilder是String的一个可变版本(但是e two肯定不是同一个Java类型,并且是不可互换的)
对抽象类型的操作进行分类
构造器筛选类型的新对象
- 一个构造器可以把一个对象当作一个参数,而不是一个被构造的对象
t* → T
生产器从类型的旧对象创建新对象
- String()的字符串方法,例如,是一个生产者:它使用两个字符串,并产生一个表示它们的级联的新字符串
T+, t* → T
观察器获取抽象类型的对象并返回不同类型的对象
- 例如,列表的size()方法返回int
T+, t* → t
变值器改变对象属性的方法
- 列表的add()方法,例如,通过在末尾添加元素来改变列表
T+, t* → void | t | T
每一个T都是抽象类型本身;
每个T都是其他类型的。
+标记表示该类型可以在签名的那一部分中出现一次或多次。
*标记指示它发生零次或多次。
|表示或
操作符号
String.concat()作为生产器
- concat: String × String → String
List.size() 作为观察器
- size: List → int
String.regionMatches 作为观察器
- regionMatches: String × boolean × int × String × int × int → boolean
构造器标志
构造器:可能实现为构造函数或静态函数
作为静态方法实现的创建者通常被称为工厂方法
Java中的各种Strord.ValueOf(object Objo)方法是作为工厂方法实现的创建者的其他示例。与 Object.toString()正好是相对的
变值器标志
变值器通常返回 void
- 如果返回值为void,则必然意 味着它改变了对象的某些内部状态
变值器也可能返回非空类
- 例如,Set.add() 返回一个布尔值,指示该集合是否被实际更改。
- 在Java的图形用户界面工具包中,Component.add() 返回对象本身,从而可以将多个add() 调用链接在一起
三、抽象数据类型示例
int and String
int是不可变的,所以它没有突变子
-创造器:数字文字0, 1, 2,…
-生产器:算术运算符 + , - , * , /
-观察器:比较运算符 == , != , < , >
-变值器:空(它是不可变的)
String是Java的字符串类型。字符串是不可变的。
-创造器:String构造函数
-生产器:concat , substring , toUpperCase
-观察器:length , charAt
-变值器:空
列表
List是Java的列表类型,是可变的。
List也是一个接口,这意味着其他类提供了数据类型的实际实现,例如 ArrayList 和LinkedList
-创造器:ArrayList 和 LinkedList 构造器,Collections.singletonList
-生产器:Collections.unmodifiableList
-观察器:size ,get
-变值器:add , remove , addAll , Collections.sort
例:
Integer.valueOf() § Creator
BigInteger.mod() § Producer
List.addAll() § Mutator
String.toUpperCase() §Producer
Set.contains() § Observer
Collections.unmodifiableList() §Producer
BufferedReader.readLine() Mutator
四、设计抽象类型
设计好的ADT,靠“经验法 则”,提供一组操作,设计其行为规约spec
经验法则1 设计简洁、一致的操作
- 最好有一些简单的操作,它们可以以强大的方式组合,而不是很多复杂的操作。- 每个操作应该有明确的目的,并且应该有一个连贯的行为,而不是一系列特殊的情况。
- 例如,我们可能不应该添加一个和操作来列出。它可以帮助那些使用整数列表的客户,但是字符串列表呢?还是嵌套列表?所有这些特殊情况都会使理解和使用变得困难
经验法则2要足以支持client对数据所做的所有操作需要,且用操作满足client需要的难度要低
在一定程度上,操作必须足够,即必须有足够的能力来完成客户端可能要做的计算。
- 一个好的测试是检查类型的对象的每一个属性可以被提取。
- 例如, 没有get()操作就无法获取list内部数据
- 基本信息不应该很难获得。
- 用遍历方式获取list的size vs 提供size()操作
经验法则3要么抽象、要么具体,不要混合 - 要么针对抽象 设计,要么针对具体应用的设计
类型可以是泛型:例如列表或集合,或图形。或者它可能是特定于领域的:街道地图、雇员数据库、电话簿等。
但是它不应该混合通用和特定于域的特性。
- 用于表示一系列扑克牌的甲板类型不应该有一个通用的添加方法,它接受诸如整数或字符串之类的任意对象。
- 相反,将一种特定领域的方法(如DealCad)放入泛型类型列表中是没有意义的
五、表示独立性
表示独立性:client使用ADT时无需考虑其内部如何实现,ADT内部表示的变化不应影响外部spec和客户端
- 抽象类型的使用与它的表示无关(用于实现它的实际数据结构或数据字段),因此表示的改变对抽象类型本身之外的代码没有影响。
- 例如,列表所提供的操作与列表是否被表示为链表或数组无关。
除非ADT的操作指明了具体的pre和post-condition,否则不能改变ADT的内部表示—spec规定了 client和implementer之间的契约
让我们编写它的测试用例(测试优先编程)
MyString s = MyString.valueOf(true);
assertEquals(“true”, s);
assertEquals(“true”, s.toString());
assertEquals(4, s.length());
assertEquals('t', s.charAt(0));
assertEquals('r', s.charAt(1));
assertEquals('u', s.charAt(2));
assertEquals('e', s.charAt(3));
MyString t = s.substring(0,2);
assertEquals('t', t.charAt(0));
一种简单的字符串表示方法
看一个简单的MyString表示:只是一个字符数组,确切地说是字符串的长度,在结尾没有多余的空间。
这里是如何声明内部表示,作为类内的实例变量:
private char[] a;
通过这种选择,操作将以一种直截了当的方式实现
MyString的相应实现
表现更好的另一种表现形式
因为此数据类型是不可变的,所以子字符串操作不必将字符复制到新数组中。
它可以指向原始的 MyString 对象的字符数组,并跟踪新子字符串对象所代表的开始和结束。
为了实现这种优化,我们可以将该类的内部表示更改为:
private char[] a;
private int start;
private int end;
六、测试抽象数据类型
如何测试ADT
通过为每个操作创建测试,我们为抽象数据类型构建测试套件。
这些测试不可避免地相互影响。
测试创建者、制作人和突变者的唯一方法是调用观察对象的结果,同样地,测试观察员的唯一方法是创建对象让他们观察。
测试creators, producers, and mutators:调用observers来观察这些 operations的结果是否满足spec; §
测试observers:调用creators, producers, and mutators等方法产生或 改变对象,来看结果是否正确
七、不变量
ADT的不变量
一个好的抽象数据类型的最重要的属性是保持不变量
不变量:在任何时候总是true
-不变性是一个重要的不变量:一旦创建,一个不可变的对象应该始终代表相同的值,它的整个生命周期。
由ADT 来负责其不变量,与client端的任何行为无关
-它不依赖于客户的良好行为。
-正确性不依赖于其他模块
为什么需要不变量?
保持程序的“正确性”,容易发现错误
-如果您可以相信字符串不会改变的事实,那么在调试使用字符串的代码时,或者当您试图为使用字符串的另一个ADT建立一个不变量时,可以排除这种可能性。
-与字符串类型进行对比,该字符串类型保证只有在客户承诺不更改的情况下,它才是不可变的。然后,您必须检查代码中可能使用字符串的所有位置。如果没有这个不变性,那么在所有使用String的地方,都要检查其是否改变了
不变性作为一类不变量
我们如何保证这些Tweet对象是不可变的—一旦创建了tweet,它的作者、消息和日期就永远不会改变?
它是可变的…
对不可变性的第一威胁来自客户端可以直接访问其字段的事实。
这个代码有什么影响?
这是一个表示泄露的例子,不仅影响不变性,也影响了表示独立性:无法在不影响客户端的情况下改变其内部表示
-像这样的Rep曝光不仅威胁不变量,而且威胁表现独立性。
-我们不能改变Tweet的实现,而不影响直接访问这些字段的所有客户端。
使它不变…
private和public关键字指示哪些字段和方法只能在类内访问,并且可以从类外部访问。
final关键字也有助于保证该不可变类型的字段在构造对象之后不会被重新分配。
这个代码有什么影响?
retweetLater() 使用tweet,并返回同一消息的另一条tweet(称为retweet),但一小时后发送。tweet的方法可能是一个系统,它能自动回应 Twitter名人所说的有趣的事情。
有什么问题吗?
这里有什么问题?
- getTimestamp调用返回tweet t引用的同一日期对象的引用。t.timestamp和d是同一个可变对象的别名。
- 因此,当日期对象由 d.setHours() 改变时,这也会影响t中的日期
推特的不变性不变量已被打破。
- 问题是 Tweet 泄露了对一个可变对象的引用,它的不变性依赖于它。
- 我们以这样的方式暴露了rep, Tweet 不能再保证它的对象是不可变的。
- 完全合理的客户端代码创建了一个微妙的bug
如何解决?防御性复制
我们可以通过使用防御性复制来修补这种类型的rep曝光:制作一个可变对象的副本,以避免泄露对rep的引用。
防御性复制是一种防御编程的方法
-假设客户端将试图破坏不变量---实际上可能是真的(恶意黑客),但更可能的是,诚实的错误
-确保类不变量生存任何输入,以尽量减少易变性
复制和克隆()
可变类型通常有一个复制构造函数,它允许您创建一个新实例,该实例重复现有实例的值。
- 在这种情况下,Dead的复制构造函数使用时间戳值,从1970年1月1日开始以毫秒为单位进行测量。
- 作为另一个例子,StringBuilder的复制构造函数采用字符串。另一种复制可变对象的方法是C克隆(),它由一些类型支持,但不是全部。
这个代码的副作用是什么?
Tweet的构造函数保存了传入的引用,因此所有24个Tweet对象都以相同的时间结束。
如何解决?再次,防御性复制
通常,您应该仔细检查所有类型的ADT操作的参数类型和返回类型。
如果任何类型都是可变的,请确保您的实现不返回对其表示的直接引用。
做这件事会引起表示泄露。
把责任留给你的客户?
你可能反对这似乎是浪费。为什么要制作这些日期的复印件?为什么我们不能通过一个精心编写的规范来解决这个问题,像这样?
当复制代价很高时,不得不这么做。但是由此引发的潜在bug也将很多
使用不可变类型代替不可变类型
除非迫不得已,否则不要把希望寄托于客户端上,ADT有责任保证自 己的invariants,并避免表示泄露
最好的办法就是使 用immutable的类型,彻底避免表示泄露
八、Rep不变量与抽象函数
两个价值空间
R:表示值的空间(Rep值)由实际实现实体的值组成。
- 一般情况下ADT的表示比较简单,有些时候需要复杂表示
A:抽象值构成的空间:client看到和使用的值
- 它们是不存在的柏拉图式实体,但它们是我们希望将抽象类型的元素视为类型的客户端的方式。
- 例如,对于无界整数的抽象类型可能具有数学整数作为其抽象值空间;事实上,它可以被实现为原始(有界)整数数组,例如,与类型的用户无关
两个空间的例子
ADT实现者关注表示空间R,用户关注抽象空间A
例如,假设我们选择使用一个字符串来表示一组字符:
然后,Rep空间R包含字符串,抽象空间A是字符的数学集合。
R与A之间的映射
每一个抽象值都映射到一些Rep值(满射)
- 实现抽象类型的目的是支持对抽象值的操作。大概,我们将需要能够创建和操作所有可能的抽象值,因此它们必须是可表示的。
一些抽象值被映射到一个以上的RRP值(未必单射)。
- 这是因为表示不是严格的编码。有多种方法将无序字符集表示为字符串。
并非所有的Rp值都映射(未必双射)。
- 在这种情况下,字符串“abbc”没有映射。在这种情况下,我们已经决定字符串不应该包含重复。这将允许我们在命中特定字符的第一个实例时终止移除方法,因为我们知道最多只能有一个。
抽象函数
抽象函数:R和A之间映射关系的函数
AF : R → A
图中的圆弧显示了抽象函数。在函数的术语中,这些性质可以通过表示函数是满射(也称为上),不一定是内射(一对一),因此不一定是双射的,通常是部分的来表示。
rep不变量
将Rep值映射到布尔值的Rep不变量
RI : R → boolean
对于一个Rep值R,RI(r)是真的当且仅当R被AF映射时。
也就是说,RI告诉我们给定的Rep值是否是良好的。
或者,您可以将RI视为一组:它是定义AF的rep值的子集。
表示不变性RI:某个具体的“表示”是否是“合法的” §
也可将RI看作:所有表示值的一个子集,包含了所有合法的表示值 §
也可将RI看作:一个条件,描述了什么是“合法”的表示值
记录RI和AF
Rep不变量和抽象函数都应该记录在代码中,紧邻Rep本身的声明:
什么决定AF和RI?
抽象值空间单独不确定AF或RI:
- 对于相同的抽象类型,可以有几个表示。
- 一组字符可以同样地表示为字符串,如上所述,或者作为位向量,每个可能的字符都有一个比特。
- 显然,我们需要两个不同的抽象函数来映射这两个不同的RIP值空间。
- 不同的内部表示,需要设计不同的AF和RI
定义Rep的类型,并因此选择Rep值空间的值,不确定哪些Rep值将被视为合法的,哪些是合法的,将如何解释它们。选择某种特定的表示方式R,进而指定某个子集是“合 法”的(RI),并为该子集中的每个值做出“解释”(AF)——即如何映射 到抽象空间中的值
例如,如果我们允许字符串中的重复,但同时要求对字符进行排序,以非递减顺序出现,那么将有相同的Rep值空间,但不同的Rep不变。
即使是同样的R、同样的RI,也可能有不同的AF,即“解释不同”。
RI和AF对ADT设计的影响
要点在于设计抽象类型不仅意味着选择两个空间—规范的抽象值空间和实现的Rep值空间—而且还决定使用什么样的Rep值以及如何解释它们。
设计ADT:(1) 选择R和A;(2) RI --- 合法的表示值; (3) 如何解释合法的表示值 ---映射AF 做出具体的解释:每个rep value如何映射到abstract value
九、有益的可变性
回想一个类型是不可变的,当且仅当类型的值在创建之后才改变。
随着我们对抽象空间A和Rep空间R的新理解,我们可以提炼这个定义:抽象的值永远不应该改变。
但是,只要它继续映射到相同的抽象值,该实现就可以自由地改变RRP值,因此该改变对于客户端是不可见的。
这种变化叫做有益的可变性。
对immutable的ADT来说,它在A空间的abstract value应是不变的。 § 但其内部表示的R空间中的取值则可以是变化的。
有益的可变性的一个例子
RatNum:这个rep具有较弱的Rep不变量,它不需要将分子和分母存储在最低的条件下。
这个较弱的ReP不变量允许RatNum算术运算序列简单地省略结果到最低项。但是,当我们向人类展示结果时,我们首先简化它:
十、AF、RI和安全文件的曝光
在代码中用注释形式记录AF和RI
表示泄漏的安全声明
给出理由,证明代码并未对外泄露其内部表示—自证清白
ADT规范可能会说些什么
ADT的规约里只能使用client可见的内容来撰写,包括参数、返 回值、异常等。
如果规约里需要提及“值”,只能使用A空间中的“值”。
ADT的规约里也不应谈及任何内部表示的细节,以及R空间中的任何值
ADT的内部表示(私有属性)对外部都应严格不可见
故在代码中以注释的形式写出AF和RI而不 能在Javadoc文档中,防止被外部看到而破坏表示独立性/信息隐藏
如何建立不变量
不变量是整个程序的一个属性,在一个对象不变的情况下,它会减少到对象的整个生命周期。
要使一个不变量保持,我们需要:
- 在对象的初始状态中使不变量为真;
- 确保对象的所有更改保持不变。
- 在对象的初始状态不变量为true,在对象发生变化时,不变量也要为true
根据ADT操作的类型翻译这一点:
- 构造器和生产器在创建对象时要确保不变量为true
- 变值器和观察器在执行时必须保持不变性。
rep暴露的风险使得情况更加复杂。如果rep被暴露,那么对象可能在程序中的任何地方被改变,而不仅仅是在ADT的操作中,并且我们不能保证在这些任意改变之后,不变量仍然成立。
表示泄漏的风险:一旦泄露,ADT内部表示可能会在程序的任何位置 发生改变(而不是限制在ADT内部),从而无法确保ADT的不变量是 否能够始终保持为true。
因此,证明不变量的完整规则——如果抽象数据类型的不变量是:
由创造者和制作者建立的
被示威者和观察者保存
没有代表曝光发生
然后,抽象数据类型的所有实例的不变量都是真的。