chapter8:ADT、OOP中的等价性
等价性有自反、对称、传递三个特点。
在现实中完全等价不可能存在,每个对象都有其独特的特点,无法完全相等,但是在人类语言和数学之中,完全相等是存在的。
之前我们提到,ADT是对数据的抽象,体现为一组对数据的操作。AF是内部表示到抽象表示的映射函数。
基于AF定义ADT的等价性为:AF对两个对象的映射结果相同,认为这两个对象等价
从外部观察者角度:对两个对象进行任何相同的操作,都得到相同的结果,认为这两个对象等价
从不同的角度判断等价性,往往会有不同的结果。但这只是两种验证视角,并不意味着两个对象是真正“等价”。
判断等价性很常用==运算符和equals()函数。==判断的是引用等价性,即二者是否引向同一块内容(之前的字符构造器那种情况),equals函数是每个类继承于Object类具有的方法,当然可以根据具体的需要对之进行重写,若不重写(Object中的equals本就是用==实现的,如下所示),那么可以认为二者的判断效果是一样的。
public class Object{
...
public boolean equals(Object that){
return this == that;
}
}
对于基本数据类型,用==进行等价性的判断,对于对象数据类型,调用equals进行判断。但是我们都知道equals可以实现==的效果,也可以重写达到想要的判断效果,所以最佳选择是不论什么数据类型,均用equals函数进行等价性判断。
对于重写过equals函数的类,它可以有两种equals方法,重写过的和继承Object的,根据调用时传入的参数决定具体是哪个方法。若重写之后的方法中需要的参数仍是Object型,一般情况下以重写后的为准(若以父类为准就没必要重写了)
equals方法中常用instanceof判断对象是否是某个类型的实例(或其子类的实例),getClass()也能实现相同的功能,但是这两个方法尽量不要在其他地方使用,仅在实现equals重写时用。
一致性:一般来说,只要对象没有被修改过,否则多次调用equals函数应该返回一样的结果。并且,两个等价的对象,其hashcode也应该一致(即将这两个对象传入哈希容器,索引一样)
任何对象equals(null)都要返回false,并且equals对于此类的任何对象都应该有效(即可以正常使用)
一般来说,要印证设计的equals方法是否正确,用自反、传递、对称三个特性逐一验证即可,有不满足即不合理。很经典的例子是,等价允许两个对象的某个属性相差在一定范围之内,例如差的绝对值在5之内,那很显然不满足传递性,A比B多4,B比C多4,A就比C多8,很显然不等价,但是按照传递性A应该与C等价,于是就矛盾,证明此equals设计有误,违反等价性。
哈希表:实现了key-value之间的映射,一般包含一个数组和链表结构,key被映射为hashcode,对应位数组的index,决定数据的储存位置。这也是哈希表RI的基本要求。
对象的hashcode方法就是返回其在哈希映射系统之中的hashcode,hashcode决定存储地址,因此当两个对象等价(指的是==)时,为了节省资源,用一块区域存放这些数据,然后两个对象引用指向它,所以等价对象的hashcode也一样。为了维持这种性质,当equals重写时,hashcode方法也一定要重写,以保证等价仍有相同结果。一般来说利用equals中利用到的所有属性的hashcode组合成新的hashcode
同样的,同一对象不发生改变多次调用hashcode也应该有同样的结果。虽然在必要时,可以让不相等的对象可以映射到相同的hashcode,但是这会破坏性能,所以一般情况下不采取。
可变数据类型的等价性:不能被通过”观察“区分开
1.观察等价性:不改变状态的条件下,两个对象看起来是否一致
2.行为等价性:调用对象的任何方法都显示一样的结果
可变数据类型判断等价性更倾向于考虑观察等价性,因为行为等价性可能在调用一些方法时改变,让判断结果比较混乱甚至有出bug、破坏RI等严重后果(当可变数据发生改变时,由于对hashcode的重写,它的hashcode已经发生改变但是hashset还没有将它的位置进行相对应的调动,就破坏了哈希表的RI)。当然Java中也有部分可变数据类型使用行为等价性判断,例如List一般判断其包含元素是否按顺序完全一致。
尤其要注意可变类型放在集合之中的情况,可变数据发生改变时,集合的行为往往很难预测。
可变数据类型实现行为等价性即可,可变数据类型只有保证两个对象的引用空间一致才可确定他们是等价的,因此不需要(其实是一定不要)重写equals方法,相对的,不可变类型一定要重写equals方法+hashcode方法。如果可变数据类型非要用观察等价性实现判断,最好另外创建一个新方法而非重写equals。
很显然,从不同的等价性考虑,结果可能会有不同。
一个小概念,浅拷贝和深拷贝:
浅拷贝指的是直接赋值实现的拷贝技术,一般来说会让两个对象指向同一块区域,对一个操作变值,另一个相应受到影响。深拷贝没有这种问题,深拷贝是指开辟一块新的地址空间存放与拷贝对象完全一样的属性。
对于Integer数据类型,-128~127均存放在同一地址空间,因此直接用==判断两个该范围内的相等的Integer会得到true。但是如果是new一块地址空间,一定是新的空间,返回false。
chapter9:面向复用的软件构造技术
复用:一个模块可以在不同应用中重复使用,可以分为以下几个层次:
源代码级别的复用、模块级别(类/接口/抽象类)的复用、库(包/API)级别的复用、系统级别(框架)的复用
面向可复用编程:开发出可复用的软件/模块
基于可复用编程:利用现有的可复用的软件模块搭建成应用系统
可复用编程技术优缺点:让设计出的软件模块具有很强的适应性,而且标准化、一致化(在不同应用中保持一致),结构清晰,可以降低开发成本和开发时间,而且经过充分测试,可靠性较高,但是针对性比较不足,性能不如针对应用实现的直接编程。
这些可复用的模块不能拿来直接使用,往往需要针对应用场景分析差异性、相似性,然后再选取、适配、修改、扩展,还要对这些可复用模块进行有效管理。模块复用的可靠性需要建立在外部基础之上。
数据类型越抽象,可复用度越高;越具体,可复用度越低。
复用能降低成本,但是开发可复用的软件模块的代价较高
但是在复用度不高时强行利用这些模块反而会多耗成本,当产品规模越大,复用率越高时,成本会低于常规编程情况。
对复用性的测量:复用的频率和代价(代价包含对可复用模块的搜索获取、适配/扩展、实例化以及与软件其他部分互连的难度)
可复用性强的模块一般有如下特点:
1.模块不大且简单
2.软件模块与标准兼容
3.模块灵活可变
4.模块可扩展
5.使用泛型参数化技术
6.功能模块化
7.变化局部性(变化不对其他部分产生影响)
8.模块稳定
9.具有丰富的文档说明
最主要的复用发生在代码层面,但是软件的各个实体都能被复用,包括设计需求、具体设计和规约、数据、测试用例、文档
复用按照源代码可见性分为:
白盒复用:源代码可见的复用,可以进行修改和扩展。操作简单,就是把源代码复制到正在开发的系统进行修改(修改会增加软件的复杂度)。这种复用的可定制化程度比较高,但是要求也高,需要我们有代码且对内部结构充分了解。
黑盒复用:源代码不可见、不能修改。只能通过API接口来使用,相比而言比较简单清晰,但是适应性差些
复用模块的来源:组织的内部代码库、第三方库、语言自身的库、示例代码、已有系统的代码、开源软件资源。
模块级别的复用:代码复用的子单元是类,源代码、类文件或jar/zip非必须,只需要包含在类路径中,可以使用javap工具获取类的公共的方法头,复用类的重要技术有继承和委托。
库级别的复用:类库和框架。其中类库的复用是开发者构造可运行软件实体,其中调用可复用库;Framework作为主程序执行,过程中调用开发者所写程序。
两种模式的调用都以API文档为界限,所以文档要求质量高:
系统级别的复用:
框架:一组具体类、抽象类及其之间的联系关系,但是只有结构和定义,没有代码实现
开发者需要根据framework规约和预定义的接口填充代码构成完整系统
黑盒框架:通过代码层面的继承进行框架扩展
白盒框架:通过实现特定接口/委托进行框架扩展
设计可复用的类:
回顾之前提到过的子类型多态,客户端以统一方式处理不同类型对象。在任何地方使用一个类型的任意对象可以成立,将之替换成其子类型的任意对象也同样成立
LSP原则:强行为子类型化
1.子类型可以增加方法但是不可以删减(因为子类型在任何时候都能替换父类型,若子类型没有父类型中的A方法,替换时会出错)
2.子类型要实现抽象类型(接口、抽象类)中所有未实现的方法
3.子类型中重写的方法必须有相同/子类型的返回值,或者符合协变规则的参数
4.子类中重写的方法必须使用同类型参数或符合逆变规则的参数(后一种情况Java会视为重载处理)
5.子类型重写的方法不能抛出额外的异常
在规约处表现为:
1.更强的不变量
2.更弱的前置条件
3.更强的后置条件
协变:由父类型到子类型,越来越具体,指定的参数类型/异常变化方向一致,也越来越具体
逆变/反协变:与协变相反,父类型到子类型,指定参数/异常反而变得抽象
Java不支持反协变,会将之当成重载处理(参数协变也是当成重载处理),其中参数协变不安全
Java中的数组是支持协变的,T类型的数据可以存放T和其子类数据,例如:
Number[] numbers = new Number[2];
numbers[0] = new Integer(10);
numbers[1] = new Double(3.14);
Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;
myNumber[0] = 3.14; //run-time error!
numbers数组已经实例化为Integer,但是仍通过Numbers[]引用进行访问,后再加入小数类型,编译时不报错,运行时会出错
泛型中的LSP:
泛型是类型不变的(例如ArrayList<Integer>是ArrayList<Integer>的子类,但是List<Integer>却不是List<Object>的子类,简单说只考虑数据的原始类型而不管该容器存放的具体类型),在编译后,类型参数被丢弃,运行时不存在,这个叫做类型擦除(在下面详细说)
但是泛型不是逆协变的
在Java虚拟机中,没有泛型类型对象,所有对象都是普通类,泛型信息只会存在于编译阶段,定义范型变量时,会赋予一个初始类型(非泛型),原始类型名字就是去掉泛型参数之后的泛型类型名。擦除时,类型变量被擦除,替换成满足限制条件的最抽象的一个类型,如无要求即为Object
用instanceof、getclass查询类型只适用于原始类型,如上所说。
我们可以采用通配符?实现泛型类的协变:当类型类中的方法均不依赖于类型参数(不调用其中的方法),如List中的方法,或者只依赖于Object类的功能时,可以使用无限制通配符,只有?,其余情况,需要对通配类型进行描述,如某个类型的子类/父类等等。
例如要打印任意类型的list,不应该是List<Object>,而是List<?>
public static void printList(List<Object> list) {
for (Object elem : list)
System.out.println(elem + " ");
System.out.println();
}
public static void printList(List<?> list) {
for (Object elem: list)
System.out.print(elem + " ");
System.out.println();
}
//√
上限通配符:<? extends A>,代表类的继承/接口的实现
下限通配符:<? supers A>,代表A的父类/父接口
满足通配要求的有多种类型,但是容器中只允许存放其中一种,于实例化时确定。
当然,一个类型变量可以有多种限定,格式如:<? extends A & B & C>则需要同时满足是所有类型的子类,我们之前谈论这个话题的时候说到,Java不允许多继承,因此上面的限定中,最多只有一个类,其余的均为接口,这里还要求要将这个类放到最前面。
我们允许限定的类型参数调用其限定中的方法,因为它一定是这些类的子类,就一定有它们的所有方法的实现。
PECS(producer-extends, consumer-super)
带有子类型限定的通配符(上限通配符)可以从泛型对象读取,带有超类型限定的通配符(下限通配符)可以向泛型对象写入,
容器指定参数类型上界为A,则只能放入A及其子类型,不可放入A的超类,我们为其分配指向一个子类之后,在后续仍可重新将它指向其他不同的子类。然后可以用A及其父类接收返回值,但是不能用A的子类接收返回值。特别的,当返回类型未知时,必须用Object接收
委托/组合:
在前面提到实现模块复用的技术,主要是类的继承和委托,继承已经在前面章节学习过
委托:一个对象请求另一个对象的功能
例如,当我们需要实现排序时,可以实现Comparetor接口并根据要求重写compare函数。
注:要实现排序功能还可以用ADT实现Compareble接口,然后重写CompareTo方法,但是这种就不叫委托了
委托的格式如下图所示,这种委托在类的属性中绑定了委托对象,委托关系可以长期稳定的存在
还有另一种方法,在需要委托的方法内部临时进行委托绑定和委派,这种委托关系只是临时存在的,仅在这个方法中存在
不论是哪一种,外部客户端均无法知道内部实现的细节
模块的复用依靠继承和委托,某个部分采取哪种方法取决于继承/委托对象的方法的复用率,一般我们不喜欢继承过多无用的方法,此时应该用委托。
委托简而言之是生成一个实例来调用方法实现我们需要的某个功能,不需要自己实现,因此委托发生在Object层面。而继承发生在Class层面。
委托有四种类型:
1.dependency(依赖) A uses B 临时性
2.association(关联) A has B 永久性
3.composition(组合) A owns B
4.aggregation(聚合) A owns B
一般认为3,4是2 的两种具体形态,只是强弱不一样,组合变得更强,且难以变化,聚合变得更弱,但是可以动态变化
1.依赖:方法的参数或者局部量调用
class Duck {
//no field to keep Flyable object
void fly(Flyable f) {
f.fly();
}
}
Flyable f = new FlyWithWings();
Quackable q = new Quack();
Duck d = new Duck();
d.fly(f);
d.quack(q);
2.关联:在属性中绑定
class Duck {
Flyable f = new CannotFly();
void Duck(Flyable f) {
this.f = f;
}
void Duck() {
f = new FlyWithWings();
}
void fly() { f.fly(); } }
Flyable f = new FlyWithWings();
Duck d = new Duck(f);
Duck d2 = new Duck();
d.fly();
3.组合:更严格的关联
class Duck {
Flyable f = new FlyWithWings();//已经确定好具体的委托对象
void fly() {
f.fly();
} }
Duck d = new Duck();
d.fly();
4.聚合:比较灵活的关联
class Duck {
Flyable f;//可以根据传入的对象灵活实现功能
void Duck(Flyable f) {
this.f = f;
}
void setFlyBehavior(f) {
this.f = f;
}
void fly() { f.fly();} }
Flyable f = new FlyWithWings();
Duck d = new Duck(f);
d.fly();
d.setFlyBehavior(new CannotFly());
d.fly();
CRP:组合优先于继承原则
例子如下,实现鸭子飞和叫的功能:
interface Ducklike extends Flyable, Quackable {} //接口的组合
public class Duck implements Ducklike //组合接口派生出具体的实现类{
Flyable flyBehavior;//委托的绑定
Quackable quackBehavior;
void setFlyBehavior(Flyable f)//设置委托的对象 {
this.flyBehavior = f; }
void setQuackBehavior(Quackable q) {
this.quackBehavior = q; }
@Override
public void fly() //通过委托对象实现具体功能{
this.flyBehavior.fly();
}
@Override
public void quack() {
this.quackBehavior.quack();
} }
//Client code:
Flyable f = new FlyWithWings();
Quackable q = new Quack();
Duck d = new Duck();
d.setFlyBehavior(f);
d.setQuackBehavior(q);
d.fly();
d.quack();
黑盒框架/白盒框架
前者依靠委托/组合,后者依靠重写进行复用
实例如下:(比较复杂,只提供截图)
计算器(常例)
运用白盒框架:继承后重写。。
黑盒框架:委托plugins完成