阅读 1273

把书读薄 | 《设计模式之美》规范与重构(上)

0x0、引言

节后第一天,本文是 规范与重构 (15-33) 的浓缩总结,同上,把实战部分(34-37) 拆到下节,这部分主要是一些编码建议和规范,过一遍,自己写代码注意下就好,比较简单。

二手知识加工难免有所纰漏,感兴趣有时间的可自行查阅原文,谢谢。


0x1、重构四问

① 重构的目的 → 为什么重构(Why)?

软件设计大师Martin Fowler对重构的定义:

重构是一种对软件内部结构的改善,目的是 在不改变软件的可见行为 的情况下,使其更易理解,修改成本更低。

可以把重构理解为:

在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。

为什么要进行代码重构

  • 时刻保证代码质量的有效手段,不至于让代码腐化到无可救药的地步;
  • 优秀的代码或架构都是迭代出来的,无法100%预见未来的需求,随着系统演进,重构不可避免;
  • 避免过度设计的有效手段,维护代码过程真正遇到问题再对代码进行重构,有效避免前期过度设计投入大量时间;
  • 对工程师本身技术的成长有重要意义,将学习到的理论知识应用到实践中一个很好的练兵场;

初级工程师在维护代码,高级工程师在设计代码,资深工程师在重构代码 (发现代码存在问题,保证代码质量处于可控的状态)。


② 重构的对象 → 重构什么(What)?

根据重构的规模,笼统地分为 大规模高层次重构小规模低层次重构,简称为大型、小型重构。

大型重构

对顶层代码设计的重构系统、模块、代码结构、类与类之间的关系 等的重构,手段有:分层、模块化、解耦、抽象可复用组件 等。工具:设计思想、原则和模式。涉及代码变动较多,影响面大,难度大,耗时长,引入bug风险也大。

小型重构

对代码细节的重构类、函数、变量等代码级别 的重构,比如:规范命名、规范注释、消除超大类或函数、提取重复代码等。手段主要是:编码规范。修改地方较集中,比较简单、可操作性强、耗时短,引入bug风险也相对小一些。


③ 重构的时机 → 什么时候重构(When)?

不要等代码烂到一定程度再去重构,提倡 持续重构,闲暇时看下项目中有哪些写的不够好、可以优化的代码,主动去重构下,或者在修改、添加某功能代码时顺手把不符合编码规范、不好的设计重构一下,就是要有 持续重构的意识


④ 重构的方法 → 如何重构(How)?

大型重构

提前做好完善的重构计划,分阶段进行,每个阶段只完成一小部分代码的重构,然后提交、测试、运行,发现没问题后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行,逻辑正确的状态。

每个阶段,都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还要写一些兼容过渡代码。只有这样,才能让每个阶段的重构不至于耗时太长(最好一天就能完成),不至于和新的功能开发相冲突。

大型重构一定是有组织、有计划且非常谨慎的,需要有经验、熟悉业务的资深同事来主导。

小型重构

随时可以去做,除了人工去发现低层次的代码质量问题,还可以借助一些成熟的静态代码分析工具(如:CheckStyle、FindBugs、PMD等),来自动发现代码中的问题,然后针对性地进行重构优化。

对于重构这件事,资深工程师、团队Leader要负起责任,没事就重构下代码,时刻保持代码质量处于一个良好的状态。否则一旦出现 "破窗效应",一个人往里堆了一些烂代码,之后就会有更多的人往里面堆更烂的代码,毕竟往项目堆砌烂代码的成本太低了。保持代码质量最好的方法还是:打造一种好的技术氛围,以此驱动大家主动关注代码质量,持续重构代码。


0x2、如何保证重构不出错

保证重构不出错,你需要熟练掌握各种设计原则、思想、模式,还有对所重构的业务和代码有足够的了解。除去这些个人能力因素外,最可落地执行、最有效的保证重构不出错的技术手段就是 单元测试(Unit Testing)。

① 单元测试与集成测试

  • 单元测试由研发工程师自行编写,用来测试自己写的代码的正确性,测试对象是 类或函数,测试是否都按照预期的逻辑执行,代码层级的测试,粒度较小;
  • 集成测试 (Integration Testing) 的测试对象是 整个系统或某个功能模块,如测试用户注册、登陆功能是否正常,一种端到端(End To End) 的测试。

② 单元测试编写示例

import java.util.regex.Pattern;

public class Text {
    private String content;
    private final Pattern pattern = Pattern.compile("[0-9]*");

