哈工大2022春软件构造学习记录(三)(ch6、7)

Ch4研究了“数据类型”及其特性,ch5研究了方法和操作的“规约”及其特性,在ch6和ch7中,我们将把数据和操作复合起来,构成ADT,学习ADT的核心特征、如何设计“好的”ADT,以及ADT的具体实现技术:OOP。

Ch6

关键词:不变量、表示泄露、抽象函数AF、表示不变量RI

1.ADT

除了编程语言所提供的基本数据类型和对象数据类型,程序员可定义自己的数据类型。

数据抽象:由一组操作所刻画的数据类型。抽象类型不再是只关注数据如何具体表示,而是强调“作用于数据上的操作”,程序员和客户无需关心数据是如何具体存储的,只需设计/使用操作即可。操作和其Spec完整定义了数据类型,通过抽象同具体的数据结构、内存存储和实现分离,也就是说ADT是由操作定义的,与其内部如何实现无关。

2.抽象类型的操作的划分方式

首先回顾ch4、5中提到的可变类型和不可变类型。对于可变类型的对象,ADT提供了可改变其内部数据的值的操作;对于不可变数据类型,其操作不可改变内部值,而是构造新的对象。有些时候,一种类我们会提供可变和不可变两种形式以供使用。

我们将对抽象类型的操作分为以下四类:

(1)构造器(Creators):它创建这个类型的一个新的对象。构造器可以以一个非本类的对象作为参数。它可能实现为构造函数或静态函数,实现为静态方法的构造器通常称为工厂方法,例如String类的valueOf方法,它拿到一个对象参数,返回参数的字符串形式。

(2)生产器(Producers):从已有的对象出发创造一个新的(另一个)对象(对象的类型没有发生改变)。例如String类的contat方法,它以两个字符串为输入,生产出一个新的表示这两个字符串拼接结果的字符串。

(3)观察器(Observers):从一个抽象类型处获取一个不同类的对象。例如List的size方法,返回一个int值。

(4)变值器(Mutators):改变对象属性(仍然是这个对象)的方法。例如List的add方法,通过向一个list的末尾添加一个元素而改变这个list对象。变值器通常返回void,如果返回值为void则必然意味着它改变了对象的某些内部状态;也有一部分变值器返回非void类型,例如Set.add()返回一个boolean表明这个set是否真正被改变了。注意不可变类型没有(不应该有)mutators。

3.ADT的例子

List是一个可变数据类型,它也是个接口,这意味着它的具体实现是由其他的类来完成的(例如ArrayList、LinkedList)。我们给出它方法分类的几个例子:
Creators:ArrayList/LinkedList constructors(构造方法) , Collections.singletonList

Producers:Collections.unmodifiableList,它返回参数Collections对象的一个不可修改的副本。

Observers:size、get

Mutators:add、remove、addAll、Collections.sort

还有String、int也是ADT,它们是不可变的,没有Mutators。

4.设计ADT:提供一组操作,设计其行为规约

Rule1:操作要简洁一致。设计一组简单的操作,通过简单操作的组合实现复杂的操作。每个操作都应当有明确的定义,并且操作的行为应当是内聚的。

内聚:故名思议,表示内部间聚集、关联的程度,那么高内聚就是指要高度的聚集和关联。高内聚是指类与类之间的关系而定,高,意思是他们之间的关系要简单,明了,不要有很强的关系,不然,运行起来就会出问题。一个类的运行影响到其他的类。由于高内聚具备可靠性,可重用性,可读性等优点,模块设计推荐采用高内聚。内聚标志一个模块内各个元素彼此结合的紧密程度,它是信息隐蔽和局部化概念的自然扩展。内聚是从功能角度来度量模块内的联系,一个好的内聚模块应当恰好做一件事。它描述的是模块内的功能联系。

Rule2:定义的操作必须足以支持client的所有需要,并且尽可能降低使用这些操作的难度。一个好的检验方法是看对象每个需要被访问到的属性是否都能被访问到。

Rule3:ADT的设计要么针对抽象设计,要么针对具体应用设计,不要混合

