软件构造——可复用性

本文是对软件构造课程软件可复用性相关内容的整理与理解,使用的编程语言为 Java。我们首先讨论可复用的软件“应该是什么样的”,然后讨论“如何构造好的可复用的软件”。

什么是软件复用?

软件复用(software reuse)是使用已有的软件组件去实现或更新软件系统的过程。

软件复用的两个视角:

  • 创造:系统地创造可复用的资源。
  • 使用:复用资源,把它们作为构建新系统的积木。

为什么要复用呢?

创造可复用的而不是短暂的东西的动机,既有审美上的、智力上的,也有经济上的,这是人类渴望不朽的一部分。“它将人类与其他生物区分开来,并将人类文明社会与原始社会区分开来。”(Wegner,1989)

  • 复用降低了成本和开发时间:通过缩短软件开发周期(用更少的人更快地开发软件)来提高软件生产效率;不浪费资源进行不必要的“重新造轮子(reinvent-the-wheel)”;降低维护成本(可以生产出质量更好、更可靠、更高效的软件)。
  • 复用会产生可靠的软件:复用已经存在一段时间并经过调试的功能,这些功能往往经过充分测试,稳定可靠。
  • 复用有利于标准化:复用 GUI 库会在应用程序中产生通用的外观;规则、一致、连贯地设计。

复用的成本

复用的成本是昂贵的:

  • 从创造的角度:可复用的组件应以明确定义、开放的方式设计和构建,使用简洁的接口规范、可理解的文档,并着眼于未来的使用。做到这些,需要成本。
  • 从使用的角度:涉及组织、技术和过程的变更,以及支持这些变更的工具的成本,以及培训人员使用新工具和变更的成本。

但随着软件规模的增大,复用的成本要比不复用的成本低得多。

如何衡量可复用性?

可复用性(reusability)意味着对构建、打包、分发、安装、配置、部署、维护和升级问题的一些显式管理。一个具有高度可复用性的软件应该满足以下要求:

  • 小且简单
  • 可移植且与标准兼容
  • 灵活可变
  • 可扩展
  • 泛型与参数化
  • 模块化
  • 变化的局部性
  • 稳定
  • 丰富的文档和帮助

可复用组件的级别和形态

最主要的复用是在代码层面,这是大多数程序员与复用的联系。但软件构造过程中的任何实体都可能被复用:源代码级别、模块级别(类、抽象类、接口)、库级别(API、包)以及系统级别(框架)。

复用可以分为两种类型:

  • 白盒复用(white box reuse):源代码可见,可修改和扩展。复制已有代码到正在开发的系统,进行修改。优点是:可定制化程度高。缺点是:对其修改增加了软件的复杂度,且需要对其内部有充分的了解。
  • 黑盒复用(black box reuse):源代码不可见,不能修改。只能通过 API 接口来使用,无法修改代码。优点是:简单清晰。缺点是:适应性差一些。

源代码级的复用

源代码级的复用就是复制粘贴部分或全部代码到你的程序中,它要求你有访问源代码的权限。

源代码级的复用往往会有一些问题:你可能需要在许多位置改动这个代码来适应你的程序;你可能需要面临较高的出错风险;你可能你要了解你使用的这部分代码如何工作的。

模块级的复用

模块级别的复用指的是对类与接口的复用。Java 提供了一种对类的复用方法——继承。子类继承了父类的属性和方法,此外,子类还可能重写父类的方法。

另一种对类的复用方法是委托。委托(delegation)是指一个对象依赖另一个对象来实现其功能的子集(一个实体向另一个实体传递一些东西)。比如,Sorter 将功能委托给某个 Comparator。委托可以被描述为在实体之间共享代码和数据的低级机制。

明智的委托可以实现代码复用。比如:

  • Sorter 可以被复用来进行任意顺序排序;
  • Comparator 可以在需要比较整数的任意客户端代码中重用。

委托分为两种:

  • 显式委托(explicit delegation):将发送对象传递给接收对象。
  • 隐式委托(implicit delegation):取决于编程语言的成员查找规则。

库级的复用

库:提供可复用功能的一组类和方法(即 API)。

系统级的复用

框架:可定制到应用程序中的可重用的框架代码。框架回调客户端代码,即好莱坞原则:“不要给我们打电话。我们会打电话给你。(Don’t call us. We’ll call you.)”

框架与库的一般区别就是:框架作为主程序加以执行,执行过程中调用开发者所写的程序。而对于库来说,开发者构造可运行软件实体,其中涉及到对可复用库的调用。

框架是对子系统的设计,它包含抽象类和具体类的集合,以及每个类之间的接口。框架是一种抽象,在框架中,提供通用功能的软件可以通过额外的用户编写的代码有选择地更改,从而提供特定于应用程序的软件。开发者根据框架的规约,填充自己的代码进去,形成完整的系统。

可复用性利用了应用程序领域的知识和有经验的开发人员先前的努力。将框架看作是更大规模的 API 复用,除了提供可复用的 API,还将这些模块之间的关系都确定下来,形成了整体应用的领域复用。框架作为主程序加以执行,执行过程中调用开发者所写的程序;开发者根据框架预留的接口所写程序。

框架不同于应用程序。两者之间抽象的层次是不同的,因为框架为一系列相关的问题提供解决方案,而不是单一的一个问题。为了适应一系列问题,框架是不完整的,它结合了热点和钩子来允许定制。

框架可以根据用于扩展它们的技术分为两类:

  • 白盒框架(white-box frameworks):通过代码层面的继承进行框架扩展。如通过继承和动态绑定实现可扩展性;通过继承框架基类和覆盖预定义的钩子方法来扩展现有的功能;通常使用模板方法等设计模式来覆盖钩子方法。
  • 黑盒框架(black-box frameworks):通过实现特定接口或委托进行框架扩展。如通过定义可插入框架的组件接口来实现可扩展性;通过定义符合特定接口的组件来重用现有的功能。这些组件通过委托与框架集成。

设计可复用的类

设计可复用的类需要用到的方法有一些已在面向对象的编程中提到,如:

  • 封装与信息隐藏
  • 继承与重写
  • 多态、子类型和重载
  • 泛型编程

在这里,我们将介绍另外的一些方法:

  • 行为子类型化和 Liskov 替代原理
  • 委托和成分

行为子类型化和 Liskov 替代原理

子类型多态(subtype polymorphism):客户端可用统一的方式处理不同类型的对象。

“设 q(x)T 类型对象 x 的一个可证明属性,那么 q(y) 对于 S 类型对象 y 也是可证明的,其中 ST 的子类型。”(Barbara Liskov)

Barbara Liskov 是美国第一位计算机科学方向的女博士,2008年图灵奖获得者。她提出了第一个支持数据抽象的面向对象编程语言 CLU,对现代主流语言如 C++、Java、Python、Ruby、C# 都有深远的影响。她所提炼出来的数据抽象思想,成为软件工程的重要精髓之一。她提出的“Liskov替换原则”,是面向对象最重要的几大原则之一。

Java 中的编译器强制规则(静态类型检查):

  • 子类型可以增加方法,但不可删除方法;
  • 类型需要实现抽象类型中的所有未实现方法;
  • 重写方法必须返回相同的类型或子类型;
  • 重写方法必须接受相同的参数类型;
  • 子类型中重写的方法不能抛出额外的异常。

对于特定的行为(方法)来说,意味着:

  • 更强的不变量
  • 更弱的前置条件
  • 更强的后置条件

LSP 是对子类型化关系的一种特殊定义,称为(强)行为子类型。在编程语言中,LSP 依赖于以下限制:

  • 前置条件不能强化
  • 后置条件不能弱化
  • 不变量要保持
  • 子类型方法参数:逆变
  • 子类型方法的返回值:协变
  • 子类型的方法不应该抛出任何新的异常,除非这些异常本身是超类型的方法抛出的异常的子类型。(异常类型:协变)

协变

更具体的类可能有更具体的返回类型,这被称为子类型中返回类型的协变(covariance)。对于子类型的异常类型的协变也是如此:为子类型的方法声明的每个异常都应该是为父类型的方法声明的某个异常的子类型。

逆变

逆变也叫反协变,指从父类型到子类型越来越抽象(不具体)。逻辑上,子类型方法参数类型要不变或越来越抽象,它被称为子类型中方法参数类型的逆变(contravariance)。

参数类型的逆变在 Java 中实际上是不允许的,因为这会使重载规则复杂化。

数组

数组是协变的:给定 Java 的子类型规则,类型为 T[] 的数组可以包含类型为 T 或者 T 的任何子类型的元素。

泛型与类型擦除

对于泛型的类型参数,类型参数的类型信息在代码编译完成后被编译器丢弃,因此,此类型信息在运行时不可用。这个过程叫做类型擦除(type erasure)。因此,泛型不是协变的。

类型擦除:将泛型类型中的所有类型参数替换为实际的类型。因此,生成的字节码只包含普通的类、接口和方法。

给定两种具体类型 ABMyClass<A>MyClass<B> 没有关系,不管 AB 是否相关。MyClass<A>MyClass<B> 的共同父类是 Object

通配符

对于在类型参数相关时如何在两个泛型类之间创建类子类型关系,可以使用通配符。无界通配符类型使用通配符 ? 指定,例如 List<?>

有两种情况下,无界通配符是一种有用的方法:

  • 你正在编写一个可以使用 Object 类中提供的功能来实现的方法。
  • 当代码在泛型类中使用不依赖于类型参数的方法时。如,List.sizeList.clear

事实上,Class<?> 之所以经常被使用,是因为 Class 中的大多数方法都不依赖于 T

除此之外,还有两种通配符:

  • 下界通配符(lower bounded wildcards):<? super A>。匹配任何 A 或者 A 的父类型。
  • 上界通配符(upper bounded wildcards):<? extends A>。匹配任何 A 或者 A 的子类型。

委托和成分

委托是指一个对象依赖另一个对象来实现其功能的子集(一个实体向另一个实体传递一些东西)。

委托与继承

委托和继承是有一些区别的:

  • 继承:通过新操作或重写操作来扩展基类。
  • 委托:捕获一个操作并将其发送给另一个对象。

许多设计模式使用委托和继承的组合。

如果子类只需要复用父类中的一小部分方法,则建议创建一个字段并在其中放置一个超类对象,将方法委托给超类对象,并摆脱继承。本质上,这种重构拆分了两个类,并使超类成为子类的助手,而不是父类。子类只将需要的方法来委托给父类对象的方法,而不是继承所有父类方法。子类不包含从父类继承的任何不需要的方法,从而避免了大量无用的方法。

复合胜于继承原则

复合胜于继承原则(composite over inheritance principle),或称为复合复用原则(composite reuse principle,CRP)指的是类应该通过它们的成分(通过包含实现所需功能的其他类的实例)实现多态行为和代码复用,而不是从基类或父类继承。也就是说,最好是组合对象的功能,而不是扩展对象的功能。

委托可以看作是对象级别的重用机制,而继承是类级别的重用机制。

在继承之上实现复合通常从创建各种接口开始,这些接口表示系统必须展示的行为。根据需要实现已标识接口的类并将其添加到业务域类中。从而,系统行为的实现无需继承。

委托的种类

根据委托者与委托之间的“耦合度”,委托可以分为以下几种:

  • 依赖(dependency):是一个对象需要其他对象来实现的一种临时关系。使用类的最简单形式是调用它的方法,两个类之间的这种形式的关系称为“uses-a”关系,在这种关系中,一个类使用另一个类,但没有实际将其合并为属性。例如,它可以是一个参数或在一个方法中局部使用。
  • 关联(association):对象类之间的持久关系,允许一个对象实例来让另一个对象实例代表它执行操作。一个类有另一个类作为属性(实例变量),这种关系被称为“has-a”关系。这种关系是结构性的,因为它指定一种对象与另一种对象相关联,而不代表行为。
  • 复合(composition):是将简单对象或数据类型组合成更复杂对象或数据类型的一种方法。一个类有另一个类作为属性(实例变量),它是一种更强的关联,但难以变化,这种关系称为“is-part-of”关系。实现为一个对象包含另一个对象。
  • 聚合(aggregation):对象存在于其他对象之外,是在外部创建的,所以它作为参数传递给构造函数。它是一种更弱的关联,可动态变化。
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值