抽象数据类型(ADT)-软件构造学习总结04


前言

这篇文章是我对软件构造课程的抽象数据类型章节的学习总结,以供未来使用。
主要内容包括

  • 用户自定义类和数据抽象
  • 数据类型和操作的分类
  • 设计抽象数据类型
  • 表示独立性
  • 表示不变量和表示泄露
  • RI和AF以及各自的记录

一、用户自定义类和数据抽象

我们已经知道,Java提供了两种数据类型——基本数据类型和对象数据类型,当然我们也可以自己定义类型,也就是用户自定义类型(User-Defined Type)。
之前所学的这两种数据类型,主要聚焦于这两种数据类型的构造和实现。但实际上,如果要契合程序的封装和安全性的话,数据类型对于使用者而言并不需要了解其实现,只需得知其能进行的操作即可。这些操作是使用者自己“脑补”的,可能其具体实现与其设想大相径庭。
但这不重要,因为我们只需要让这个数据类型从具体实现中抽象出来即可。这就是所谓的数据抽象(Data Abstraction):由一组操作(Operation)刻画的数据类型。
举个例子:当我们使用List数据类型时,我们会发现无论是使用ArrayList还是LinkedList,在大多数情况下我们并不会对二者加以区分:因为它们都是List的具体实现,但操作都是一样的。我们在使用的过程并不关心是ArrayList还是LinkedList。


二、数据类型和操作的分类

数据类型的分类

之前已经学习过数据类型可以分为可变数据类型和不可变数据类型。具体可以查看这篇文章:数据类型与类型检验。这里不再赘述。


操作的分类

一个抽象类具有多种操作,具体可分为:

  • 构造器(Creators):可以根据参数构造出这个类型的新对象。但参数不能是该类型已经被创建完成的对象。
  • 生产器(Producers):基于该类型的对象构建出一个新的对象。比如String类型的concat()方法。
  • 观察器(Observers):获取该类型对象的某个属性值。比如List的size()。
  • 变值器(Mutators):增加/删除/修改该类型对象的某个组成。比如List的add()。

其中,构造器既可以使用构造函数,也可以使用工厂方法。这个之后再写。
如果一个方法的返回值是void,那么它必然是变值器。但变值器的返回值不一定是void,比如可以返回boolean类型来判断是否成功修改。


三、设计抽象数据类型

抽象数据类型的设计就是选择一些好的操作,并确定其行为准则。
主要有下面的一些规定:

  • 设计简洁、一致的操作:一组简单清晰的操作要胜于一组复杂的操作。对于复杂的需求,要将其分解为简单的操作组合,这些简单的操作要尽量是目的性强的、泛用性广的、可以复用的。比如对于List,我们就不要定义sum操作。因为sum操作并不适配于元素是String类型的情况。
  • 要满足client的所有需求,且操作难度要低:比如client想要查看内部数据,就要设计get()操作(观察器)。client想要获取元素的个数,这种重复性强的操作使用便利过于繁琐,所以可以提供size()操作直接返回目标值。
  • 要么抽象要么具体,但不要混杂:可以像List那样抽象,也可以像StreetMap那样具体。这里的“抽象”和“具体”并不是指实现,而是应用的场景。显然,抽象与具体混杂的ADT是没有意义的。

四、表示独立性

一个好的ADT应该具有表示独立性*(Representation Independence):client使用ADT时无需考虑内部的具体实现,而ADT内部表示的变化对外部Spec和客户端没有影响
举例,假如有这样一个ADT:
在这里插入图片描述
我们可以有两种实现方法:
第一种:
在这里插入图片描述
第二种:
在这里插入图片描述
二者如果执行相同的操作,画出快照图:
第一种:
在这里插入图片描述
第二种:
在这里插入图片描述

  • 二者的实现有所不同,甚至连内存中数据存储也不同。但对于client来说,从“抽象”的角度来看,使用二者的操作得到结果是完全相同的。因为client仅仅只需要得到一个其“脑补”的字符串。至于这个字符串如何存在,如何得到,client根本不需要关心,因为他们只能看得到规约Spec,而无法得知ADT的表示(Representation)与实现(Implementation)。
  • 这个ADT的表示独立性是比较好的,因为即使使用了完全不同的表示,对代码实现进行了较大的修改,客户端的调用代码并不需要修改,client仍然能得到期望的结果。这就是表示独立性的便利之处。
  • 这个例子也说明,当需要对ADT进行修改时,一定要考虑如何保持其表示独立性。

五、保持不变量和表示泄露

一个好的ADT的重要特性是它能够保持不变量(invariants)。不变量是项目的特性,它在整个运行过程中保持不变。比如不可变的数据类型就是一种不变量,因为它一旦被创建就将无法被改变。

  • 对于不变量的要求是:ADT来负责不变量,它与客户端任何的行为都无关。
  • 不变量的好处是:能够保持程序的正确性,debug时更加容易
    如果ADT能够确保一个不变量,那么我们在debug时就不需要时刻检查其是否被改变。当然,这个不变量是不可被client破坏的。所以在设计ADT的过程中,我们要时刻假设client会想方设法的破坏ADT的不变量

如果外部代码能够直接对表示进行查看或者修改,那么就称为表示泄露(Representation Exposure)。表示泄露不光会破坏不变量,还会破坏表示独立性。因为client获取了不变量的非法权限,同时内部表示的暴露和修改也会对外部代码和操作的抽象性产生影响。


