在学习完SOLID原则后,再了解一些编程中常用到的指导原则,分别是KISS、YAGNI和DRY原则。这些更像是一些心法,了解了后写代码的时候随时给自己提个醒。
理解KISS-保持简单原则
KISS原则:Keep It Simple and Stupid。翻译成中文就是:尽量保持简单。代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
什么是代码简单
并不是代码行数越少就越“简单”,还要考虑逻辑复杂度、实现难度、代码的可读性等。
// 第一种实现方式: 使用正则表达式 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; } // 第一种实现方式: 使用正则表达式 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; } // 第三种实现方式: 不使用任何工具类 public boolean isValidIpAddressV3(String ipAddress) { char[] ipChars = ipAddress.toCharArray(); int length = ipChars.length; int ipUnitIntValue = -1; boolean isFirstUnit = true; int unitsCount = 0; for (int i = 0; i < length; ++i) { char c = ipChars[i]; if (c == '.') { if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false; if (isFirstUnit && ipUnitIntValue == 0) return false; if (isFirstUnit) isFirstUnit = false; ipUnitIntValue = -1; unitsCount++; continue; } if (c < '0' || c > '9') { return false; } if (ipUnitIntValue == -1) ipUnitIntValue = 0; ipUnitIntValue = ipUnitIntValue * 10 + (c - '0'); } if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false; if (unitsCount != 3) return false; return true; }
上面这段检查输入的字符串 ipAddress 是否是合法的 IP 地址的代码,第一种实现方式利用的是正则表达式,第二种实现方式使用了 StringUtils 类、Integer 类提供的一些现成的工具函数,来处理 IP 地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理 IP 地址中的字符,来判断是否合法。
- 第一种方式不符合KISS原则,虽然代码行数最少,看似最简单,实际上却很复杂,因为它使用了正则表达式,复杂的正则表达式会导致代码的可读性和可维护性变差
- 第三种方式不符合KISS原则,第三种虽然和第二种代码行数了类似,但实现起来要比第二种更加有难度,更容易写出 bug,也就是逻辑复杂度较高
只有第二种方式使用了现成的工具类,代码逻辑不复杂,易读也易维护,满足KISS原子。但即使这样也需要看适配场景,如果本身问题比较复杂,那如果一段代码的逻辑复杂、实现难度大、可读性也不太好,但能解决这样复杂的问题,也满足KISS原则。例如用KMP算法解决字符串匹配问题,本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则
如何写出满足KISS的代码
KISS原则其实本来就比较主观,不过还是有一些验证方式的:
- 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。如果想用,培训一下大家
- 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
- 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
主观验证的方式就是代码CR,让大家看看是否满足。
理解YAGNI-不要做过度设计原则
YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计
比如,系统暂时只用 Redis 存储配置信息,以后可能会用到 ZooKeeper。根据 YAGNI 原则,在未用到 ZooKeeper 之前,我们没必要提前编写这部分代码。当然,这并不是说我们就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现 ZooKeeper 存储配置信息这部分代码
理解DRY-不要写重复代码原则
DRY 原则。它的英文描述为:Don’t Repeat Yourself。中文直译为:不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码。
什么是重复的代码
下面说到的这三种重复:实现逻辑重复,功能语义重复,代码逻辑重复。实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则,可以通过更细粒度的SRP改造来去除重复代码。实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。除此之外,代码执行重复也算是违反 DRY 原则,后边这两种情况需要通过重构去除重复方法、逻辑代码块或重构来满足DRY
实现逻辑重复
实现逻辑重复很多情况下看似违反 DRY,实际上并不违反,拿用户的登录验证来说:
public class UserAuthenticator { public void authenticate(String username, String password) { if (!isValidUsername(username)) { // ...throw InvalidUsernameException... } if (!isValidPassword(password)) { // ...throw InvalidPasswordException... } //...省略其他代码... } private boolean isValidUsername(String username) { // check not null, not empty if (StringUtils.isBlank(username)) { return false; } // check length: 4~64 int length = username.length(); if (length < 4 || length > 64) { return false; } // contains only lowcase characters if (!StringUtils.isAllLowerCase(username)) { return false; } // contains only a~z,0~9,dot for (int i = 0; i < length; ++i) { char c = username.charAt(i); if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') { return false; } } return true; } private boolean isValidPassword(String password) { // check not null, not empty if (StringUtils.isBlank(password)) { return false; } // check length: 4~64 int length = password.length(); if (length < 4 || length > 64) { return false; } // contains only lowcase characters if (!StringUtils.isAllLowerCase(password)) { return false; } // contains only a~z,0~9,dot for (int i = 0; i < length; ++i) { char c = password.charAt(i); if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') { return false; } } return true; } }
isValidUserName
函数和 isValidPassword
函数。重复的代码被敲了两遍,或者简单 copy-paste 了一下,看起来明显违反 DRY 原则。为了移除重复的代码,我们对上面的代码做下重构,将 isValidUserName() 函数和 isValidPassword() 函数,合并为一个更通用的函数 isValidUserNameOrPassword
public class UserAuthenticatorV2 { public void authenticate(String userName, String password) { if (!isValidUsernameOrPassword(userName)) { // ...throw InvalidUsernameException... } if (!isValidUsernameOrPassword(password)) { // ...throw InvalidPasswordException... } } private boolean isValidUsernameOrPassword(String usernameOrPassword) { //省略实现逻辑 //跟原来的isValidUsername()或isValidPassword()的实现逻辑一样... return true; } }
合并之后的 isValidUserNameOrPassword
函数,负责两件事情:验证用户名和验证密码,违反了单一职责原则和接口隔离原则
isValidUserName
和 isValidPassword
两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓语义不重复指的是:从功能语义上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName() 和 isValidPassword() 的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。
尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则。对于包含重复代码的问题,我们可以应用更细粒度的SRP原则将方法抽象成更细粒度函数的方式来解决。比如将校验只包含 a~z、0~9、dot
的逻辑封装成 boolean onlyContains(String str, String charlist)
函数
功能语义重复
功能语义重复很多情况下看似符合 DRY,实际上却违反了DRY。在同一个项目代码中有下面两个函数:isValidIp
() 和 checkIfIpValid
()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的
public boolean isValidIp(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 checkIfIpValid(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原则的工具类。在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则。例如使用的同事有可能研究了半天,觉得功能是一样的,但又有点疑惑,觉得是不是有更高深的考量,才定义了两个功能类似的函数,更危险的是如果有一天,判断IP是否有效的逻辑变了,只改了一个方法,另一个方法却察觉不到,这就导致系统功能bug。这一点我深有体会,现在的项目中自己的依据流程去获取门店的相似功能方法就有好几个,违反了DRY。
我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。
代码逻辑重复
这种情况下是一个方法中既不存在实现逻辑重复,也不存在功能语义重复,而是代码逻辑被重复执行,例如:UserService 中 login() 函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。
public class UserService { private UserRepo userRepo;//通过依赖注入或者IOC框架注入 public User login(String email, String password) { boolean existed = userRepo.checkIfUserExisted(email, password); if (!existed) { // ... throw AuthenticationFailureException... } User user = userRepo.getUserByEmail(email); return user; } } public class UserRepo { public boolean checkIfUserExisted(String email, String password) { if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } if (!PasswordValidation.validate(password)) { // ... throw InvalidPasswordException... } //...query db to check if email&password exists... } public User getUserByEmail(String email) { if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } //...query db to get user by email... } }
这段代码有如下问题:
EmailValidation.validate(email)
被重复执行了两次,一次是校验的时候,一次是获取用户的时候getUserByEmail
和checkIfUserExisted
都对数据库进行了获取数据操作,进行了两次IO,实际上一次即可
代码重构后如下:
public class UserService { private UserRepo userRepo;//通过依赖注入或者IOC框架注入 public User login(String email, String password) { check(String email, String password); User user = userRepo.getUserByEmail(email); if (user == null || !password.equals(user.getPassword()) { // ... throw AuthenticationFailureException... } return user; } public void check(String email, String password){ if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } if (!PasswordValidation.validate(password)) { // ... throw InvalidPasswordException... } } } public class UserRepo { public boolean checkIfUserExisted(String email, String password) { //...query db to check if email&password exists } public User getUserByEmail(String email) { //...query db to get user by email... } }
把校验逻辑抽取出来,去掉重复的校验逻辑,并且不再调用checkIfUserExisted
,而是增加了check
方法专门用于校验参数有效性,单凭getUserByEmail
即可判断对象是否存在,这其实也符合SRP原则,方法功能尽可能单一。
DRY和代码的可复用性
简单辨析下DRY原则、代码复用行为、代码复用性能力的区别,以及了解一下常用的提供代码复用性的方法。
辨析DRY原则、代码复用行为、代码复用性能力
代码复用表示一种行为:我们在开发新功能的时候,尽量复用已经存在的代码。代码的可复用性表示一段代码可被复用的特性或能力:我们在编写代码的时候,让代码尽量可复用
DRY原则是一条原则要保证的是不要写重复的代码,但并不意味着DRY就提高了代码的可复用性,举个例子,我们实现了一个方法【通过一系列参数判断比亚迪汽车质量是否合格】,我们不会再去写一个方法【通过一系列参数判断比亚迪汽车质量是否合格】,因为这叫重复造轮子,但这只意味着系统中不会有重复功能的代码,但并不意味着原本的方法【通过一系列参数判断比亚迪汽车质量是否合格】是一段可复用性强的代码,我们只是砍掉一个不应该造的轮子,而不是说本身的这个轮子很强,很通用。所以在这个项目里只有这一个方法是实现该功能的,满足DRY原则,但【通过一系列参数判断比亚迪汽车质量是否合格】并不能复用到所有四轮轿车的质量合格性判断上,它也不是一段可复用性强的代码。
尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性
提高代码复用性的方法
常用的提高代码复用性的方法有如下几种:
- 减少代码耦合,对于高度耦合的代码,当希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,要尽量减少代码耦合。
- 满足单一职责原则,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。
- 模块化,模块不单单指一组类构成的模块,还可以理解为单个类、函数。要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。
- 业务与非业务逻辑分离,越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,需要将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
- 通用代码下沉,从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,通常只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
- 继承、多态、抽象、封装,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
- 应用模板等设计模式,一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
除此之外还有一些跟编程语言相关的特性,也能提高代码的复用性,比如泛型编程等。实际上,除了上面讲到的这些方法之外,复用意识也非常重要。在写代码的时候要多去思考一下,这个部分代码是否可以抽取出来,作为一个独立的模块、类或者函数供多处使用。在设计每个模块、类、函数的时候,要像设计一个外部 API 那样,去思考它的复用性。
总结一下
KISS 原则讲的是“如何做”的问题(尽量保持简单),它的目的是提升代码的可读性、可维护性,而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做),它的目的是降低代码的冗余度,提升代码的可维护性。DRY原则说的是不要做重复的事,它的目的是减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,bug 会比从零重新开发要少,也就提高了代码健壮性。所以要有代码复用意识、写可复用性强的代码、不要违反DRY的去添加重复功能。不要违反YAGNI去过度设计,不要违反KISS去单纯的炫技。