一、单一职责原则
1.1 如何理解单一职责原则
单一职责(Single Responsibility Principle, SRP)原则:A class or module should have a single responsibility,一个类或模块只负责完成一个职责(或者功能)。
一种理解:把模块看作比类更加抽象的概念,类也可以看作模块。另一种理解:把模块看作比类更加粗粒度的代码块,模块中包含了多个类,多个类组成一个模块。
不要设计大而全的类,要设计粗粒度小、功能单一的类。一个类如果包含了两个及以上业务不相干的功能,那就是职责不单一,应该将它拆分成多个功能更加单一、粒度更细的类。
一个类如果有多种功能,那就会增加它被其他类依赖的可能性,这就增加了耦合。具体地说,当需要改变这个类的时候,需要考虑更多上游代码对这个类的依赖,这是典型的“牵一发而动全身”,这时的修改会变得棘手。功能单一、小粒度的类,为上层提供了一个更加清晰的抽象接口。如果有复杂的功能需求,应当是组合多种功能抽象,而不是把这些功能放在一个类中。
1.2 如何判断类的职责是否足够单一
不同的应用场景、不同阶段的背景需求下,对同一个类的职责是否单一的判定,可能都是不一样的。此外,从不同的业务层面去看待同一个类的设计,对类是否职责单一,也会有不同的认识。
评价一个类的职责是否足够单一,并没有一个很明确的、可以量化的标准,这是件很主观、仁者见仁智者见智的事情。实际的开发中,也没必要过于未雨绸缪,过度设计。可以先写粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越大,代码越来越多,这时在将这个粗粒度的类,拆分成几个更细粒度的类,所谓的持续重构。
- 既然无法从正面衡量一个类的职责是否单一,不妨从反面判断一个类的职责是否过于庞大,以下的判断原则,比主观地去思考类是否单职责单一,要更有指导意义、更具有可执行性:
- 类中的代码行数、函数或属性过多,影响代码的可读性和可维护性,就需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,也要考虑对类进行拆分;
- 私有方法过多,需要考虑能否将私有方法独立到新的类中,设置成公有方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的词语命名,这就说明类的职责定义得可能不够清晰。
- 类中大量的方法都是集中操作类的某几个属性,可以考虑将这几个属性和对应的方法拆分出来。 从另一个角度看,读一个类的代码让人头大,实现某个功能不知用哪个方法、想用的方法半天找不到、用到一个小功能却要引入整个类(类中包含了很多无关此功能实现的方法)的时候,就说明类的行数、函数和属性过多了。
1.3 类的职责是否设计得越单一越好
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
- 内聚和耦合其实是对一个意思(即合在一块)从相反方向的两种阐述。
- 内聚是从功能相关来谈,主张高内聚。把功能高度相关的内容不必要地分离开,就降低了内聚性,成了低内聚。
- 耦合是从功能无关来谈,主张低耦合。把功能明显无关的内容随意地结合起来,就增加了耦合性,成了高耦合。
二、如何做到“对扩展开放,对修改关闭”
开闭原则是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则。难理解的原因在于:
- 怎样的代码改动才被定义为”扩展“?
- 怎样的代码改动才被定义为“修改”?
- 怎么才算满足或违反“开闭原则”?
- 修改代码就一定意味着违反“开闭原则”吗? 难以掌握的原因在于:如何在项目中灵活应用‘开闭原则’。最有用的依据是:扩展性是代码质量的重要衡量标准,在 23 中经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题二存在的,主要遵从的设计原则就是开闭原则。
2.1 如何理解“对扩展开放、修改关闭”?
开闭原则(Open Closed Principle, OCP): software entites(modules, classes, functions, etc) should be open for extension, but closed for modification.
添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
直接修改代码存在的影响:
- 如果是修改接口,意味着调用这个接口的代码都要做相应的修改;
- 接口相对应的单元测试都要修改。 如果是扩展代码,只需要为新扩展的代码添加单元测试,老的单元测试不会失败,也不用修改。
2.2 修改代码就意味着违背开闭原则吗?
开闭原则可以应用在不同粒度的代码中,可以是模块,也可以是类,还可以是方法(及其属性)。同样一个代码改动,在粗粒度下,被认定为“修改”;在细粒度下,又可以被认定为“扩展”。比如:添加一个类的属性和方法相当于修改类,从类这个层面看,这个改动被认定为“修改”;但这个改动没有修改已有的属性和方法,在方法(及其属性)层面,又被认定为“扩展”。
开闭原则的设计初衷:只要改动没有破坏原有的代码正常运行,没有破坏原有的单元测试,就可以说,这是一个合格的代码改动。
添加一个新功能,不可能任何模块、类、方法的代码都不“修改”。类也需要创建、组装、并且做一些初始化操作,才能构建成可运行的程序。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
2.3 如何做到“对扩展开放、修改关闭”
开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。为了尽量写出扩展性好的代码,要时刻具备扩展意识、抽象意识、封装意识,这些“潜意识”可能比任何开发技巧都重要。
写代码的时候,多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体架构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
识别出代码可变部分和不可变部分,将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化后,只需要给予相同的抽象接口,扩展一个新的实现,替换掉老的实现即可。
2.4 如何在项目中灵活应用开闭原则
只有熟悉业务需求,才容易写出扩展性好的代码。即便你有非常好的抽象意识,扩展意识以及封装意识,在不熟悉的业务领域内,也难以将这些意识发挥到极致。
合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码之初,就可以事先做些扩展性设计。
此外,开闭原则也不是免费的。有些情况下,代码的扩展性和可读性相冲突。为了更好滴支持扩展性,代码变得复杂了很多,理解起来也更加有难度。需要在扩展性和可读性之间做权衡。
为什么需要“对扩展开放,对修改关闭”?
对扩展开放是为了应对变化(需求),对修改关闭是为了保证已有代码的稳定性;最终结果是为了让系统更有弹性!
三、里式替换(LSP)原则
里式替换原则(Liskov Substitution Principle, LSP)。
子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
If S ia a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program.
3.1 如何理解“里式替换原则”?
从上面的定义描述来看,里式替换原则和多态有点类似,但实际上它们完全是两回事。
- 多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法,是一种代码实现的思路。是满足里式替换原则的实现方式。
- 里式替换原则是一种设计原则,用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
相比多态,里式替换原则要求更加严格,不仅仅可以替换原有的或类,还得要求执行的结果也必须是一致的。举个例子:使用父类的时候,不会抛出异常,而使用子类替换后可能会抛出异常,这就是改变了整个程序的运行逻辑,便违反了里式替换原则。
3.2 哪些代码明显违背了 LSP?
里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design by Contract”,即“按照协议来设计”。
设计子类的时候,要遵守父类的行为约定(或协议)。父类定义了方法的行为约定,子类可以改变方法的内部实现逻辑,但不能改变方法原有的行为约定。这里的行为约定包括:
- 方法要声明实现的功能;
- 对输入、输出、异常的约定;
- 甚至包括注释中所罗列的任何特殊说明。 针对上面三点,很容易想到一些违反里式替换原则的例子:
- 父类提供的 sortByAmount,是按照金额从小到大排序的;而子类重写这个方法后,是按照创建日期来排序的。
- 父类的方法约定,运行出错返回 nil,数据为空也返回 nil;而子类重载之后,实现发生了变化,运行出错抛出异常,数据为空返回错误。
- 父类中的函数约定,输入数据可以是任意整数;但子类实现的时候,只允许输入正整数,负数就报错。即,子类对输入参数的校验比父类更加严格。
- 父类的 withdraw() 提现方法的注释:用户的提现金额不得超过账户余额;而子类重写 withdraw() 方法后,针对 VIP 账户实现了透支提现的功能,那这个设计也是不合理的。
判断子类的设计实现是否违反里式替换原则的小窍门:拿父类的单元测试去验证子类的代码。如果有些单元测试失败了,可能说明,子类的设计没有完全地遵守父类的约定,可能违反了里式替换原则。
四、接口隔离原则
接口隔离原则(Interface Segregation Principle, ISP)。
”接口“这个名词可以用在很多场合中,生活中可以用它来指插座接口等。在软件开发中,可以把它看作一组抽象的约定,也可以具体指系统于系统之间的 API 接口,还可以特指面向对象编程语言中的接口等。
Clients should not be forced to depend upon interfaces that they do not use.
客户端不应该被强迫依赖它不需要的接口,其中的“客户端”,可以理解为接口的调用者或者使用者。
4.1 如何理解“接口隔离原则”?
4.1.1 把“接口”理解为一组 API 接口集合
举个例子:微服务的用户系统提供了一组跟用户相关的 API 给其他系统使用。
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}
public class UserServiceImpl implements UserService {
//...
}
现在,后台管理系统要实现删除用户的功能,希望提供用户系统提供一个删除用户的接口。最简单的方式,就是在 UserService 中添加一个删除用户的方法,这种做法可以解决问题,但也添加了隐藏了一些安全隐患。
删除用户是一个谨慎操作,只希望通过后台管理系统来执行,因此,这个接口只限于给后台管理系统使用。如果按照上述的方法,不加限制地被其他业务系统调用,就有可能导致误删用户。
最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。另外一种方式,就是参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 提供给后台管理系统来使用。
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}
public interface RestrictedUserService {
boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}
public class UserServiceImpl implements UserService, RestrictedUserService {
// ...省略实现代码...
}
在设计微服务或者类库接口的时候,如果部分接口只被部分使用者使用,就需要将这部分接口隔离出来,单独给对应的使用者使用,而不是强迫其他使用者也依赖这部分不会被用到的接口。
4.1.2 把“接口”理解为单个 API 接口或函数
把“接口”理解为单个接口或函数,那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个接口或函数中实现。
接口隔离原则与单一职责有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计;而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考角度也不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
接口隔离,强调的是调用方,是否只使用了接口中的部分功能?若是,则违反接口隔离,应当细粒度拆分接口。
单一职责,不强调是否为调用方,只要能某一角度观察出,一个模块/类/方法,负责了多于一件事情,就可判定其破坏了单一职责。
4.1.3 把”接口“理解为 OOP 中的接口概念
- 基于接口隔离原则的设计
// 定时更新
public interface Updater {
void update();
}
// 可视化
public interface Viewer {
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implemets Updater, Viewer {
//...省略其他属性和方法...
@Override
public void update() { //... }
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}
public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig implements Viewer {
//...省略其他属性和方法...
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Viewer>> viewers = new HashMap<>();
public SimpleHttpServer(String host, int port) {//...}
public void addViewers(String urlDirectory, Viewer viewer) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Viewer>());
}
this.viewers.get(urlDirectory).add(viewer);
}
public void run() { //... }
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater =
new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater =
new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mysqlConfig);
simpleHttpServer.run();
}
}
- 设计大而全的 Config 接口
public interface Config {
void update();
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}
public class KafkaConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}
public class MysqlConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}
public class ScheduledUpdater {
//...省略其他属性和方法..
private Config config;
public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {
this.config = config;
//...
}
//...
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Config>> viewers = new HashMap<>();
public SimpleHttpServer(String host, int port) {//...}
public void addViewer(String urlDirectory, Config config) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Config>());
}
viewers.get(urlDirectory).add(config);
}
public void run() { //... }
}
在同样的代码量、实现复杂度、同等可读性情况下,第一种设计思路显然要比第二种好很多,原因如下:
- 第一种设计思路更加灵活、易扩展、易复用。因为 Updater, Viewer 职责更加单一,单一就意味着通用、复用性好。
- 第二种设计思路在代码实现上做了一些无用功。如果要往 Config 中继续添加一个新的接口,所有的实现类都要改动;相反,如果接口的粒度比较小,那涉及改动的类就比较少。
接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
五、控制反转、依赖反转、依赖注入有何区别和联系
5.1 控制反转
控制反转(Inversion of Control, IOC):这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用了框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。
实现控制反转的方法有很多,例如,类似于模板设计模式的方法、依赖注入等。所以,控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
5.2 依赖注入
依赖注入(Dependency Injection, DI),是一种具体的编码技巧,这个概念听起来很高大上,实际上,理解、应用起来非常简单。一句话概括:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。使用依赖注入的方式将依赖的类对象传递进来,提高了代码的扩展性。同时,也是编写可测试性代码最有效的手段。
5.3 依赖注入框架(DI Framework)
实际的软件开发中,项目会存在很多的类,类对象的创建和依赖注入会变得非常复杂,靠程序员来编码完成,很容易出错且成本也高。而对象创建和依赖注入的工作,本身跟具体的业务无关,完成可以抽象撑框架来自动完成。
这个就是“依赖注入框架”。通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
现成的依赖注入框架:Google Guice, Java Spring, Prico Container, Butterfly Container等。
5.4 依赖反转原则
依赖反转原则(Dependency Inversion Principle, DIP),也称为依赖倒置原则。
依赖反转原则最原汁原味的描述:
High-level modules shouldn't depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn't depend on details. Details depend on abstractions.
这里看似在要求高层次模块,实际上是在规范低层次模块的设计。低层次模块提供的接口要足够的抽象、通用,在设计时需要考虑高层次模块的使用种类和场景。
明明是高层次模块要使用低层次模块,对低层次模块有依赖性。现在反而低层次模块需要根据高层次模块来设计,出现了“倒置”的显现。
所谓高层模块和底层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于底层。
六、KISS、YAGIN 原则看似简单,却经常被用错
6.1 如何理解 KISS 原则?
KISS 原则的英文描述有好几个版本:
- Keep It Simple and Stupid;
- Keep It Short and Simple;
- Keep It Simple and Straightforward. 要表达的意思都差不多:尽量保持简单。
KISS 原则算是一个万金油类型的设计原则,可以应用在很多场景。KISS 原则是保持代码可读性和可维护性的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
6.2 代码行数也少就越“简单”吗?
// 第一种实现方式: 使用正则表达式
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;
}
如上所示,检查输入的字符串是否为合法的 IP 地址,有三种不同的实现方式。
方法一,使用正则表达式,代码行数最少,看似最简单,实际上却很复杂。正则表达式本身就比较复杂;而且不是每个程序员都精通正则表达式,可能导致代码的可读性和可维护性变差。这种实现方式不符合 KISS 原则。
方法三,通过逐一处理 IP 地址中的字符,来判断是否合法,相比方法二使用一些工具函数,显然更有难度,更容易写出 bug。所以从可读性上来说,方法二的代码逻辑更清晰、更好理解,更加符合 KISS 原则。
方法三的性能可能会更到一些。通常来说,工具类的功能都比较通用和全面,所以在实现上,需要考虑和处理更多的细节,执行效率有所影响。除非 isValidIpAddress 函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显。
6.3 代码逻辑复杂就违背 KISS 原则吗?
本身就复杂的问题,用复杂的方式解决,并不违背 KISS 原则。使用 KMP 算法,而不是循环遍历方式匹配字符串,是合理的。
同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。
6.4 如何写出满足 KISS 原则的代码?
- 不要使用同事可能不懂的技术来实现代码。例如,一些非常复杂的正则表达式、一些编程语言中过于高级的语法等。
- 不要重复造轮子,要善于使用已有的工具库。自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
- 不要过度优化,不要过度使用一些奇淫技巧(位运算代替算术运算,一些过于底层的函数等)优化代码,牺牲了代码的可读性。
评价代码是否足够简单是一个很主观的评判,一个有效的间接方法,就是 code review。如果在 code review 的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够“简单”,需要优化啦。
在开发的时候,一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力。
6.5 YAGIN 跟 KISS 说的是一回事吗?
YAGIN 原则的全称:You Ain't Gonna Need It,直译就是:你不会需要它。应用到软件开发中,它的意思就是:不要去设计当前用不到的功能;不要去编写当前用不到的代码,核心思想就是:不要过度设计。
YAGIN 原则跟 KISS 原则并非一回事。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGIN 原则说的是“要不要做”的问题(当前不需要的就不要做)。
七、重复的代码就一定违反 DRY 吗?
DRY(Don't Repeat Yourself)原则,应用在编程中,可以理解为:不要写重复的代码。
7.1 DRY 原则
7.1.1 实现逻辑重复
举个例子:校验用户名和密码的逻辑都一样,那应该分别写 isValidPassword 函数 和 isValidUsername 函数(这两个函数的内部逻辑一摸一样),还是应该写一个 isValidUsernameOrPassword 函数呢?
答案是前者,因为 isValidUsernameOrPassword 函数违反了“单一职责原则”和“接口隔离原则”;即使isValidPassword 函数 和 isValidUsername 函数从代码实现逻辑上看是重复的,但语义并不重复:从功能上来看,这两个函数干的是完全不重复的两件事情。在未来的场景中,可能密码校验有的新的规则,那实现逻辑就会不一样了,还是会面临函数拆分的场景。
尽管代码的实现逻辑是相同的,但语义不同,所以并不违反 DRY 原则。对于包含重复代码的问题,可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含 a-z, 0-9, dot 的逻辑封装成一个处理函数。
7.1.2 功能语义重复
举个例子:在同一个项目代码中有下面两个函数:isValidIp() 和 checkIfIpValid()。函数命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法。
尽管代码的实现逻辑不重复,但语义重复,也就是功能重复,违反了 DRY 原则。 功能语义重复会给同事增加代码阅读的难度,同时,也增加了维护的成本。
7.1.3 代码执行重复
在代码中存在“执行重复”,这也是不合理的编码方式。如果重复的是 IO 操作,那这样的优化是很有必要的。
7.2 代码复用性(Code Reusability)
- 代码复用:表示一种行为,开发新功能时,尽量复用已经存在的代码。
- 代码的可复用性:表示一段代码可被复用的特性或能力,编写代码时,让代码尽量可复用。
- DRY 原则:不要写重复的代码。
这三者描述好像有些类似,但还是不太一样:
- “不重复”并不代表“可复用”。在一个项目代码中,可能不存在任何重复的代码,但并不表示里面有可复用的代码,不重复和可复用完全是两个概念。DRY 原则跟代码的可复用性讲的是两回事。
- “复用”和“可复用性”关注角度不同。“复用”是从代码使用者的角度来讲的,代码“可复用性”是从代码开发者的角度来讲的。
复用、可复用性、DRY 原则从理解上有所区别,但实际上要达到的目的都是类似的,减少代码量,提高代码的可读性、可维护性。复用已经验证过的代码,也更加稳定可靠。
7.3 如何提高代码复用性?
7.3.1. 减少代码耦合
对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。
7.3.2. 满足单一职责原则
如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。
7.3.3. 模块化
这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。
7.3.4. 业务与非业务逻辑分离
越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
7.3.5. 通用代码下沉
越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。所以,通用的代码应该尽量下沉到更下层。
7.3.6. 继承、多态、抽象、封装
越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
7.3.7. 应用模板等设计模式
一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
此外,还有一些跟编程语言相关的特性,也能提高代码的可复用性,比如泛型编程等。 复用意识很重要,写代码的时候,要多去思考一下,这个部分代码是否可以抽取出来,作为一个独立的模块、类或者函数供多处使用。
7.4 辩证思考和灵活运用
除非有非常明确的复用需求,否则,为了暂时用不到的复用需求,花费太多的时间、精力,投入太多的开发成本,不是一个值得推荐的做法。也违反了 YAGIN 原则。
有个“Rule of Three”原则,应用到代码开发就是,在第一次写代码的时候,如果没有复用场景,而未来的复用需求也不是特别明确,且开发成本高,就不考虑代码的复用性。之后开发新的功能时,发现可以复用之前写的这段代码,就重构这段代码,让其变得更加可复用。
八、迪米特法则(LOD)实现“高内聚、松耦合”
8.1 何为“高内聚、松耦合”?
“高内聚、松耦合”是一个比较通用的设计思想,用来指导不同粒度代码的设计与开发,比如系统、模块、类,甚至是函数,也可以应用到不同的开发场景中,比如微服务、框架、组件、类库等。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。高内聚有助于松耦合,松耦合又需要高内聚的支持。
高内聚
所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,好维护。单一职责原则就是实现代码高内聚非常有效的设计原则。
松耦合
所谓松耦合,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。依赖注入、接口隔离、基于接口而非实现编程,及迪米特法则都是为了实现代码的松耦合。
8.2 迪米特法则
迪米特法则(Law of Demeter, LOD),更加达意的名字,叫作最小知识原则(The Least Knowledge Principle)。
迪米特法则的英文定义
Each unit should have only limited knowledge about each other units: only units "closely" related to the current unit. Or: Each unit should talk to its friends; Don't talk to strangers.
不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(即定义中的“有限知识”)。
基于最小接口而非最大实现编程。
迪米特法则约定,要基于依赖的接口编程,但是同时要满足高内聚,高内聚的思想会引导我们把一些相关的方法写到同一类中,就好比序列化和反序列方法。他们被写到了一起,但是如果在其他方法里,有些调用方只需要序列化方法,那么他在依赖的时候,会看到附带了个反序列化的方法过来,这种情况是有违背与迪米特的。 基于最小接口编程,让实现类实现高内聚,不同接口之间去做迪米特。
九、总结
设计原则的最终目的,都是为了实现代码“高内聚、低耦合”,也是写代码的终极追求吧!单一职责原则是从自身提供的功能出发,迪米特法则是从关系设计出发,基于接口而非实现编程这是从使用者的角度出发,接口隔离原则也是从使用者的角度出发。殊途同归,写出更具拓展性和可维护性的代码。