代码整洁之道

2020-11-25  本文已影响0人  若琳丶

前言

按照自己理解,结合自己平时习惯来做读书笔记,边读边理解边总结。

大师级程序员把系统当做故事来讲,而不是当做程序来写。他们使用选定编程语言提供的工具构建一种更为丰富且更具表达里的语言,用来讲那个故事。那种领域特定语言的一个部分,就是描述在系统中发生的各种行为的函数层级。在一种狡猾的递归操作中,这些行为使用他们定义的与领域紧密相关的语言讲述自己那个小故事。

一、有意义的命名

1.2 变量

list 为后缀的名称

定义一组账号,或一个账号组
String accountList = "xxx,xxx"; 可能会有歧义
String accounts = "xxx,xxx"; ok
String accountGroup = "xxx,xxx"; ok

list 作为变量名后缀一般习惯有特殊的含义,一般变量的类型都是 List 集合。

循环变量使用有意义的名称

虽然这样做实际意义并不大,但是却是个好习惯

List<Table> tableList = getTableList();
for (int i = 0; i < tableList .size() ; i ++) {
  Table table = tableList .get(i);
  List<Row> rowList = table.getRowList()
  for (int j = 0; j < rowList .size(); j ++) {
    ...
  }
}

用 tableIndex 或 tIndex 和 rowIndex 或 rIndex 去替代 i 和 j 比较好。

1.3 类和接口

无用前缀

定义接口时无需使用I来开头,去强调该类是个接口
比如 IShapeFactory,ICustomerService。可以直接就将接口定义为 ShapeFactory,CustomerService,这样做的目的用户可以直接把它们当成工厂,当成服务本身,而无需知道它仅仅是个接口。

类名使用意义明确的名词不要使用动词

用 Customer、AccountI,不用 Data 或 Info 等含糊意义的名词。

1.4 方法

做好名称区分

getAccount();
getAccounts();
getAccountInfo();
getAccount();
接口调用者如何知道该调用哪个方法?让接口调用者尽量不用读注释就能区分出方法之间的区别。

方法名使用动词或动词短语

一个概念对应一个词

比如:fetch、retrieve 和 get 都表达取到,获取的意思,尽量统一使用一个。
又比如:我们业务端对火车票改签的命名,changeOrder、rescheduleOrder,用哪一个的都有,我在写业务的时候,不知道用那个更合适。

1.5 雷区

尽量别用【抖机灵】的词语来定义名称,否则做工作交接的时候,同事不太明白意义所在。

二、函数

2.1 短小

一个方法尽量不超过一屏幕。长最好不超过 150 个字符,行数不超过20行。

2.2 只做一件事

一个函数应该只做一件事,做好这件事,只做一件事。

2.3 每个函数一个抽象层级

函数中的语句都要在同一抽象层级上。

public CustomerInfo buildCustomerInfo() {
   /* step 1: 构建基础信息 */
   Address addrInfo = buildAddressInfo();
   BankInfo bankInfo = buildBankInfo();
   CompanyInfo companyInfo = buildCompanyInfo();
   
   /* step 2: 构建客户信息 */
   CustomerInfo customer = new CustomerInfo ();
   customer.setAddress(addrInfo);
   customer.setBankInfo(bankInfo);
   customer.setCompanyInfo(companyInfo);
   return customer;
}

private Address buildAddressInfo() {}

private BankInfo buildBankInfo() {}

private CompanyInfo buildCompanyInfo() {}

不要将构建 CustomerInfo 的所有细节全部塞入 buildCustomerInfo 方法中,因为某些细节不在同一个抽象层中。讲流程拆解为几个部分,划分为几个抽象层来表示。

2.4 向下规则

让代码拥有自顶向下的阅读顺序,让每个函数后面都跟着位于下一抽象层的函数,这样依赖,在查看函数列表时,就能循着抽象层级向下阅读了(上方的demo也可以说明)。

2.5 使用描述性的名称

别害怕长名称:

2.6 函数参数

函数的参数越少越好,便于单测的编写和执行。
当三个或者三个以上的函数,可以把部分参数封装成一个对象,传入方法内,或者对方法的意义重新思考,进行拆分和分层。
可变参数吧。。。能少用就少用吧,平时用的也不多。