    public Text(String content) {
        this.content = content;
    }

    public Integer toInt() {
        if (content == null || content.isEmpty()) return null;
        String temp = content.replace(" ", "");
        if(pattern.matcher(temp).matches()) {
            return Integer.parseInt(temp);
        }
        return null;
    }
}
复制代码

比如要对上面这个Text类的toInt()方法进行测试,先设计测试用例(输入 → 期望输出):

  • "123" → 123
  • null或空字符串 → null
  • " 123"、" 123 "、"123 "、"1 23 "、"1 2 3 "、" 1 2 3 " → 123
  • "123a"、"1*23"、"abc" → null
  • "1234567890" → 1234567890

用例设计更多考验程序员思维的缜密程度,看能否设计出覆盖各种正常/异常情况的测试用例,来保证代码在任何预期或非预期情况下都能正确运行。写完用例,接着就是将其翻译成带么了(此处没用任何测试框架)

// 结果校验类
public class Assert {
    public static void assertEquals(Integer expectedValue, Integer actualValue) {
        if (actualValue.intValue() != expectedValue.intValue()) {
            System.out.println(String.format("测试失败:期待值:%d,实际值: %d", expectedValue, actualValue));
        } else {
            System.out.println("测试成功");
        }
    }

    public static boolean assertNull(Integer actualValue) {
        boolean isNull = actualValue == null;
        if (isNull) {
            System.out.println("测试成功");
        } else {
            System.out.println("测试失败,实际值不为null:" + actualValue);
        }
        return isNull;
    }
}


// 测试用例类
public class TextTest {
    public void testToNumber() {
        Assert.assertEquals(123, new Text("123").toInt());
    }

    public void testToNumber_nullOrEmpty() {
        Assert.assertNull(null);
        Assert.assertNull(new Text("").toInt());
    }

    public void testToNumber_containsSpace() {
        Assert.assertEquals(123, new Text(" 123").toInt());
        Assert.assertEquals(123, new Text(" 123 ").toInt());
        Assert.assertEquals(123, new Text("123 ").toInt());
        Assert.assertEquals(123, new Text("1 23 ").toInt());
        Assert.assertEquals(123, new Text("1 2 3 ").toInt());
        Assert.assertEquals(123, new Text(" 1 2 3 ").toInt());
    }

    public void testToNumber_containsInvalidCharacters() {
        Assert.assertNull(new Text("123a").toInt());
        Assert.assertNull(new Text("1*23").toInt());
        Assert.assertNull(new Text("abc").toInt());
    }

    public void testToNumber_large() {
        Assert.assertEquals(Integer.MAX_VALUE, new Text("" + Integer.MAX_VALUE).toInt());
    }
}

// 运行用例类
public class TestCaseRunner {
    public static void main(String[] args) {
        TextTest test = new TextTest();
        test.testToNumber();
        test.testToNumber_nullOrEmpty();
        test.testToNumber_containsSpace();
        test.testToNumber_containsInvalidCharacters();
        test.testToNumber_large();
    }
}
复制代码

测试结果如下:

从上面的示例我们可以总结出:写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,用将用例翻译成代码的过程。

另外,翻译代码时,可利用单元测试框架(如JUnit、TESTNGINX、Spring Test等) 来简化测试代码的额编写。


③ 为什么要写单元测试

  • 帮你发现代码中的BUG (节省fix低级bug的时间,写出Bug Free代码是判断工程师编码能力的重要标准之一);
  • 帮你发现代码设计上的问题 (代码的可测试性是评判代码质量的重要标准,难写单元测试一般说明代码设计可能有问题);
  • 单元测试是对集成测试的有力补充 (复杂系统,集成测试也无法覆盖得很全面);
  • 写单元测试的过程本身就是代码重构的过程
  • 阅读单元测试能帮助你快速熟悉代码(单元测试案例实际上就是用户案例,反映了代码的功能及使用,在没有文档或注释的情况下,它可以起替代性作用,借助单元测试案例,无需深入阅读代码,即可了解代码实现了什么功能);
  • 单元测试是TDD可落地执行的改进方案 (Test Driven Development,测试驱动开发,测试用例优于代码编写);

④ 编写单元测试的经验总结

写单元测试真的是件耗时的事吗

代码量多,写的过程繁琐,但并不是很耗时,因为不用考虑太多代码设计上的问题,大部分是cv操作。

对单元测试的代码质量有什么要求吗

单元测试毕竟不会在生产环境运行,类的测试代码都相对独立,代码质量要求可以放低些,命名不是很规范、代码重复有些重复,也是可以的。

单元测试只要覆盖率高就够了吗

测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计(如JaCoCo、Cobertura等)。覆盖率的计算方式也有很多种,最简单的语句覆盖、稍微高级点的:条件覆盖、判定覆盖、路径覆盖。

盲目追求高覆盖率是不可取的,更重要的是看测试用例是否覆盖了所有可能的情况,特别是一些边界条件。

过度关注覆盖率会导致开发人员为了提高覆盖率,写了很多没必要的测试代码(如get、set)。从过往经验来讲,一个项目的单元测试覆盖率在60~70%即可上线,当然如果项目对代码质量要求严格,亦可适当提高覆盖率要求。

写单元测试需要了解代码的实现逻辑吗

不需要,单元测试只关心被测函数实现了什么功能,切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。

如何选择单元测试框架

团队内部统一即可,自己写的代码用已选定的单元测试框架无法测试,多半是代码写的不够好,可测试性差,这个时候要重构自己的代码,使其更易测试,而不是找另一个更加高级的单元测试框架。

单元测试为何难落地执行

  • 写单元测试本身较繁琐,技术挑战不大,很多程序员不愿意去写;
  • 国内研发比较偏向"快、糙、猛",容易因为开发进度紧,导致单元测试执行的虎头蛇尾;
  • 团队成员没有建立对单元测试的正确认识,觉得可有可无,单靠督促很难执行得很好;

0x3、代码的可测试性

代码的可测试性,粗略地讲就是:针对代码编写单元测试的难易程度,对于一段代码很难为其编写单元测试,或者写起来很费劲,需要依赖单元测试框架中很高级的特性,那么往往意味着代码设计不够合理,代码的可测试性不高。

如果代码中依赖了外部系统或不可控组件(如数据库、网络通信、文件系统等),就需要将被测代码与外部系统解依赖,这种解依赖的方法称作 "Mock",即用一个 "假" 的服务替代真正的服务,Mock服务在我们的控制下,模拟输出我们想要的数据。Mock方式又分两种,手动和使用框架Mock。

常见的测试不友好的代码有这几种

  • 代码中包含未决行为逻辑 (输入随机或不确定,如时间、随机数相关代码,应将其抽取到到方法中,方便测试替换)
  • 滥用可变全局变量 (测试用例可能相互影响);
  • 滥用静态方法 (同上,还有静态方法可能不好Mock)
  • 使用复杂的继承关系 (父类需要mock某个依赖对象才能进行单元测试,所有子类都要mock这个依赖对象);
  • 高度耦合的代码 (一个类要依赖十几个外部对象才能完成工作,单元测试时可能要mock这十几个依赖的对象);

0x4、如何通过封装、抽象、模块化、中间层等解耦代码

① 解耦为何如此重要?

  • 重构 是保证 代码质量 不至于腐化到无可救药的有效手段;
  • 解耦 则是对代码重构,保证代码不至于 复杂 到无法控制的有效手段;

"高内聚、低耦合" 的特性可以让我们聚焦在某一模块或类中,而不需要了解太多其他模块或类的代码,让焦点不过于分散,降低阅读和修改代码的难度。依赖关系简单,耦合小,修改代码也不至于牵一发而动全身,代码改动集中,引入bug的风险也少了,而且可测试性更佳,容易Mock或只需Mock少量外部依赖。

② 如何判断代码是否需要解耦

除了看改代码会不会牵一发动全身外,还可以把 模块间、类与类间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。如果依赖关系复杂混乱,那从代码结构上讲,可读性和可维护性肯定不是太好。

③ 如何给代码解耦?

  • 封装与抽象 (隐藏复杂性,隔离变化,对外提供稳定易用接口);
  • 中间层 (简化模块或类之间的依赖关系);
  • 模块化 (模块只提供封装了内部实现细节的借口给其它模块使用,以此减少不同模块间的耦合度);
  • 其他设计思想和原则 (单一职责、基于接口而非实现编程、依赖注入、多用组合少用继承,迪米特法则);

0x5、最快速改善代码质量的20条编程规范

