0%

【读书笔记】代码不朽——编写可维护软件的十大要则

瞎写一时爽,维护火葬场

天天写尸堆,自己都看不下去了!什么时候开始自己的代码变得那么丑陋了呢?经过一番回想,我发现凡是没有约束的代码,变得丑陋只是时间问题。 综上所述,我拜读了Joost Visser的Building Maintainable Software这本书(中文译名为“代码不朽”),受益匪浅,在此做一下笔记以防遗忘。

代码不朽

——编写可维护软件的十大要则


①编写较小的代码单元

原则:

  • 代码单元的长度应该限制在15行代码以内。
  • 为此首先不要编写超过15行代码的单元,或者将长的单元分解成多个更短的单元,直到每个单元都不超过15行代码。
  • 该原则能提高可维护性的原因在于,短小的代码但愿易于理解,测试及重用。

如何应用本原则?

——使用重构技巧来应用原则

㈠重构技巧:提取方法

将内容代码较多的单元提取为几个代码较少的单元,此时代码总量将增加,但是我们总要在代码总量与后期可维护性之间做出权衡,不止这里,后面讲的方法也或多或少地增加了代码总量,但却能大大提高后期可维护性。 Example——吃豆人游戏中的一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void start()
{
if(inProgress){
return;
}
inProgress=true;
//如果玩家死亡则更新观察者
if(!isAnyPlayerAlive()){
for(LevelOvserver o:observers){
o.levelLost();
}
}
//如果所有的豆都被吃光则更新观察者
if(remainingPellets()==0)
{
for(LevelObserver o:observers){
o.levelWon();
}
}
}

原来有1行代码,现在我们提取方法,可将其分解成三个小方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void start()
{
if(inProgress){
return;
}
inProgress=true;
updateObservers();
}
private void updateObservers(){
updateObserversPlayerDied();
updateObserversPelletsEaten();
}
private void updateOberversPlayerDied(){
if(!isAnyPlayerAlive()){
for(LevelObserver o:observers){
o.levelLost();
}
}
}
private void updateObserversPelletsEaten(){
if(remainingPellets()==0){
for(LevelObserver o:observers){
o.levelWon();
}
}
}
㈡重构技巧:将方法替换为方法对象

在传入参数较少时使用提取方法较为方便,上面的例子就提取出了三个无参数的函数。但是若是要提取的方法调用了较多临时变量,提取方法时就要传入大量参数,造成代码冗余,对于这种情况可以尝试将方法替换为方法对象 Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Board CreatBoard(Square[][] grid){
assert grid!=null;
Board board=new Board(grid);
int width=board.getWidth();
int height=board.getHeight();
for(int x=0;x<width;x++){
for(int y=0;y<height;y++){
Square square=grid[x][y];
for(Direction dir:Direction.velues()){
int dirX=(width+x+dir.getDeltaX())%width;
int dirY=(height+y+dir.getDeltaY())%height;
Square neighbour=grid[dirX][dirY];
square.link(neighbour,dir);
}
}
}
}

若要提取上面循环中的方法,则需要传递七个参数,分别是width,height,x,y,dir,square,grid。拥有很长参数的方法显得丑陋无比,下面使用方法对象来将其替换之:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class BoardCreator{
private Square[][] grid;
private Board borad;
private int width;
private int height;
BoardCreator(Square[][] grid){
assert grid!=null;
this.grid=grid;
this.board=new Board(grid);
this.width=board.getWidth();
this.height=board.getHeight();
}
Board create(){
for(int x=0;x<width;x++){
for(int y=0;y<height;y++){
Square square =grid[x][y];
for(Direction dir:Direction.values()){
setLink(Square,dir,x,y);
}
}
}
return this.board;
}
private void setLink(Square square,Direction dir,int x,int y){
int dirX=(width+x+dir.getDeltaX())%width;
int dirY=(height+y+dir.getDeltaY())%height;
Square neighbour=grid[dirX][dirY];
square.link(neighbour,dir);
}
}

如此,有了以上方法类,createBoard方法即可重构为:

1
2
3
public Board createBoard(Square[][] grid){
return new BoardCreator(grid).create();
}

本章警告:

⑴不要牺牲可维护性来优化性能,除非有可靠的性能测试能够证明确实存在性能问题,并且你的性能优化措施也真的有效果。 ⑵为你的继任者(也为将来的自己)编写易于阅读和理解的代码。 ⑶当似乎可以重构但是并没有什么意义时,请重新思考系统的架构。 ⑷精挑细选可以描述功能的方法名,并将代码放在短小的代码单元中(最多15行代码)。

②编写简单的代码单元

原则:

  • 限制每个代码单元分支点的数量不超过4个。
  • 你应该将复杂的代码单元拆分成多个简单的单元,避免多个复杂的单元在一起。
  • 该原则能提高可维护性的原因在于,分支点越少,代码单元越容易被修改和测试。

概念:

  • 代码单元分支点: 能够覆盖分支点所有分支的路径数量。 在Java中,这些语句均被认为是分支点:
    1. if
    2. case
    3. ?
    4. &&,
    5. while
    6. for
    7. catch
  • McCabe复杂度:分支点数量加一 ,又称圈复杂度或循环复杂度。因此这个原则相当于“限制McCabe复杂度不超过5”。

如何使用本原则?

Ⅰ.处理链式条件语句