举个例子:
在这里插入图片描述
这个ADT的实例变量使用了public来修饰。这样外部代码就可以直接访问到并进行修改。显然出现了表示泄露。
在这里插入图片描述
但如果这样修改,将实例变量全部定义为私有且final的,看似所有实例变量就无法修改了。但是如果我们联想在第二章数据类型中的知识,我们就知道所谓的final仅仅只限制了此变量与数据之间的指向关系不变,而当有其他变量指向同一数据,可以通过其他变量(别名)来对数据进行修改。
上图中用红框圈出的地方均可以使用这种别名法对不变量进行非法修改。比如:
在这里插入图片描述
此时d就是t的timestamp的一个别名。画出快照图:
在这里插入图片描述
可见,虽然timestamp是final的,在图中表示为双线箭头,但又有一个变量d,其单线箭头指向存储着Date的同一内存区域。这样就会导致原有的对象和新建的对象二者的timestamp指向同一内存区域,当对其中一个进行修改时另一个也会随之改变,因此出现了表示泄露。

那么应该如何解决这一问题呢?其实答案我们已经知晓:那就是使用防御性拷贝
在这里插入图片描述
这里获取timestamp时,实际上获取的是与其值完全相同的副本。因此在副本的基础上创建新的对象,就不会出现上面多个别名的情况。值得注意的是,这里的d仍然指向着新对象的timestamp。但是这个操作在ADT内部进行,client无法获取d,所以这是可行的。


因此,我们可以总结出一种可行的具体解决方案:对ADT内部的作为不变量的可变数据类型,定义copy()或者clone()方法,返回一个与原变量具有完全相同数据的另一个变量(使用new实现),它们分别指向不同的内存区域。当需要对该不变量进行取值或者赋值操作时,永远调用copy()或者clone()方法来实现。

当然,最好的方法就是尽量使用不可变的数据类型,这样可以最大限度的避免表示泄露。


六、表示不变量(RI)和抽象函数(AF)

定义与构建步骤

我们已经提到,对于一个ADT,其内部的实现与client的“脑补”一般是有所差异的。于是可以将这两个数据的空间规范的定义:

  • 表示空间(Rep values):简称R。值的确切实现的存在形式。由开发者关注。
  • 抽象空间(Abstract values):简称A。client看到和使用的值。由client关注。

举例:一个表示字符集合(A)的类,可以使用字符串®来具体实现。
在这里插入图片描述
这里可以用离散数学中的映射来表示二者间的关系。
它们之间符合:

  • 满射:任意A中的元素在R中必有一个或者多个对应的具体表示。
  • 未必单射:A中的元素可以有多个具体表示。
  • 未必满射:R中可能会有非法的具体表示无法映射到A中。这里并不符合映射的定义。

这样的类似映射的关系就被称为抽象函数(Abstract function):AF : R → A
针对R中的非法值,也提供一个函数来进行判断,称为表示不变性(Representation Invariant),简称RI:RI : R → boolean 当且仅当r合法,也就是在A中有对应抽象表示时,RI( r ) = true。RI既可以看做是合法值的集合,也可以看做是一个条件。

针对同一个需求下的ADT,A是不变的,但具体的实现R可以不同。R不同,意味着RI和AF也会随之变化。
整体步骤就是:选择一种特定的内部表示R,定义其合法值RI,并为RI中的每个值做出“解释”AF,将其映射到A中的值。
当设计完成后,还需要把这些表示和解释以注释"//"的形式写在代码中,以便开发和维护。

为了便于尽早的捕获bug,要编写checkRep()方法:使用assert关键字来判断不变量是否没有被改变。在任何可能改变Rep的方法中,都要调用该方法进行检查。(观察器可以不用,但以防万一也可以检查)


有益的不变性

对于一个不可变的ADT而言,其抽象空间A应该也是不变的。但实际上,由于AF并非单射,对于同一个A中的元素可以有多个实现,所以R空间是可以变化的。这种变化就叫做有益的不变性(beneficent mutation)。
举例,一个表示分数的ADT:
在这里插入图片描述
提供一个转换为字符串形式的方法:
在这里插入图片描述
我们发现,一个作为Observer的方法toString(),原本是不应该修改ADT内部的private的属性值的,但这里却对分子和分母两个属性值进行了修改以得到最简分数。但实际上,这一操作并不影响抽象空间A中的值,因为同一个分数,其分子和分母可以同时放缩任意倍而值不变。所以说,这一个方法是符合ADT规则的,而其中的可变性是无害的,甚至说是有益的:因为它牺牲了部分不变性的原则,来换取了效率和性能

这种有益的不变性可以分为下面几类:
在这里插入图片描述


七、记录AF、RI、表示泄露的安全声明

在代码中用注释的形式记录AF、RI以及表示泄露的安全声明是一种良好的编程实践。

  • RI:要精确地解释R空间中为何有些值是合法的而其他值是不合法的,给出判断标准的描述。
  • AF:要精确地解释R空间中的每一个合法值是如何映射到抽象空间的一个值的。
  • 表示泄露的安全声明:自证清白,给出代码为什么不会发生表示泄露的理由。

下面就是一个标准的例子:
在这里插入图片描述
注意到,这些信息使用了"//"来注释,而这种注释方法对client来说是不可见的。
为什么?因为RI,AF,以及安全声明涉及到了ADT的内部实现,基于ADT的设计原则,这是不能暴露给用户的。client需要得知的所有信息应该保存在Spec,即规约中

  • 54
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值