Note7- 面向可维护性的软件构造技术

本文面向另一个质量指标:可维护性——软件发生变化时,是否可以以很小的代价适应变化

什么是软件维护

所谓软件维护就是修改已经发布的软件,来更正其中的错误或者改善它的性能

软件开发周期图:

可维护性如何度量

什么是可维护性?

可维护性就是要考虑如下问题:

以下部分都是可维护性的度量指数

可维护性指数 (Maintainability Index)

通过公式计算的一个 0 到 100 之间的值,它表示维护代码的相对难易程度

更高的值表示更好维护,基于以下指标计算:

先介绍这几个概念:

圈/环复杂度 (Cyclomatic Complexity)

该指数测量代码的结构复杂度

计算方法:

如果在控制流图中增加了一条从终点到起点的路径,整个流图形成了一个闭环。圈复杂度其实就是在这个闭环中线性独立回路的个数

如图,线性独立回路有:

所以复杂度为 2

对于简单的图,我们还可以数一数,但是对于复杂的图,这种方法就不是明智的选择了

也可以使用计算公式:

V(G) = e – n + 2 * p

代码行数 (LOC)

代码容量 (HV)

代码容量关注的是代码的词汇数,有以下几个基本概念

参数 含义
n1 Number of unique operators,不同的操作元(运算子)的数量
n2 Number of unique operands,不同的操作数(算子)的数量
N1 Number of total occurrence of operators,为所有操作元(运算子)合计出现的次数
N2 Number of total occurrence of operands,为所有操作数(算子)合计出现的次数
Vocabulary n1 + n2,词汇数
length N1 + N2,长度
Volume length * Log2 Vocabulary,容量

最后,就能得到计算公式:

1715.2ln(HV)0.23cc16.2ln(LOC)+50.0sin2.46COM

继承 (inheritance) 的层次数

类之间的耦合度 (coupling)

单元测试的覆盖度 (coverage)

此外,还有很多其它的可维护性指标:

实现高可维护性的设计原则

软件设计的目标就是将系统划分成不同的模块,并在不同的模块之间分配规则:

模块化降低了程序员在任何时候必须处理的总复杂性

评估模块化的五个标准·

可分解性 (Decomposability)

可分解性就是将问题分解为各个可独立解决的子问题,使模块之间的依赖关系显式化和最小化

比如,自顶向下(top-down) 的结构设计

可组合性 (Composability)

可组合性就是模块可以很容易的组合起来形成新的系统,使模块可在不同的环境下复用

比如,数学计算库,UNIX 命令、管道

可理解性 (Understandability)

可理解性就是每个子模块都很容易理解

比如:Unixshell 命令 Program1 | Program2 | Program3

可持续性 (Continuity)

规约的变化只会影响一小部分模块而不会影响整个体系结构

异常保护 (Protection)

运行时出现的不正常情况只会局限于小范围的模块内

模块设计的五条原则

直接映射 (Direct Mapping)

即模块的结构与现实世界中问题领域的结构保持一致

对以下评价标准产生影响:

尽可能少的接口 (Few Interfaces)

模块应尽可能少的与其它模块通讯

对以下评价标准产生影响:

尽可能小的接口 (Small Interfaces)

如果两个模块通讯,那么它们应交换尽可能少的信息

对以下评价标准产生影响:

显式接口 (Explicit Interfaces)

两个模块的通讯应该很明显

反例:

对以下评价标准产生影响:

信息隐藏 (Information Hiding)

经常可能发生变化的设计决策应尽可能隐藏在抽象接口后面

对以下评价标准产生影响:

耦合 (Couping) 与内聚 (Cohesion)

什么是耦合?

耦合是模块之间依赖关系的度量。 如果一个模块的更改可能需要另一个模块的更改,则两个模块之间存在依赖关系。

两个模块直接的耦合由下面的参数决定:

举例:HTML, CSS, JavaScript 之间的耦合

一个好的的网络应用(web app) 程序模块:

如下图所示:

什么是内聚?

内聚衡量模块的功能之间的关联程度

好的设计应该是高内聚低耦合的

OO 设计原则:SOLD

SOLD 表示五个设计原则:

接下来逐一说明

单一责任原则 (SRP)

指 ADT 中不应该由多于 1 个原因使其发生变化,否则就拆分开

如果一个类包含了多个责任,那么将引起不良后果:

举例:

开放/封闭原则 (OCP)

类应该对扩展开放

但是应该对修改封闭

解决这个问题的办法就是:抽象

举例:

再比如,设计画不同图形的代码:

// Open-Close Principle - Bad example
class GraphicEditor {
public void drawShape(Shape s) {
    if (s.m_type==1)
        drawRectangle(s);
    else if (s.m_type==2)
        drawCircle(s);
}
public void drawCircle(Circle r)
    {....}
public void drawRectangle(Rectangle r)
    {....}
}

class Shape {
    int m_type;
}
class Rectangle extends Shape {
	Rectangle() {
		super.m_type=1;
	}
}
class Circle extends Shape {
	Circle() {
		super.m_type=2;
	}
}

