软构学习——面向复用软件的构造技术
文章目录
什么是软件复用?
-
软件复用是实现或更新软件的过程中使用现有软件组件。
-
软件复用的两个角度
-
面向复用编程:开发出可复用的软件
lab2中的Graph<L>就是面向复用的编程。
复用性越强,就越抽象,并且性能差。
-
基于复用编程:利用已有的可复用软件搭建应用系统
lab2中的Poetic Walks 、Friendship Social Network就是基于复用的编程,它使用Graph<L>完成的编程。
-
-
为什么复用?
“The drive to create reusable rather than transitory artifacts has aesthetic and intellectual as well as economic motivations and is part of man’s desire for immortality.
– It distinguishes man from other creatures and civilized from primitive societies” (Wegner, 1989).
- 复用降低成本和开发时间。
- 复用产品经过充分测试,可靠、稳定。
- 复用产生标准化,在不同应用中保持一致。
-
复用代价
可复用组件的设计和构建应明确、定义明确、方式开放、接口规范简洁、文档易懂,并着眼于未来的使用。做到以上都需要代价。
不仅仅是面向复用编程代价高,有时候基于复用编程的代价也高。
可复用组件:被使用的产品规模越大,数量越多,复用的成本越低。
开发成本高于一般软件的成本:要有足够高的适应性。
性能差些:针对更普适场景,缺少足够的针对性。
如何测量复用性?
-
复用的机会有多频繁?复用的场合有多少?
复用的次数越多,复用性越高。
-
复用的代价有多大?
复用的代价有:搜索、获取、适配、扩展、适配、扩展的花费,以及与软件其他 部分的互连的难度。
所以高复用性的软件应该小、简单、与标准兼容、灵活可变、可扩展、泛型、参数化、模块化、变化的局部性、稳定、丰富的文档和帮助。
可复用部件的等级与形态
-
最主要的复用是在代码层面
但软件构造过程中的任何实体都可能被复用:需求 、设计/规约、数据 、测试用例 、文档等。
-
复用部件的级别
-
源代码级( Source code level)
复用方法语句等。
-
模块级( Module level)
复用类与接口。
-
库级( Library level)
java库:jar包。
-
架构级(Architecture level)
框架。
-
源代码复用——最低级
- 从网上复制粘贴(
ctrl + v 是吧?) - 维护问题
–需要在多个位置更正代码
–代码太多,无法使用(版本太多)- 编程时发生错误概率高
- 可能需要了解所用软件的工作原理
- 需要访问源代码
模块级复用
复用类
-
类是代码重用的原子单元
–不需要源代码、类文件或jar/zip
–只需要包含在类路径中
–可以使用javap工具获取类的公共方法头
-
文档非常重要(Java API)
-
封装有助于重用
-
要管理的代码更少
-
版本控制、向后兼容性仍然存在问题
-
需要将相关类打包在一起–静态链接
-
-
复用的方法——继承
Java提供了一种名为继承的代码复用方法
–类扩展了现有类的属性/行为
–此外,它们可能会覆盖现有行为-
不需要提出仅仅是提出或委派工作的虚拟方法
-
可以更好地描述真实世界
-
通常需要在实现之前设计继承层次结构
-
无法取消属性或方法,因此必须小心做过头了
-
-
复用的方法——委托
- 委派仅仅是指一个对象依赖另一个对象来实现其功能的某个子集(一个实体将某物传递给另一个实体)
–例如,分拣机正在将功能委托给某个比较器 - 明智的委托支持代码重用
–分拣机可与任意分拣订单一起重复使用
–比较器可以与需要比较整数的客户端复用 - 显式委托:将发送对象传递给接收对象
- 隐式委派:根据语言的成员查找规则
- 委托可以描述为在实体之间共享代码和数据的低级机制。
- 委派仅仅是指一个对象依赖另一个对象来实现其功能的某个子集(一个实体将某物传递给另一个实体)
库等级复用——API/Package
- 库:提供可重用功能的一组类和方法(API)
- 好的API特性
- 易于学习
- 易于使用,甚至无需文档
- 难以滥用
- 易于阅读和维护使用它的代码
- 足够强大以满足要求
- 易于更新
- 适合大众
系统级的复用
-
框架:一组具体类、抽象类、及其之间的连接关系
– 只有“骨架”,没有“血肉”
-
开发者根据 framework的规约,填充自己的代码进去,形成完整系统
-
领域知识的复用
– 将framework看作是更大规模的API复用,除了提供可复用的API,还将这 些模块之间的关系都确定下来,形成了整体应用的领域复用。
– 框架作为主程序加以执行,执行过程中调用开发者所写的程序。
– 开发者根据 框架预留的接口来写程序。
-
框架不同于应用程序
–抽象级别不同,因为框架为一系列相关问题提供了解决方案,而不是单个问题。
–为了适应一系列问题,该框架是不完整的,包含了热点和挂钩,以允许定制 -
框架可以根据用于扩展它们的技术进行分类。
–白盒框架 通过代码层面的继承进行框架扩展
–黑盒框架
通过实现特定接口/delegation进行 框架扩展
设计可复用的类
行为子类型与Liskov替代原则(LSP)
-
子类型多态
客户端可用统一的方式处理不同类型的对象
Animal a = new Animal(); Animal c1 = new Cat(); Cat c2 = new Cat();
例如猫类是动物类的子类,则动物类用的地方,猫类都能替换。
如果对于类型T的对象x,q(x) 成立,那么对于类型T的子类型S的对象y,q(y) 也成立。——Barbara Liskov
-
Java中编译器强制执行的规则(静态类型检查)
子类型可以增加方法,但不可删除方法。
子类型需要实现抽象类型 (接口、抽象类)中所有未实现的方法。
子类型中重写的方法必须有相同或子类型的返回值或者符合返回值协变的参数。
子类型中重写的方法必须使用同样类型的参数或者符合参数逆变的参数(此种情况Java目前按照重载overload处理)
子类型中重写的方法不能抛出额外的异常。
-
也适用于具体的行为(方法)
更强的或相同的不变量
更弱的或相同的前置条件
更强的或相同的后置条件
-
Example
子类汽车有比父类交通工具更强的不变性,子类重写的方法规约强度不变。
子类有更强的不变量,子类重新的方法有更弱的前置条件,更强的后置条件。
-
LSP是子类型关系的特定定义,称为强行为子类型化
-
在编程语言中,LSP依赖于以下内容限制:
- 前置条件不能强化
- 后置条件不能弱化
- 不变量要保持
- 子类型方法参数:逆变
- 子类型方法的返回值:协变
- 子类型方法的返回值:协变
-
协变
父类型→子类型:
越来越具体specific
返回值类型:不变或变得更具体
异常的类型:也是如此。
-
逆变
父类型→子类型:
越来越具体specific
参数类型:
要相反的变化,要不变或越来越抽象
目前Java中遇到这种情况,当作overload看待(不支持参数的逆变)。
-
数组是协变的
对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!
在运行时,Java知道这个数组被实例化 为Integer数组,只是通过一个Number[]引用进行访问。
-
泛型是类型不变的
类型参数在编译后被丢弃,在运行时不可用。这个过程叫类型擦除。也就是说泛型是不能协变的。
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
泛型规约保证了list是某个具体类型的数组,如果支持协变,就会违反规约产生危险。
sum方法中利用List<Number>接受类型为Number子类的List是不可行的。因为泛型不支持协变。
-
虚拟机中没有泛型类型对象-所有对 象都属于普通类!
-
泛型信息只存在于编译阶段,在运行时会被”擦除”
-
定义泛型类型时,会自动提供一个对应的原始类型(非泛型类型),原始类型的名字就是去掉类型参数后的 泛型类型名。
-
定义泛型类型时,会自动提 供一个对应的原始类型(非泛型类型),原始类型的名字就是去掉类型参数后的 泛型类型名。
-
使用通配符实现两个泛型的协变
无限定通配符List<?>
使用情况:
情况1:方法的实现不依赖于类型参数(不调用其中的方法),如List中的 方法;
情况2:或者只依赖于Object 类中的功能
一般用于定义一个引用变量,其可以指向多个不同类型的变量:
SuperClass<?> sup0 = new SuperClass<String>(); sup0 = new SuperClass<People>(); sup0 = new SuperClass<Animal>();
下限通配符<? super A>
上限通配符<? extend A>
限定的类型参数允许调用限定类型中的方法。
这里的 extends既可以代表类的extends,也可以代表接口的implements。
Class A { /* ... */ } interface B { /* ... */ } interface C { /* ... */ } class D <T extends A & B & C> { /* ... */ }
一个类型变量如果有多个限定(类或接口),则它是所有限定类型的子类型;如果多个限定中有类(至多只允许一个类),要写到声明的最前面。
委托与聚合
-
委派/委托:一个对象请求另一个对象的功能
委派是复用的一种常见形式。
委托可以描述为在实体之间共享代码和数据的低级机制。
–显式委托:将发送对象传递给接收对象
–隐式委派:根据语言的成员查找规则 -
Example
class A { void foo() { this.bar(); } void bar() { print("a.bar"); } } class B { private A a; // delegation link public B(A a) { this.a = a; } void foo() { a.foo(); // call foo() on the a-instance } void bar() { print("b.bar"); } } client: A a = new A(); B b = new B(a); // establish delegation between two objects b.foo();
-
委派 vs 继承
继承:通过新操作扩展基类或覆盖操作。
委派:捕获操作并将其发送到另一个对象。很多设计模式将继承和委托结合使用。
-
问题:
子类只使用其超类的一部分方法(或者不可能继承超类数据)。
-
解决方案:
创建一个字段并在其中放置一个超类对象,将方法委托给超类对象,并摆脱继承。
-
优点:
本质上,这种重构将两个类分开,并使超类成为子类的助手,而不是其父类。
一个类不需要继承另一个类的全部方法,通过委托机制调用部分方法。从而避免继承大量无用的方法。
-
-
组合优先于继承(Composite Reuse Principle CRP) 注:组合是委派的一种
类应该通过组合(通过包含实现所需功能的其他类的实例)而不是从基类或父类继承来实现多态行为和代码重用。
“委托” 发生在对象层面,而“继承”发生在类层面。
-
委托的分类
这种分类是根据委托类和被委托类之间的“耦合度”进行的。
-
依赖(Dependency)
(A use B)临时性的委派
两个类之间的这种形式的关系被称为“uses-a”关系,在这种关系中,一个类使用另一个类,而实际上没有将其合并为属性。例如,它可以是参数,也可以在方法中局部使用。
-
联合(Association)
(A has B)永久性的委派
一个类将另一个类作为属性/实例变量。这种关系是结构化的,因为它指定一种对象与另一种对象相连接,而不表示行为。
-
组合/聚合(Composition/aggregation)**
(A owns B)
可以认为组合/聚合是联合的两种具体形态。
-
组合
更强的联合,但难以变化。一个类将另一个类作为属性/实例变量。
example: class Duck { Flyable f = new FlyWithWings(); void fly() { f.fly(); } }
-
聚合
更弱的联合,可动态变化。对象存在于另一个对象之外,是在外部创建的,因此它作为参数传递给构造函数。
example: class Duck { Flyable f; void Duck(Flyable f) { this.f = f; } void setFlyBehavior(f) { this.f = f; } void fly() { f.fly();} }
-
-
设计可复用的框架和库
-
库:
提供可重用功能的一组类和方法(API)
-
API为什么重要?
- API是程序员最重要的资产和“荣耀”,吸引外部用户,提高声誉。
- 好的代码是模块化的–每个模块都有一个API
–用户投入巨大:获取、写作、学习
–根据API思考可以提高代码质量
–成功的公共API捕获用户 - 也可能是你最大的责任之一
–错误的API可能会导致无休止的支持调用流
–会抑制向前进的能力 - 难度:要有足够良好的设计,一旦发布就无法再自由改变。
-
黑盒框架与白盒框架
-
白盒框架
–通过子类化和重写方法进行扩展
–通用设计模式:模板法
–子类有main方法,但控制框架example: public abstract class PrintOnScreen { public void print() { JFrame frame = new JFrame(); JOptionPane.showMessageDialog(frame, textToShow()); frame.dispose(); } protected abstract String textToShow(); } public class MyApplication extends PrintOnScreen { @Override protected String textToShow() { return "printing this text on " + "screen using PrintOnScreen " + "white Box Framework"; } } client: MyApplication m = new MyApplication(); m.print();
Whitebox框架使用子类化/子类型化—继承
–允许扩展每个非私有方法
–需要了解超类的实现
–一次只扩展一处
–一起编译
–通常是所谓的开发人员框架 -
黑盒框架
–通过实现插件接口进行扩展
–通用设计模式:策略、观察者
–插件加载机制加载插件并控制框架example: public final class PrintOnScreen { TextToShow textToShow; public PrintOnScreen(TextToShow tx) { this.textToShow = tx; } public void print() { JFrame frame = new JFrame(); JOptionPane.showMessageDialog(frame, textToShow.text()); frame.dispose(); } } public interface TextToShow { String text(); } public class MyTextToShow implements TextToShow { @Override public String text() { return "Printing"; } } client: PrintOnScreen m = new PrintOnScreen(new MyTextToShow()); m.print();
黑盒框架使用组合–委派/组合
–允许扩展界面中公开的功能
–只需了解界面
–多个插件
–通常提供更多的模块化
–可以单独部署(.jar、.dll、…)
–通常是所谓的最终用户框架、平台
-