文章目录
前言
这篇文章是我对软件构造课程的面向复用的软件构造技术章节的学习总结,以供未来使用。文章中的图片均来自课程教师的讲义。
主要内容包括:
- 基本概念
- 复用的层面
- 设计可复用类
一、基本概念
什么是复用(Reuse)
正如字面意思,复用就是重复的使用。面向复用的软件构造技术可以分为两个方面:面向复用编程(Programming for reuse)和基于复用编程(Programming with reuse)。
它们二者的区别就在于,面向复用编程强调开发出可以复用的软件,而基于复用的编程则强调对可复用的软件进行复用以构建新的软件。
软件的复用性有很多有益之处:
- 降低复用时开发成本和时间
- 经过充分的测试,复用后安全稳定
- 标准化,在不同的应用中保持一致
但是,软件的复用性也有弊端:
- 开发成本高:要有较强的适应性
- 性能较差:面向普适场景,对特定场景缺乏针对性
因此我们可以说,复用是一种兼具高代价和高收益的策略。
如何衡量复用性
总的来说,复用性的衡量可以概括为两个方面:复用的频率和代价。
复用的分类
复用根据可见性,可以分为:
- 白盒复用(White box reuse):源代码可见、可修改和扩展。
将已有的代码复制到正在开发的系统,通常要对其进行修改。
优点:可定制程度高。
缺点:修改增添了复杂度,且需要充分了解复用代码的内部实现。 - 黑盒复用(Black box reuse):源代码不可见,不能修改。
通过API接口实现,无法对内部实现进行修改。
优点:简单且清晰
缺点:复用性较差。
二、复用的层面
在这里,我们主要关注代码层面上的复用,但实际上,软件构造过程中的任何成员,比如需求、规约、数据、测试、文档等等,都可以进行复用。
可以将复用的层面分为:
- 源代码级别的复用
- 模块级别的复用:类/抽象类/接口
- 库级别的复用:API/包
- 系统级别的复用:框架
源代码级别
模块级别
Java本身就提供了一种模块级别的复用——继承。有关继承的相关知识见于面向对象的编程(OOP)
库级别
库(Library):一组能够提供可复用性的类和方法(APIs)。
系统级别
框架(Framework)是一个编程的概念,指的是一个预先编写好的、可重用的代码结构,用于支持和简化特定类型的应用程序开发。
框架强调一组实现类、抽象类与它们之间的连接关系。
框架提供了一种抽象,允许开发人员在通用功能的基础上编写自定义代码,从而创建出满足特定应用程序需求的定制化软件。
也就是说,开发者可以根据framework的规约(骨架),填充自己的代码进去(血肉),以形成完整的系统。
将framework看作是更大规模的API复用,除了提供可复用的API,还将这些模块之间的关系都确定下来,形成了整体应用的领域复用。
Framework与Library的作用是相对的:
Frame可以分为两种
- 白盒框架(Whitebox framework):通过代码层面的继承进行框架扩展。
- 通过继承和动态绑定实现可扩展性。
- 通过子类化框架基类并重写预定义的钩子方法来扩展现有功能。
- 常常使用设计模式,比如模板方法模式来重写钩子方法。
- 黑盒框架(Blackbox framework):
- 通过定义可以插入框架的组件的接口/委托来实现扩展。
- 通过定义符合特定接口的组件来重用现有功能。
- 这些组件通过委托与框架集成。
三、设计可复用类
类的复用包括很多方面:封装与信息隐藏、继承和重写、多态、泛型等等,这些内容在面向对象的编程(OOP)中已经介绍。
这里主要介绍复用的其他两种策略:行为子类型化(Behavioral subtyping)和Liskov替换原则( Liskov Substitution Principle,LSP),委托(Delegation)和组合(Composition)。
行为子类型化和Liskov替换原则
对于子类型,有这样一个性质:
- 让 q(x) 是关于类型 T 的对象 x 可证明的一个属性,那么对于类型 S 的对象 y,其中 S 是类型 T 的一个子类型,应该可以证明 q(y)。
也就是说,子类型应该具有其超类的所有特性。由此可以衍生出子类型多态的概念:客户端可以统一处理不同类型的对象。
像这样子类型与超类之间的关系,实际上就是面向对象OO中一个非常重要的原则——LSP的体现。
Java中的一些静态类型检查,以及规约强度的定义,都采用了LSP的思想:
总之,LSP 是 (强) 行为子类型化的一个特定定义。
在编程语言中,LSP要依赖于以下限制:
这里涉及到了子类型与父类型之间的两种变化——协变和逆变。
这两种变化是相对父类型到子类型——spec变得更加具体而言的。
- 协变(Covariance):不变或变的更具体。
- 逆变(Contravariance):不变或变的更抽象。
泛型中的LSP
泛型是类型不变的,泛型类型参数的子类型关系被保留,即使类型参数具有相同的边界。
比如ArrayList< String> 是 List< String>的子类型,但List< String> 不是List< Object>的子类型。
编译器在编译代码完成后会丢弃类型参数的类型信息;因此,在运行时,这些类型信息是不可用的。
这一过程叫做类型擦除(type erasure):将泛型类型中的所有类型参数替换为它们的边界,或者如果类型参数是无边界的,则替换为 Object。因此,生成的字节码只包含普通类、接口和方法。
举例来说,假设有一个泛型接口 Box< T>,它的类型参数 T 具有边界 Number。那么,Box< Integer> 和 Box< Double> 都被限制为持有 Number 类型的对象。因为在类型擦除过程中,参数T都被替换为了边界Number。
因此,泛型中类型参数的子类型关系并不能延续到泛型类的子类型关系,于是就无法使用子类型多态:
但是如果需要在泛型类之间建立类似于子类型的关系该怎么办呢?对于这种情况,Java提供了通配符(Wildcards)来解决。
- 无界通配符(Unbounded Wildcards):使用问号(?)表示,例如 List<?>,它表示可以持有任意类型的列表。
- 有界通配符(Bounded Wildcards):使用 extends 和 super 关键字来限制通配符的类型范围。
- 使用 extends 关键字,例如 List<? extends Number>,表示可以持有任何是 Number 类型或其子类型的列表。
- 使用 super 关键字,例如 List<? super Integer>,表示可以持有任何是 Integer 类型或其父类型的列表。
由此,便可以构建泛型类之间的子类型关系。
如果< T>对于<?>/<? extends …>/<? super …>,将?替换为T之后,仍然正确,那么它们之间就存在子类型关系。如下:
再看下面这个例子:
该方法有一个类型参数< T >。在调用该方法时,编译器会自动推断T的类型,称之为类型推断。
对于第一个参数中? super T,对应List< Object >,所以T会限定为Object及其子类。第二个参数中? extends T,对应List< Number >,所以T会限定为Number及其父类。
最终,T会被确定为这个范围内最具体的类,即Number。
委托
委托/委派(Delegation):一个对象请求另一个对象的功能。委派是复用的一种常见形式。
举例:
当想要实现对一组ADT进行排序的功能时,可以很自然的想到在ADT内部设计一个比较ADT大小的方法,然后再编写排序函数。这样可以让ADT实现Comparable接口,然后重写compareTo() 方法。但这并不是一个委托。
其实,Collections类提供了sort方法来对列表中的元素进行排序,但要求是需要先实现Comparator接口以获取比较标准。这样就可以先实现这个接口,然后再调用Collections类的sort方法进行排序。也就是说,这个ADT的sort()方法调用了Collections类的sort()方法来实现,那么这就算作是一个委托。
这里是对ADT的一个方法进行了委托。实际上,如果将这种模式套用在整个类上,就形成了委托模式( delegation pattern)。委托模式是基于动态绑定进行的,其过程如下:
在委托模式中,对象可分为三种——客户端、接收对象和委托对象。
客户端调用接收对象的操作,但接收对象的操作实际上是委托给委托对象来完成的。这样一来,就可以避免用户对委托对象的滥用,即隐藏了具体的实现类。
举例:
这里的LoggingList类是能记录操作的List。所以每一个操作都需要被打印。但如果对每个操作都完全重写的话,开发成本大且完全没有必要。这时就可考虑使用委托模式,将各个功能委托给List来执行。该类的成员变量list被定义为List,所以list.add()和list.remove()都会调用List中的方法(动态绑定),从而委托给List完成部分功能。
组合复用原则CRP
我们可以看到,委托与继承有许多相似之处。它们都可以复用一个已经实现的类,来帮助我们完成一个正在开发的类。但二者的不同之处在于,继承强调对父类的“全盘接受”,而委托则强调对已实现类的“选择调用”。
在这个图中我们可以看到,如果子类只需要父类中一小部分方法,那就不需要使用继承,而是使用委托来实现,因为这样可以避免大量无用的方法。
这样就引申出了组合复用原则(Composite Reuse Principle,CRP):软件工程中的一个设计原则,旨在通过组合已有的组件来构建新的软件系统,而不是通过继承来扩展已有的组件。
这里所谓的组合,就是通过委派多个类,选择所需的功能来组合出新的类。
委托是在Object层面,而继承是在Class层面。
这里举一个使用CRP的典型例子:
在这个例子中,需要我们计算不同职员的工资。但显然不同职员,其工资的计算方式也不同。这属于Object层面而并非Class层面。于是便将计算工资这一方法委派给另外三个类的完成,在这三个类中完成对不同职员工资的计算。
更普遍的,可以概括为:
当然,上面这个例子可能看不出委托模式的好处,因为我们大可以在类中编写计算方法。但是,当涉及到多个ADT使用多个共性方法时,委托模式的优点就体现出来了。
这里老师给出了一个建议:
- 遵循CRP原则,尽量避免通过继承机制进行面向复用的设计,尽量通过CRP设计两棵继承树,通过delegation实现“事物”和“行为”的动态绑定,支撑灵活可变的复用
如何建立委托关系
- 依赖关系(Dependency):临时性的委托
使用类的最简单形式是调用其方法;
两个类之间的这种关系称为“使用-关系”(use-a relationship),其中一个类利用另一个类而不实际将其作为属性加入。例如,它可能是一个参数,或者在方法中局部使用。
依赖关系:对象对其他对象(供应者)的实现有临时性依赖。
此时,委托关系通过方法的参数传递建立起来。
- 关联关系(Association):永久性的委托
对象类之间的持久关系,允许一个对象实例代表另一个对象执行操作。
拥有(has_a):一个类将另一个类作为属性/实例变量。
这种关系是结构性的,因为它指定了一种对象与另一种对象连接的方式,并且不代表行为。
此时,委托通过固有的field建立起来。
- 组合关系(Composition):更强的关联关系,但难以变化
组合是将简单对象或数据类型组合成更复杂对象的一种方式。
是部分(is_part_of):一个类将另一个类作为属性/实例变量。
实现为一个对象包含另一个对象。
Composition是Association的一种特殊类型,其中Delegation关系通过类内部field初始化建立起来,无法修改。
- 聚合关系(Aggregation):更弱的关联关系,但可动态变化
对象存在于其他对象之外,是在外部创建的,因此作为参数传递给构造函数。
在组合中,对象被销毁时,其包含的对象也会被销毁,但聚合并非如此。
比如:
- 一个大学拥有各种部门,每个部门都有若干教授。如果大学关闭,部门将不再存在,但这些部门中的教授仍将继续存在。
- 一个大学可以被视为由各个部门组成,而部门有教授的聚合。一个教授可能在多个部门工作,但一个部门不能属于多个大学。
这里可以理解为在组合中,对象的存储空间包括了其包含的对象,而聚合中,对象的成员变量仅仅是其他对象的引用。
四、黑盒框架与白盒框架
系统级别的复用包括框架的复用,而框架在上面提到被分为两类——黑盒框架和白盒框架。因为对这两种框架较为生疏,所以在这里单独介绍。
- 白盒框架通过继承和动态绑定实现可扩展性。
- 通过子类化框架基类并重写预定义的钩子方法来扩展现有功能。
- 通常使用设计模式如模板方法模式来重写钩子方法。
- 黑盒框架通过实现特定接口或委托实现可扩展性。
- 通过为可插入框架的组件定义接口来实现扩展性。
- 通过定义符合特定接口的组件来重用现有功能。
- 这些组件通过委托与框架集成。
这里所谓的钩子方法(hook methods)是一种在软件设计中常见的模式,它允许子类在父类中定义的算法框架中插入自定义代码。这些方法通常是在父类中被定义为虚拟或抽象方法,子类可以选择性地重写这些方法以实现特定的行为。钩子方法提供了一种扩展现有功能的灵活方式,同时保留了框架的整体结构和算法。
对于这两种框架,可以这样理解:
对于白盒框架,内部结构和实现细节是可见的,这意味着开发者可以直接访问和修改框架的源代码。因此,通过继承和重写钩子方法,开发者可以在框架的执行过程中插入自定义的代码,这是一种非常灵活的方式。通过这种方式,开发者可以定制框架的行为,以满足特定的需求或定制化要求。
对于黑盒框架,内部结构和实现细节是不可见的,开发者只能使用框架提供的接口来与框架进行交互。黑盒框架会提供一些接口或者组件模板,开发者可以通过实现这些接口来获取可用的组件。然后,开发者将这些组件交给主类来完成相应的功能。在这个过程中,主类会将一些功能的实现委托给这些组件来完成,主类本身就是这些组件的组合。这种方式确实提供了更多的灵活性和可扩展性,因为开发者可以通过替换或者添加不同的组件来改变框架的行为,而无需了解框架的内部实现细节。
一个白盒框架的例子:
在这个例子中,白盒框架提供了一个抽象类PrintOnScreen,并提供了抽象方法textToShow()以供拓展。
拓展功能通过对抽象类继承,并对抽象方法重写来实现。
一个黑盒框架的例子:
在这个例子中,黑盒框架提供一个实现类PrintOnScreen,其内部是不可见的。同时还提供了接口textToShow。
我们需要对这个接口进行实现,并将这个组件交给实现类(通过构造方法,将组件作为成员变量),以完成功能的拓展。在运行过程中,print()方法的部分功能会委派给MyTextToShow来实现。
总之,
-
白盒框架使用子类化/子类型化 — 继承
- 允许扩展每个非私有方法
- 需要理解超类的实现
- 一次只能扩展一个
- 编译在一起
- 通常被称为开发者框架
-
黑盒框架使用组合 — 委托/组合
- 允许扩展接口中公开的功能
- 只需要理解接口
- 可以有多个插件
- 通常提供更多的模块化
- 可以进行单独的部署(.jar、.dll等)
- 通常被称为终端用户框架、平台