2.7 分隔指令和询问

函数要么做什么事,要么回答什么事,两者不可兼得。
case:

public boolean set(String attr, String value);

boolean的返回值,意义为设置成功还是失败。那如何去区分“指定属性为空,所以设置失败” 还是 “摄制过程中发生某种异常,导致失败”?
调用者无法判断其含义,但我觉得还行。当然按照书中比较推荐的做法,就是将含义描述清除,如果包含多个含义的方法,可以拆解为多个方法,设置失败可以通过异常抛出来:

public boolean isAttrExists(String attr);
public void set(String attr, String value) throws Exception;

2.8 抽取 try 代码块

将 try catch 代码抽取到下级函数中:

public void delete (Page page) {
    deletePageAndAllReferences(page);
}
private void deletePageAndAllReferences(Page page) throws Exception {
    try {
        doDeletePage(page);
    } catch(Exception e) {
        logger.error("delete exception", e);
    }
}

catch 中尽量不要写跟业务相关的代码,同样 finally 也是。如果是回滚,那就只通过 Spring 的注解去搞,就把异常抛出去。

三、注释

注释,是为了弥补我们在用代码表达意图时遭遇的失败。
糟糕过时的注释,不如清晰准确的注释
清晰准确的注释,不如清晰准确的方法和类以及变量的定义

当然,业务代码和框架代码不太一样。有些情况,甚至大部分情况,不得不用注释去描述复杂的业务,描述业务场景,讲述业务背景和目的,当然最好这些都应该写到wiki中。

四、格式

4.1 概念间垂直方向上的区隔

在封包声明、导入声明和每个函数之间,都有空白行隔开。这样能将整个类分隔成一个个干净的方法区域,方法中也可以适当的用空行来分隔。这样可以避免整个类像一团乱麻一样展现在你的眼前。

4.2 概念相关

概念相关的代码应该放到一起,相关性越强,彼此之间的距离就该越短。这样在阅读代码的时候,方法与方法之间的调用,距离比较近,就不用上下翻飞。否则再加上逻辑的嵌套,特别容易看丢。

六、对象和数据结构

6.1 数据抽象

隐藏实现并非只是在变量之间放一个函数层那么简单。隐藏实现关乎抽象,类并不简单的用取值器和赋值器将其变量推向外间,而是暴露抽象接口,以便用户无需了解数据的实现,就可以操作数据本体。

以最好的方式,呈现某个对象包含的数据。

6.2 数据结构和对象的反对称性

过程式代码:

public class Squeare {
    public Point topLeft;
    public double side;
}

public class Rectangle {
    public Point topLeft;
    public double height;
    public double width;
}

public class Circle {
    public Point center;
    public double radius;
}

public class Geometry {
    public final double PI = 3.1415;

    public double area(Object shape)  throws NoSuchShapeException {
          if (shape instanceof Square) {
                Square s = (Square)shape;
                return s.side * s.side;
          }  
          else if (shape instanceof Rectangle) {
                Rectangle r = (Rectangle)  shape;
                return r.height * r.width;
          }
          else if (shape instanceof Circle ) {
                Circle c = (Circle) shape;
                return PI * c.radius * c.radius;
          }
    }
}

过程式代码,便于在不改动既有的数据结构的前提下,添加新函数,但是难以添加新类,因为需要改动所有函数;

面向对象代码:

public class Squeare {
    private Point topLeft;
    private double side;

    public double area() {
        return side * side;
    }
}

public class Rectangle {
    private Point topLeft;
    private double height;
    private double width;

    public double area() {
        return height * width;
    }
}

public class Circle {
    private Point center;
    private double radius;
    public final double PI = 3.1415;

    public double area() {
        return PI  * radius * radius;
    }
}

面向对象代码,便于在不改动既有函数的前提下,添加新类;但是难以添加新函数,因为需要改动所有类。

ps:虽然书上对两种代码做了两面性分析,但是很明显,在 java 中使用面向对象比较好。

七、错误处理

7.1 给出异常发生的环境说明

