在设计中,最重要的原则就是隔离,将ADT和client、将method和client通过spec都隔离开,确保内部的变化不会影响到client,而client也不知道我的内部的实现,这样的目的是为了便于未来的升级和维护,也可以最大限度的减少恶意者的攻击。
3.1 Data Type and Type Checking
1. 编程语言的数据类型
在Java中,数据类型分为基本数据类型(int、boolean、char等)和对象数据类型(String、Integer等)。所有的基本数据类型都是Immutable的,而且在栈中分配内存,代价也比较低。而对象数据类型有的是Immutable的,有的是Mutable的,分配的内存都在堆中,代价相对昂贵。因此在能使用基本类型的情况下尽量使用基本数据类型,降低代价。
对象数据类型是OOP的核心,由于对象数据类型存在继承(extends)机制,因此在OOP中可以更好的复用代码。
基本类型被包装为对象类型,通常只有在定义集合的时候使用,其他情况下尽量避免使用。基本类型和对象类型之间一般可以自动转换。
2. 类型检查
静态类型语言(Java)可执行静态类型检查,在编译阶段进行类型检查,这意味着避免了将错误带入到运行阶段,可以提高程序的正确性/健壮性,例如语法错误、类名/函数名错误,参数类型或数目错误、返回值类型错误都可以在静态类型检查时发现;动态类型语言(python)只有动态类型检查,在运行阶段才会进行类型检查,例如非法的参数值 (最典型的NULL引用)、非法的返回值、越界等等。
静态类型检查是关于数据类型的检查,它不会关心具体的值,而动态类型检查是关于值的检查。
例如int n=1.1在静态类型检查的时候就会报错,但double a=0; double b=2/a;只有在运行之后,执行动态类型检查的时候才会报告除零错。
3. Mutability和Immutability
改变变量:使变量指向存储着另一个值的空间
改变变量的值:变量指向的空间不变,变化的是存储的内容。
Immutability:不变性,一个重要的设计原则,设计ADT时尽量保证这个原则。
Immutable types:不可变的数据类型,当实例对象被创建以后,该对象的值就不可变化了,也就是该ADT中不能有mutator方法。
在编写程序的时候使用final关键字可以保证该变量不可再被改变,但不能保证该变量的值不变。所以,尽量使用final变量作为方法的输入参数、作为局部变量。
final类无法派生子类
final变量无法改变值/引用
final方无法被子类override(重写)
比较immutable和mutable
不变对象:一旦被创建,始终指向同一个值/引用
可变对象:拥有方法可以修改自己的值/引用
一个例子:比较String和StringBuilder
在这个例子中,String和StringBuilder所达到的效果是相同的
/* String部分 */
String s = "a"; //开辟一个存储空间,里面存着字符a,s指向这块空间
s = s.concat("b"); //把字符a和字符b连接,然后把“ab”放在一个新的存储空间,最后让s指向这块空间
/* StringBuilder部分 */
StringBuilder sb = new StringBuilder("a"); //开辟一个存储空间,里面存着字符a
sb.append("b"); //取出a,然后与字符b连接,然后把“ab”仍然放在这块空间内,把原来的“a”覆盖了,sb的指向没变
在这个例子中,String和StringBuilder所达到的效果出现了差别
/* String部分 */
String s = "a"; //开辟一个存储空间,里面存着字符a,s指向这块空间,记为space1
String t = s; //让t指向s所指向的空间即space1
s = s.concat("b"); //把字符a和字符b连接,然后把“ab”放在一个新的存储空间,记为space2,最后让s指向这块空间
//我们可以看到,现在s和t所指向的是两块不同的空间,空间中的内容也不一样,因此s和t的效果是不一样的
/* StringBuilder部分 */
StringBuilder sb = new StringBuilder("a"); //开辟一个存储空间,里面存着字符a
StringBuilder tb = sb; //开辟一个存储空间,里面存着字符a
sb.append("b"); //取出a,然后与字符b连接,然后把“ab”仍然放在这块空间内,把原来的“a”覆盖了,sb的指向没变
//在这个情况下,由于从始至终只用到了一块存储空间,所以sb和tb的效果实际上是相同的
可变对象的优点:虽然mutable类型由于指向的是同一个存储区域,所以更改对象的内容后会在意想不到的位置产生意想不到的变化,所以更推荐使用Imutable的数据类型,但是使用不可变类型,对其频繁修改会产生大量的临时拷贝(需要垃圾回收),比如依次将’a’~'z’连接到一个空字符串上,就会产生25个临时拷贝,而使用可变类型则最少化拷贝以提高效率。
使用可变数据类型,可获得更好的性能。但是在质量指标中,性能的优先级较低,所以即使mutable类型有这个优点也更倾向于选择imutable的类型。
也适合于在多个模块之间共享数据。在这里强烈不推荐使用Global variables。
例子:mutable潜在的风险,这种情况非常难以跟踪发现,也难以被其他开发者理解
/* @return the sum of the numbers in the list */
public static int sum (List<Integer> list) {
int sum =0;
for (int x: list) sum += x;
return sum;
}
/* @return the sum of the absolute values of the numbers in the list */
public static int sumAbsolute (List<Integer> list) {
// let's reuse sumo), because DRY, so first we take absolute values
for (int i =0; i<list.size(); i++)
list.set(i, Math.abs(list.get(i)));
return sum(list);
}
//client
public static void main(String[] args) {
List<Integer> my Data Arrays asList(-5, -3,-2);
System.out.println(sumAbsolute(myData)); //期望值10,实际值10
System.out.println(sum(myData)); //期望值-10,实际值10
}
解决的办法:通过防御式拷贝,给客户端一个副本,客户端即使对数据做了更改,也不会影响到自己。我们解决了外部对内部的无意改动,但为此付出的代价就是空间的浪费。而如果使用immutale类型的数据,就不存在这种风险。同时,我们编程的时候也要注意避免出现一个对象的多个引用,也就是说尽量不要让一个对象出现别名。
4. Snapshot diagram
a code-level, run-time, and moment view
基本类型的值
在这里插入图片描述
对象类型的值
mutable对象,单线圈
在这里插入图片描述
immutable对象,双线圈
在这里插入图片描述
可变的引用,单线箭头,见下图中的age
不可变的引用,双线箭头,针对使用final关键字限定了的变量
在这里插入图片描述
5. 集合类型
一些常用集合类的snapshot图
List
在这里插入图片描述
Set
在这里插入图片描述
Map
在这里插入图片描述
遍历集合类中的元素并做删除的时候,要使用Iterator来做迭代器,避免bug
上述的这些集合类型被传到其他地方时有可能会被更改,于是可用Collections.unmodifiabbleList()、Collections.unmodifiabbleSet()、Collections.unmodifiabbleMap() 做一次封装,这样给外界的时候就是一个不可更改的类型了,而且如果封装前的类型的对象做了增删改,那么封装后的也会表现出增删改后的状态,因为实际上这两个指向的是同一块内存区域。如下,list有改动,listCopy也会被改动
List<String> list = new ArrayList<>();
List<String> listCopy = Collections.unmodifiableList(list);
list.add("a"); //list中有一个元素a,listCopy中也有了一个元素a
3.2 Designing Specification
1. 为什么要写注释?
因为单靠代码自己无法把开发者的设计决策全部清晰直观地表现出来,如 final 关键字本身就是一种设计决策,开发者很容易理解,但这个的只要目的不是为了给人读,是为了给编译器读,如果未来程序员对这个部分做了什么改动,编译器可以很快的在静态代码分析中就能发现错误,避免错误带入后面的开发中,而只有注释,才能够让其他人清晰的看到这部分干了什么,甚至是怎么实现的等等信息。
2. 为什么要写spec?
spec是程序员自己对所写的方法的规约,它规定了方法应该做什么,不应该做什么,有了spec就可以编写测试用例了,因为程序员所编写的代码必定是符合spec的,否则就是不合格的,符合spec的代码也必然能通过根据spec所设计出的测试。同时,有了spec,客户端在使用所写的代码时就有所依据,客户端可以轻松的知道他需要为这个方法提供什么样的参数,以及会得到什么样的结果,而不必知道内部逻辑是怎么样的,大大节省了客户端使用自己的API时所需要的时间,并且大大降低了客户端对自己所编写的代码的误解。
而且,拥有精确的spec,有助于区分责任,很容易地就可以找到是哪一部分的代码出了问题。
最后,由于客户端并不需要了解内部的实现也就意味着你可以在满足spec的大前期下对实现方法进行任意的改动而不需要告知客户端,因为无论你怎么改,只要满足spec,在客户端看来,你的行为 (作用) 都是相同的。
没有spec的代码就是垃圾!!!
3. spec的结构
precondition前置条件:对客户端的约束,在使用方法时必须满足的条件
使用@param annotation说明每个参数的前置条件
postcondition后置条件:对开发者的约束,方法结束时必须满足的条件
使用@return annotation说明后置条件
使用@throws annotation说明出现异常的时候会发生什么
在方法声明中使用static等关键字声明,可据此进行静态类型检查
当客户端满足前置条件的时候,结果必须满足后置条件;当前置条件不满足的时候,方法内部可以做任何事情,但作为开发者,应该尽量让程序做到fail fast。
spec不能有什么?
spec不能暴露实现细节,不应该暴露局部变量,也不应该暴露私有的数据域,这些东西一旦暴露,就有可能给被非法的程序员利用,发现漏洞并实施攻击。
注意:方法不应该改变输入参数的取值,如果改了,则必须在spec中做出说明。所以不推荐使用mutable的对象。另外,我们无法强迫类的实现体和客户端不保存可变变量的“别名”,因此,如果直接返回本来的mutable对象,客户端可能修改它的值造成内部实现的错误,同样,如果返回了的是原来mutable对象的拷贝,虽然内部不用再担心客户端的更改影响到自己,但客户端无法知道内部是否保留了被返回的拷贝的别名,因此双方无法完全的信任彼此,故而不推荐使用mutable类作为返回值类型。
4. spec的评判标准
评判哪个规约更好的三个方面:规约的确定性、规约的陈述性、规约的强度
重点是规约的强度的判断,spec变强的要求是更宽松的前置条件+更严格的后置条件,在这种情况下,就可以用变强了的spec去替换原来的spec。越强的规约,意味着implementor的自由度和责任越重,而client的责任越轻。
强的spec可以替换弱的spec,这一点会在第4章的LSP中得到应用
用椭圆表示spec的强度
如下图,大椭圆表示更弱的spec,小椭圆表示更强的spec,椭圆的大小表示的是开发者的自由度,小椭圆有更强的后置和更弱的前置,因此所包含的结果的点就少,所以就小。
在这里插入图片描述
一个好的spec应该是内聚的即只做一件事,并且有足够的信息不让客户端产生歧义,强度也要适中,以防过强难以开发&过弱使客户端难以使用。
3.3 Abstract Data Type (ADT)
抽象数据类型:由一组操作定义的数据类型,与数据类型大的内部如何实现是没有关系的
1. ADT的操作
Creators构造器:用于使用 new 关键字创建一个新的对象。还有一种方法是静态方法,如Arrays.asList()、String.valueOf(Object Obj)等。
Producers生产器:用于使用一个存在的对象产生一个新的对象,例如String.concat()就是使用已存在的字符串构造出一个新的对象,而且不会改动原先存在的对象。
Observers观察器:不对数据做任何改动,只是查看一个已经存在的对象的各个值,如List.size()、所有的getter方法等。
Mutators变值器:用于改变对象属性的方法,如List.add()。mutator通常返回void,因为它不需要对外界做出反应,只是对ADT的数据域做了更改;mutator也可能返回非空,比如返回boolean表示修改成败等。
2. 设计ADT
设计一个好的ADT需要靠开发者的经验来设计它的操作的spec,设计一个ADT要遵循下面三个原则:(1)设计简洁一致的操作(2)要足以支持client所需要的对数据的所有操作,且用操作的难度要低(3)要么抽象要么具体,不要混合——要么针对抽象设计,要么针对具体应用的设计。
实现一个ADT的三个部分:specification、representation、implementation
Representation Independence 表示独立性
client不应该知道内部的数据域是怎么实现的,最好client只能通过ADT提供的getter方法获得ADT存储的数据。
client使用ADT时无需考虑其内部如何实现,ADT内部表示的变化不应影响外部spec和客户端。其目的一个是为了便于未来的升级和维护,当内部发生变化的时候不会影响到client。
Testing an ADT
因为测试相当于client使用ADT,所以它也不能直接访问ADT内部的数据域,所以只能调用其他方法去测试被测试的方法。
针对creator:构造对象之后,用observer去观察是否正确
针对observer:用其他三类方法构造对象,然后调用被测observer,判断观察结果是否正确
针对producer:produce新对象之后,用observer判断结果是否正确
Rep Invariant(RI) and Abstraction Function(AF)
Invariants:不变量,与程序运行无关,在任何时候都应该满足的一些条件
两个空间 R 和 A:R空间是ADT的内部表示的空间,A空间是ADT能够表示的存在于实际当中的对象。ADT的开发者关注的是R空间,client关注的是A空间。
Abstraction Function:从R空间到A空间存在一个映射,这个映射是一个满射,这个映射将R中的每一个值解释为A中的一个值。这个解释函数就是AF。
Rep Invariant:这是一个集合,是R空间所有值的子集,它包含了所有合法的表示值,而只有满足RI的值,才是合法值,才会在A空间内有值与其对应。
相同的R空间有肯能会有不同的RI。
即使是同样的R、同样的RI,也可能有不同的AF,即“解释不同”。
checkRep():用于随时检查RI是否满足。使用assert检查RI,在所有的方法最好都加入调用这个检查方法。checkRep()在检查时有可能耗费大量的时间影响性能,所以只需要在开发阶段保留这部分。
表示泄露:client可以拿到数据域的本身或别名。一旦表示泄露,client就有可能无意间改动数据,而如果在设计中,要求一个ADT是Immutable的,而如果它出现了表示泄露,就有可能违反Immutable的原则。
Documenting the AF, RI, and Safety from Rep Exposure
在代码中用注释的形式记录AF(如何解释每个R值)和RI(rep中哪些数据是有效的)。
在代码中用注释记录表示泄露的安全声明,证明代码并未对外泄露其内部表示。比如传入和传出都使用了防御式拷贝。
// Immutable type representing a rational number
public class RatNum {
private final int numer;
private final int denom;
// Rep invarian
// denom >0
// numer/denom is in reduced form, i.e. gcd(I numerI, denom)=1
// Abstraction Function:
// represents the rational number numer/denom
// Safety from rep exposure
// All fields are private, and all types in the rep are immutable
// Operations(specs and method bodies omitted to save space
public RatNum(int n)i...)
public RatNum(int n, int d) throws ArithmeticException(..)
...
}
3.4 Object-Oriented Programming (OOP)
1. Interface接口
接口可以实现静态方法,使用 static 关键字。
通过static关键字可以实现静态工厂方法,从而将接口的实现类封装,实现对外信息隐藏。
接口中也允许使用 default 关键字来定义并实现实例方法,这个应用有点类似于抽象类的功能。
通过default方法,在接口中统一实现某些功能,无需在各个类中重复实现它。
2. 继承和重写
子类可以继承父类的数据域和方法。
严格继承:子类只能添加新方法,无法重写超类中的方法。原因:父类中的方法使用了final关键字限定。
重写:子类的方法覆盖了父类的方法。重写的方法应该与父类方法有相同的签名,只有这样编译器才会判定为重写的方法。使用@Override annotation强制检查是否重写了超类中的方法。
在子类中如果想调用被重写的父类的方法,可以使用super.method()。
如果是在构造方法中调用父类的构造方法,则必须在构造方法的第一行调用super()
抽象类
3. 多态
三种多态:特殊多态、参数化多态、子类型多态
特殊多态:功能重载
重载:多个方法具有同样的名字,但有不同的参数列表或返回值类型。参数列表必须不同,返回值类型、可见性、异常均为可以相同也可不同。重载不仅可以发生在类内,也可发生在父类与子类之间。
父类与子类之间发生重载的例子如下。这两个情况都不能编译成功,以为无论是a还是h,他们的运行时类型都是Animal,而不是通过new创建的具体类型。
class Animal{
public void eat(){...}
}
class Horse{
public void eat(){...}
public void eat(String food){...}
}
//两种不能通过编译的情况
Animal a = new Animal();
a.eat("Apple");
Animal h = new Horse();
h.eat("Apple");
override是在运行时决定的,所以运行h.eat()时,调用的是Horese.eat()
overload是在静态分析时决定的,所以运行h.eat("Apple");会失败——详见第四章、第六章
重载的价值在于方便client调用,client可用不同的参数列表,调用同样的函数。
参数化多态:泛型
使用泛型参数代替具体的类型。作为一个泛型接口,当实现的时候可以实现一个具有具体类型的子类型,也可以实现一个具有泛型接口的实现类。
通配符?,只在使用泛型的时候出现,不能在定义中出现。
子类型多态
终极目的:不同类型的对象可以统一处理而无需区分。
遵循的设计原则:LSP
4. Object类的子类型
所有的类都是继承自Object类,因此必然继承了Object的三个方法:equals()、hashCode()、toString()。因为这三个方法在Object中的实现非常简陋,我们大概率需要重写这三个方法。toString()方法比较简单,重点在equals()和hashCode()方法,具体内容见下一小节(3.5)。
3.5 Equality in ADT and OOP
等价性是基于等价关系的,满足自反、对称、传递三个性质,它的空间意义是:如果R中的多个值都对应于A中的同一个值,那么这些R值都应该是等价的。
1. 不可变类型的等价性
判断等价的两个方式:
AF映射到同样的结果,则等价。
站在外部观察者角度:对两个对象调用任何相同的操作,都会得到相同的结果,则认为这两个对象是等价的。反之亦然。
两个等价的对象调用相同的Observer应该返回相同的结果。
等价性
== vs. equals()
引用等价性:使用==判断地址是否相同作为判断是否等价的依据。对基本数据类型,必须使用这种办法判断是否相等。
对象等价性:使用equals()方判断两个对象是否相同法作为判断是否等价的依据,对于对象类型,使用这种办法来判断对象是否等价,如果只用==则是在判断两个对象的ID(内存里的同一空间)是否相等。
对于如下代码:
String input = "abc";
if(input == "abc") return true;
else return false;
返回的值为true,达到了与equals()相同的效果,这是由于JVM在分配内存的时候,对于双引号中的内容作为常量存储在堆中的常量缓冲区,因此两个”abc“指向的是同一块内存空间。
强烈不建议使用这种办法,有可能造成潜在的bug
equals()和hashCode()
在Object中,equals()的实现为==,而hashCode()的实现为返回内存地址。所以体验极差,需要重写。
重写Object中的equals()方法时要注意参数类型为Object,重写的时候要保持方法参数列表、可见性、返回值必须与父类一致。所以最好使用@Override声明。
equals()方法应该满足下面三个一条件:
等价关系:自反、传递、对称
除非对象被修改了,否则调用多次equals应同样的结果
“相等”的对象,其hashCode()的结果必须一致
关于第3点,java在判断两个对象是否相等的时候,先去判断这两个对象的hashCode是否相等,如果不相等,则不会再去调用equals()方法,直接返回false不相等。所以等价的对象必须有相同的hashCode,而不相等的对象,也可以映射为同样的hashCode,但性能会变差。
所以,当实现equals()方法的时候,最好同时实现hashCode()方法,除非你能保证说设计的ADT不会被放入到hash类型的集合中,显然这不太现实。
2. 可变数据类型的等价性
等价性
观察等价性:在不改变状态的情况下,两个mutable对象是否看起来一致。调用Observer方法表现出相同的结果。约等于对象等价性。
行为等价性:调用对象的任何方法都展示出一致的结果。包括mutator、producer、observer。约等于引用等价性。
对可变类型来说,往往倾向于实现严格的观察等价性, 但在有些时候,观察等价性可能导致bug,甚至可能破坏RI。如下:
List<String> list = new ArrayList<>O:
list.add ("a")
Set<List<String>> set = new HashSet<List<String>>o:
set.add(list);
set.contains(list); //true
list.add("goodbye");
set.contains(list); //false!
//甚至还有如下神奇的事情
for(List<String> l: set)
set.contains(l); //false
!!!务必小心:如果某个mutable的对象包含在Set集合类中,当其发生改变后,集合类的行为不确定。
在JDK中,不同的mutable类使用不同的等价性标准:
Date.equals()、List.equals()实现的是观察等价性。
StringBuilder.equals()实现的是行为等价性,它的equals()方法实际上是直接继承自Object类。
equals()和hashCode()
对可变类型,实现行为等价性即可,也就是说,只有指向同样内存空间的对象才是相等的。所以对可变类型来说,无需重写这两个函数,直接继承Object的equals()和hashCode()即可。
3. 自动封装
手动封装的结果:
Integer x = new Integer(3);
Integer y = new Integer(3);
x.equals(y); //true
x == y; //false
(int) x == (int) y; //true
自动封装的结果:
Map<String, Integer> a = new HashMap<>(), b = new HashMap<>();
a.put("c", 130); //被自动转换成了Integer
b.put("c", 130);
a.get("c") == b.get("c"); //false,get获得的是Integer,并不会自动转换成原始的int
a.get("c").equals(b.get("c")); //true
特殊情况:
Map<String, Integer> a = new HashMap<>(), b = new HashMap<>();
a.put("c", 1);
b.put("c", 1);
a.get("a") == b.get("a"); //true
原因:JVM为-128~127分配的空间位于常量池中,所以即使被自动封装成了对象类型也可以用==判断相等。
Integer x = 2;
Integer y = 2;
x == y; //true
/* 仅限于上面的方式创建的对象,通过new创建的对象不行 */
Integer x = new Integer(2);
Integer y = new Integer(2);
x == y; //false