软件构造第三部分——面向可复用性和可维护性内容梳理
一.面向复用的软件构造技术
1.What is Software Reuse?
面向复用编程:开发出可复用的软件;
基于复用编程:利用已有的可复用软件搭建应用系统。
已有软件往往无法拿来就用,需要适配。
2.How to measure “reusability”?
(1)复用的机会有多频繁?复用的场合有多少?
(2)复用的代价:搜索、获取;适配、扩展;实例化;与其他部分互联的难度。
可复用性:小、简单;与标准兼容;灵活可变;可扩展;泛型、参数化;模块化;变化的局部性;稳定;丰富的文档和帮助。
3.Levels and morphology of reusable components(可重用组件的级别和形态)
Source code level: methods, statements(方法,注释等)
Module level: class and interface(类和接口)
Library level: API(Java Library, .jar)(第三方库)
Architecture level: framework(框架)
白盒复用:源代码可见,可修改和扩展。其复制已有代码到正在开发的系统,进行修改。其可定制化程度高,但对其修改增加了软件的复杂度,且需要对其内部充分的了解。
黑盒复用:源代码不可见,不能修改。其只能通过API接口来使用,无法修改代码。虽然简单清晰,但适应性较差。
(1)Source code reuse(直接搜代码cv)
(2)Module-level reuse: class/interface(组成/聚合;委派/联合)
封装有助于重用,但版本控制、向后兼容性仍然存在问题——需要把相关类打包到一起(静态链接)。
重用类的方法:
a.继承:通常需要在实现之前设计继承层次结构,但无法取消属性或方法,因此必须小心不要过度。
b.委托:将一个实体的任务委派给另一个实体。明智的委托支持代码重用。
明确授权:将发送对象传递给接收对象;
隐式委托:根据语言规则的成员查找。
委托可以描述为在实体之间共享代码和数据的低级机制。
(3)Library-level reuse: API/Package
Library: A set of classes and methods (APIs) that provide reusable functionality.
Framework: Reusable skeleton code that can be customized into an application.
Libraary需要我们的代码去调用,framework用来调用我们的代码。
(4)System-level reuse: Framework
框架:一组具体类、抽象类、及其之间的连接关系(只有骨架)。
开发者根据framework的规约,填充自己的代码进去,形成完整系统。
将framework看作是更大规模的API复用,除了提供可复用的API,还将这些模块之间的关系都确定下来,形成了整体应用的领域复用。
抽象级别是不同的,因为框架为一系列相关问题提供了解决方案,而不是单个问题。
为了适应这一系列问题,该框架是不完整的,包含了热点和挂钩,以允许定制。
白盒框架:通过代码层面的继承进行框架扩展。现有功能通过对框架基类进行子类化和重写预定义的钩子方法来扩展。模板方法模式之类的设计模式用于覆盖钩子方法。
黑盒框架:通过实现特定接口/delegation进行框架扩展。通过为可插入框架的组件定义接口来实现可扩展性;通过定义符合特定接口的组件来重用现有功能;这些组件通过委派与框架集成。
4.Designing reusable classes(设计可重用类)
(1)Behavioral subtyping and Liskov Substitution Principle (LSP):行为子类型与里氏替换原则。
子类型多态:客户端可用统一的方式处理不同类型的对象。
子类可以扩展父类的功能,但不能改变父类原有的功能——LSP。
静态类型检查:子类型可以增加方法,但不可删;子类型需要实现抽象类型中的所有未实现方法;子类型中重写的方法必须有相同或子类型的返回值或者符合co-variance(协变)的参数;子类型中重写的方法必须使用同样类型的参数或者符合contra-variance(反协变)的参数;子类型中重写的方法不能抛出额外的异常。
更强的不变量;更弱的前置条件;更强的后置条件(LSP)
LSP:强行为子类型化:前置条件不能强化,后置条件不能弱化,不变量要保持,子类型方法参数:逆变,子类型方法的返回值:协变,异常类型:协变。
子类的返回值协变:父类型à子类型:越来越具体specific;返回值类型:不变或变得更具体;异常的类型:也是如此。
子类方法的反协变:父类型à子类型:越来越具体specific;参数类型:要相反的变化,要不变或越来越抽象。这种重写情况目前被Java看作重载。
数组是协变的:给定Java的子类型规则,T[]类型的数组可能包含T类型的元素或T的任何子类型。在运行时,Java知道这个数组实际上被实例化为一个整数数组,而这个数组恰好是通过类型为Number[]的引用访问的。
泛型是类型不变的。代码编译完成后,编译器丢弃类型参数的类型信息;因此,此类型信息在运行时不可用,这被叫做类型擦除。
类型擦除:如果类型参数是无界的,则用其边界或对象替换泛型类型中的所有类型参数。因此,生成的字节码只包含普通类、接口和方法。
对于列表来说,类型标签的子类关系不适用于对应列表结构的类型关系(编译器拒绝)。
给定两种具体类型A和B(例如,数字和整数), MyClass<A>与 MyClass<B>没有关系,无论A和B是否相关。MyClass<A>和MyClass<B>的共同父级是Object。
通配符:类型参数相关时在两个泛型类之间创建类似子类型的关系。
<?>被称作无限定的通配符;<? extends T>被称作有上限的通配符(类型T及T的子类);<? super T>被称作有下限的通配符(T及T的超类)。
无限定通配符经常与容器类配合使用,它其中的?其实代表的是未知类型,所以涉及到?时的操作,一定与具体类型无关,你在这种方法内只能调用与类型无关的方法。
无限定通配符使用条件:正在编写一个可以使用Object class中提供的功能实现的方法;当代码在泛型类中使用不依赖于类型参数的方法时。
泛型的通配符的LSP原则:List<Number> is a subtype of List<?>;List<Number> is a subtype of List<? extends Object>;List<Object> is a subtype of List<? super String>
(2)Delegation and Composition(委派与组合)
如果你的ADT需要比较大小,或者要放入Collections或Arrays进行排序,可实现Comparator接口并override compare()函数。
另一种方法:让你的ADT实现Comparable接口,然后override compareTo() 方法。与使用Comparator的区别:不需要构建新的Comparator类,比较代码放在ADT内部。
委派/委托:一个对象请求另一个对象的功能。委派是复用的一种常见形式。
委派模式:通过运行时动态绑定,实现对其他类中代码的动态复用。过程:接收方对象将操作委托给委托对象;接收方对象确保客户端不会滥用委托对象。
继承是继承一个基本类并改写方法,委托是捕捉一个操作并送到另一个对象里。
如果子类只需要复用父类中的一小部分方法,可以不需要使用继承,而是通过委派机制来实现。本质上,这种重构将两个类分开,并使超类成为子类的助手,而不是其父类。一个类不需要继承另一个类的全部方法,通过委托机制调用部分方法,从而避免大量无用的方法。
尽量使用对象组合,而不是继承来达到复用的目的。合成复用原则(CRP)就是在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用功能的目的。简言之:复用时要尽量使用组合/聚合关系(关联关系),少用继承。
“委托”发生在object层面,而“继承”发生在class层面。
CRP实现通常从创建各种接口开始,这些接口表示系统必须展示的行为。根据需要,构建实现已识别接口的类并将其添加到业务域类中。使用接口定义系统必须对外展示的不同侧面的行为,接口之间通过extends实现行为的扩展(接口组合),从而规避了复杂的继承关系。
a.临时性的delegation:一个类使用另一个类,但实际上没有将其合并为属性:例如,它可以是参数,也可以在方法中局部使用。
依赖关系:对于两个相对独立的对象,当一个对象负责构造另一个对象的实例,或者依赖另一个对象的服务时,这两个对象之间主要体现为依赖关系。
b.永久性的delegation:对象类之间的持久关系,允许一个对象实例使另一个对象实例代表其执行操作。一个类将另一个类作为属性/实例变量;这种关系是结构化的,因为它指定一种类型的对象与另一种类型的对象相连接,而不表示行为。
关联关系:对于两个相对独立的对象,当一个对象的实例与另一个对象的一些特定实例存在固定的对应关系时,这两个对象之间为关联关系。在java中,单向关联表现为:类A当中使用了类B,其中类B是作为类A的成员变量。
c.Composition(组合): 更强的association,但难以变化。 实现为一个对象包含另一个对象。
组合关系:一种耦合度更强的关联关系。存在组合关系的类表示“整体-部分”的关联关系,“整体”负责“部分”的生命周期,他们之间是共生共死的;并且“部分”单独存在时没有任何意义。(用构造函数赋值)
d.Aggregation(聚合):更弱的association,可动态变化。对象存在于另一个对象之外,是在外部创建的,因此它作为参数传递给构造函数。
聚合关系:耦合度强于关联,他们的代码表现是相同的,仅仅是在语义上有所区别。关联关系的对象间是相互独立的,而聚合关系的对象之间存在着包容关系,他们之间是“整体-个体”的相互关系。(用set赋值)
在组合中,当拥有的对象被销毁时,包含的对象也会被销毁;在聚合中未必。
6.Designing system-level reusable API libraries and Frameworks(运用API和Frameworks设计系统级复用)
白盒框架用继承,黑盒框架用委派/组合。
二.Construction for Change(面向可维护性的构造技术)
1. Software Maintenance and Evolution(软件维护与进化)
软件维护:修复错误、改善性能。最大的问题:修改后没有足够的文档记录和测试。
可维护性:模块化,OO设计原则,OO设计模式,基于状态的构造技术,表驱动的构造技术,基于语法的构造技术。
2.Metrics of Maintainability(维修性指标)
可维护性,可扩展性,灵活性,可适应性,可管理性,支持性。
圈复杂度 :程序中分支、嵌套、循环的多少,与程序的复杂度有关。
可维护性指数:
继承的层次数,类之间的耦合度:通过参数、局部变量、返回类型、方法调用、泛型或模板实例化、基类、接口实现、外部类型上定义的字段和属性修饰来度量与唯一类的耦合。
3.Modular Design and Modularity Principles(模块化设计和模块化原则)
高内聚,低耦合,分离关注点,信息隐藏。
(1)评估模块化的五个标准:可分解性,可组合性,可理解性,可持续性,异常保护。
(2)模块化设计的五条规则:直接映射,尽可能少/小的接口,显示接口,信息隐藏。
(3)松耦合和高内聚:耦合:模块间接口数量、每个接口的复杂性。
内聚:如果模块的所有元素都朝着相同的目标努力,则模块具有很高的内聚性。
4. 面向对象设计的几个原则:SOLID
单一责任原则(Single Responsibility Principle),开放-封闭原则(Open-Closed Principle),Liskov替换原则(Liskov Substitution Principle),依赖转置原则(Dependency Inversion Principle),接口聚合原则(Interface Segregation Principle)。
(1)单一责任原则(SRP):不应该有多于1个原因让你的ADT发生变化,否则就拆分开。
(2)(面向变化的)开放/封闭原则(OCP):
对扩展性的开放:模块的行为应是可扩展的,从而该模块可表现出新的行为以满足需求的变化;
对修改的封闭:模块自身的代码是不应被修改的,扩展模块行为的一般途径是修改模块的内部实现,如果一个模块不能被修改,那么它通常被认为是具有固定的行为。
解决方案:抽象技术,软件实体(类、模块、函数等)应开放以供扩展,但应关闭以供修改”,即使用继承和组合/委派更改类的行为。
(3)里氏替换原则(LSP):子类型必须能够替换其基类型;派生类必须能够通过其基类的接口使用,客户端无需了解二者之间的差异。
(4)接口隔离原则(ISP):不能强迫客户端依赖于它们不需要的接口:只提供必需的接口;不要强制类实现它们无法实现的方法;不要用很多方法污染接口。
胖接口具有很多缺点,需要分解成多个小的接口。
(5)依赖转置原则(DIP):具体应依赖于抽象,抽象不应依赖具体。
委托的时候要通过接口而不是具体子类建立联系。
5.语法驱动的构造:有一类应用,从外部读取文本数据,在应用中做进一步处理。
字节或字符序列可能是来自:输入文件有特定格式,程序需读取文件并从中抽取正确的内容;从网络上传输过来的消息,遵循特定的协议;用户在命令行输入的指令,遵循特定的格式;内存中存储的字符串,也有格式需要。
语法:使用grammar判断字符串是否合法,并解析成程序里使用的数据结构,通常是递归的数据结构。根据语法,开发一个它的解析器,用于后续的解析。
(1)语法成分:用语法定义一个“字符串”,是语法解析树的叶子节点,无法再往下扩展,我们通常用引号写叶节点(‘http’ 或者 ‘:’)。
遵循特定规则,利用操作符、终止节点和其他非终止节点,构造新的字符串。非终结符是表示字符串的树的内部节点。语法的一个非终结符被指定为根。语法识别的字符串集与根非终结符匹配。
(2)语法中的运算符:连接(x ::= y z),重复(x ::= y*),选择(x ::= y | z)
按照惯例,后缀运算符*?+具有最高优先级,这意味着它们首先应用;交替|的优先级最低,这意味着它是最后应用的。
其他运算符:可选择的(x ::=y?)(x是y或者空),重复(x ::= y+)(不包含空),列表选择一个字符(x ::= [a-c],等价于x ::= 'a' | 'b' | 'c' ),除列表外选一个字符(x ::= [^a-c],等价于x ::= 'd' | 'e' | 'f' | ...)。
(3)语法中的递归:某个单词调用自己。
(4)解析树:将语法与字符串匹配可以生成一个解析树,该树显示字符串的各个部分如何对应于语法的各个部分。
(5)标记语言和HTML:表示文本中的排印样式。
(6)正则语法和正则表达式
正则语法:简化之后可以表达为一个产生式而不包含任何非终止节点。(递归的不是,没有递归项的都是)
正则表达式:去除引号和空格。
上下文无关语法:并非所有上下文无关语言都是正则;也就是说,有些语法不能简化为单个非递归结果。HTML语法与上下文无关,但非正则。通常,任何具有嵌套结构(如嵌套括号或大括号)的语言都是上下文无关的,但不是正则的。
(7)解析器(根据语法产生对应的树,检查是否符合语法规范)
(8)Java中的正则语言表达式(String.split,String.matches,java.util.regex.Pattern)
Pattern是对regex正则表达式进行编译之后得到的结果。
三.面向可复用性和可维护性的设计模式
设计模式分类:创建型模式、结构型模式、行为类模型
1.创建模式:简单工厂模式;工厂方法模式;抽象化工厂模式。
工厂模式又叫虚拟构造器,其为创建对象定义一个接口,让子类决定初始化哪个类,工厂化模式将类初始化延伸至子类。
当client不知道要创建哪个具体类的实例,或者不想在client代码中指明要具体创建的实例时,用工厂方法。定义一个用于创建对象的接口,让其子类来决定实例化哪一个类,从而使一个类的实例化延迟到其子类。
静态工厂方法:既可以在ADT内部实现,也可以构造单独的工厂类。
优势:无需将特定于应用程序的类绑定到代码;代码只处理产品接口(Trace),因此它可以处理任何用户定义的具体产品(FileTrace、SystemTrace)。
劣势:客户可能需要创建Creator的子类,以便创建特定的具体产品;如果客户端必须对创建者进行子类化,那么这是可以接受的,但如果不是,那么客户端必须处理另一个进化点。
2.结构模式:
(1)适配器模式:将一个类的接口转换成客户希望的另外一个接口。
适配器模式分为类结构型模式和对象结构型模式两种,前者类之间的耦合度比后者高,且要求程序员了解现有组件库中的相关组件的内部结构,所以应用相对较少些。
找到别人的代码后,写一个委托别人代码的类,在这个类被实例化时也实例化一个别人的类。采用继承或Delegation,加个“适配器”(接口)以便于复用。
(2)装饰边框与被装饰物的一致性:装饰(Decorator)模式的定义:指在不改变现有对象结构的情况下,动态地给该对象增加一些职责(即增加其额外功能)的模式,它属于对象结构型模式。
装饰结构包含:
抽象构件角色:定义一个抽象接口以规范准备接收附加责任的对象。
具体构件角色:实现抽象构件,通过装饰角色为其添加一些职责。
抽象装饰角色:继承抽象构件,并包含具体构件的实例,可以通过其子类扩展具体构件的功能。
具体装饰角色:实现抽象装饰的相关方法,并给具体构件对象添加附加的责任。
应用场景:当需要给一个现有类添加附加职责,而又不能采用生成子类的方法进行扩充时。
当需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,而采用装饰模式却很好实现。
方案: 实现一个通用接口作为要扩展的对象,将主要功能委托给基础对象(stack),然后添加功能(undo,secure,..),以递归的方式实现。
接口用来定义装饰物执行的公共操作,在其基础上添加功能,将通用方法放到具体构件上;Decorator抽象类是所有装饰类的基类,里面包含的成员变量component 指向了被装饰的对象。
装饰在运行时合成特性,继承在编译时组合特性。
客户端需要一个具有多种特性的object,通过一层一层的装饰来实现,像穿衣服。
3.行为模式
(1)整体替换算法:针对特定任务存在多种算法,调用者需要根据上下文环境动态的选择和切换。定义一个算法的接口,每个算法用一个类来实现,客户端针对接口编写程序。
为不同的实现算法构造抽象接口,利用委托,运行时动态传入client倾向的算法类实例。
(2)模板方法模式:不同的客户端具有相同的算法步骤,但是每个步骤的具体实现不同。因此在父类中定义通用逻辑和各步骤的抽象方法声明,子类中进行各步骤的具体实现。
在父类声明一个通用逻辑,模板模式用继承+重写的方式实现算法的不同部分。策略模式用委托机制实现不同完整算法的调用(接口+多态)。框架实现了算法的不变性,客户端提供每步的具体实现。
(3)迭代器模式:客户端希望遍历被放入容器/集合类的一组ADT对象,无需关心容器的具体类型。也就是说,不管对象被放进哪里,都应该提供同样的遍历方式。
(4)访客模式:对特定类型的object的特定操作(visit),在运行时将二者动态绑定到一起,该操作可以灵活更改,无需更改被visit的类。
访问者模式实际做的是创建一个使用其他类中数据的外部类visitor。如果操作逻辑发生变化,那么我们只需要在visitor实现中进行更改,而不是在所有的item类中进行更改。
作用:为ADT预留一个将来可扩展功能的“接入点”,外部实现的功能代码可以在不改变ADT本身的情况下通过delegation接入ADT。(visitor可以被改,还可以执行这个ADT的功能)。ADT本身只需要accept(visitor)即可。
迭代器:以遍历的方式访问集合数据而无需暴露其内部表示,将“遍历”这项功能delegate到外部的iterator对象。
访客:在特定ADT上执行某种特定操作,但该操作不在ADT内部实现,而是delegate到独立的visitor对象,客户端可灵活扩展/改变visitor的操作算法,而不影响ADT。
4.设计模式的共性与差异
共性:只使用“继承”,不使用“delegation”;核心思路:OCP/DIP;依赖反转,客户端只依赖“抽象”,不能依赖于“具体”;发生变化时最好是“扩展”而不是“修改”。
模板方法模式:要提供一个统一的算法方法,final的,按次序调用一系列代表算法步骤的abstract方法;要提供一组abstract方法,分别代表算法的某个步骤。
策略: