0%

软件复用的代码设计理论与心得

前言

自从学习了软件构造相关知识后,让我受益匪浅。虽说在我开发个人项目时,无形中会有一些代码设计的基准,但自从我了解到软件开发的30多种设计模式令我茅塞顿开。
过去开发个人项目中常常在开发到最后,就有些后悔前期没能形成一个好的标准框架。
现在里我将结合我的软件构造课程的学习与软件复用的理解,来记录下我的心得。

下面将会根据类,API,框架三个方面来展开。

设计原则

在谈论软件复用之前,首先我们简单回顾一下软件设计原则。

1. 单一职责原则(SRP)

单一职责原则(Single Responsibility Principle),是最为简单的设计原则,用于控制类的粒度大小。

定义

一个对象应该只包含单一的职责,并且该职责被完整的封装在一个类中。
也即是类的职责设计要单一,一个类仅仅只做某一件事,尽可能不要不让一个类担任太多职责。

2. 开闭原则(OCP)

开闭原则(Open-Closed Principle),最为重要的原则。

定义

软件实体对扩展开放,但对修改关闭。也就是设计一个模块时,此模块要以将来可在不修改的前提下进行扩展而被设计,也即是不改变源码来改变其行为。

分析

开闭原则的关键是抽象化,其功能行为将由子类来完成实现或扩展。

3. 里氏替换原则(LSP)

里氏替换原则(Liskov Substitution Principle),是软件复用的一个较为关键的原则,是实现开闭原则的重要方式之一。

定义

所有引用基类的地方必须能透明地使用其子类对象。

分析

通俗理解:软件中如果能使用父类对象,那么一定能使用其子类对象,把父类对象替换为子类对象,也不会影响代码逻辑,但是反过来能使用子类对象,却不一定能使用父类对象。

一个小例子:

  • 基类
    1
    2
    3
    4
    5
    public class Word{
    public void spell(){
    System.out.println("W-O-R-D");
    }
    }
  • 子类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Pencil extends Word{
    public void spell(){
    System.out.println("P-E-N-C-I-L");
    }
    }

    public class Book extends Word{
    public void spell(){
    System.out.println("B-O-O-K");
    }
    }
  • 主函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 想使用父类
    Word word = new Word();
    word.spell();

    // 想使用子类
    Word word = new Pencil();
    word.spell();
    // 或者
    Word book = new Book();
    book.spell();

    注意

  1. 子类公共方法在父类中声明,或者子类来实现父类中声明的所有方法。
  2. 尽量把父类设计为抽象类或接口,这样能确保子类会实现所有父类公开声明的功能。
  3. 里氏替换原则最为重要一点就是使用父类的代码,就算使用子类,也不会产生错误异常。
  4. 关键在于子类的行为是否可以完全(不是仅仅的重写,而是行为的影响,作用做到完全)替代父类的行为。

4. 依赖倒转原则(DIP)

依赖倒转原则(Dependency Inversion Principle),十分重要的原则,也是我常常会忽视的一个原则。

定义

高层模块不应该依赖低层模块,它们都应该抽象。抽象不应该以来细节,细节应该依赖于抽象。
换句话说就是要针对抽象或接口编程,不应该针对具体类编程。

分析

实现开闭原则的关键是抽象化,并且从抽象化导出具体化实现,如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要手段。

类之间的耦合关系
  • 零耦合关系
  • 具体耦合关系
  • 抽象耦合关系

依赖倒转原则则是要求依赖于抽象耦合,以抽象方式耦合是依赖倒转的关键。
里氏替换原则是实现依赖倒转的基础。

关于依赖注入可以参考:

Spring-构造函数依赖注入
Spring-Setter函数依赖注入
Spring-注入内部bean

5. 接口隔离原则(ISP)

接口隔离原则(Interface Segregation Principle)

定义

使用多个专门接口来取代一个统一的接口。也就是将比较大的接口分割成许多小接口,这样就防止一个接口扮演多种角色了。

6. 合成复用原则(CRP)

合成复用原则(Composite Reuse Principle),也叫组合/聚合复用原则

定义

尽量多的使用对象组合,而不是继承来达到复用的目的。

与继承复用对比

  • 继承复用:实现简单,易于扩展。破坏系统的封装性;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;只能在有限的环境中使用。(“白箱”复用 )
  • 组合/聚合复用:耦合度相对较低,选择性地调用成员对象的操作;可以在运行时动态进行。(“黑箱”复用 )

7. 迪米特法则(LoD)

迪米特法则(Law of Demeter),也叫最少知识原则。

定义

一个软件实体对其他实体的引用越少越好,或者说如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,而是通过引入一个第三者发生间接交互。

分析

就是指一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易,这是对软件实体之间通信的限制,它要求限制软件实体之间通信的宽度和深度。

在迪米特法则中,对于一个对象,其朋友包括以下几类:

  1. 当前对象本身(this);
  2. 以参数形式传入到当前对象方法中的对象;
  3. 当前对象的成员对象;
  4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  5. 当前对象所创建的对象。

类的复用设计

在面向对象设计中设计可复用类的设计方式

  • 封装与信息隐蔽
  • 继承和重写
  • 多态,子类和重载
  • 泛型编程
  • LSP原则
  • 委托和组合

尽量多用组合,而非继承关系

关于这一个设计原则,更进一步来讲,我们首先需要了解清楚组合与继承的异同。

继承

继承我们都用的比较熟练,子类继承父类的所有公开方法与属性。

但是我们在创建类的同时要考虑到信息的隐蔽封装。某个类只想向外界提供想要展示的方法,或者通过类提供的公共方法来访问私有属性。

在类的继承中,往往父类的变量与方法由 protected 关键字修饰,其主要为了专门使子类继承,仅在包内可被访问。
而子类继承父类保护的属性后,使得子类或者包内可以任意修改父类的属性,这就破坏了信息的隐蔽与封装。

除此以外通过继承来扩展功能,往往会增加内部的复杂度,增加了耦合度。

另外也不支持动态继承,使得子类无法选择不同的父类。

组合

恰好的是,组合关系很好的解决了继承所存在的问题。
组合是一种较为弱的关系,主要通过将要扩展类作为成员变量,在方法中调用基类的方法,同时也能进一步扩展。在策略模式中就使用到了组合的关系,详见: 设计模式-策略模式

组合不会破坏封装,同时低耦合。也具有良好的扩展性,支持动态组合。

只不过不如继承没法做到向上转型,也就是没有多态的特性(多态必要条件:继承,重写,基类引用指向派生类)

组合与继承比较

虽说多用组合少用继承,但具体也要视情况而定。

  • 需要用到向上转型则(回溯到基类)只能使用继承
  • 继承结构,子类可以获取父类的内部细节,破坏封装。组合看不到基类的部分细节,确保了封装性。
  • 继承只能单继承,且不可动态继承。组合可以动态组合。
  • 开发过程中,如果要对类进行扩展或重写考虑继承。如果考虑到安全性与封装则应该使用组合。

重写与重载

其实这里要关注到注解 @Override
在子类继承父类中,往往子类重写父类方法,我们需要加上注解 @Override。但是在我初学 Java 中却从来没有遇到过要使用 @Override
@Override 注解,标明了此子类方法重写的父类的方法,而不写此注解也是重写。但是一个最大的问题就在于子类重载父类的方法。

如果不加 @Override 的注解,那么子类在重载父类的方法时(子类中并没有重写父类方法),并不会有任何提示,这就容易导致,在调用此方法过程中,可能重载的功能与原功能相差甚远,这就会造成一系列开发混乱。

@Override 相当于一个关键字,它会标注出子类重写的所有父类方法,这这样如果不小心重载了父类的方法,就会有所提示,确保了我们在开发时,不会造成我们在不希望重载时,进行了重载。

泛型编程

说到泛型,其一个特点是可以保证类型的安全。在C++ 中被称为模板。
在 java 中泛型是比较常见的 如 List,ArrayList 等集合框架类型,泛型非常高效,安全的做到类的复用,是一种抽象的方式实现。

在java1.5之前,像 ArrayList 都是基于 object 来实现,这是使得存取数据时要进行类型转换,不仅麻烦,也容易产生一些问题。
泛型的编程实现,使用可替换类型 T 。

实现案例:

1
2
3
4
5
6
7
8
9
10
public class Printer<T>{
private T msg;

public void setMsg(T msg){
this.msg = msg;
}
public T getMsg(){
return this.msg
}
}

泛型的一大好处是编译时严格类型检查,另外也消除大多数的类型转换。

委托

委托能很好的实现方法的动态调用,具体详见:java委托浅谈C#委托机制

LSP

前面虽然简单讲解了里氏替换原则,这里将会更进一步详细讲解。

LSP 的产生

里氏替换原则时由 MIT 计算机科学实验室 Liskov女士提出,主要阐述有关继承的一些原则,有关什么时候应该使用继承,而什么时候不该使用继承。
而简单解释为:一个软件系统中,子类可以替换任何基类出现的地方,且替换后代码依旧正常工作。

一个案例: 正方形不是长方形

这是个比较经典的例子,在我们数学中 “正方形是长方形,只不过是个特殊的长方形”。而根据这个定理,我们通过计算机代码实现( java语言 )。

  • 长方形类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rectangle{
double length; // 长
double width; // 宽

// setter / getter
public void setLength(double length){
this.length = length;
}
public void setWidth(double Width){
this.width = width;
}
public double getLength(){return this.length}
public double getWidth(){return this.width}

}
  • 正方形类,一般而言我们会继承长方形类 Rectangle
1
2
3
4
5
6
7
8
9
10
11
12
class Square extends Rectangle{
// 属性继承父类
// 仅该重写setter方法
public void setLength(double length){
this.length = length;
this.width = length
}
public void setWidth(double Width){
this.width = width;
this.length = width;
}
}
  • 测试类,用于动态增加长方形的宽度。
1
2
3
4
5
6
7
8
9
class TestRectagle{
// 不断增加矩形宽度
public void resize(Rectangle rect){
// 增长的限制是,宽度不能超过长度
while(rect.getWidth() <= rect.getLength()){
rect.setWidth(rect.getWidth() + 1);
}
}
}

在主函数运行此代码,会发现:

  • 如果我们传入的是长方形 Rectangle 类对象,那么宽度会不断增加,直到宽度大于长度时,将会停止。
  • 而如果*传入的是正方形 Square类对象 *那么随着宽度增加,长度也会随之增加,始终保证宽度与长度相等,这样代码就会永远的循环下去,直到内存溢出等错误出现。

从这里可以看出,子类正方形并不能替换父类长方形,子类替换父类后代码会出现问题,那么很明显这并不符合里氏替换原则,因而正方形与长方形之间的继承关系就不成立。

分析理解

就上面的案例,现实理论中的关系,却并不能构成正真的继承关系。造成这个问题的原则有以下:

类的继承关系没有明确

其实上面的案例会发现,我们继承的依据是:两者有共同的属性。而这却忽略了两者共有的行为产生的行为结果不同。

面向对象设计应该关注的是对象的行为,以对象的行为来对实体对象进行分类。面向对象继承中的 “Is-A” 的关系,其实是行为的 “ Is-A ”关系,里氏替换原则形象来说“B as A ” 的关系。

上面的正方形是长方形的案例中,我们将它们的属性 长和宽 作为依赖关系的依据,而忽略了正方形行为方面无法完全替代长方形的行为。这既是我们强行将正方形继承自长方形,导致预期结果不同。

继承关系也依赖具体使用环境

继承并不能滥用,必须根据我们的具体情况来使用。不同的情况我们的继承关系间的实体对象也不通过。

如果我们设计的测试类,或者实际中的操作方法,是计算获取它们的面积,或者根据面积来求长和宽,那么案例中的继承关系是成立的。从这个角度来看,长方形与正方形的属性是相同,求解方法(行为)也是相同的。这样,当我们用正方形来替换长方形,并不会造成错误异常,这就符合了里氏替换原则。

如果依旧是案例中的操作,长方形与正方形的属性虽然相同,但是长方形的长和宽的行为关系,与正方形的长和宽行为关系是不同的。而案例中的操作,是依赖于长和宽的关系来设计的操作行为,这就导致 正方形替换长方形 会造成系统错误。

正确使用里氏替换原则

综上所述,我们在设计继承关系时,就必须依据对象间的行为以及我们实际开发的需求。这样做才能更符合里氏替换原则,也避免后期类的复用时,造成可能的非必要修改。

里氏替换原则是符合了开闭原则,是对开闭原则的一种实现。违背了里氏替换原则,必然就违背了开闭原则。反之未必如此。

符合里氏替换原则,保证了类的扩展性安全,也启发我们如何更加正确的设计继承关系。

API 复用

开发中更多时候都在使用别人开发的库,获取库的途径有多种,例如 Android中 的 maven依赖仓库。API 可以说是开发者世界中最为丰富的资源。

开发可复用API根本要做到,开发库是针对职责进行开发;是面向复用来设计。
可能存在的问题是,一旦API发布后,就不能自由更改,必须开发新的版本来替换。

API 设计原则

在编写 API 时,也要遵循几个原则:

  1. 职责原则
    设计接口时,必须请明确接口的职责,接口解决什么问题。

  2. 单一性原则
    也就是接口职责尽量单一,而 API 也不去做除了此职责以外的其他职责,以防影响其他 API 的职责功能。

  3. 协议规范
    设计接口时要明确接口协议,是采用 HTTP 协议,还是 HTTPS 协议,还是FTP协议。

  4. API版本
    明确 API 版本,防止接口的 URL 获取到的依赖库错误。

  5. 安全性原则
    接口的暴露考虑,接口跨域的考虑,接口防攻击的考虑。

  6. 可扩展性原则
    设计接口时,充分考虑到接口的可扩展性。

  7. API 的性能
    尽量采用较为优秀的设计思路,设计方法,设计性能较好的 API。

  8. API 的向后兼容
    就是兼容旧版本的接口或开发环境。这是 API 可否长久存在的一个要点。也是 API 是否健壮的关键。

  9. 可读性强
    设计可读性强的 API ,能有效帮助自己进行测试和改进,也能有助于使用者更快理解内部结构,更加灵活的使用 API。可能帮助使用者对 API 进行审查和改进。

  10. API 文档说明
    API 的文档是十分重要的,这不仅有助于使用者快速上手使用 API,同时也能应对 API 使用中存在的问题。
    文档应该十分详细且清晰易懂。其中包括使用环境以及版本,依赖环境的安装方法(可提供链接),API 的使用方法,FAQ,开源协议,API 结构等。

框架复用设计

框架设计的原则,在文章已介绍 设计原则
框架分为白盒框架和黑盒框架。

白盒框架

白盒框架是基于面向对象的继承机制实现的,特点是可见性,被继承的父类的内部实现细节对于子类来说是可知的。通过子类继承和重写父类的方法来实现。

白盒的框架层次清晰,但是一般继承的灵活性不太好,而父类不一定要实现自己的方法,可以将父类设计为抽象类,这样保证了子类对父类的方法的全部实现。

黑盒框架

黑盒框架基于对象构建组装。也即是系统框架通过整理,组装对象来实现。用户无需了解具体实现,只需直到对象组件提供的功能即可。

黑盒框架十分灵活,可以动态改变,类似于类之间的组合关系,与策略模式有所相似。黑盒框架的扩展性也较好。

参考

应用框架的基本思想

API设计原则