抽象的:a list or a set, or a graph, for example.

针对具体应用场景的:a street map, an employee database, a phone book, etc.

面向具体应用的类型不应包含通用方法,面向通用的类型不应包含面向具体应用的方法。

5.表示独立性(Representation Independence)

表示独立性:client使用ADT时无需考虑其内部如何实现,ADT内部表示的变化不应影响外部spec和客户端。关键就是通过前提条件和后置条件充分刻画ADT的操作,spec规定client和implementer之间的契约,这样client知道可以依赖哪些内容,implementer知道可以安全更改哪些内容。

违背表示独立性的一个例子:

在这个实现中,在没有改变规约的前提下,开发者更改了Family类的内部实现,结果造成client的代码不能正常使用,这就说明这个类的实现违反了表示独立性,正常情况下我们构造的能让client使用的操作不应该与我们如何实现产生关联。

6.如何测试ADT

(1)测试creators, producers, and mutators:调用observers来观察这些操作的结果是否满足spec;

(2)测试observers:调用creators, producers, and mutators等方法产生或改变对象,用observers来看结果是否正确。

这样的测试方法也有风险:如果被依赖的其他方法有错误,可能导致被测试方法的测试结果失效。

7.不变量(Invariants)

不变量是程序在任何时候总是应该保持的性质,例如Immutability(不可变性)就是一个典型的不变量。无论客户端做出怎样的行为,ADT都要始终保持其不变量。

不变量有什么用?

当一个ADT保持了它的不变量时,保持程序的“正确性”就更加容易。试想,如果有一个类型String能保证它的不变性,那么我们就可以在编写程序时放心地使用它,并可以为另一个通过String实现的ADT构建不变量;如果String没有这个不变量,那么在所有要使用String的地方,都要检查它是否改变了。这涉及到一个编程原则:defensive programming,即总是要假设client有“恶意”破坏ADT不变量的行为。

7.表示泄露(Representation exposure)

不变性是ADT的不变量的一种。保持这个不变量的一个重要威胁就是:client可以直接接触到它的fields(因此可以直接对其做出不可预测的更改)。这就是一种“表示泄露”,这不仅影响不变量的保持,也影响了表示独立性,因为客户端可能依赖于我们的某个具体实现(因为这对他来讲是可以访问的),所以我们无法在不影响用户端的情况下改变ADT的内部表示了。

对上面提到的情况来说,有什么手段能防止表示泄露?

(1)我们可以使用private修饰字段,这样对类的外部来讲这个字段就是不可见的。

(2)使用final关键字修饰。这保证了某个字段在初始化之后不会被修改。

(3)传递给用户拷贝。给用户一个拷贝后得到的结果而不是它本身,这样用户对其做出任何更改都不会对内部产生什么影响,因而保证了操作的正确性。这其实体现了之前提到的一个思想:尽可能去使用immutable的类型,彻底避免表示泄露。

8.抽象函数(AF)

表示空间:R空间,由表示值构成的空间,是实现者看到和使用的值。一般情况下ADT的表示比较简单,有些时候需要复杂表示。

抽象空间:A空间,抽象值构成的空间,是client看到和使用的值。

ADT开发者关注表示空间R,client关注抽象空间A。

抽象函数:R和A之间映射关系的函数,即如何将R中的每一个值解释为A中的每一个值。

AF:R -> A,AF是满射,未必单射、未必双射。R中有一部分值并非合法的表示,因此在A中无映射值。

9. 表示不变性(RI)

表示不变性RI:某个具体的“表示”是否是“合法的”

也可将RI看作:所有表示值的一个子集,包含了所有合法的表示值

也可将RI看作:一个条件,描述了什么是“合法”的表示值

RI和AF往往是一起出现的,同一个ADT可以有多种表示,不同的内部表示需要设计不同的AF和RI。选择某种特定的表示方式R,进而指定某个子集是“合法”的(RI),并为该子集中的每个值做出“解释”(AF)——即如何映射到抽象空间中的值。

注意,同样的表示空间R,可以有不同的RI:

