前段时间在B站偶然发现了一个关于讲Clean Code的课程,非常不错,对我自己很受用。所以针对课程的内容,同时结合自己的一些经验,总结了一些关于Clean Code的内容。作者视频中使用的语言是Javascript/Typescript,代码示例比较容易,而且Clean Code很多理念是语言无关的,大家可以放心观看,课程链接CleanCode,感谢UP的资源。
关于Clean Code
Clean Code 是什么?
Clean Code通常具备以下一些特点
- 可读的、有意义的、理解成本低的
- 精确的,不会存在歧义
- 避免不直观的命名、复杂的嵌套、超大的代码块
- 遵循最佳的实践和设计原则
为什么要强调Clean Code
而对于Clean Code的重要性,也有几个比较重要的点
- 首先,Clean Code更关于代码的可读性,因为大量的时间会耗费在读懂代码
- 其次,一个代码库能够持久存活,代码可读性是关键。
有时候业务压力等多种因素的影响下,我们会写一些Quick Code。这种方式在短期的产出会比较高,但是随着时间发展,越来越难以维护,也就会越来越影响产出。下面这张图也就描述了这种情况,横轴是时间,纵轴是产出情况。
Clean Code Vs Pattern&Principle Vs Clean Architecture
Clean Code
- 更侧重于如何写代码
- 更强调代码的可读性和可理解性
- 更着眼于单个问题和文件
Clean Architecture(整洁架构)
- 更侧重于在哪里写什么样的代码
- 更着眼于整个项目
Pattern&Principle(设计模式和设计原则)
- 更强调代码可维护性和可扩展性
如何编写Clean Code
关于如何编写CleanCode,这里主要有以下几个方面,命名、注释和格式化、函数、流程控制、类和对象。
命名
每个命名都应该是有意义的,一个好的命名甚至可以省去读代码的人很多时间,因为不必进入到内部,就能知道含义。
如何正确命名
对于我们通常需要命名的内容,一个大的前提就是在没有冗余信息的情况下提供尽可能多的描述信息。
通常可以做以下划分
- 变量和常量:通常是一些数据容器,命名应该是名词或者是带有修饰词的短语
- 函数/方法:通常是一些需要执行的命令或者是计算结果,命名应该是动词或者是带有修饰词的短语
- 类:通常用于创建一些对象,命名应该是名词或者是名词短语
变量、常量和属性命名
这里的命名需要能够描述这个值,对Boolean类型来说,是需要回答一些true/flase问题的。
变量命名举例
变量 | Bad | OK | GOOD |
---|---|---|---|
用户对象(包含年龄、姓名等) | u/data | userData/person | user/customer |
过于宽泛,可以指任何事情 | userData略有冗余,person不够具体 | user可以描述信息;customer非常具体 | |
针对用户输入的校验结果 | v/val | correct/validatedInput | isCorrect/isValid |
过于宽泛 | 没有描述true/false结果 | 描述true/false结果 |
函数或方法命名
函数的命名需要描述该函数执行的操作,对于Boolean类型来说,需要描述它要回答的问题。
函数命名举例
函数作用 | Bad | OK | GOOD |
---|---|---|---|
将用户数据存储到数据库 | process(..)/handle(..) | save(..)/storeDate(..) | saveUser(..)/user.save() |
不够具体,没有指明是什么处理 | 能够知道函数的作用是存储,但不确定被处理对象 | 非常清晰,而且user.save特别明确 | |
针对用户输入的校验结果 | process(..)/handle(..) | validateSave()/check(..) | validate(..)/isValid(..) |
不够具体,没有指明是什么处理 | 没有描述true/false结果 | 描述true/false结果 |
类命名
类的命名需要准确描述这个对象
对象命名举例
对象 | Bad | OK | GOOD |
---|---|---|---|
一个对象 | UEntity/ObjA | UserObj | User/Admin |
过于宽泛 | 略有冗余 | user很好,Admin某些业务场景下很合适 | |
一个数据库 | Data/DataStorage | Db | Database/SQLDatabase |
无法通过名称获知这是一个数据库 | 还可以 | Database很好,如果是支持SQL的数据库,SQLDatabase也很好 |
实践经验
- 要在名称中包含一些冗余的信息,避免不必要的描述词,提供准确的描述
Not Good:
@Data
public class User {
private String userName;
private int userAge;
private String userAddress;
}
该示例中,每个属性中的user前缀是有些多余的,可以采用下面的形式。
Better:
@Data
public class User {
private String name;
private int age;
private String address;
}
- 避免不清晰的或者不通用的缩略词,甚至一些错误的描述
Not Good:
String ymdt = "2021-12-14T10:17:18.391+0800";
// allResults是错误的描述,因为这里是对数据集进行了过滤
List<String> allResults = input.stream().filter(value -> value.startsWith("clean")).collect(Collectors.toList());
Better:
String dateWithTimezone = "2021-12-14T10:17:18.391+0800";
// 重新命名
List<String> filteredStartWithClean =
input.stream().filter(value -> value.startsWith("clean")).collect(Collectors.toList());
- 尽可能选取有区分度的名称
Not Good:
analysis.getDailyData(day);
analysis.getDailyData();
Better:
analysis.getDailyData(day);
analysis.getDataForToday();
- 在整个项目中,对于同个事情的命名要保持一致
比如这里,都是查询数据,query/fetch/get应该保持一致。
Not Good:
public List<User> queryUserList();
public List<Account> fetchAccountList();
public List<Address> getAddressList();
Better:
public List<User> queryUserList();
public List<Account> queryAccountList();
public List<Address> queryAddressList();
注释
关于注释,作者特别强硬的指出,除了一些法律合规、警告、必要的解释描述信息,其他的注释都应该尽可能避免。
不是很好的注释
冗余的注释
public int sum(int ... args) {
// 初始化为0
int sum = 0;
for (int arg : args) {
// 循环累加
sum = sum + arg;
}
return sum;
}
这里的注释会增加代码的行数,但是对于提高可读性并没有很大的帮助
容易误导的注释
有一些注释的更新不及时,很多时候更新了代码,却没有同步更新注释,容易误导读代码的人。
一些被注释掉的代码
现在的版本管理工具已经非常成熟,如果某段代码确实不需要了,可以直接删除,大面积被注释掉的代码确实没有必要。
那什么样子的注释是比较合理的呢?
推荐的注释
法律合规类注释
这里主要是一些licence
/*
* Copyright 2002-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
一些必要的解释
比如正则表达式的可读性不好,我们一方面可以通过命名增强可读性,还可以增加一些注释说明。
// 身份证的正则表达式
String isIDCard=/^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$/;
还有我们的版本,作者,发布时间等,此类通常是作为类的注释出现。
* @author Kazuki Shimizu
* @author Sam Brannen
* @since 3.0
TODO Notes
对于TODO类型的注释,我们需要明确是谁添加的,将要做什么。而且这个TODO是真的要做的,这里作者的话还是比较经典的,You should write code, not leave code。看到这里,马上去代码里面看看自己的TODO项。
// TODO add yichao.jiang 以下代码临时保留,便于后续整体迁移时使用
一些文档的地址
比如我们引用的算法文档,或者我们API发布的文档地址等。
* @see <a href="https://tools.ietf.org/html/rfc7231#section-3.1.1.1">
* HTTP 1.1: Semantics and Content, section 3.1.1.1</a>
代码格式化
好的格式化对于提高代码可读性来说非常重要,但是对于不同的语言,格式化标准不太一样。比如有的语言是Tab,有的是Space。再比如有的花括号{在句尾,有的必须重新开一行。这里网上有些模板校验,比如像Java就有很多。
Google Java Style
阿里巴巴Java开发手册
垂直格式化
-
代码之间适时增加空格,可以提升可读性
filename = UrlPathHelper.defaultInstance.decodeRequestString(servletRequest, filename); String ext = StringUtils.getFilenameExtension(filename); pathParams = UrlPathHelper.defaultInstance.decodeRequestString(servletRequest, pathParams); String extInPathParams = StringUtils.getFilenameExtension(pathParams);
代码块的长度,一般的推荐做法是代码的长度不要超过一屏,即不需要纵向滚屏就可以看到方法的全貌。
-
从上到下读取代码不存在过多的跳跃,相关的代码要尽可能放到一起
private boolean safeExtension(HttpServletRequest request, @Nullable String extension) { /***省略部分代码***/ // resolveMediaType函数和safeMediaType函数会距离该函数很近,方便阅读 MediaType mediaType = resolveMediaType(request, extension); return (mediaType != null && (safeMediaType(mediaType))); } @Nullable private MediaType resolveMediaType(ServletRequest request, String extension) { /**省略部分代码**/ } private boolean safeMediaType(MediaType mediaType) { /**省略部分代码**/ }
水平格式化
避免长句子,如可以进行一些变量提取,把长句子分隔为多个短句子
-
避免变量特别冗长的命名
Not Good:
MyKeyExpirationEventMessageListener myKeyExpirationEventMessageListener = new MyKeyExpirationEventMessageListener(container);
Better:
MyKeyExpirationEventMessageListener listener = new MyKeyExpirationEventMessageListener(container);
-
同一行中也可增加一些空格
String pass = httpServletRequest.getParameter("pass");
注意单行的长度,同样可以以滚动条横向滚动为准。
注意缩进
函数
组成部分
- 参数:针对方法调用者来说,对于它们而言,方法参数的个数、类型、顺序,返回值等都应该是易于理解的。
- 函数体:相比较而言,函数体更偏向方法提供者,需要控制方法体的长度,便于后续的维护和阅读。
首先是参数部分
最小化入参个数
过多的参数,对于调用者来说会非常有难度,该传什么值,顺序是什么样子。
参数个数 | 说明 | 示例 |
---|---|---|
0 | 最好,容易理解和调用 | System.currentTimeMillis(); |
1 | 非常好, 比较容易理解和调用 | String.valueOf(10); |
2 | 调用时就需要参数顺序和类型 | Point(10,20);// 常识情况下第一个参数是X,第二个参数是y |
3 | 会增加调用难度,需要借助IDE和源码获取参数含义,可以借助对象作为参数 | calculate(5, 10, "add"); // 前两个参数是参与运算的数,第三个是运算类型,如果是除法,谁是被除数呢,容易用错 |
多于3个 | 难度增加,适当情况下,可以借助对象作为参数 | Coordinate(10,20,30,40)// 如果调用该构造函数,容易赋值错误 |
这里有一个特例,就是某些语言中的可变参数,特定情况下可变参数是可以提升可读性的。
// 计算一堆数字的累加和
public int sum(int ... args) {
int sum = 0;
for (int arg : args) {
sum = sum + arg;
}
return sum;
}
避免output参数,尤其是意料之外的output参数
output参数是指,我们对于输入的参数会进行一些修改。作者不推荐对入参进行修改,但是某些业务场景下,不可避免的会有需要修改的情况,这种情况下,需要通过函数命名提醒调用者函数会对入参进行怎样的修改。
在该函数中,对输入参数进行了修改,但是我们没有办法直接通过函数名知道具体的参数是什么。
public void iterate(List<Person> personList) {
personList.forEach(person -> {
String name = person.getName();
person.setName(name.toUpperCase());
});
}
public void personNameToUpperCase(List<Person> personList) {
personList.forEach(person -> {
String name = person.getName();
person.setName(name.toUpperCase());
});
}
而对于函数体,同样有一些建议
尽可能小且仅完成一件事情
函数的职责应该单一且明确
不要将抽象层级不同的代码放置到一起
这里作者介绍了一个抽象层级的概念,还是非常有帮助意义的。
High Level:
这里我们知道email会被校验,它不会控制email是如何被校验的
isEmail(String email)
Low Level:
需要明确控制email是如何被校验的
null != email && email.contains("@")
相比较而言,High Level的抽象更容易理解,而Low Level的代码是需要一些额外解释工作的。当然我们代码中一定会包含HighLevel和LowLevel的抽象,但是最好的做法是不要在一个方法中同时包含两种抽象。
Not Good:
public void saveUser(User user) {
// 低级别的抽象代码
if (null == user.getEmail() && !user.getEmail().contains("@")) {
throw new IllegalArgumentException("email must contains @");
}
// 高级别的抽象代码
mapper.saveUser(user);
}
Better:
public void saveUser(User user) {
validateUser(user);
mapper.saveUser(user);
}
private void validateUser(User user) {
if (null == user) {
throw new IllegalArgumentException("user is null");
}
String email = user.getEmail();
if (null != email && email.contains("@")) {
return;
}
throw new IllegalArgumentException("email must contains @");
}
合理拆分
- 如果一些代码的作用比较类似,考虑将其提取为一个函数
- 避免过度拆分,当你发现拆分完之后,发现很难给新的函数起一个名字,或者拆分后发现并没有提升可读性,那就先保持不动
DRY(Do not repeat yourself)
- 如果发现存在复制粘贴的情况,大概率存在重复代码,可以考虑将代码提取复用。
- 如果发现当要修改某个功能时,需有多处地方需要做同样的修改,这个时候也可能存在重复代码,可以考虑提取复用。
避免函数产生意外的副作用影响
对于同样的输入,应该总会得到一样的输出。
所谓副作用: 即函数除了对输入参数执行操作,也会产生一些其他操作,会影响整个系统的状态,比如每次用户登录成功,都会开启分布式session。对于某些副作用,我们是有实际需求的,这里强调的是不要产生unexpected的副作用,我们可以通过函数命名让调用者意识到该方法可能存在其他作用。
易测试
相比于冗长的方法,没有副作用且短小的方法更容易被测试,我们可以通过给函数写测试,来判断是否需要对函数进行拆分。
流程控制
避免过深的代码嵌套,对于代码嵌套特别深的代码,学到了一个名字,飞机代码,真的好像一个飞机。
这里嵌套比较深的主要是指if-else语句,对于这里的解决主要有以下一些建议。
优先正向判断
对于判断语句,更倾向于选择正向的,因为正向的比较符合大众的认知。
Not Good:
public void dummyCode(User user) {
boolean isRich = isRich(user);
if (!isRich) {
processPoorUser(user);
}
}
public boolean isRich(User user) {
return user.totalMoney > 1000;
}
Better:
public void dummyCode(User user) {
boolean isPoor = isPoor(user);
if (isPoor) {
processPoorUser(user);
}
}
public boolean isPoor(User user) {
return user.totalMoney <= 1000;
}
合理应用守护和快速失败
Not Good:
public void dummyProcess(List<String> data) {
if (null != data && data.size() > 0) {
for (String tmpData : data) {
System.out.println(tmpData);
}
}
}
Better:
public void dummyProcess(List<String> data) {
if (null == data || data.size() == 0) {
return;
}
for (String tmpData : data) {
System.out.println(tmpData);
}
}
合理利用错误处理
Not Good:
private boolean isValidUser(User user) {
if (StringUtils.isEmpty(user.getName())) {
return false;
}
if (StringUtils.isEmpty(user.getAddress())) {
return false;
}
return true;
}
// 调用执行
public void dummyExecute() {
User user = new User();
boolean isValidUser = isValidUser(user);
if (isValidUser) {
// process user
}
}
Better:
private void validateUser(User user) {
if (StringUtils.isEmpty(user.getName())) {
throw new IllegalArgumentException("name is mandatory");
}
if (StringUtils.isEmpty(user.getAddress())) {
throw new IllegalArgumentException("address is mandatory");
}
}
// 调用执行
public void dummyExecute() {
try {
User user = new User();
validateUser(user);
// process user
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
合理利用多态
这里通常会是一个工厂模式+策略模式,推荐一个Spring下常用的工厂+策略模式示例
合理利用某些语言中的默认值
这里以Python代码为例
Not Good:
def log(msg, item):
print(msg);
if item is not null:
print(item);
Better:
def log(msg, item = {}):
print(msg);
print(item);
类和对象
Short & Small
应该让类满足SRP,即单一职责原则
内聚
最高级别的内聚:所有方法都用到了类所有的参数,想要做到这一点非常难。
最低级别的内聚:所有方法没有用到类的任何属性
迪米特法则
最小知识原则,不要依赖那些间接可以访问到的对象。
Demeter法则建议,在一个函数内部,可以直接访问的内部属性和方法有以下几种
- 持有该方法的对象
- 持有该方法的对象内部的其他属性
- 函数入参的对象
- 函数内部创建的对象
假定现在有一个类DeliveryJob,它拥有一个Customer对象和一个WareHouse对象,而Customer对象持有一个最后购买的对象lastPurchase,WareHouse对象负责按照日期进行派单。
@Data
public class DeliveryJob {
private Customer customer;
private WareHouse wareHouse;
}
@Data
public class Customer {
private Purchase lastPurchase;
}
@Data
public class Purchase {
private Date date;
}
Version1 - Not Good:
public class WareHouse {
// 按照日期派送
public void deliveryPurchaseByDate(Customer customer, Date date) {
}
}
@Data
public class DeliveryJob {
private Customer customer;
private WareHouse wareHouse;
public void deliveryLastPurchaseV1() {
// 间接访问了lastPurchase的date属性,违反了Demeter法则
Date lastPurchaseDate = this.customer.getLastPurchase().getDate();
wareHouse.deliveryPurchaseByDate(this.customer, lastPurchaseDate);
}
}
Version2-Not Bad
@Data
public class Customer {
private Purchase lastPurchase;
// 提供获取最后派单日期的方法
public Date getLastPurchaseDate() {
return lastPurchase.getDate();
}
}
@Data
public class DeliveryJob {
private Customer customer;
private WareHouse wareHouse;
public void deliveryLastPurchaseV2() {
// 访问customer的方法,满足Demeter法则
Date lastPurchaseDate = this.customer.getLastPurchaseDate();
wareHouse.deliveryPurchaseByDate(this.customer, lastPurchaseDate);
}
Version3 - Good
public class WareHouse {
// 按照订单派送,如果需要订单的其他属性,可以自己获取
// Do tell, not ask
public void deliveryPurchase(Purchase purchase) {
}
}
@Data
public class DeliveryJob {
private Customer customer;
private WareHouse wareHouse;
public void deliveryLastPurchaseV3() {
// 满足Demeter法则
Purchase lastPurchase = this.customer.getLastPurchase();
wareHouse.deliveryPurchase(lastPurchase);
}
}
SOLID
设计原则对于编写Clean Code是有一定帮助的,作者特别强调了S和O对于Clean Code的重要性。
单一职责(Single Responsibilty Principle)
说明:不要因为多个原因对类进行修改,这里的单一职责并非意味着只有一个方法,而是同一个业务领域的,这条原则有助于保证类可以专注于提供一类职责。
开闭原则(Open and Close Principle)
说明:面向扩展开放,面向修改关闭,这个原则有助于保证类的small,因为我们需要扩展出新的类,而不是对原有类上进行修改。
里氏替换原则(Liskvo substitution Principle)
说明:对象可以被他们的子类所替换,而且这种替换不会改变类的行为,也就是子类对象即是父类对象。这条原则强制子类必须满足一定的约束,不会改变父类的行为。
接口隔离原则(Interface Segregate Principle)
说明:相比于提供宽泛的、复合的接口,面向特定client的、小的接口反而更好,因为某些情况下,client并不需要那么多的接口。
依赖倒转原则(Dependency Inverson)
说明:依赖抽象,而不是依赖具体,避免依赖变动时,必须同步变动。
总结
对于CleanCode,该视频以及本文章都是结合自己遇到的问题总结的。而在实际业务中,每个人遇到的情况不尽相同,还需要根据自己的实际情况进行优化。
命名
- 描述性的名字
- 变量和属性采用名词或者带有修饰词的短语;类采用名字;方法采用动词或者带有修饰词的短语
- 尽可能具体,避免冗余信息
- 避免利于、不通用的缩略词
- 保持一致的命名
注释和格式化
- 除了特定场景下的注释(法律合规、警告等)应避免冗余注释
- 垂直格式化和水平格式化
- 遵循一些语言特定的指导规范
函数
- 控制函数的参数
- keep function small & do only one thing
- 不要在一个函数中融合多个抽象层级的代码
- DRY & 避免未知的副作用
流程控制
- 优先正向判断
- 合理应用守护和快速失败
- 合理利用error和exception
- 利用面向对象的多态特性
类和对象
- class应该是small的,聚焦在单一职责
- 遵循Demeter法则
- 遵循SOLID原则,尤其是SRP和OCP原则
除了以上的这些,还有一些建议
- 尽管我们强调SRP,但并非意味着要将类拆分成很小,一定要避免粒度过细的拆分。
- 对于设计模式的引入,一定要有实际的收益,否则的话很有可能增加代码复杂度,降低可读性。
- 优化和重构一定要进行,但是切记不要过早优化,毕竟过早优化是一切问题的源泉。此外,如果进行重构,可以考虑将重构的代码和正在开发的功能代码做一定的隔离,比如通过不同的commit提交,这样对CR或者后续Revert都有帮助。
- 尽管我们提到面向抽象编程,而不是面向具体编程。但是当功能非常独立,而且并不会有多种实现的情况下,可以酌情考虑将接口省略。