这样设计会有一大堆复杂的 if-else,很难维护,后续想要画别的图案也很难修改

下面的设计就好了很多:

// open-Close principle - Good example
class GraphicEditor {
    public void drawShape(Shape s) {
        s.draw();
    }
}
class Shape {
    abstract void draw();
}
class Rectangle extends Shape {
    public void draw(){
        // draw the rectangle
    }
}

Liskov 替换原则 (LSP)

这个在前一讲中已经讲过了

https://zhuanlan.zhihu.com/p/524625004

接口隔离原则 (ISP)

所谓接口隔离就是不能强迫客户端依赖于它们不需要的接口,只提供必需的接口

说白了,就是防止某个接口功能过多

举例:

//bad example (polluted interface)
interface Worker {
    void work();
    void eat();
}
ManWorker implements Worker {
    void work() {…};
    void eat() {…};
}
RobotWorker implements Worker {
    void work() {…};
    void eat() {//Not Appliciable for a RobotWorker};
}

这个接口就显得太大了,可以分解一下:

interface Workable {
    public void work();
}
interface Feedable{
    public void eat();
}
ManWorker implements Workable, Feedable {
    void work() {…};
    void eat() {…};
}
RobotWorker implements Workable {
    void work() {…};
}

依赖转置原则 (DIP)

举例,设计一个从某个位置读,然后写到某个位置的程序:

可以这样设计:

void Copy(OutputStream dev) {
    int c;
    while ((c = ReadKeyboard()) != EOF)
    if (dev == printer)
        writeToPrinter(c);
    else
        writeToDisk(c);
}

这样的设计显然不符合要求,后续的扩展将会很麻烦

应该将先分别抽象出来,再实现

interface Reader {
    public int read();
}
interface Writer {
    public int write(c);
}
class Copy {
    void Copy(Reader r, Writer w) {
    int c;
    while (c=r.read() != EOF)
        w.write(c);
    }
}

总结:OO 设计的两大武器

抽象(abstraction):模块之间通过抽象隔离开,将稳定部分和容易变化的部分分开

分离 (separation):Keep It Simple, Stupid (KISS)

基于语法的构造技术

一些应用要从外部读取文本数据,然后在应用中做进一步处理,显然要考虑这些

显然要为这些数据设计特定的文法(grammar)

根据文法,开发一个它的解析器,用于后续的解析

文法 (grammar)

为了描述一串符号,无论它们是字节、字符还是从固定集合中提取的某种其他类型的符号,我们使用一种紧凑的表示,这种表示称为语法

例如,URL 的语法将指定 HTTP 协议中合法 URL 的字符串集

下面我们来描述正则表达式

文法中特定的字符,我们称为终止节点(terminals)

例如,图中语法解析树的蓝色部分:

文法由产生式(productions) 描述

利用操作符、终止节点、非终止节点,我们就能递归地构造出字符串

每一个产生式格式如下:

nonterminal ::= expression of terminals, nonterminals, and operators

其中一个非终止结点将作为根节点,比如:

url ::= 'http://' hostname '/'
hostname ::= 'mit.edu' | 'stanford.edu' | 'google.com'

url,hostname 都是非终止节点,其中 url 为根节点

文法中的操作符

三种主要的:

还有一些其他的:

解析树 (Parse Tree)

比如,下面的文法:

url ::= 'http://' hostname (':' port)? '/'
hostname ::= word '.' hostname | word '.' word
port ::= [0-9]+
word ::= [a-z]+

构造串 http://didit.csail.mit.edu:4949/ 的解析树为:

树的叶子节点从左到右连接起来,就是最终的生成串

正则语言与正则表达式 (regex)

正则语言:简化之后可以表达为一个产生式而不包含任何非终止节点

正则表达式中的一些特殊操作符

.   // matches any single character (but sometimes excluding newline, depending on the regex library)

\d  // matches any digit, same as [0-9]
\s  // matches any whitespace character, including space, tab, newline
\w  // matches any word character including underscore, same as [a-zA-Z_0-9]

考虑如下正则表达式:

[A-G]+(b|#)?

他就能表示如下字符串:

Ab
C#
GFE

Java 中的正则语法解析器

java.util.regex 主要由三个类组成:

具体规则可见:

https://docs.oracle.com/javase/tutorial/essential/regex/index.html

这里讨论一下匹配模式:

Greedy Reluctant Possessive Meaning
X? X?? X?+ X, once or not at all
X* X*? X*+ X, zero or more times
X+ X+? X++ X, one or more times
X X{n}? X{n}+ X, exactly n times
X X{n, }? X{n, }+ X, at least n times
X X{n, m}? X{n, m}+ X, at least n but not more than m times

这三种匹配模式是完全不一样的:

举例

正则语法:.*foo

输入字符串:xfooxxxxxxfoo

匹配结果:

总结

本章面向软件的可维护性,从可维护性的评价指标开始,介绍了实现高可维护性的五个标准、模块设计的五条原则、以及OO 设计的五大原则,并在讲解每一个原则时都附上了例子说明。最后,面对可扩展性,讲解了文法尤其是 Java 中正则语言解析器的使用

参考资料