理论七:重复的代码就一定违背DRY吗?如何提高代码的复用性?
DRY原则(Don't Repeat Yourself)
- 不要重复自己,即不要写重复的代码。
- 针对三种情况讨论:实现逻辑重复,功能语义重复和代码执行重复。
实现逻辑重复
先看一个例子是否违背了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; } }
-
我个人看来整个用户名和密码的逻辑是一样的,可以合并成一个validateAuthStr的函数,但是一个担心是密码和用户名从业务逻辑上来说是不同的类型,可能后续会增加更多的不同的校验逻辑。
-
作者也是同样的思路,这个重构后的函数违反了单一职责原则和接口隔离原则。concern也是一样的例子。
- 所以尽管实现逻辑是一样的,但是语义不一样,我们判定它并不违反DRY原则。对于重复代码的问题,我们可通过抽象更细粒度函数的方式来解决。比如将校验只包含 a~z、0~9、dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数。
功能语义重复
-
现在我们再来看另外一个例子。在同一个项目代码中有下面两个函数: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; }
-
不仅代码的易读性变差了(混合两个不同的实现,但是其实在做同一件事);而且可维护性也变差,如果以后ip地址的校验逻辑变化了,要同时对这些个方法去做修改。
-
应该统一实现,对于一件事只调用相同的函数逻辑。
代码执行重复
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... } }
- 这个例子也比较明显,在登录的函数中,做了一次检查用户存在与否的操作
checkIfUserExisted
这个函数中又是validate了email和password;在调用完checkIfUserExisted
之后,又去获取用户,getUserByEmail
中又去对email做了一次validation。 - 为去除重复可以将getUserByEmail中的校验逻辑删掉。而实际上从代码设计来说,login函数不需要validate email和password只需要将从db查得到的信息和用户传进来的作对比即可。
- 这样的优化很有必要,因为重复的地方都是对db的读操作,这样的I/O操作又是相对耗时的环节。
可以重构成为如下:删掉checkIfUserExisted函数的调用在login中,直接校验用户名和密码即可。
public class UserService { private UserRepo userRepo;//通过依赖注入或者IOC框架注入 public User login(String email, String password) { if (!EmailValidation.validate(email)) { // ... throw InvalidEmailException... } if (!PasswordValidation.validate(password)) { // ... throw InvalidPasswordException... } User user = userRepo.getUserByEmail(email); if (user == null || !password.equals(user.getPassword()) { // ... throw AuthenticationFailureException... } return user; } } 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... } }
代码复用性(Code Reusability)
什么是代码的复用性
- 区分三个概念:代码复用性,代码复用,DRY原则
- 代码复用表示一种行为,我们在开发新功能的时候,尽量复用已经存在的代码。
- 代码可复用性表示一段代码被复用的特性或能力:在编写代码的时候,让代码尽量可复用。
- DRY是一条原则:不要写重复代码。
- 代码不重复和可复用是两回事。
- 复用和可复用性关注角度不同。复用是代码使用者的角度,可复用性是代码的开发者角度来讲。
如何提升代码的复用性
- 减少代码耦合:如果我们想复用一段代码,往往是将其抽离出来,高度耦合的代码很难去做修改。
- 满足单一职责原则:还是有点类似上一点,不满足单一职责原则,同时做几件事,比较难会被抽离出来复用在其他地方。
- 模块化:类、函数、一组类的封装。
- 业务与非业务逻辑分离。针对业务的代码逻辑多数时候难以复用。
- 通用代码下沉:越底层的代码越通用,被更多的模块调用。代码分层之后,为了避免交叉调用导致关系混乱,一般只允许上层代码调用下层代码以及同层调用。
- 继承多态抽象封装:类似上面一点,比如子类和父类的分层。抽象和封装是更广义的分层。
- 应用模板等设计模式。
辩证思考灵活运用
对于当下没有复用需求,为未来考虑的场景,编写可复用的代码就比较难。避免过度考虑和设计,当下或者近期没有特别需求要复用不需要花很多精力在上面。
除此之外,有一个著名的原则,叫作“Rule of Three”。这条原则可以用在很多行业和场景中,你可以自己去研究一下。如果把这个原则用在这里,那就是说,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。也就是说,第一次编写代码的时候,我们不考虑复用性;第二次遇到复用场景的时候,再进行重构使其复用。需要注意的是,“Rule of Three”中的“Three”并不是真的就指确切的“三”,这里就是指“二”。