前言
DRY 原则,英文描述为: Don’t Repeat Yourself。中文直译:不要重复自己。将它应用在编程中,可理解为:不要写重读的代码。
可能你认为,这个原则很简单。只要两段代码长得一样,那就是违反 DRY 原则了。真的是这样吗? 答案是否定的。这是很多人对这条原则存在的误解。实际上,重读的代码不一定违反 DRY 原则,而有些看似不重复的代码也可能违反 DRY 原则。
DRY 原则(Don’t Repeat Yourself)
DRY 原则的定义非常简单,我就不再过度解读了。今天,主要分析三种典型的代码重复情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有些看似违反 DRY 原则,实际上并不违反;有的看似不违反,实际上却违反了。
实现逻辑重复
先看一段代码
public class UserAuthenticator {
public void authenticate(String username, String password) {
if (!isValidUsername(username)) {
// throw new InvalidUsernameException...
}
if (!isValidPassword(password)) {
// throw new InvalidPasswordException...
}
// 省略其他代码...
}
private boolean isValidUsername(String username) {
if (StringUtils.isEmpty(username)) { return false; }
// check length: 4-64
int length = username.length();
if (length < 4 || length > 64) { return false; }
// contains only lower case letters
if (StringUtils.isAllLowerCase(username)) { return false; }
// contains only z~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) {
if (StringUtils.isEmpty(password)) { return false; }
// check length: 4-64
int length = password.length();
if (length < 4 || length > 64) { return false; }
// contains only lower case letters
if (StringUtils.isAllLowerCase(password)) { return false; }
// contains only z~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()
。重复的代码被敲了两遍,看起来明显违反了 DRY 原则。为了移除重复的代码,我们进行下重构,将 isValidUsername()
和 isValidPassword()
合并为一个更通用的函数, isValidUsernameOrPassword()
。
public class UserAuthenticator {
public void authenticate(String username, String password) {
if (!isValidUsernameOrPassword(username)) {
// throw new InvalidUsernameException...
}
if (!isValidUsernameOrPassword(password)) {
// throw new InvalidPasswordException...
}
// 省略其他代码...
}
private boolean isValidUsernameOrPassword(String usernameOrPassword) {
if (StringUtils.isEmpty(usernameOrPassword)) { return false; }
// check length: 4-64
int length = usernameOrPassword.length();
if (length < 4 || length > 64) { return false; }
// contains only lower case letters
if (StringUtils.isAllLowerCase(usernameOrPassword)) { return false; }
// contains only z~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = usernameOrPassword.charAt(i);
if (!((c >= 'a' && c <= 'z') || !(c >= '0' && c <= '9') || c != '.')) { return false; }
}
return true;
}
}
重构之后,代码行数减少了,也没有重复代码了,是不是更好呢?
单从名字上看,合并之后的 isValidUsernameOrPassword()
函数,负责两件事情:验证用户名和密码,违反了单一职责原则和接口隔离原则。实际上,即便将两个函数合并成 isValidUsernameOrPassword()
,代码仍然存在问题。
因为 isValidUsername()
和 isValidPassword()
,虽然代码实现逻辑上看起来是重复的,但是从语义上并不重复。尽管在目前的设计中,两个校验逻辑完全一样,但是如果按照第二种写法,将两个函数合并,那就回存在潜在的问题。在未来的某一天,如果我们修改了密钥校验逻辑,比如,允许密码包含大写字符,允许密码长度为 8 到 64 个字符,那这个时候, isValidUsername()
和 isValidPassword()
的实现逻辑就会不相同。我们需要把合并后的函数,重新拆分成合并前的两个函数。
所谓 “语义不重复” 是指:从功能上看,这两个函数干的是完全不重复的事情,一个是校验用户名,一个是校验密码。
尽管代码的实现逻辑相同,但语义不同,我们判定它并不违反 DRY 原则。对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含 a-z、0-9、dot 的逻辑都封装成函数。
功能语义重复
在看另一个例子。在同一个项目代码中有下面两个函数: isValidIp()
和 checkIfIpValid()
。尽管命名不同、实现逻辑不同,但是功能是相同的,都是用来判定 IP 是否合法的。
出现这个现象的原因,可能是其中的一个同事不知道已有了
isValidIp()
的情况下,自己又定义并实现了相同用来校验 IP 地址是否合法的checkIfIpValid()
函数。
这两个函数如下所示,它们是否违反了 DRY 原则?
public boolean isValidIp(String ip) {
if (StringUtils.isBlank(ip)) { 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 ip.matches(regex);
}
public boolean checkIfIpValid(String ip) {
if (StringUtils.isBlank(ip)) { return false; }
String[] ipUnits = StringUtils.split(ip, ".");
if (ipUnits.length != 4) { return false; }
for (int i = 0; i < ip.length(); 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;
}
在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复(即功能重复),我们认为它们违反了 DRY 原则。
我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用一个函数。
假设,我们不统一实现思路,有些地方调用了 isValidIp()
,有些地方又调用了 checkIfIpValid()
,这就会导致代码看起来很奇怪,相当于给代码 “挖坑”,给不熟悉的这部分代码的同事增加了阅读难度。同事可能研究了半天,觉得功能一样,但又有点疑问,觉得是不是有更高深的考量,才定义了两个功能类似的函数,最终发现居然是代码设计的问题。
另外,如果哪天项目中 IP 地址是否合法的判定逻辑改了,比如: 255.255.255.255 判定不合法,相应地,我们对 isValidIp()
的实现逻辑做了修改,但却忘记修改 checkIfIpValid()
,这样就会导致有些代码仍然用老的 IP 判定逻辑,导致出现一些莫名其妙的 BUG。
代码执行重复
前两个例子,一个是实现逻辑重复,一个是语义重复,在看下第三个例子。其中 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...
}
}
上面的代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码存在 “执行重复”。
重复执行最明显的地阿福,就是在 login()
中,email 的校验逻辑执行了两次。一次是在调用 checkIfUserExisted()
函数的时候,另一次是调用 getUserByEmail()
的时候。这个问题解决起来比较简答,只要将校验逻辑从 UserRepo
中移除,统一放到 UserService
中就可以了。
此外,代码中,还有移除比较隐藏的执行重复: login()
函数并不需要调用 checkIfUserExisted()
,只需要调用一次 getUserByEmail()
,从数据库中获取用户的 email、password 等信息,然后跟输入的 email、password 信息做比对,判断是否登录成功。
这样的优化是很有必要的。因为
checkIfUserExisted()
和getUserByEmail()
都需要查询数据库,而数据库类的 I/O 操作是比较耗时的。我们在写代码的时候,应该尽量减少 I/O 操作。
按照刚刚的思路,我们重构下代码。
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)
什么是代码的复用性?
首先区分三个概念: 代码复用性(Code Reusability)、代码复用(Code Reuse)、DRY 原则。
- 代码复用表示一种行为:我们在开发新功能的时候,尽量复用已存在的代码。
- 代码复用性表示一段代码可被复用的特性或能力:我们在编写代码的时候,尽量让代码可复用。
- DRY 原则是一条原则:不要写重复的代码。
首先,“不重复” 不代表 “可复用”。在一个项目中,可能不存在任何重复的代码,但也不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY 原则和可复用性讲的是两回事。
其次,“复用” 和 “可复用性” 关注角度不同。代码 “可复用性” 是从代码开发者的角度来讲的,“复用” 是从代码使用者角度来讲的。比如 A 同事编写了一个 UrlUrils
类,代码的 “可复用性” 很好。同事 B 在开发新功能是,直接 “复用” A 同事编写的 UrlUrils
类。
虽然复用性、复用、DRY 原则这三者从理解上有区别,但是它们的目的是一样的,都是为了较少代码量,提高代码的可读性、可维护性。此外,复用已经过测试的老代码,bug 会比从零开发的要少。
“复用” 这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的生产也都是为了达到复用的目的。比如,Spring 框架、UI 组件等等。
怎么提高代码复用性?
一共有 7 条规则:
- 减少代码耦合。对于高耦合的代码,当希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数时,往往会牵一发而动全身。所以,高耦合度的代码会影响到代码的可复用性。
- 满足单一职责原则。 前面讲过,如果职责不够单一,模块、类设计得大而全,那就增加了代码的耦合度(依赖它的,它依赖的代码就会比较多)。也会影响到代码的可复用性。相反,粒度越细的代码,代码的通用性会越好,容易被复用。
- 模块化。这里的 “模块”,不单单只一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像积木,更加容易复用,直接拿来搭建更加复杂的系统。
- 业务与非业务逻辑分离。越是和业务无关的代码越容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
- 通用代码下层。从分层角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
- 继承、多态、抽象、封装。在讲面向对象特性的时候,我们讲过,利用继承可以将公共代码抽取到父类,子类复用父类的属性和方法。利用多态,可以动态替换一段代码的部分逻辑,让这段代码可复用。此外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性层面来理解的话,越抽象、越不依赖具体实现,越容易复用。代码封装成模块,隐藏可变细节、暴露不变的接口,就越容易复用。
- 应用模板等设计模式。一些设计模式,也能提高代码复用性。比如,模板模式利用了多态技术来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
除了上面讲到的 7 点,还有一些跟编程语言相关的特性,也可以提高代码的复用性,比如泛型编程等。另外,除了上面讲到的知识,复用意识也很重要。在写代码的时候,要取多思考,这部分代码是否可以抽取出来,作为一个独立模块、类或者函数供多处使用。在设计每个模块、类、函数的时候,要像设计一个外部 API 一样,去思考它的复用性。
辩证思考和灵活应用
编写可复用的代码并不简单。如果在编写代码时,已经有复用的需求场景,那根据复用的需求去开发可复用的代码,可能还不算难。但是,如果当下没有复用的需求,只是希望现在编写的代码具有可复用的特点,能在未来某个同事开发某个新功能时复用得上。在这种没有具体复用需求的情况下,就需要去预测未来代码会如何复用,这就比较有挑战了。
实际上,除非有明确的复用需求,否则,为了暂时用不到的复用需求,花费太多时间、精力,投入太多成的开发成本,并不是一个值得推荐的做法。也违反我们之前讲到的 YAGNI 原则。
实际上,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的需求也不是特别明确,并且开发复用代码的成本比较高,那我们就不需要考虑它的复用性。在之后,开发新能够的时候,发现可以复用之前的代码,那我们就重构它,让其变得更加复用。
总结
1.DRY 原则
讲解了三种重复的情况:实现逻辑重复、语义重复、执行逻辑重复。
- 实现逻辑重复,但功能语义不重复,并不违反 DRY 原则。
- 实现逻辑不重复,但是功能语义重复,则违反了 DRY 原则。
- 此外,代码执行重复也是违反 DRY 原则。
2.代码复用性
提高代码可复用性的 7 点方法:
- 减少代码耦合
- 满足单一职责原则
- 模块化
- 业务与非业务逻辑分离
- 通用代码下沉
- 继承、多态、抽象、封装
- 应用模板等设计模式
除了上面讲到的方法外,复用意识也非常重要。在设计每个模块、类、函数时,要像设计一个外部 API 一样思考它的复用性。
在定义编写代码时,如果当下没有复用需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。之后开发新功能时,发现可以复用之前的代码,那我们就重构这段代码,让其变得更加可复用。
相对于代码可复用,DRY 原则适用性更强一些。我们可以不写可复用的代码,但一定不能写重复的代码。