Skip to content
快速预览

不要重复自己(DRY)

✍️ w 🕒 2023-07-23 12:23:51(10 months ago) 🔗 G.设计模式

DRY 原则。它的英文描述为:Don’t Repeat Yourself。中文直译为:不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码

这个原则的核心思想是,避免在代码中重复相同的逻辑或代码段,而是应该将这些逻辑或代码段抽象出来,封装成可复用的函数或类。这样可以减少代码的冗余,提高代码的可维护性和可读性。

这个原则最主要的使用是要区分 什么样子的算是重复的代码,是否是两段一模一样的代码就算重复代码么?

DRY 原则的核心思想是避免在代码中重复相同的逻辑或代码段,而不是简单地避免写一模一样的代码。因此,即使两段代码看起来完全相同,如果它们的逻辑或用途不同,它们也不应该被视为重复的代码。在编写代码时,我们应该根据代码的逻辑和用途来判断是否需要将其抽象出来,封装成可复用的函数或类。

实现逻辑重复

下面代码中 isValidUserName() 函数和 isValidPassword() 函数 代码内容一摸一样是否就是违背 DRY 原则呢

java
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;
  }
}

如果认为是违背了 将代码重新改造后,发现代码行数减少了,但光从重构的名字上来看发现,isValidUserNameOrPassword() 函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”

java
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;
  }
}

从业务本质来看isValidUserName() 和 isValidPassword() 两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName() 和 isValidPassword() 的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。

尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则

功能语义重复

在日常工作的时候你会遇到,一个相同的业务,被两个不同的同事去实现了,尽管他们起的方法名不同,举个例子在同一个项目代码中有下面两个函数:isValidIp() 和 checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。

通过代码可以发现两个同事用了完全不同的方法去现实了一个相同的业务,校验ip ,上一个例子是代码实现逻辑重复,但语义不重复,我们并不认为它违反了 DRY 原则。而在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则

java
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;
}

这类代码最容产生的问题就是,假设我们不统一实现思路,那有些地方调用了 isValidIp() 函数,有些地方又调用了 checkIfIpValid() 函数,这就会导致代码看起来很奇怪,相当于给代码“埋坑”,给不熟悉这部分代码的同事增加了阅读的难度。同事有可能研究了半天,觉得功能是一样的,但又有点疑惑,觉得是不是有更高深的考量,才定义了两个功能类似的函数,最终发现居然是代码设计的问题。除此之外,如果哪天项目中 IP 地址是否合法的判定规则改变了,比如:255.255.255.255 不再被判定为合法的了,相应地,我们对 isValidIp() 的实现逻辑做了相应的修改,但却忘记了修改 checkIfIpValid() 函数。又或者,我们压根就不知道还存在一个功能相同的 checkIfIpValid() 函数,这样就会导致有些代码仍然使用老的 IP 地址判断逻辑,导致出现一些莫名其妙的 bug

代码执行重复

java
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...
  }
}

这段代码中存在代码执行重复的问题。在 UserRepo 类中,checkIfUserExisted() 和 getUserByEmail() 方法中都有对 email 参数的有效性进行验证的代码,这段代码重复了。因为 checkIfUserExisted() 函数和 getUserByEmail() 函数都需要查询数据库,而数据库这类的 I/O 操作是比较耗时的。我们在写代码的时候,应当尽量减少这类 I/O 操作。重新设计后

移除“重复执行”的代码,只校验一次 email 和 password,并且只查询一次数据库。我们只需要将校验逻辑从 UserRepo 中移除,统一放到 UserService 中就可以,来进行校验。重构之后的代码如下所示:

java
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...
  }
}

提高代码复用性

  1. 减少代码耦合:减少代码之间的依赖关系,尽量让代码之间解耦,这样可以避免修改一个功能时影响到其他相关的代码。

  2. 满足单一职责原则:将模块、类设计得足够小,职责足够单一,这样可以减少模块之间的依赖关系,提高代码的复用性。

  3. 模块化:将功能独立的代码封装成模块,这样可以提高代码的通用性,便于复用。

  4. 业务与非业务逻辑分离:将业务逻辑和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等,这样可以提高代码的复用性。

  5. 通用代码下沉:将通用的代码下沉到更下层,这样可以避免交叉调用导致调用关系混乱,提高代码的复用性。

  6. 继承、多态、抽象、封装:利用面向对象特性,如继承、多态、抽象、封装等,可以提高代码的复用性。

  7. 使用函数式编程:函数式编程强调函数的复用性和组合性,通过将函数作为一等公民来实现代码的复用和组合。

  8. 使用库和框架:使用现有的库和框架可以大大提高代码的复用性,避免重复造轮子。

编写可复用代码并不简单,特别是在没有具体复用需求的情况下,需要去预测将来代码会如何复用,这是一项挑战性很高的任务。因此,除非有非常明确的复用需求,否则,在第一次编写代码的时候花费太多的时间和精力去开发可复用的代码,并不是一个值得推荐的做法,这也违反了YAGNI原则。另外,有一个著名的原则叫作“Rule of Three”,即在第三次复用相似的代码之前,不要去开发可复用的代码。因此,在第一次编写代码的时候,我们可以不考虑复用性,而在第二次遇到复用场景的时候,再进行重构,让其变得更加可复用。这样可以减少开发成本,提高开发效率。“Rule of Three”中的“Three”并不是真的就指确切的“三”,这里就是指“二”。

参考

理论七:重复的代码就一定违背DRY吗?如何提高代码的复用性?

Released under the MIT License.