即使是同样的R、同样的RI,也可能有不同的AF,即“对表示空间的解释不同”

于是我们可以总结设计ADT的步骤:(1)选择R和A(2)RI——合法的表示值(3)如何解释合法的表示值——映射AF。并且要把这些选择和解释明确写到代码当中。在编写程序的过程中随时检查RI是否满足(调用一个自己写的checkRep方法),在所有可能改变Rep的方法内都要检查,Observer方法可以不用,但也建议检查。

10.有益的可变性

对于immutable的ADT来说,它在A空间的抽象表示值应该是不变的,但其内部表示的R空间中的取值则可以是变化的。这种可变性称为有益的可变性,它只是改变了R值,并未改变A值,对client来说其实是immutable的,他所看到和使用的值并没改变(对应上面提到了AF并不一定是单射,可以从一个R值变成了另一个R值),但是注意这并不代表在一个immutable的类中就可以随意出现mutator!

Ch7 面向对象的编程(OOP)

1.基本概念

对象

真实世界的对象有两大属性:状态(或属性)和行为。辨别出真实世界对象有哪些状态和行为是面向对象编程的一个很好的渠道。例如狗的state有名字、颜色、品种、饥饿的,行为有叫、摇尾巴;自行车的状态有档位、速度,行为有刹车、变速调档等。在java中,对象的字段(成员变量)fields是状态的反映,对象支持的行为用方法methods表达。

每个对象都对应一个类,类定义有成员变量和方法,类定义了对象的类型和实现。和类直接关联(而不是和某个对象关联)的变量是类成员变量,和类直接关联的方法叫类方法;其他的叫实例方法和实例成员变量。

例如:类方法:Math.min(10,20); System.out.printlt(Math.max(10,20))都是用类名直接调用的方法。

注意静态方法无法直接调用非静态成员

2.接口

接口也是定义和实现ADT的重要手段,不过是由接口的实现类来做到的。接口中只有方法的定义没有实现;接口之间可以继承与扩展,也就是说一个类可以实现多个接口(从而具备了多个接口中的方法),一个接口可以有多种实现类。

常用的方式是由接口来确定ADT规约,类来实现ADT,在定义变量时更倾向于通过接口定义而不是哪个具体的实现类。

不过,接口定义中不包含constructor,这样的话客户端使用的时候就需要知道某个具体实现类的名字来创建对象,这就打破了抽象的边界,是不好的。因此我们可以通过在接口中定义一个静态的方法实现统一的类似constrcuctor的功能,这样的方法称为工厂方法,例如String.valueOf方法。工厂方法是一个default方法,接口的每个实现类都默认使用这个方法而不需要再重新写一次,只要在需要更改实现的时候重写一个即可。

3.封装和信息隐藏

评价一个模块好不好的重要因素就是看它是否能把内部的数据和其他实现同其他模块尽可能地分开。一个好的设计应该能把具体实现细节完全隐藏起来:API和实现的分离、模块间只通过API交互、互相不知道内部细节。

API:不严格地讲,一个类里面的方法就是它的API(Application Programming Interface),定义了使用者如何与实例交互。

实现方式

使用接口类型声明变量(而不是具体实现类类型);

客户端仅使用接口中定义的方法;

让客户端代码无法直接访问属性。

修饰符的可见性

4.继承和重写——实现代码复用的一种技术

(1)重写(Override):

默认的情况下,java允许将一个方法重写来得到这个方法的另一种实现;在严格继承的情况下,子类只能添加新方法,而无法重写超类中的方法,不能被重写的方法可以通过final修饰符来实现这样的效果。

final关键字:不可变引用、不可重写方法、不可被继承的类

子类重写的方法需要和基类的这个方法有完全相同的方法签名,在实际执行中调用的是重写的方法还是原来的方法需要在运行时才能确定。通常,对应子类型共性的方法就可以直接使用从父类继承来的方法,如果子类型有自己的特殊性,可以通过重写方法或者添加新方法实现。但是需要注意在重写方法的时候尽量不要采用违背方法本意的实现。

对于重写了方法的子类型来说,仍然可以通过使用super关键字来调用父类型的这个方法

