转载

[读书笔记]编写可维护软件的10大原则(Java版)

[读书笔记]编写可维护软件的10大原则(Java版)

为什么要写这个总结

  1. 并不是原文的简单复制, 而是记录对原则的思考。特别是对于自己项目的一些反思。
  2. 原文是Java版本, 有一些规则是否非常符合我当前主力语言Python来使用?记录我的一些相关思考
  3. 排版原因,原文的代码讲解其实看的并不是非常明白。希望通过电子档更清晰明白的展示出来。加深自己的理解,也方便其他人学习

适用其他语言?

这本书的绝大部分思想都是非常合适其他语言的。只不过可能需要根据具体情况具体分析。

比如原则4要求“参数个数<=4”. 这一点非常适用Java,但是对于Python其实有更好的解决方案,一些很著名的开源类库也没有这样做。

但是比如编写短小、简单的代码单元,需要注意重用而不是复制黏贴代码,这些原则放到其他语言都是非常合适的。

备注: 代码单元 (Unit of Code)

可独立维护或者执行的最小代码集合。在Java之中代码单元就是方法或者构造函数。

原则1:编写短小的代码单元

要点:

  1. 代码长度 不超过15行
  2. 把超过15行长度的代码单元 拆分到15行以内
  3. 动机:短小的代码 容易理解、测试、重用

重构技巧1: 提取方法

原始代码:

public void start() {
    if (inProgress) {
        return;
    }
    inProgress = true;
    // Update observers if player died:
    if (!isAnyPlayerAlive()) {
        for (LevelObserver o: observers) {
            o.levelLost();
        }
    }
    // Update observers if all pellets eaten:
    if (remainingPellets() == 0) {
        for (LevelObserver o: observers) {
            o.levelWon();
        }
    }
}
 

重构1:把监控逻辑抽取出来

片段1

public void start() {
    if (inProgress) {
        return;
    }
    inProgress = true;
    updateObservers(); // 被重构抽取出来的函数
}
 

片段2

private void updateObservers() {
    // Update observers if player died:
    if (!isAnyPlayerAlive()) {
        for (LevelObserver o: observers) {
            o.levelLost();
        }
    }
    // Update observers if all pellets eaten:
    if (remainingPellets() == 0) {
        for (LevelObserver o: observers) {
            o.levelWon();
        }
    }
}
 

进一步重构:进一步拆分 updateObservers

private void updateObservers() { // 被拆成了两个函数
    updateObserversPlayerDied();
    updateObserversPelletsEaten();
}
private void updateObserversPlayerDied() {
    if (!isAnyPlayerAlive()) {
        for (LevelObserver o: observers) {
            o.levelLost();
        }
    }
}
private void updateObserversPelletsEaten() {
    if (remainingPellets() == 0) {
        for (LevelObserver o: observers) {
            o.levelWon();
        }
    }
}
 

重构技巧2: 使用对象替代方法

原始代码:

public Board createBoard(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.values()) {
                int dirX = (width + x + dir.getDeltaX()) % width;
                int dirY = (height + y + dir.getDeltaY()) % height;
                Square neighbour = grid[dirX][dirY];
                square.link(neighbour, dir);
            }
        }
    }
    return board;
}
 

:point_up_2:上面这个方法一共18行,已经超过15行的标准。首先可以尝试把 10~13 行单独抽取成一个函数,比如

public voide setLink(){
    // skip
}
 

但是会发现,需要传入的参数太多,可以把整体重构成一个类 BoardCreator

class BoardCreator {
    private Square[][] grid;
    private Board board;
    private int width;
    private int height;
    BoardCreator(Square[][] grid) {
        // init ...
    }
    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) {
        // ...
    }
}
 

:point_up_2: 上面这样重构的好处:

  1. 代码单元足够小
  2. 每个方法的入参个数也并没有很长

但也是有代价的:

  1. 整体代码行数大大增加了 (上面代码已经被我忽略了一些,还比原来的长)
  2. 个人感觉:阅读的复杂度似乎并没有降低, 反而提高。对于使用者来说, 难度可能差不多

原则2: 编写简单的代码单元

要点:

  1. 限制每个代码单元的分支的数量 <= 4
  2. 复杂代码单元拆分成简单的代码单元
  3. 动机:简单的代码单元易于修改与测试

解释:

