目录链接
文章的内容是我在复习这门课程时候看PPT时自己进行的翻译和一些总结,如果有错误或者出入希望大家能和我讨论!同时也希望给懒得翻译PPT而刷到这篇博客的你一些帮助!
Part I What is Software Reuse?
什么是软件复用呢?软件复用是指使用现有的软件进行实现或者更新的过程。有两个方面:
- 面向复用编程:开发出可复用的软件
- 基于复用编程:利用已有的可复用软件搭建应用系统
那么我们又为什么要进行复用呢?
- 复用可以降低成本和开发时间
我们不能浪费时间去总去造轮子去,也就是说每当我们要使用轮子的时候,要想着使用之前造过的轮子。
- 复用的部分软件经过充分的测试,比较可靠稳定
- 足够标准化,在不同应用中保持一致
但是,软件的复用是需要一些代价的。一个好的可服用的部件应该有清晰的描述、开放的方式、简洁的接口规约以及容易理解的文档。一个软件如果服用的次数足够多,那么这个软件的复用就是有意义有效果的。
Part II How to measure “reusability”?
可复用性的衡量指标:
- How frequently:复用的频繁性,也就是我们与预期复用多少次,都有什么样的场合可以服用这个功能或者方法。
- How much:服用的代价,这当然是我们关注的另一个问题。复用的代价体现在搜索获取、适配扩展、实例化和与软件其他部分的互联难度等几个方面。
那么一个拥有着良好复用性的软件应该包含以下的特点:小而简单、与标准兼容、灵活可变、可扩展、泛型化、模块化、变化的局部性、稳定、有着丰富的文档和帮助。
Part III Levels and morphology of reusable components
复用有几个不同层级的层面,其中最主要的是代码层面的复用。但是我们可以说,软件构造过程中的任何实体都可能被复用,包括require、Spec、data、testcase以及Doc等。
在复用这一块儿我们的关注点是这样的:
- 代码层面:复用方法以及实现体
- 模块层面:复用类和接口
- 库文件层面:使用API
- 接口层面:使用框架
framework
复用又有两种形式,白盒复用和黑盒复用。
- 白盒复用:
是源代码可见的复用。简单来说就是复制已有代码到正在开发的系统,然后进行适配和修改。
优点:可定制化的程度更高
缺点:它的修改增加了软件的复杂度,而且需要对代码内部实现和工作原理有着充分的了解 - 黑盒复用:
源代码不可见的复用。我们只能通过API接口来使用服用的部分,无法对底层实现进行修改。
优点:简单清晰
缺点:适应性较白盒复用来说差些
3.1Source code reuse
这个小节讲的是源代码层级的复用,这个是最低层级的复用。以至于它的实现方式非常的原始但是常用,因为我们在应付作业的时候使用的就是这种复用方法(bushi)。可以在Github上clone或者在searchcode.com上寻找所需代码。
3.2Module-level reuse
模块层级的复用是将我们实现过或者别人实现过的类和接口进行复用。我们有两种方法:继承和委托。这两种方法结合能够很好高效的提高代码复用性。在下面会进行详细地介绍。
3.3Library-level reuse
库文件层级的复用使用的是一些API和包。这个所谓的library
也就是库文件就是一些能够提供可复用功能的类和方法构成的一个集合,而这些类和方法我们称之为API。注意我们使用lib的时候是我们来主动的使用lib中的内容。
那么什么是好的API呢?一个好的API应该具易于学习、易于理解、易于使用、易于维护等等特点。
3.4System-level reuse
系统层级的复用使用的是framework
,也就是框架。它是一组具体类、抽象类机器之间的练习关系组成的体系,开发者可以根据framework的Spec,填充自己的代码进去。需要注意的是,与上面的lib不同,在使用framework
的时候,在运行它的时候是它调用你填充的东西,与上面正好是反向的。
Part V-I Designing reusable classes
讲了这么多前置知识,这里开始进入我们今天的重点课题,设计一个可复用的类。
5.1Behavioral subtyping and Liskov Substitution Principle (LSP)
子类型多态:客户端可以使用统一的方式处理不同类型的对象。
其实这个概念我们并不陌生,在第六章中我们略有描述只是没有详细的分析。如果一个类型为T的对象x,有q(x)成立,那么一个类型T的子类型S的对象y,q(y)也成立。这是著名的计算机科学博士Barbara Liskov的一句观点,她也提出了出名的里式替换法则也就是LSP法则。
LSP内容:
- 子类型可以增加方法,但是不可以删减方法
- 子类型需要实现抽象类型(包括接口和抽象类)中所有未实现的方法
- 子类型中重写的方法必须有相同或子类型的返回值或者符合协变要求的参数
- 子类型中重写的方法必须使用同样类型的参数或者符合反协变的参数(此种情况Java目前按照重载overload处理)
- 子类型中重写的方法不能抛出额外的异常
简单的总结一下LSP要求的内容就是:更强的不变量、更弱的前置条件和更强的后置条件。
Example1
我们看这个例子中红色方框框住的部分。在子类中进行重写方法相较于父类中的方法有着更多的不变性要求(invariant)和相等的前置条件。那么这个例子就是符合LSP原则的。
Example2
同样的我们看红色方框中的部分。对于不论是子类型中新定义的变量还是进行重写的方法,我们可以看出都满足更强的不变性、更弱的前置条件和更弱的后置条件得要求。因此满足LSP法则。
LSP 是子类型关系的特定定义,称为强行为子类型。在强调一遍重点:
- 前置条件不能强化
- 后置条件不能弱化
- 不变量要保持
- 子类型方法的参数要满足协变规则
- 子类型方法返回值要满足反协变规则
- 子类型不应该抛出新的异常
接下来我们说一说前面提到过的协变与反协变。
5.2Covariance and contravaiance
协变:
协变是指变得越来越具体或者和之前一样具体,对方法和异常来说都是一样的。我们看一下例子:
在这个例子中,方法从Object
重写为String
,异常从Thorwable
重写为IOException
,都变得越来越具体了。
反协变:
顾名思义,与协变进行的过程相反。也有一段示例代码:
可惜的是,这段代码在Java中会报错,因为目前Java还不支持反协变。Java中,反协变会被当做重载overload进行处理而不是重写override,因此我们使用了@Override
就会报错。
在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!
这段代码的最后一句为什么会报错呢?因为在运行的时候,我们的myNumber
数组指向了一个Integer
类型的数组,也就是说在运行时它被实例化为一个Integer
数组,所以自然不能存储一个浮点型变量。
5.3Consider LSP for generics
泛型是类型不变的。
如何理解这句话呢?我们还是举一个例子:
- ArrayList<String>是List<String>的一个子类型
- List<String>不是List<Object>的一个子类型
原因是,类型参数在编译后就被丢弃了,在运行时不可用。也就是说泛型在运行阶段时是不存在的,都被替换成了别的类型,称为类型擦除。
List<Integer> myInts = new ArrayList<>();
myInts.add(1);
myInts.add(2);
List<Number> myNums = myInts; //compiler error
myNums.add(3.14);
static long sum(List<Number> numbers) {
long summation = 0;
for(Number number : numbers) {
summation += number.longValue();
}
return summation;
}
List<Integer> myInts = Arrays.asList(1,2,3,4,5);
List<Long> myLongs = Arrays.asList(1L, 2L, 3L, 4L, 5L);
List<Double> myDoubles = Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0);
sum(myInts); //compiler error
sum(myLongs); //compiler error
sum(myDoubles); //compiler error
上面这两段代码都是错误的示范,它们的错误都是误认为泛型是协变的,而采取了想当然的操作,其实是编译不通过的。
我们都知道在进行泛型编程的时候,真正实例化使用它们的时候,泛型变量都被替换成了具体的兑现类型。反省只存在于编译阶段,运行时会被擦除。那你可能想如果没有定义的泛型被擦除了的话那不就有个大缺口了嘛?因此,如果没有限定类型的反省就会被替换为Object类作为缺省值。
那么如果我们想实现两个泛型类之间的协变要怎么办呢?这时候我们就需要使用通配符?
。
5.4Wildcards in Generics
?
是一个Java中的语义符号,表示无限定通配符,又被叫做未知类型集合。有两种情况使得这种无限定通配符的使用非常的方便且高效:
- 方法的实现不依赖于类型参数,也就是说在实现的过程中不依赖于具体类型的方法,比如我使用
List
的属性List.length
- 实现过程中只依赖于Object类中的功能。
在下面的示例代码中,我们看到如果使用了无限定型通配符?
,只定义一个引用变量就可以让他只想多个不同类型的变量。
SuperClass<?> sup0 = new SuperClass<String>(); //使用?
sup0 = new SuperClass<People>();
sup0 = new SuperClass<Animal>();
SuperClass<String> sup1 = new SuperClass<String>(); //不使用?
SuperClass<People> sup2 = new SuperClass<People>();
SuperClass<Animal> sup3 = new SuperClass<Animal>();
通配符的使用可以解决一些泛型协变的问题,比如下面的例子:
public static void printListObj(List<Object> list) {
for (Object elem : list)
System.out.println(elem + " ");
System.out.println();
}
public static void printListWild(List<?> list) {
for (Object elem: list)
System.out.print(elem + " ");
System.out.println();
}
//client
List<Integer> li = Arrays.asList(1, 2, 3);
List<String> ls = Arrays.asList("one", "two", "three");
printListWild(li);
printListWild(ls);
在这段代码中,我们可以看到printListObj
方法,由于泛型的不协变,因此我们在传递参数的时候只能传递List<Object>
进去使之打印;而在我们使用了通配符之后,就可以传递不同的参数列表进去了。
通配符还有两种限定形式,上限通配符<? extends A>
和下限通配符<? super A>
。我们拿上限通配符作为例子来解释他的含义。例如List<? extends Number> list
,List后面的尖括号中的对象类型可以是Number
类或者它的子类,也就是说Number
是这个泛型的继承上限。
有一个非常好玩的地方,就是限定的类型参数允许调用限定类型中的方法。不好理解,举个例子你就明白了。
public class NaturalNumber<T extends Integer> {
private T n;
public NaturalNumber(T n) { this.n = n; }
public boolean isEven() {
return n.intValue() % 2 == 0;
}
// ...
}
我们看代码的第5行,它使用了一个.intValue()
的方法,但是它是一个泛型类型的变量。这种做法就是上面描述的那种做法,可以使用限定的类型中的方法。
5.5PECS*
PECS其实就是一个简称,Producer-Extends, Consumer-Super。又叫做Get and Put Principle,直译过来就是获取放置原则。说的是带有子类型限定的通配符(上限)可以从泛型对象读取,带有超类型限定的通配符(下限)可以向泛型对象写入。比如下面的例子:
public static <T> void copy(List<? super T> dest, List<? extends T> src)
其中dest是复制的目的地,src是源址,这样做的目的通俗的解释就是,目的的最子类型是源址的最父类型,这样做就可以保证最后复制的结果一定是将一个子类型赋给了父类型。
Producer-Extends:
List<? extends Cat> animals= new ArrayList<Cat>();
animals.add(new whiteCat()); //compile error
animals.add(new Cat()); //compile error
animals.add(new Animal()); //compile error
animals.add(new Object()); //compile error
animals.add(null); //succeed, but it is meaningless.
在这个animals数组中,我们不能放入任何一种类型。因为因为编译器只知道animals中应该放入Cat的某种子类型,但具体放哪种子类型它无法确定。所以Producer-Extends不能用来进行写入。
但是它可以用来进行读取数组中的内容,如下:
Cat s1 = animals.get(0); //类型上界为Cat,Cat及其父类都能接收返回值
Animal s2 = animals.get(0); //Cat类型可以用Animal接收
WhiteCat s3 = animals.get(0);
Consumer-Super:
class Animal{}
class Cat extends Animal{}
class whiteCat extends Cat{}
class BlackCat extends Cat{}
List<? super Cat> b = new ArrayList<>(); //参数类型下界是Cat
b.add(new Cat()); //ok 只能放入Cat类型及其子类型
b.add(new WhiteCat()); //ok 子类型也可以
b.add(new Animal()); //error 超类不可以
b.add(null); //ok
这种下限通配符就可以向数组进行写入了。
Part V-II Delegation and Composition
这一部分我们来学习一下委托的思想。
5.6Delegation
委派/委托:一个对象请求另一个对象的功能。它也是复用的一种常见形式。我们简单的举一个例子:
我么可以看到,在类B中,它的void foo()
方法调用了类A中的实现,这就是委托。在很多设计模式中,委派和委托结合着使用,它在一个类中实例化另一个对象,然后调用这个对象的方法。
委托这种机制有很多好处:如果子类只需要用到父类中的一小部分方法,可以不使用继承而是使用委托,从而避免继承大量的无用的方法。委托发生在对象层面,而继承发生在类的层面。
5.7Types of delegation
我们再来看一个更为复杂一点例子,下面的讲解需要这段代码:
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();
}
}
委托被人为的分为不同的强度:Dependency、Association和Composition/aggregation。
- Dependency: 临时性的delegation
通过方法的参数或者在方法的局部中使用发生联系。我们看一下实例:
这种临时性体现在,我们发送委派请求的对象内部只有一个我们想要使用的方法,而没有一个成员变量能够存储方法结果。
- Association: 永久性的delegation
这个和上面的代码进行对比来看区别就比较明显,在我们请求调用的方法内部,有一个Flyable
类型的对象来存储我们的调用结果。
- Composition: 更强的association,但难以变化
composition是一种将简单的对象或数据类型和一个更复杂的对象结合的association。
- Aggregation: 更弱的association,可动态变化
aggregation是一种更弱的association对象存在于另一个之外,是在外部创建的,因此它作为参数传递给构造函数。
这一部分的知识到此就结束了,感谢你能看到这里,说明你真的有好好学习。如果你觉得我写的还行,还请一键三连!