① 命名

  • 命名长短 → 以能 准确达意 为目标,在能达意的情况下越短越好,默认或大家都熟知的词,推荐用缩写;
  • 利用上下文简写命名 → 作用域较小的临时变量、借助类这个上下文成员变量和函数参数,可以简化命名(如User类中的name);
  • 命名要可读、可搜索 → 不要用生僻、难发音的单词来命名,统一命名习惯(如都用selectXXX表查询,你就不要用queryXXX);
  • 接口和抽象类命名 → 接口前缀加"I" 或后缀加Impl,抽象类加或不加前缀Abstract,选择哪种命名方式都可以,但是要在项目里统一!

② 注释

注释到底该写什么?(做什么、为什么、怎么做),代码示例如下:


/**
* (what) Bean factory to create beans. 
* 
* (why) The class likes Spring IOC framework, but is more lightweight. 
*
* (how) Create objects from different sources sequentially:
* user specified object > SPI > configuration > default object.
*/
public class BeansFactory {
  // ...
}
复制代码

尽管做什么、怎么做可以从代码中体现出来,但是还是建议在注释中写明,原因如下:

  • 注释比代码承载的信息更多 → 类包含信息较多,简单的命名可能不够详尽,在注释中写明做什么更便于阅读;
  • 注释起到总结性、文档的作用 → 让阅读代码的人通过注释就能大概理解代码的实现思路;
  • 一些总结性注释能让代码结构更清晰 → 逻辑较复杂、较长又不好提炼拆分的函数,借助总结性注释让代码结构更清晰;

当然,不是注释写得越多就越好,太多意味着代码写得不够刻度,而且会对代码本身的阅读造成干扰,后面维护成本也较高。

一般来说:类和函数一定要写注释,且尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的明明、提炼函数、解释性变量、总结性注释来提高代码的可读性。


③ 代码风格 (团队/项目统一)

类、函数多大才合适

很难给出一个确切的值,一个间接的判断标准:当一个类代码读起来让你感觉头大、找个函数找半天、用一个小功能就要引入整个类(类中包含很多无关此功能的实现函数)时,就说明类的函数过多了。

一行代码多长最合适

一行代码最长不能超过IDE显示的宽度,滚动鼠标才能查看一行的完整代码,显然不利于代码的阅读。

善用空行分割单元块

对于较长、又不方便抽取成小函数的函数,除了用总结性注释分隔外,还可以用空行来分割代码块。除此之外,类成员与函数间、静态成员变量和普通成员变量间、各函数间等,都可以通过添加空行的方式让其界限更加明确。

四格缩进还是两格缩进

都可以,但项目内要统一,建议不要使用Tab缩进,因为不同IDE的Tab缩进显示宽度不同,要用也行,要统一配置好!

大括号是否要另起一行

都可以,推荐括号与语句同一行,这样可以节省代码行数,另起一行也行,左右括号垂直对齐,可以方便的看到哪些代码属于哪个代码块,各有千秋,还是那句话项目统一。

类中成员的排列顺序

建议:类所属包名 → import引入的依赖类(字母序从小到大排列) → 成员变量 → 函数,变量和函数间都是按照先静态后普通,作用域范围从大到小排列。实际上,还有种排列习惯,就是将有调用关系的函数放到一块。


④ 编程技巧

将代码分割成更小的单元块

当代码逻辑较复杂时,才建议提炼类或函数,如果提炼出的函数只包含两三行代码,阅读代码时还得跳过去看,反而增加了阅读成本。

避免函数参数过多

参数大于等于5个时,会影响到代码的可行,用起来也不方便,先考虑是否职责单一拆解,或者将函数的参数封装成对象传递。

勿用函数参数来控制逻辑

不要在函数中使用boolean类型的标识来控制内部逻辑,ture走这块逻辑,false走另一块,这违背了单一职责和接口隔离原则,建议拆成两个函数。还有一种判断参数是否为null控制逻辑的情况,同样建议拆解。

函数设计要职责单一

函数设置将更要满足单一职责原则,能多单一就多单一。

移除过深的嵌套层次

过深的嵌套本身理解起来比较费劲,嵌套超两层就要思考是否能减少嵌套,几种解决思路:去掉多余的if或 else语句使用编程语言提供的continue、break、return关键字提前退出嵌套调整执行顺序将部分逻辑封装成函数调用多态 等。

学会使用解释性变量

常量取代魔法数字使用解释性变量来解释复杂表达式,示例如下:

public double CalculateCircularArea(double radius) {
  return (3.1415) * radius * radius;
}

// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
  return PI * radius * radius;
}

if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
  // ...
} else {
  // ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START) && date.before(SUMMER_END);
if (isSummer) {
  // ...
} else {
  // ...
} 
复制代码

文章分类
Android