原始代码:

public List < Color > getFlagColors(Nationality nationality) {
    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;
}
 

:point_up_2:上面代码之中, 使用 switch 做条件判断,本身逻辑还是很清晰的。但是如果还需要不断的添加新的国家的时候,如果忘记写 break 语句,返回的结果就错误了。这种小bug其实反而是我们日常主要的bug。

如何重构呢?重构的方法比较多,书上推荐的做法: 使用Map的数据结构

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 > colors = FLAGS.get(nationality);
    return colors != null ? colors : Arrays.asList(Color.GRAY);
}
 

:point_up_2:如上, 这样分支点的数量就不是线性增加, 而是常量 2 了。

书中还提到了可以定义一个 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);
    }
}
 

如果仅仅是针对这个例子, 我觉得这种方法不一定合适,因为这个数据结构很简单。 但是如果数据结构比较复杂, 我觉得这样才能体现出优势。

思考:

PS: 这个思考是个人的思考,并不是书上的内容。

1) 如果有的业务就是这样多的 if-else 分支。对其进行拆分也不能减小复杂度、或者减少分支点啊?

我想起之前同事展示的财务的相关代码。可能有20+个 if-else 分支。之前在做电商相关业务,计算物流费用的时候也遇到类似的场景。即使是在我们的API代码之中, 比如确定按照哪个维度进行Group就7~8个分支点。

是否有解决方案呢? 一个可能的方案拆分方法:拆分成两种。即:

  1. 按照属性维度(比如所属产品、日期)
  2. 按照广告指标维度(比如:所属账户、Campaign、Ad)进行聚合(Group)

2) 分支覆盖率的测试,我感觉重点还是分支条件之间是否独立

比如类似 switch 这种分支点, 大部分情况下可能还是可以忍受的。因为毕竟分支点是线性增加,而且条件之间相互应该是独立的。 最难测试的是条件有先后顺序的情况

原则3:不写重复代码

要点:

  1. 不要复制代码
  2. 应该编写可重用的代码,或者调用已有的代码。
  3. 动机:bug只需要在一个地方修复。

重构方法

  1. 首先想到的是提取方法;但若是一个方法是另一个类的私有方法怎么办?
  2. 这时应当将提取的方法放到一个工具类中。
  3. 如果重复代码(6行以上完全相同)已不存在,但代码相似,具有相同的逻辑,这时应该考虑提取父类。

思考

1) 如何处理长文本?甚至需要对长文本做一些细微的调整?

比如有一些比较复杂的SQL,不同的SQL可能只是统计的指标或者Group的字段不太一样。这种情况跟具体使用的语言无关。比较好的方法:

  1. 提取方法,通过字符串追加或者参数变换的方式组装SQL
  2. 使用模板引擎进行SQL组装

如果使用Python进行SQL模板拼装的话, 可以尝试一下 jinjasql 这个类库。

原则4: 保持代码单元的接口简单

要点:

  • 限制每个代码单元的 参数不超过4个
  • 应该将 多个参数提取成对象
  • 动机:更容易理解与重用

举例:

private voide render(int x, int y, int w, int h) {
    // skip
}
 

上面的 x,y,w,h 是长方形的坐标与长宽。可以如下重构:

public class Rectangle{
    // skip
}
 
private voide render(Rectangle) {
    // skip
}
 

好处:在Java之中,如果多个参数一起传入,就需要很仔细的不要把参数位置弄混。特别是类型相同的情况之下。

对于Python就要好很多了,入参的时候可以指定关键字,这样即使参数位置调整了,在调用的时候也不需要很辛苦的修改调用代码。而且Python这种动态语言,编译的时候都无法检查出来。

比如我们项目之中一个拼接SQL的进行查询的函数。

def read_toutiao_performance_stat_report(
         start_day, end_day, 
         filters_source=None, group_by_key=None,
         filters_bu=None, filters_app=None, filters_account=None,
         filters_campaign_id=None,
         filters_geo=None, filters_adset_id=None, by="daily", to_dollar=False,
         limit=None):
    pass
 

↑ 主要分成好几组:

  • 时间
    • start_day / end_day
  • 过滤条件
    • filters_xxxx
  • group 条件
    • group_by_key
  • 其他:
    • by: 控制读取的是分天还是分时表
    • to_dollar: 控制最终是用人民币还是美元单位
    • limit: 每次多少条数据

