理论与实践:如何写好一个方法
个人认为一个好的方法主要表现在可读性、可维护性、可复用性上,本文通过设计原则和代码规范两章来讲解如何提高方法的可读性、可维护性、可复用性。这些设计原则和代码规范更多的是表现一种思想,不仅仅可以用在方法上,也可以用在类上、模块上。
下面通过具体的例子来讲解。
一、设计原则
1、单一原则
单一职责解释是一个模块只负责完成一个职责或者功能,主要是提升方法的可维护性和复用性。下面看一个例子:
public boolean checkUsernameOrPassword(String str) { return str != null && str.length() > 5 && str.length() < 18; }
这是一个校验用户名和密码是否合法的方法,它们的实现逻辑一样,很多时候我们为了简单,就把他们合并成一个。随着业务的发展,校验密码需要在原有的逻辑上加上要包含大小写和数字,如果在原方法基础上,就会影响到校验用户名的逻辑,这是就需要拆分成两个单一的方法。如下:
public boolean checkPassword(String password) { boolean checkLen = password != null && password.length() > 5 && password.length() < 18; return checkLen && (校验大小写和数字); } public boolean checkUsername(String username) { return userName != null && userName.length() > 5 && userName.length() < 18; }
如果一开始就满足单一原则,只需放心要在“checkPassword” 修改就好了,完全不用担心影响其他业务逻辑。
为了举例,用了比较简单的方法,线上的业务逻辑要复杂许多,很难一眼就看出影响面。如果一个方法不满足单一原则,日后他人在修改这块代码时,很容易踩坑,导致线上故障。
2、KISS原则
Keep It Simple and Stupid, 解释是尽量保持代码简单简洁,主要提升方法的可读性和可维护性,那么如何理解简单呢?
代码简单就是代码行数少?
先来看下两种校验IP方法:
出处:设计模式之美 // 第一种使用正则 public boolean isValidIpAddressV1(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$"; return ipAddress.matches(regex); } // 第二种使用根据规则逐级判断 public boolean isValidIpAddressV2(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String[] ipUnits = StringUtils.split(ipAddress, '.'); if (ipUnits.length != 4) { return false; } for (int i = 0; i < 4; ++i) { int ipUnitIntValue; try { ipUnitIntValue = Integer.parseInt(ipUnits[i]); } catch (NumberFormatException e) { return false; } if (ipUnitIntValue < 0 || ipUnitIntValue > 255) { return false; } if (i == 0 && ipUnitIntValue == 0) { return false; } } return true; }
很明显虽然第一种方法虽然代码比第二种方法代码行数少了一倍还多,但是并不简单。从实现性、易读性和可维护性来说却比第二种方法难了一倍还多,所以代码行数少并不能代表代码简单。
代码复杂就违背了kiss原则?
上面提到,简单并不代表代码行数少,而是从实现性、易读性和可维护性来考虑。那么代码复杂就违背了KISS原则?
下面示例一段字符串搜索两种不同实现方式:
出处:知乎《KMP 算法详解》 // 暴力搜索 int search(String pat, String txt) { int M = pat.length(); int N = txt.length(); for (int i = 0; i <= N - M; i++) { int j; for (j = 0; j < M; j++) { if (pat[j] != txt[i+j]) break; } // pat 全都匹配了 if (j == M) return i; } // txt 中不存在 pat 子串 return -1; } // 使用KMP public class KMP { private int[][] dp; private String pat; public KMP(String pat) { this.pat = pat; int M = pat.length(); // dp[状态][字符] = 下个状态 dp = new int[M][256]; // base case dp[0][pat.charAt(0)] = 1; // 影子状态 X 初始为 0 int X = 0; // 构建状态转移图(稍改的更紧凑了) for (int j = 1; j < M; j++) { for (int c = 0; c < 256; c++) dp[j][c] = dp[X][c]; dp[j][pat.charAt(j)] = j + 1; // 更新影子状态 X = dp[X][pat.charAt(j)]; } } public int search(String pat ,String txt) { int M = pat.length(); int N = txt.length(); // pat 的初始态为 0 int j = 0; for (int i = 0; i < N; i++) { // 计算 pat 的下一个状态 j = dp[j][txt.charAt(i)]; // 到达终止态,返回结果 if (j == M) return i - M + 1; } // 没到达终止态,匹配失败 return -1; } }
很明显,KMP实现难度、可读性、可维护性都比第一种高。但是如果在特定的场景,比如处理大文本字符串出现性能瓶颈的时候,第二种能比第一种效率高N倍。而解决性能等困难的问题,本来就要用更复杂的方法,所以并不违背Kiss原则。
怎么才是满足KISS原则
1.不要过度“炫技”,如果KMP只是用来处理平时工作用到的小字符串,那么也是违背Kiss原则的。
2.非必要不要使用同事不懂的技术,比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。
很多刚入职的小伙伴、包括我在刚入职的时候,喜欢搞一些复杂的东西来“炫一炫”,放着工具类不用,自己写一堆复杂的实现,导致后面代码基本自由自己才能看得懂,还容易出Bug。其实在满足当前业务场景下,解决问题的方法越简单,反而更能体现一个人的代码能力,引用《重构》一句经典的话来说就是:"任何一个傻瓜都能写出计算机可以理解的程序,只有写出人类容易理解的程序才是优秀的程序员。"
3、DRY原则
Don’t Repeat Yourself ,解释是不要写重复的代码,表现在 实现逻辑重复、功能语义重复和代码执行重复 三个方面,主要提升方法的可复用性和可维护性。
下面详细说一下这三个方面。
实现逻辑重复
实现逻辑重复,本质是两个方法的代码基本一样,那代码一样那就一定违反DRY吗?
先看一个示例:
public boolean checkPassword(String password) { return password != null && password.length() > 6 && password.length() < 18; } public boolean checkUsername(String userName) { return userName != null && userName.length() > 6 && userName.length() < 18; }
上面两个方法去校验用户名和密码,它们实现逻辑相同,但是功能语义不一样,如果把它们合到一个方法 “checkUserNameOrPassWord”里面,一个方法做了两件事,违反了单一原则,日后随着业务发展,如果校验密码的规则改变了,那么就会影响到校验用户名逻辑,又需要重新拆分。
实现逻辑相同,功能语义完全不一样,并不违反DRY原则。
实现逻辑不一样就不违反DRY?
还是以校验IP为:
// 第一种使用正则 public boolean isValidIpAddressV1(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\." + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$"; return ipAddress.matches(regex); } // 第二种使用根据规则逐级判断 public boolean isValidIpAddressV2(String ipAddress) { if (StringUtils.isBlank(ipAddress)) return false; String[] ipUnits = StringUtils.split(ipAddress, '.'); if (ipUnits.length != 4) { return false; } for (int i = 0; i < 4; ++i) { int ipUnitIntValue; try { ipUnitIntValue = Integer.parseInt(ipUnits[i]); } catch (NumberFormatException e) { return false; } if (ipUnitIntValue < 0 || ipUnitIntValue > 255) { return false; } if (i == 0 && ipUnitIntValue == 0) { return false; } } return true; }
上面两个方法实现逻辑完全不一样,但是功能语义一样,他们的应用场景也基本相同。如果系统有些地方调了“isValidIpAddressV1”,有些地方调了 “isValidIpAddressV2”相当于给代码“埋坑”。
1.扰乱视线,增加理解成本:如果两个功能一样,但是实现逻辑不一样代码,会让人觉得是不是有更高深的考量,才定义了两个功能类似的函数,如果代码开发者在还能回答你是代码设计问题,不然可能又是一处历史谜题,也不敢轻易改动。
2.增加维护成本:如果到时候需要改变校验IP的逻辑,那么很可能改了一个忘记改了另一个,导致系统出现故障。
实现逻辑不一样,但是功能语义完全一样,也是违反DRY原则的。
代码执行重复
主要表现为一次调用,多次执行目的相同的代码。
下面看个例子:
public class UserService{ @Autowired private UserManager userManager; public ServiceResult<User> getUserById(long userId){ boolean existed = userManager.checkUserExisted(userId); if(!existed){ ServiceResult.getFailureResult("code","msg"); } User user = userManager.getUserById(userId); return ServiceResult.getSuccessResult(user); } } public static class UserManager { public User getUserById(long userId){ boolean existed = userManager.checkUserExisted(userId); if(!existed){ // 抛异常 } return 查询数据库结果; } public boolean checkUserExisted(long userId){ return 根据一定规则校验的结果 } }
上面这段代码,分别两次的去调用了 “checkUserExisted”方法,导致了执行重复,违反了DRY原则。一般对于执行重复,可以调整代码顺序,统一规范来避免这个问题。比如说对于业务校验,我们可以放在Manager层统一处理。
避免执行重复,可以有效提升方法性能。特别是对于数据库和RPC这类I/O 操作是比较耗时的,我们在写代码的时候, 应当尽量减少这类 I/O 操作。
二、好的代码习惯
尽管遵守了上面原则,但是没有好的代码习惯,类似于纸上谈兵,也是不行的。本章来聊一聊写方法时好的代码规范。
1、命名
命名是一个既简单又难的问题,简单的命名只有一个单词,而难的地方在于,命名的好坏直接影响到整个代码的可读性。
方法的名字贯穿着整个方法的思想,如果方法的命名和实现的功能牛头不对马嘴,整个代码读起来事倍功半,甚至会被错误理解而造成整个系统的故障,那么怎样命名才合适呢?
命名详细越好?
上文提到,名字直接贯穿整个方法的思想。那我尽可能详细描述方法的意图,哪怕名字因为太长被切成两行也无所谓?
相反在能够表达含义的前提下,名字越短越好,太长的命名反而会影响易读性,增加理解成本。下面提几个在不影表达含义的前提下,缩减命名的好的方式。
1.利用上下文缩减单词,如getUserName,在User类这个上文环境下,可缩减成getName。
2.用缩写来代替,如 to=>2、business=>biz、DailyActiveUse => DAU。
3.检查方法是否足够单一。
在命名完以后,最好以使用者的角度去看下,是否命名通俗易懂。
命名要统一
你是否在调用DAO层查询一个数据的时候,看到 “selectXXX”、“queryXXX” 、“getXXX” 不知用哪一个、搜索一个方法的时候,使用“selectXXX”搜索不到,再用“queryXXX”在搜索一次。如果命名不统一的话,就很容易出现上述问题,最好项目组定一个统一的规范,在开发和项目维护时避免很多问题。
2、参数定义
避免参数过多
当方法参数大于等于 4 个的时候,参数就有点过多了,主要影响到代码的可读性和易用性,我们可以看好的框架源码,如Spring定义方法入参很少超过三个。如果参数过多,一般有两种方法:
1.当前函数是否满足单一原则。
2.将多个参数封装为对象。
需要注意的是,对外的接口最好都将参数封装成对象,如果需要添加新的参数的时候,接口调用方有可能就不需要修改代码来兼容新的接口了,接口提供方也可以不用重载方法来兼容老接口调用方。
“Integer” 还是 “int”
为什么有的方法入参是Integer而有的又是int,定义类型使到底使用Integer还是int?如果不知道可以根据下面几个类型区分。
1.null是否有业务意义。比如说更新表字段时,null代表不更新、查询数据时null代表没有。以上场景用Integer更合适。
2.参数是否必填,如果必填的话,使用int更合适。
3.尊重原有数据类型,避免没必要的拆包。如:Order中的id类型为Long,肯定不为空,但在定义方法入参时,没有必要用long类型,可以直接用Long,避免没必要的拆包。
需要注意的是 ,如果团队平时Integer和int没特别规范的话,入参最好少用int,不然容易造成NPE。
3、方法体
方法体多大才合适
方法多大合适,每个人都用自己的看法,但我观点是,方法最好不要超过横着的一屏,超过一屏之后,在阅读代码的时候,为串联前后的代码逻辑,就可能需要频繁地上下滚动屏幕,阅读体验不好不说,还容易出错。
判断一个方法多大合适,最简单的那就是,当一个方法读了下文让你快忘了上文的时候,基本说明这个方法代码过多了。
代码适当分块
对于比较长的方法,如果逻辑可以分为几个独立的部分,可以利用空行来分割成几块代码块,每块代码前加上适当的注释,可以让代码的整体结构看起来更加有清晰、有条理。
下面贴一段Spring 源码的 “registerBeanPostProcessors” 方法代码分块示例:
出处:Spring框架源码 public static void registerBeanPostProcessors( ConfigurableListableBeanFactory beanFactory, AbstractApplicationContext applicationContext) { // ... // Register BeanPostProcessorChecker that logs an info message when // a bean is created during BeanPostProcessor instantiation, i.e. when // a bean is not eligible for getting processed by all BeanPostProcessors. int beanProcessorTargetCount = beanFactory.getBeanPostProcessorCount() + 1 + postProcessorNames.length; beanFactory.addBeanPostProcessor(new BeanPostProcessorChecker(beanFactory, beanProcessorTargetCount)); // Separate between BeanPostProcessors that implement PriorityOrdered, // Ordered, and the rest. List<BeanPostProcessor> priorityOrderedPostProcessors = new ArrayList<BeanPostProcessor>(); List<BeanPostProcessor> internalPostProcessors = new ArrayList<BeanPostProcessor>(); List<String> orderedPostProcessorNames = new ArrayList<String>(); List<String> nonOrderedPostProcessorNames = new ArrayList<String>(); for (String ppName : postProcessorNames) { if (beanFactory.isTypeMatch(ppName, PriorityOrdered.class)) { BeanPostProcessor pp = beanFactory.getBean(ppName, BeanPostProcessor.class); priorityOrderedPostProcessors.add(pp); if (pp instanceof MergedBeanDefinitionPostProcessor) { internalPostProcessors.add(pp); } } else if (beanFactory.isTypeMatch(ppName, Ordered.class)) { orderedPostProcessorNames.add(ppName); } else { nonOrderedPostProcessorNames.add(ppName); } } // First, register the BeanPostProcessors that implement PriorityOrdered. sortPostProcessors(priorityOrderedPostProcessors, beanFactory); registerBeanPostProcessors(beanFactory, priorityOrderedPostProcessors); // Next, register the BeanPostProcessors that implement Ordered. List<BeanPostProcessor> orderedPostProcessors = new ArrayList<BeanPostProcessor>(); for (String ppName : orderedPostProcessorNames) { BeanPostProcessor pp = beanFactory.getBean(ppName, BeanPostProcessor.class); orderedPostProcessors.add(pp); if (pp instanceof MergedBeanDefinitionPostProcessor) { internalPostProcessors.add(pp); } } //... }
移除过深的嵌套层次
如果代码 if 嵌套者for 、for 嵌套着 if,不知道大家读着什么感受,可能才看到就开始头晕了。嵌套过深的代码一眼看去,高情商说他在叠一座金字塔,低情商说看不出来堆的什么,非常影响易读性。如果嵌套过深,可以尝试下面的解法:
1.利用 return,continue 等关键字
// 改造前 public void example(List<String> strList,String substr) { if(CollectionUtils.isNotEmpty(strList) && StringUtils.isNotBlank(substr)){ for (String str :strList) { if(str != null){ if(str.contains(substr)) { // 调用存在业务方法 }else { // 调用不存在业务方法 } } } } } // 改造后 public void example(List<String> strList, String substr) { // 利用return提前退出 if (CollectionUtils.isEmpty(strList) || StringUtils.isBlank(substr)) { return; } for (String str : strList) { // 利用continue,提前结束本次循环 if (str == null) { continue; } if (str.contains(substr)) { // 调用业务方法 }else { // 调用不存在业务方法 } } }
2.去除多余的if-else
public void example(String str) { if (StringUtils.isBlank(str)) { return; } else { // 此处的else可以去掉 // 做其他业务逻辑 } }
3.抽出部分嵌套逻辑,封装为方法
4.使用策略+工厂等设计模式等
定义变量不宜过早
在方法开始时,一开始就把所以变量定义好,然后在几十行后在使用,阅读者在阅读这种风格的代码时,一开始不知道这个参数干嘛用,到后面不知道这个参数怎么定义的,还要上下滚动屏幕,大大降低了易读性,定义变量的时候,最好在需要它的前一行定义好,上下文之间联系一目了然。
4、注释
注释主要用于一段代码的解析,可以让阅读者更易理解一个方法,所以并没有强制的规定写什么,写多少。如果你觉得名字表达不清楚或者需要注意的地方等都可以写在注释里。
但是写注释一般需要注意以下几点:
1.不要太依赖注释,这会让你有兜底的想法,而忽略代码里的可读性。
2.注释写太详细,后面修改代码逻辑时,往往也需要修改注释的内容,而改代码的时候很容易忽视注释,导致注释和代码对不上,大大降低了代码可读性。
参考资料和书籍:
《设计模式之美》https://time.geekbang.org/column/intro/250
《重构》https://book.douban.com/subject/30468597/
https://mp.weixin.qq.com/s/akf4Ttb5gpNq8uqa7vZw1w