以以下代码为例。对于一个国家来说,getFlagColors方法会返回正确的国旗颜色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public List<Color> getFlagColors(Nationality notionality){
List<Color> result;
switch(nationality){
case DUTCH:
result=Arrays.asList(Color.RED,Color.WHITE,Color.BLUE);
break;
case GERMAN:
result=Arrays.asList(Color.BLACK,Color.RED,Color.YELLOW);
break;
case BELGIAN:
result=Arrays.asList(Color.BLACK,Color.YELLOW,Color.RED);
break;
case FRENCH:
result=Arrays.asList(Color.BLUE,Color.WHITE,Color.RED);
break;
case ITALIAN:
result=Arrays.asList(Color.GREEN,Color.WHITE,Color.RED);
break;
case UNCLASSIFIED;
default:
result=Arrays.asList(Color.GRAY);
break;
}
return result;
}

显然,该方法的复杂度达到了6+1=7,我们需要对其进行优化。 方法一:引入一个Map数据结构: 这种方法的核心观念是:创建一个字典,以便使用一值直接映射到另一个值,从而将判断语句省略。

1
2
3
4
5
6
7
8
9
10
11
12
private static Map<Nationality,List<Color>> FLAGS=new HashMap<Nationality,List<Color>>();
static{
Flags.put(DUTCH,Arrays.asList(Color.RED,Color.WHITE,Color.BLUE));
Flags.put(GERMAN,Arrays.asList(Color.BLACK,Color.RED,Color.YELLOW));
Flags.put(BELGIAN,Arrays.asList(Color.BLACK,Color.YELLOW,Color.RED));
Flags.put(FRENCH,Arrays.asList(Color.BLUE,Color.WHITE,Color.RED));
Flags.put(ITALIAN,Arrays.asList(Color.GREEN,Color.WHITE,Color.RED));
}
public List<Color> getFlagColors(Nationality nationality){
List<Color> color=FLAGS.get(nationality);
return colors!=null?colors:Arrays.asList(Color.GRAY);8
}

方法二:使用多态来代替条件判断 这种方法的核心观念是:让每个国旗都拥有一个自己的类型,并实现同一个接口。Java语言的多态性会保证在运行时调用到正确的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//为了进行这次重构,我们首先定义一个共用的Flag接口
public interface Flag{
List<Color> getColors();
}
//为不同的国家定义不同的国旗类型,例如荷兰国旗:
public class DutchFlag implements Flag{
public List<Color> getColors(){
return Arrays.asList(Color.RED,Color.WHITE,Color.BLUE);
}
}
//以及意大利国旗:
public class ItalianFlag implements Flag{
public List<color> getColors(){
return Arrays.asList(Color.GREEN,Color.WHITE,Color.RED);
}
}
//中间的国旗类定义不予赘述

private static final Map(Nationality nationality){}
static{
Flags.put(DUTCH,new DutchFlag());
Flags.put(GERMAN,new GermanFlag());
Flags.put(BELGIAN,new BelgianFlag());
Flags.put(FRENCH,new FrenchFlag());
Flags.put(ITALIAN,new ItalianFlag());
}
public List<Color> getFlagColors(Nationality nationality){
Flag flag=FLAGS.get(nationality);
flag=flag!=null?flag:new DefaultFlag();
return flag.getColors();
}

这种重构技巧提供了最灵活的实现方式,例如,你只需要实现新的国旗类型,就可以不断增加所支持的国旗种类,并且可以对它进行独立测试。不过这种方式的不好之处是引入了更多的类和代码。开发人员必须在可扩展性和简洁性之间做出选择。

Ⅱ.嵌套条件语句

如下例所示,我们给定一个二分查找树的根节点和一个整数,calculateDepth方法会找出整数在树中的位置。如果找到,该方法会返回整数在树中的深度,否则抛出一个TreeException异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public int calculateDepth(BinaryTreeNode<Integer>t,int n){
int depth=0;
if(t.getValue()==n){
retrun depth;
}else{
if(n<t.getValue()){
BinaryTreeNode<Inter> left=t.getLeft();
if(left=null){
throw new TreeException("Value not found in tree!");
}else{
return 1+calculateDepth(Left,n);
}
}else{
BinaryTreeNode<Inter> right=t.getRight();
if(right==null){
throw new TreeException("Value not found in tree!");
}else{
return 1+calculateDepth(right,n);
}
}
}
}

为了提高可读性,我们可以标识出各种独立的情况,并插入return语句来代替嵌套式的条件语句,这种做法在重构中被称为使用卫语句来代替嵌套的条件语句

1
2
3
4
5
6
7
public static int calculateDepth(BinaryTreeNode<Integer> t,int n){
int depth=0;
if(t.getValue()==n)return depth;
if(n<getValue()&&t.getLeft()!=null)return 1+calculateDepth(t.getLeft(),n);
if(n<getValue()&&t.getRight()!=null)return 1+calculateDepth(t.getRight(),n);
throw new TreeException("Value not found in tree!");
}

这样一来,代码就变得更容易理解了,但是其复杂度并没有降低,为了降低复杂度,应该将嵌套的条件语句提取到其他方法中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static int calculateDepth(BinaryTreeNode<Integer> t int n){
int depth=0;
if(t.getValue()==n)return depth;
else return traverseByValue(t,n);
}

public static int traverseByValue(BinaryTreeNode<Integer> t,int n){
BinaryTreeNode<Integer> childNode=getChildNode(t,n);
if(childNode==Null){
throw new TreeException("Value not found in tree!");
}else{
return 1+calculateDepth(childNode,n);
}
}

private static BinaryTreeNode<Integer> getChildNode(BinaryTreeNode<Integer> t,int n)
{
if(n<t.getValue())return t.getLeft();
else return t.getRight();
}

<====To be continue====>