调用方式:

dataset = fetch_report(
    start_day, end_day, filters_source=None,
    group_by_key=group_by_key,
    filters_bu=filters_bu, filters_app=filters_app,
    filters_account=filters_account,
    filters_campaign_id=filters_campaign_id,
    filters_adset_id=filters_adset_id,
    filters_geo=filters_geo, by=by, to_dollar=to_dollar,
    limit=limit)
 

这样就不会引起混乱了。

备注:这一条可能对Java比较适用,Python很多类库都有很长的参数列表,比如Pandas的 read_excel 函数:

def read_excel(
    io,
    sheet_name=0,
    header=0,
    names=None,
    index_col=None,
    usecols=None,
    squeeze=False,
    dtype=None,
    engine=None,
    converters=None,
    true_values=None,
    false_values=None,
    skiprows=None,
    nrows=None,
    na_values=None,
    keep_default_na=True,
    verbose=False,
    parse_dates=False,
    date_parser=None,
    thousands=None,
    comment=None,
    skipfooter=0,
    convert_float=True,
    mangle_dupe_cols=True,
    **kwds,
):
    pass
 

原则5~7: 架构、组件松耦合

具体略

原则8:保持小规模代码库

要点:

  • 保持代码库规模尽可能小
  • 控制代码库增长,主动减少系统代码体积
  • 动机: 拥有小型代码库是成功的因素之一

动机

  1. 大型代码库容易失败
  2. 大型代码库更加难以维护
  3. 大型系统会出现更密集的缺陷

如何执行?

功能层面:

  • 控制需求蔓延:不要做无用的需求
  • 功能标准化:将相似而又略有区别的功能合并,提供复用程度

技术层面:

  • 不要复制代码
  • 重构已有代码:
    • 重新梳理代码、简化结构、删除代码冗余以及重用度
    • 也有仅仅删除一些无用的过期的功能
  • 使用第三方库和框架:
    • 不要重复造轮子
    • 也不要去改造轮子(除非你的改造被合并到开源类库之中)

原则9:自动化开发部署和测试

要点:

  • 对你的代码进行自动化测试
  • 通过测试框架编写自动化测试
  • 动机:大大降低风险

感触

想说爱你不容易

备注:

原文标题:Automated Tests 。即仅仅是自动化测试。

原则10:编写简洁的代码

要点:

  • 编写简洁的代码
  • 不要留下坏味道
  • 动机:简洁的代码才是可维护的代码

7条开发人员“童子军军规”

  1. 不要编写单元级别的代码坏味道。
    1. 具体参考前面几个原则
  2. 不要编写不好的注释
    1. 看了书上举的例子也不是很明白。但是力求简明清晰
  3. 不要注释代码。
    1. 比如有一段代码不用了,不要通过注释掉来保留而是应该彻底删除。因为Git/SVN都能恢复回来
  4. 不要保留废弃代码。
    1. 方法中无法到达的代码
    2. 无用的私有方法
    3. 注释中的方法
  5. 不要使用过长的标识符名称。
    GlobalProjectNamingStrategyConfiguration
    
  6. 不要使用魔术常量。
  7. 不要使用未正确处理的异常。推荐做法:
    1. 捕获一切异常:记录系统所有失败的行为并加以改进
    2. 捕获特定的异常:
      1. 为了追踪某些特定事件,就应该单独捕获特定的异常
      2. 不要直接Throwable、 Exception、RuntimeException
    3. 展示给终端用户之前,先将特定异常信息转成通用信息。
      1. 终端用户完全看不懂
      2. 也不安全:提供了过多的内部信息

感触

这一条我感觉放在最后是最合适的,因为这一条才是最难最难的。

这个需要开发人员的经验积累,培养良好的习惯,跟自己的懒惰作斗争。

脑图

图片来源:https://zhuanlan.zhihu.com/p/81777441

[读书笔记]编写可维护软件的10大原则(Java版)

参考资料:

  • 英文版PDF: http://www.java1234.com/a/javabook/javabase/2016/0315/5817.html
  • 2016 Presentation Slides
[读书笔记]编写可维护软件的10大原则(Java版)
原文  https://www.flyml.net/2020/06/30/building-maitainable-software-java/
正文到此结束
Loading...