抛出的异常对象,应创建信息充分的错误信息,需要包含失败的操作和失败类型,以及失败的原因。

7.2 不要在 catch 中添加业务相关的代码

7.3 别返回 null 值

比起返回 null,不如直接抛出异常。当然这里的 null 的意思是,中间出现了异常,然后返回 null。
比如:查询用户信息,未找到用户信息,此时不能返回 null,可以抛出“未找到该用户” 的异常,当然也可以返回一个空的 dto,然后 dto 中有一个 msg 中写着 “未找到该用户”。
查询列表的方法中,未查询到也可以返回一个空 List,这样至少调用方不会直接抛 NPE。

7.4 别传递 null 值

道理其实是相同的,传递 null 的最直接的影响,就是使用方可能会出现 NPE,为了避免,就必须要去加一层判断。这种判空越在上层做,越恶心。
让调用方省心一点,一些必要的判断就在下层做。

九、单元测试

9.1 整洁的测试

整洁的测试有三个要素:

和其他代码相同:明确,简介,有足够的表达力。
编写单测的三个部分:

9.2 每一个测试只有一个概念,一个断言

无需对超长的函数进行单元测试,把一个超长的测试拆成多个单一概念的单元测试。单元测试的“单元”二字,尽显其义。每个单测只负责一个单元概念的执行和验证,只有一个断言。

9.5 单测的 FIRST 原则

PS:
在我上家公司,单测的推进和保证是 RD 非常头疼的一件事情。部门提出强制要求,保证代码覆盖率在 60 以上,导致大家为了覆盖率而编写单测。一旦为了覆盖率而编写单测,单测就会变成纯粹的负担:既无法保证安全代码的上线,又占用 RD 的开发时间。

单测不应成为负担,它理应是这样的:在你代码上线之前,执行一边单测以后,你就有底气了,上线不怂了。
同时单测也能反哺你代码设计如何。如果你的方法好几百行,要编写单测非常复杂,而且执行的 case 非常多,那就说明你的方法需要进行拆解多层,每层进行独立的单测。

十、类

10.1 单一权责原则

系统应该由许多段晓得嘞而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并且与少数其他类一起写协同达成期望的系统行为。

10.2 内聚

方法操作的变量越多,就越粘聚到类上。如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。

保持内聚性,就会得到许多短小的类:
有一个有许多变量的大函数,我们需要把这个函数拆解成多个小函数。拆解这个过程中,拆解后与拆解前的大函数之间,多个参数传递可能没有得到什么改变。
比如原来的大函数buildStudent的参数列表中有5个参数,拆解后buildStudent变成了buildStudent和buildStudent2,他们可能有的也需要这四5个参数。

/**
 * 学生
 */
public class Student {

    private String name;
    private Integer age;
    private String country;
    private String province;
    private String street;

    public Student(String name, Integer age, String country, String province, String street) {
        this.name = name;
        this.age = age;
        this.country = country;
        this.province = province;
        this.street = street;
    }
}

此时我们不需要将方法 buildStudent 的所有参数都传递下去,我们可以将参数上升为类的成员变量,这样就无需传递任何变量,就能拆解代码了。
但是这样做也丧失了类的内聚性,因为随着大函数的拆解,变量上升为实体变量,会堆积越来越多“只为允许少量函数共享而存在的实体变量”。
此时,我们可以将这些“只为允许少量函数共享而存在的实体变量”和“使用他们的函数”(buildStudentAddr() 和 country, province, street)从原来的类中拆解出来,构成一个小类(Address)。
此处将地址信息拆解出来。

/**
 * 地址
 */
public class Address {

    private String country;
    private String province;
    private String street;

    public Address(String country, String province, String street) {
        this.country = country;
        this.province = province;
        this.street = street;
    }
}
/**
 * 学生
 */
public class Student {

    private String name;
    private Integer age;
    private Address address;

    public Student(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public Student(String country, String province, String street) {
        this.address = new Address(country, province, street);
    }
}

将大函数拆解为许多小函数,往往也是将类拆分成多个小类的时机。
ps:举的例子不是特别贴切,仅仅是为了表达描述的意思。

上一篇下一篇

猜你喜欢

热点阅读