微信公众号:牛奶 Yoka 的小屋
有任何问题。欢迎来撩~
最近更新:2024/07/09
[大家好,我是牛奶。]
当年面试时背了很多八股文,但在日渐重复的机械工作中(产品业务开发),计算机网络、操作系统、算法等很多晦涩难懂的基础知识已在脑海日渐模糊,每天打开 IDEA,思考的永远只有两件事:
- 新需求的代码怎么写?
- 新 BUG 单在代码上怎么改?
这是程序员在工作中最核心的两件事。所以就想聊聊这两件事所带来的两个问题:
- 怎样写好代码?
- 怎样重构代码让 BUG 更少且性能更好?
最近闲来无事读了《代码整洁之道》和《重构》,刚好这两本书,一个提供规范,一个优化代码,所以这篇文章,我分享三个看似简单,实则陷阱重重甚至很多反直觉的代码规范和重构技巧,这也是我在日常工作中最常用的三个规范(如下图),愿对诸君有所帮助。
代码命名的学问
代码命名规范
命名的学问可远比我想象的大的多。最初以为命名不好无非不方便理解,多添加注释即可,现在想来太天真,命名不仅可能影响代码阅读,还可能影响代码优化甚至代码运行。下面介绍几个比较经典的命名问题,talk is cheap,上代码:
public class ProgrammerSalaryCalculator {
public static void main(String[] args) {
int age = 30;
boolean isReadCleanCodeBook = true;
String p = "架构师";
double salary = salary(age, isReadCleanCodeBook, p);
}
// 计算程序员薪水。
// 规则如下:如果是25-35岁的程序员,且读过《代码整洁之道》和《重构》这两本书,平均月薪2万元;
//如果是35-45岁程序员,如果岗位是架构师或管理,则薪资根据公司-薪资表获取。
public static double salary(int age, boolean isReadCleanCodeBook, String p) {
int result = 0;
if (age >= 25 && age < 35&& isReadCleanCodeBook){
return 20000;
} else if (age >= 35 && age <= 45) {
if ("架构师".equals(p) || "管理".equals(p)) {
result = salaryDAO.getSalaryInfo(age);
return result;
}
} else {
return 0;
}
}
}
这段代码有 5 处问题,诸位共发现几处?我们来一一识别一下:
1、**方法名称使用动词+名词表示。**有动词有名词,才能完整表示要做的事。比如只写名词basketball,很难根据名字判断出该方法是买篮球还是打篮球,但是命名为playBasketball便一目了然。『上述计算程序员薪水的方法命名,应优化为 calculateSalary。』
2、**变量名称用含义明确的名词。**不要使用意义不明的一个字母作为变量;不要使用不通用的缩写作为变量;也不要使用数字结尾的无效命名作为变量;这一类的命名很难向读者表达这些变量的意图。『上述的p变量命名,应优化为position表示岗位的含义。』
3、**布尔类型的变量都不要加is。**这条出自阿里的《Java开发手册》。核心原因是,一些用于远程调用的RPC框架,在对从远程过来的对象进行反序列化解析时,会将is开头的布尔类型属性字段解析成不带is的属性字段,因为如fastJson、jackson这些工具反序列化的机制是根据字段的get方法名获取,但在JavaBean规范中,如isSuccess和success两个属性的get方法都是isSuccess(),因此解析就会出错,这就是我上文所说命名影响代码运行。『上述表示是否读过整洁代码书籍的变量命名,应优化为ReadCleanCodeBook。』
4、**区分不同层的增删改查命名。**基础设施层对数据库操作的增删改查,现在基本公认使用insertXxx、deleteXxx、updateXxx、selectXxx的方法命名;为了区分基础设施层,接口层、应用层、领域层一般使用createXxx,removeXxx,modifyXxx,getXxByYy的方式进行方法命名。当然命名根据个人喜好,只要容易加以区分即可。『上述根据公司-薪资表查询薪资的DAO层方法命名可优化为selectSalaryInfoByAge。』
5、最后一点非命名问题,却和命名有关。Bob大叔在书里说:
“每次写注释,你都该做个鬼脸,感受自己在表达能力上的失败。”——Robert C. Martin
Bob大叔强调,**能用代码表示意图的地方就不要用注释。**注释越多,反而表明你代码的表达能力越弱。他倒是不排斥长命名,书里的很多案例的命名都挺长,看他的代码,你能明显感觉到为了让读者能看懂代码,各种命名真是煞费苦心,哈哈。注释还有一个弊端在于,我们更新大量代码逻辑后,往往不会同步更新注释,当较多的注释和代码逻辑不一致时,就会给后续代码理解造成误解。当然,有些注释不能删,比如版权注释,比如一些实在晦涩难懂的逻辑等。
除以上几个关键的命名问题,还有包名小写,类名首字母大写;在接口类前面不要加一个I前缀,因为它是C# 的习惯(这个有些反直觉);集合类变命名展示出key和value的集合含义等。这些相对容易理解,这里就不展开分析了。
代码命名优化
修改变量名需要全局修改,修改前先使用快捷键 crtl+shift+F10 跑一下测试用例,确保功能正常。shift+F6 全局修改命名,修改完成后再次跑一下测试用例,确保优化无误。优化后正确代码如下:
public class ProgrammerSalaryCalculator {
public static void main(String[] args) {
int age = 30;
boolean readCleanCodeBook = true;
String position = "架构师";
double salary = calculateSalary(age, readCleanCodeBook, position);
}
public static double calculateSalary(int age, boolean isReadCleanCodeBook, String position) {
int result;
if (age >= 25 && age < 35&&isReadCleanCodeBook){
return 20000;
} else if (age >= 35 && age <= 45) {
if ("架构师".equals(position) || "管理".equals(position)) {
return salaryDAO.SelectSalaryInfo(age);
}
} else {
return 0;
}
}
}
代码重复
“在简单代码规则中,我最在意代码重复。如果同一段代码反复出现,就表示某种想法未在代码中得到良好的体现。”——Robert C. Martin
代码重复是大佬最重视的一点。像我这种菜鸟,最开始以为重复代码可以一眼识别,IDEA也有辅助工具提示代码重复,没有多重要,too young too simple。诸君且看,重复代码共分为三类:
- 同一类中的代码重复
- 互为兄弟的子类间代码重复
- 不同类间的代码重复
我们常见第一类,但第二三类重复也同样重要,且较为隐蔽。由于我们很多系统开发都是多人协作。很难避免不会出现不同类间存在重复代码。所以,这里针对三类重复代码提供一些优化方式:
同一类中的代码重复
这类处理最简单。直接将两个方法合并为一个方法。IDEA提供了快捷键的方式,**选中重复代码块,按下 Crtl+Alt+M,IDEA就会自动识别重复代码块并抽取公共方法,之后给这个方法命名即可。**当然,在修改代码前后,别忘了 crtl+shift+F10 跑一下测试用例,确保重构前后代码逻辑没有发生实质变化。具体操作过程如下:
具体视频地址
互为兄弟的子类间代码重复
两个兄弟类中代码重复,我们第一时间就是想办法把重复代码提到父类中。怎么提呢?
-
先利用Crtl+Alt+M快捷键把重复代码抽取为方法。
-
使用快捷键Crtl+Alt+Shift+T->Pull Members up(有些版本IDEA需要去Refactor中去找Pull Members up)将重复方法上移父类。
-
将另一个类中重复代码块替换为父类对应方法。
同理,上述每一步对代码的改动都需要crtl+shift+F10 运行相应测试用例,保证代码逻辑无改动。
具体演示如下,该案例直接将两个方法合并至父类:
具体视频地址
不同类间的代码重复
这种最复杂,不过和上一类重复大差不差。只不过将重复方法提取到新类中:
-
先利用Crtl+Alt+M快捷键把重复代码抽取为方法。
-
再通过Crtl+Alt+Shift+T->Extract Delegate抽取为新类+方法
-
将另一个类中重复代码块替换为新类对应方法。
具体操作如下:
具体视频地址
最后,并非所有的重复都需要消除。多微服务间的数据模型、模型转换、client连接、以及完全不相干业务中极少量的逻辑业务重复等,不需要消除重复。过度的消除重复可能会导致代码太过耦合,是否需要消除重复还是需要视具体的业务场景来定。
视频中所使用的快捷键如下:
快捷键 | 含义 |
---|---|
crtl+shift+F10 | 跑该类对应的测试用例 |
Crtl+Alt+M | 重复代码抽取公共方法 |
Crtl+Alt+Shift+T->Pull Members up | 将方法上移到父类中 |
Crtl+Alt+Shift+T->Extract Delegate | 将方法提取到一个新/旧类中 |
视频中对应的代码:
public class Main {
public static void main(String[] args) {
calculateChenGuangMarketPrice("apple", 1);
calculateDaShanMarketPrice("apple", 1);
System.out.println("Hello world!");
}
public static Double calculateChenGuangMarketPrice(String type, Integer number) {
Double prices;
switch(type) {
case "Orange":
prices = 3.0;
break;
case "apple":
prices = 5.0;
break;
default:
throw new IllegalArgumentException("error type:" + type);
}
return prices * number;
}
public static Double calculateDaShanMarketPrice(String type, Integer number) {
Double prices;
switch(type) {
case "Orange":
prices = 3.0;
break;
case "apple":
prices = 5.0;
break;
default:
throw new IllegalArgumentException("error type:" + type);
}
return prices * number;
}
}
测试代码:
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
public class MarketTest {
@InjectMocks
private Market market;
@Test
public void calculateChenGuangMarketPriceTest() {
String type = "apple";
Integer number = 2;
Double expected = 10.0;
Assert.assertEquals(expected, market.calculateChenGuangMarketPrice(type, number));
}
@Test
public void calculateDaShanMarketPriceTest() {
String type = "apple";
Integer number = 2;
Double expected = 10.0;
Assert.assertEquals(expected, market.calculateDaShanMarketPrice(type, number));
}
}
“消除重复和提高表达力让我在整洁代码方面获益良多,只要铭记这两点,改进脏代码时就会大有不同。”——Robert C. Martin
合格的方法
类中的方法编码时,针对入参、方法实现、出参,有三个在实际开发中常用的规范需要注意:
- 方法入参不要超过5个
- 方法行数不超过50行
- 方法出参不要返回null
针对每一点,咱们都展开来说说。
方法入参优化
方法入参不要超过5个。超过5个我们就要想办法简化。我们常见的优化手法一般是参数合并法,比如针对多个入参是从同一数据结构中抽取,直接传递该数据结构作为入参;如果几项关联参数总是同时出现,将这几个关联参数整合为对象参数传递;在《重构》一书中提出的另外三种优化建议,我觉更应该引起重视:
- 移除标记参数
- 以查询取代参数
- 函数组合成类
移除标记参数
对于标记参数,我真是深受其害,我曾在实际开发过程中看到两行代码,调用了一个方法四次进行条件判断,这个方法的后两个参数是布尔类型,调四次就是对这两个布尔类型入参的四种组合。每次看到这里,我都要去思考下这四种入参分别是执行方法中的哪一个逻辑,阅读非常吃力。在《重构》一书第11章第3节强调:
“我不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。拿到一份 API 以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义——在调用一个函数时,我很难弄清 true 到底是什么意思。如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。”——Martin Fowler
这里的标记参数具体是指调用者用它来指示被调函数应该执行哪一部分逻辑的参数。并非只有布尔类型是标记参数,举个非布尔类型标记参数案例:
public Integer setDimension (String name, String value) {
int res = 0;
if (Objects.equals(name, "height")) {
this.height = value;
res = 1;
return res;
}
if (Objects.equals(name, "width")) {
this.width = value;
res = 2;
return res;
}
return res;
}
优化后为:
public Integer setHeight (String value) {
this.height = value;
return 1;
}
public Integer setwidth (String value) {
this.width = value;
return 2;
}
一个方法中,存在多个处理逻辑,本身就违背单一职责原则(SRP),所以针对标记参数,强烈建议使用正确的命名拆分该方法。
以查询取代参数
我们写的接口,除了给前端提供,很多时候需要对外部系统提供,这个时候,如果一个参数我们可以自己获取,就不要再让外部来传参了。不然联调接口的时候会出现很多麻烦事。调用方可能参数传错,可能参数漏传,可能要求非必填等等。重构的作者也强调:
“如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复。这个本不必要的参数会增加调用者的难度,因为它不得不找出正确的参数值,其实原本调用者是不需要费这个力气的。
”——Martin Fowler
函数组合成类
这一点不是很好理解。看下图,两个方法,转变为类中的两个方法。同时,这两个方法的参数没了。我觉得这个重构手段更名为**“参数消失术”**还更好理解点。
这样做的原因在于,两个方法参数相同,且这两个方法总是和其参数同时出现,我们便将关联性较强的功能和数据放到一个类中。而类本身的定义为:**具有相同特性(数据元素)和行为(功能)的对象的抽象就是类。**因此,我们不过就是从参数的角度,对数据和行为做了一次抽象。《重构》的作者认为:
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。——Martin Fowler
方法内容优化
我第一次优化代码,做的第一件事,便是把我写的好几处代码提取为各自的方法。在这之前,我本能的认为,一个方法里面调用好多方法,是一种不规范的行为。没想到不论是Robert C. Martin还是Martin Fowler大佬,都非常支持小函数。他们指出,**活的最长、最好的程序,其中的函数都比较短。**看到这句话,彻底打破我之前的编码风格。不过,针对方法/函数的抽取也不能太过随意,应尽可能的将业务逻辑从上往下依次抽象出方法。用比如打印发票举个例子,我绘制了一个打印发票的思维导图(如下图),在我看来,从左往右,每个分支便可抽象出一个方法,所有的业务逻辑都可以在思维导图中逐层拆解最后转换为包含一层层方法的代码:
另外,Martin Fowler也给出提取方法的时机:
每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。——Martin Fowler
当我们习惯不断递归的抽象出方法进行开发,那一般方法行数基本不会超过50行。
方法出参优化
我写代码时,最烦的一件事,便是对接口返回的数据进行判空处理。几乎任何一个接口返回,都需要进行判空。虽然很烦,又不得不做。不然你不知道什么情况下就会出现空指针异常。Bob大叔建议,如果你想返回null值,不如抛出异常,或者返回特例对象。举个例子:
List<Employee> employees = getEmployees();
if (employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}
像上述这种判空,如果getEmployees方法直接返回空集合Collections.emptyList(),则完全可以避免判空操作:
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
因此,出参为集合的接口返回,如果为空,建议一律返回 java.util.Collections.emptyList/emptyMap/emptyXXX等自带的空集合。
后记
编码的问题其实非常多,除这三个代码规范,还包括为提高代码健壮性对线程池、序列化/反序列化的规范;为防止OOM对接口返回大对象的规范,对循环调用RPC的规范;为保证数据库事务问题对事务注解的规范;为防止随意打印日志对日志框架和日志级别的规范等。
我这里指出的代码规范和重构技巧仅仅九牛一毛,想要写出好的代码,还需要多写多练,提高基本功,总结出自己的优秀编码习惯。
愿我们每位开发者不断提高编码能力,此生再无BUG!!!