(2)抽象类(Abstract Class)

抽象方法是只有定义而没有实现的方法,通过abstract关键字标识。如果一个类含有最少一个抽象方法就把这个类称为抽象类,用abstract标识。抽象类是不能实例化的,一般在它的子类(或子类的子类…)中重写它的抽象方法,所有抽象方法都有了具体实现,才能实例化。

通常的用法是:如果某些操作是所有子类型都共有,但彼此有差别,可以在父类型中设计抽象方法,在各子类型中重写;所有子类型完全相同的操作,放在父类型中实现,子类型中无需重写;有些子类型有而其他子类型无的操作,不要在父类型中定义和实现,而应在特定子类型中实现。

我们会发现,接口实际上正是所有方法都为抽象方法的一个抽象类。

5.多态、子类型、重载

(1)特殊多态:方法重载。

一个方法可以有多个同名的实现(名字相同,参数列表不同,返回值类型、可见性、异常可以不同),client可以用不同的参数列表调用同样的函数。既可以在同一个类内重载,也可在子类中重载。重载是一种静态多态,根据参数列表进行最佳匹配,编译器执行静态类型检查,因此在编译阶段就决定了具体是要执行哪个方法。(而重写方法是在运行时进行动态检查

Tips:如果是调用一个重载的方法,那么就相当于是调用了声明处的类型(最左边)的这个方法;如果是调用一个重写的方法,那么就相当于是调用了实际(最右边)的类型的这个方法。

(2)参数化多态:泛型。

方法针对多种类型时具有同样的行为(这里的多种类型应具有通用结构),此时可使用统一的类型变量表达多种类型,在运行时根据具体指定类型确定具体类型(编译成class文件时,会用指定类型替换类型变量“擦除” )

泛型编程是一种编程风格,其中数据类型和函数是根据待指定的类型编写的,随后在需要时根据参数提供的特定类型进行实例化。泛型编程围绕“从具体进行抽象”的思想,将采用不同数据表示的算法进行抽象,得到泛型化的算法,可以得到复用性、通用性更强的软件。

泛型用”<>”表示,例如List<E>。

使用泛型变量的三种形式:泛型类、泛型接口、泛型方法

1.泛型类:类中如果声明了一个或多个泛型变量,则为泛型类,这些类型变量称为类的类型参数。

2.泛型接口:定义了一个或多个泛型变量。一个例子是Set<E>,通过用泛型来实现Set,避免了重复地去设计Set<String>/Set<Integer>/……泛型接口的实现类可以是非泛型的,例如定义一个CharSet实现Set<Character>,也可以是泛型的,例如HashSet<E>是Set<E>的实现类。

3.泛型方法

泛型类/接口是在实例化类的时候指明泛型的具体类型 ;泛型方法是在调用方法的时候指明泛型的具体类型

泛型方法可以出现在普通类中,也可以出现在泛型类中。

需要注意泛型方法的泛型参数和泛型类的泛型参数并不一定一致(即便它们都以相同的标识符例如T表示)。

另外,静态方法不能使用所在泛型类定义的泛型变量,如果静态方法要使用泛型的话,必须将静态方法也定义成泛型方法。

(3)子类型多态/包含多态:

一个变量名字可以代表多个子类型的实例,不同类型的对象可以统一的处理而无需区分,从而隔离了“变化”。后面课程提到的LSP原则会更加详细做介绍。

有一点需要注意,就是子类型的规约不能弱化超类型的规约

6.Object类的一些重要方法

equals、hashcode、toString方法,常常需要重写。Java中“==”比较值/引用是否相同,equals方法在默认情况下就相当于“==”,但是对于不可变类型的对象来说这几乎总是错的,这样的比较方式不符合在抽象空间中的比较方式,所以我们需要重写equals,自己按实际需要做一个实现(例如两个电视如果它们的厂家和型号相同我们就可以认为他们是相同的)。通常hashcode和equals一起重写。

Tips:建议在重写方法的时候标记@Override避免把重写错写成重载(编译器会帮你检查)。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值