本文主要记载
- 单一责任原则
- 如何理解 “单一职责原则”?
- 如何判断类的职责是否足够单一?
- 类的职责是否设计的越单一越好?
- 开闭原则
- 如何理解 “对扩展开放、修改关闭”?
- 如何做到 “对扩展开放、修改关闭”?
- 如何正在项目中灵活应用开闭原则?
- 里式替换原则
- 如何理解 “里式替换原则”?
- 如何做到 “里式替换原则”?
- 接口隔离原则和单一职责原则的区别
- 依赖反转原则
- 控制反转
- 依赖注入
- 依赖反转原则
- KISS 原则
- 代码行数越少越 “简单” 吗?
- 代码逻辑复杂就违背 KISS 原则吗?
- 如何写出满足 KISS 原则的代码?
- YANGNI 原则跟 KISS 原则说的是一回事吗?
- DRY 原则
- 实现逻辑重复
- 功能语义重复
- 代码执行重复
- 怎么提高代码复用性?
- 思考和灵活应用
- 迪米特法则
- 何为 “高内聚,松耦合”?
- 不该有直接依赖关系的类之间,不要有依赖
- 有依赖关系的类之间,尽量只依赖必要的接口
🚩单一责任原则
(Single Responsibility Principle SRP)
1 如何理解 “单一职责原则” ?
A class or module should have a single reponsibility(一个类或者模块只负责完成一个职责(或者功能))
一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
就一个类而言,应该仅有一个引起变化的原因
把因为相同的原因而改变的事物聚集在一起。把那些因为不同原因而改变的东西分开。
2 如何判断类的职责是否足够单一?
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。举个栗子🌰
1 | public class UserInfo { |
UserInfo 类的设计是否满足单一职责原则呢?
对于这个问题,有两种不同的观点。一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。
哪种观点更对呢?实际上,要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。
一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
- 类中的代码行数、函数或者属性过多;
- 类依赖的其他类过多,或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性;
至于如何判断 过多 ,实际上,这个问题并不好定量地回答,就像你问大厨 “放盐少许” 中的 “少许” 是多少,大厨也很难告诉你一个特别具体的量值,都是经验与感觉。
从另一个角度来看,当一个类的代码,读起来让你头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数、函数、属性过多了。项目做多了,代码写多了,在开发中慢慢 “品尝” ,自然就知道什么是 “放盐少许” 了,这就是所谓的 “专业第六感” 。
3 类的职责是否设计得越单一越好?
为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。
同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。
但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
举个栗子🌰 Serialization 类实现了一个 简单协议 的 序列化 和 反序列 功能
1 | public class Serialization { |
如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。但是,现在如果我们修改了协议的格式,或者序列化方式从 JSON 改为了 XML ,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。拆分之后,代码的可维护性变差了。
🚩开闭原则
开闭原则 (Open-Closed Principle 简称 OCP) 是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则。
1 如何理解 “对扩展开放、修改关闭” ?
software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification(软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。)
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。
- 开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
- 同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
2 如何做到 “对扩展开放、修改关闭” ?
举个栗子🌰 一个警告通知系统,通过各种判断是否需要通知。
1 | public class Alert { |
业务逻辑主要集中在 check() 函数中。现在,如果我们需要添加一个警告功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?
1 | public class Alert { |
这样的代码修改实际上存在挺多问题的。
- 一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。
- 另一方面,修改了
check()函数,相应的单元测试都需要修改
这个代码改动是基于 “修改” 的方式来实现新功能的。如果我们遵循开闭原则,也就是 “对扩展开放、对修改关闭” 。那如何通过 “扩展” 的方式,来实现同样的功能呢?
先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:
- 第一部分是将
check()函数的多个入参封装成ApiStatInfo类; - 第二部分是引入
handler的概念,将 if 判断逻辑分散在各个handler中。
1 | public class Alert { |
然后再写一个单例提供配置和使用。
1 | public class ApplicationContext { |
如果再添加上面的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?
- 第一处改动是:在
ApiStatInfo类中添加新的属性timeoutCount。 - 第二处改动是:添加新的
TimeoutAlertHander类。 - 第三处改动是:在
ApplicationContext类的initializeBeans()方法中,往 alert 对象中注册新的timeoutAlertHandler。 - 第四处改动是:在使用
Alert类的时候,需要给check()函数的入参apiStatInfo对象设置timeoutCount的值。
1 | public class Alert { // 代码未改动... } |
重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。
3 如何在项目中灵活应用开闭原则?
写出支持 “对扩展开放、对修改关闭” 的代码的关键是预留 扩展点 。那问题是如何才能识别出所有可能的扩展点呢?
要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。不过,有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
而且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。比如,我们之前举的 Alert 报警的例子。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。
总之,这里没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。
🚩里氏替换原则
(Liskov Substitution Principle 简称 LSP)
1 如何理解 “里式替换原则” ?
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
(子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。)
里式替换原则更像是一个多态的规范。里式替换原则是用多态实现的,多态实现的子类不一定符合里式替换原则。
2 如何做到 “里式替换原则” ?
举个栗子🌰 子类 SecurityTransporter 继承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息。
1 | public class Transporter { |
在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。
如果我们对 SecurityTransporter 类中 sendRequest() 函数稍加改造一下。改造前,如果 appId 或者 appToken 没有设置,我们就不做校验;改造后,如果 appId 或者 appToken 没有设置,则直接抛出 NoAuthorizationRuntimeException 未授权异常。
1 | // 改造后: |
在改造之后的代码中,如果传递进 demoFunction() 函数的是父类 Transporter 对象,那 demoFunction() 函数并不会有异常抛出。
但如果传递给 demoFunction() 函数的是子类 SecurityTransporter 对象,那 demoFunction() 有可能会有异常抛出。
子类替换父类传递进 demoFunction 函数之后,整个程序的逻辑行为有了改变。这样就违背了里式替换原则,但是他还是由多态实现的。
多态 和 里式替换 有点类似,但它们关注的角度是不一样的。
多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。
而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
3 哪些代码明显违背了 LSP?
子类违背父类声明要实现的功能
父类中提供的
sortOrdersByAmount()订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个sortOrdersByAmount()订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定:运行出错的时候返回
null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回null。那子类的设计就违背里式替换原则。在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
在父类中,某个函数约定,只会抛出
ArgumentNullException异常,那子类的设计实现中只允许抛出ArgumentNullException异常,任何其他异常的抛出,都会导致子类违背里式替换原则。子类违背父类注释中所罗列的任何特殊说明
父类中定义的
withdraw()提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写withdraw()函数之后,针对VIP账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
🚩接口隔离原则
(Interface Segregation Principle 简称 ISP)
1 如何理解 “接口隔离原则” ?
Clients should not be forced to depend upon interfaces that they do not use (客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者)
在这条原则中,我们可以把“接口”理解为下面三种东西:
- 一组
API接口集合 - 单个
API接口或函数 OOP中的接口概念
2 如何做到 “接口隔离原则” ?
- 一组
API接口集合
微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。
1 | public interface UserService { |
现在,我们的后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口。
删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。
我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。
1 | public interface RestrictedUserService { |
在刚刚的这个例子中,我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。
- 单个
API接口或函数
把接口理解为单个接口或函数,那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。
1 | public class Statistics { |
按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。
1 | public Long max(Collection<Long> dataSet) { //... } |
在某种意义上讲,count() 函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。判定功能是否单一,除了很强的主观性,还需要结合具体的场景。
如果在项目中,对每个统计需求,Statistics 定义的那几个统计信息都有涉及,那 count() 函数的设计就是合理的。如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。count() 函数的设计就有点不合理了。
OOP中的接口概念
我们还可以把“接口”理解为 OOP 中的接口概念,比如 Java 中的 interface。
举个栗子🌰 假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。
1 | public class RedisConfig { |
现在,我们有一个新的功能需求,以固定时间频率(periodInSeconds)来调用 RedisConfig、KafkaConfig 的 update() 方法更新配置信息。
1 | public interface Updater { |
新的 ScheduledUpdater 类,只依赖 Updater 这个相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。功能是定时调用传入的 Updater 的 update 方法。
现在,我们又有了一个新的监控功能需求。我们想暴露 MySQL 和 Redis 的配置信息,输出项目的配置信息到一个固定的 HTTP 地址。
1 | public interface Viewer { |
SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则。
这种方式比普通大而全的 Config 接口更加灵活、易扩展、易复用,因为 Updater、Viewer 职责更加单一,添加新需求也容易应对。同时也防止了重写无用的接口,可读性更高。
3 接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。
接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。
接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
🚩依赖反转原则
在讲 “依赖反转原则 (Dependency Inversion Principe 简称 DIP)” 之前,我们先讲一讲基础知识。
1 控制反转
( Inversion Of Control IOC)
这里的 ”控制” 指的是对程序执行流程的控制,而 “反转” 指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员 “反转” 到了框架。
举个栗子🌰
1 | public class UserServiceTest { |
在上面的代码中,所有的流程都由程序员来控制。我们再来看,如何利用 ”控制反转“ 来实现同样的功能。
首先是框架代码
1 | public abstract class TestCase { |
我们只需要在框架预留的扩展点,也就是 TestCase 类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了。
1 | public class UserServiceTest extends TestCase { |
这就是典型的通过框架来实现 “控制反转” 的例子。
框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。
程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。
控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
2 依赖注入
(Dependency Injection DI)
有一个非常形象的说法,那就是:依赖注入是一个标价 25 美元,实际上只值 5 美分的概念。也就是说,这个概念听起来很 “高大上” ,实际上,理解、应用起来非常简单。
那到底什么是依赖注入呢?
我们用一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
举个栗子🌰
1 | public class Notification { |
通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。
这一点在我们之前讲 “开闭原则” 的时候也提到过。
上面代码还有继续优化的空间,我们还可以把 MessageSender 定义成接口,基于接口而非实现编程。
1 | public class Notification { |
依赖注入非常简单,但却非常有用!!它是编写可测试性代码最有效的手段。
“基于接口而非实现编程” 跟 “依赖注入” ,看起来非常类似,那它俩有什么区别和联系呢?
二者都是从外部传入依赖对象而不是在内部去 new 一个出来。
区别是 “基于接口而非实现编程” 强调的是 “接口” ,强调依赖的对象是接口,而不是具体的实现类;而 “依赖注入” 不强调这个,类或接口都可以,只要是从外部传入不是在内部 new 出来都可以称为依赖注入。
3 依赖反转原则
(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. (高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。)
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者 属于高层,被调用者 属于低层。
依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。
举个栗子🌰
Tomcat 是运行 Java Web 应用程序的容器。
我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个 “抽象” ,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
🚩KISS 原则
Keep It Simple and Stupid.
Keep It Short and Simple.
Keep It Simple and Straightforward.
(尽量保持简单。)
代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
1 代码行数越少就越“简单”吗?
举几个栗子🌰 对比一下。 检查输入的字符串 ipAddress 是否是合法的 IP 地址。一个合法的 IP 地址由四个数字组成,并且通过 “.” 来进行分割。每组数字的取值范围是 0~255。第一组数字比较特殊,不允许为 0。
1 | // 第一种实现方式: 使用正则表达式 |
第一种实现方式利用的是正则表达式
只用三行代码就把这个问题搞定了。它的代码行数最少,那是不是就最符合 KISS 原则呢?答案是否定的。虽然代码行数最少,看似最简单,实际上却很复杂。这正是因为它使用了正则表达式。一方面,正则表达式本身是比较复杂的,写出完全没有
bug的正则表达本身就比较有挑战;另一方面,并不是每个程序员都精通正则表达式。这种实现方式会导致代码的可读性和可维护性变差,所以,从KISS原则的设计初衷上来讲,这种实现方式并不符合KISS原则。第二种实现方式使用了
StringUtils类、Integer类提供的一些现成的工具函数处理IP地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理
IP地址中的字符,来判断是否合法。第三种实现方式完全是自己操作底层字符,只针对
IP地址这一种格式的数据输入来做处理,没有太多多余的函数调用和其他不必要的处理逻辑,所以,在执行效率上,这种类似定制化的处理代码方式肯定比通用的工具类要高些。从性能上来说,尽管第三种实现方式性能更高些,但还是更倾向于选择第二种实现方法。那是因为第三种实现方式实际上是一种过度优化。除非
isValidIpAddress()函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显。从代码行数上来说,这两种方式差不多。但是,第三种要比第二种更加有难度,更容易写出
bug。从可读性上来说,第二种实现方式的代码逻辑更清晰、更好理解。
所以,在这两种实现方式中,第二种实现方式更加 “简单” ,更加符合
KISS原则。
2 代码逻辑复杂就违背 KISS 原则吗?
刚刚我们提到,并不是代码行数越少就越“简单”,还要考虑逻辑复杂度、实现难度、代码的可读性等。那如果一段代码的逻辑复杂、实现难度大、可读性也不太好,是不是就一定违背 KISS 原则呢?其实这也是由业务场景决定的。举个栗子🌰
1 | // KMP algorithm: a, b分别是主串和模式串;n, m分别是主串和模式串的长度。 |
这是一段 KMP 字符串匹配算法,它以快速高效著称。KMP 字符串匹配算法完全符合我们刚提到的逻辑复杂、实现难度大、可读性差的特点,但当我们需要处理长文本字符串匹配问题(几百 MB 大小文本内容的匹配),或者字符串匹配是某个产品的核心功能(比如 Vim、Word 等文本编辑器),又或者字符串匹配算法是系统性能瓶颈的时候,这时它并不违反 KISS 原则。
本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。
不过,平时的项目开发中涉及的字符串匹配问题,大部分都是针对比较小的文本。在这种情况下,直接调用编程语言提供的现成的字符串匹配函数就足够了。如果非得用 KMP 算法、BM 算法来实现字符串匹配,那就真的违背 KISS 原则了。
也就是说,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。
3 如何写出满足 KISS 原则的代码?
- 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。
- 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出
bug的概率会更高,维护的成本也比较高。 - 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替
if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
评判代码是否简单,有一个很有效的间接方法,那就是 code review。如果在 code review 的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够 “简单” ,需要优化啦。
我们在做开发的时候,一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力。
4 YAGNI 原则跟 KISS 原则说的是一回事吗?
You Ain’t Gonna Need It(你不会需要它)
不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。
对于 Java 程序员来说,我们经常使用 Maven 或者 Gradle 来管理依赖的类库(library),有些同事为了避免开发中 library 包缺失而频繁地修改 Maven 或者 Gradle 配置文件,提前往项目里引入大量常用的 library 包。实际上,这样的做法也是违背 YAGNI 原则的。
KISS 原则讲的是 “如何做” 的问题(尽量保持简单),而 YAGNI 原则说的是 “要不要做” 的问题(当前不需要的就不要做)。
🚩DRY 原则
Don’t Repeat Yourself(不要重复自己)
可以理解为:不要写重复的代码。
你可能会觉得,这条原则非常简单、非常容易应用。只要两段代码长得一样,那就是违反 DRY 原则了。真的是这样吗?
答案是否定的。这是很多人对这条原则存在的误解。实际上,重复的代码不一定违反 DRY 原则,而且有些看似不重复的代码也有可能违反 DRY 原则。
1 实现逻辑重复
举个栗子🌰 有两处非常明显的重复的代码片段:isValidUserName() 函数和 isValidPassword() 函数
1 | public class UserAuthenticator { |
重复的代码被敲了两遍,或者简单 copy-paste 了一下,看起来明显违反 DRY 原则。为了移除重复的代码,我们对上面的代码做下重构,将 isValidUserName() 函数和 isValidPassword() 函数,合并为一个更通用的函数 isValidUserNameOrPassword() 。
1 | public class UserAuthenticatorV2 { |
经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?答案是否定的!!
合并之后的 isValidUserNameOrPassword() 函数,负责两件事情:验证用户名和验证密码,违反了 “单一职责原则” 和 “接口隔离原则” 。
从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓 “语义不重复” 指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。
经过重构之后,在未来的某一天,如果我们修改了密码的校验逻辑,我们就要把合并后的函数,重新拆成合并前的那两个函数。
尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则。对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。
2 功能语义重复
在同一个项目代码中有下面两个函数:isValidIp() 和 checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP地址是否合法的。
之所以在同一个项目中会有两个功能相同的函数,那是因为这两个函数是由两个不同的同事开发的,这时经常遇到的,其中一个同事在不知道已经存在相同功能的情况下又写了一个。
1 | public boolean isValidIp(String ipAddress) { |
在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则。我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。
假设我们不统一实现思路,给不熟悉这部分代码的同事增加了阅读的难度。同事有可能研究了半天,觉得功能是一样的,但又有点疑惑,觉得是不是有更高深的考量,才定义了两个功能类似的函数,最终发现居然是代码设计的问题。
除此之外,如果哪天项目中 IP 地址是否合法的判定规则改变了,相应地,我们对 isValidIp() 的实现逻辑做了相应的修改,但却忘记了修改 checkIfIpValid() 函数。又或者,我们压根就不知道还存在一个功能相同的 checkIfIpValid() 函数,这样就会导致有些代码仍然使用老的 IP 地址判断逻辑,导致出现一些莫名其妙的 bug。
3 代码执行重复
第三个栗子🌰 UserService 中 login() 函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。
1 | public class UserService { |
上面这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码中存在 “执行重复” 。
email 的校验逻辑被执行了两次。一次是在调用 checkIfUserExisted() 函数的时候,另一次是调用 getUserByEmail() 函数的时候。我们只需要将校验逻辑从 UserRepo 中移除,统一放到 UserService 中就可以了。
代码中还有一处比较隐蔽的执行重复,login() 函数并不需要调用 checkIfUserExisted() 函数,只需要调用一次 getUserByEmail() 函数,从数据库中获取到用户的 email、password 等信息,然后跟用户输入的 email、password 信息做对比,依次判断是否登录成功。
这样的优化是很有必要的。因为 checkIfUserExisted() 函数和 getUserByEmail() 函数都需要查询数据库,而数据库这类的 I/O 操作是比较耗时的。我们在写代码的时候,应当尽量减少这类 I/O 操作。
按照刚刚的修改思路,我们把代码重构一下
1 | public class UserService { |
4 怎么提高代码复用性?
减少代码耦合
对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。
满足单一职责原则
如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。
模块化
这里的 “模块” ,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。
业务与非业务逻辑分离
越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。
通用代码下沉
从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。
继承、多态、抽象、封装
利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
应用模板等设计模式
一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。
5 辩证思考和灵活应用
实际上,编写可复用的代码并不简单。
除非有非常明确的复用需求,否则,为了暂时用不到的复用需求,花费太多的时间、精力,投入太多的开发成本,并不是一个值得推荐的做法。这也违反我们之前讲到的 YAGNI 原则。
有一个著名的原则,叫作 “Rule of Three” 。这条原则可以用在很多行业和场景中,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。
在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。
也就是说,第一次编写代码的时候,我们不考虑复用性;第二次遇到复用场景的时候,再进行重构使其复用。需要注意的是,“Rule of Three” 中的 “Three” 并不是真的就指确切的 “三” 。
🚩迪米特法则
Law of Demeter LOD
Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.(每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)。)
不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。
迪米特法则。尽管它不像 SOLID、KISS、DRY 原则那样,人尽皆知,但它却非常实用。利用这个原则,能够帮我们实现代码的 “高内聚、松耦合” 。
1 何为 “高内聚、松耦合” ?
在这个设计思想中,“高内聚” 用来指导类本身的设计,“松耦合” 用来指导类与类之间依赖关系的设计。不过,这两者并非完全独立不相干。高内聚有助于松耦合,松耦合又需要高内聚的支持。
所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。
相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。实际上,单一职责原则是实现代码高内聚非常有效的设计原则。
所谓松耦合是说,在代码中,类与类之间的依赖关系简单清晰。
即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。实际上,我们前面讲的依赖注入、接口隔离、基于接口而非实现编程,以及迪米特法则,都是为了实现代码的松耦合。
##2 不该有直接依赖关系的类之间,不要有依赖
迪米特法则的前半部分,
2 不该有直接依赖关系的类之间,不要有依赖
举个栗子🌰 代码中包含三个主要的类。其中,NetworkTransporter 类负责底层网络通信,根据请求获取数据;HtmlDownloader 类用来通过 URL 获取网页;Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。
1 | public class NetworkTransporter { |
首先看
NetworkTransporter类作为一个底层网络通信类,我们希望它的功能尽可能通用,而不只是服务于下载
HTML,所有这里的参数HtmlRequest就限制了这个功能,我们不应该直接依赖太具体的发送对象HtmlRequest。从这一点上讲,NetworkTransporter类的设计违背迪米特法则,依赖了不该有直接依赖关系的HtmlRequest类。有一个很形象的比喻。假如你现在要去商店买东西,你肯定不会直接把钱包给收银员,让收银员自己从里面拿钱,而是你从钱包里把钱拿出来交给收银员。这里的
HtmlRequest对象就相当于钱包,HtmlRequest里的address和content对象就相当于钱。我们应该把address和content交给NetworkTransporter,而非是直接把HtmlRequest交给NetworkTransporter。1
2
3
4
5
6public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(String address, Byte[] data) {
//...
}
}再来看
HtmlDownloader类根据
NetworkTransporter的修改HtmlDownloader也得做相应的修改。1
2
3
4
5
6
7
8
9
10public class HtmlDownloader {
private NetworkTransporter transporter;//通过构造函数或IOC注入
// HtmlDownloader这里也要有相应的修改
public Html downloadHtml(String url) {
HtmlRequest htmlRequest = new HtmlRequest(url);
Byte[] rawHtml = transporter.send(
htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
return new Html(rawHtml);
}
}最后是
Document类主要有三点。
第一,构造函数中的downloader.downloadHtml()逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性
第二,HtmlDownloader对象在构造函数中通过new来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性
第三,从业务含义上来讲,Document网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class Document {
private Html html;
private String url;
public Document(String url, Html html) {
this.html = html;
this.url = url;
}
//...
}
// 通过一个工厂方法来创建Document
public class DocumentFactory {
private HtmlDownloader downloader;
public DocumentFactory(HtmlDownloader downloader) {
this.downloader = downloader;
}
public Document createDocument(String url) {
Html html = downloader.downloadHtml(url);
return new Document(url, html);
}
}这样就很好的解决了三个问题。
3 有依赖关系的类之间,尽量只依赖必要的接口
举个之前的栗子🌰 Serialization 类负责对象的序列化和反序列化
1 | public class Serialization { |
单看这个类的设计,没有一点问题。
假设在我们的项目中,有些类只用到了序列化操作,而另一些类只用到反序列化操作。那基于迪米特法则后半部分 “有依赖关系的类之间,尽量只依赖必要的接口” ,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口。
那我们是不是又得把这个类拆开成两个类?那不就和之前在单一职责中说的冲突了吗?之前我们就说拆分后代码的内聚性显然没有原来 Serialization 高了。那有没有既不违背高内聚的设计思想,也不违背迪米特法则的方法呢?🤔🤔
既然在这说了,肯定是有的😁
我们可以利用 “接口隔离原则” 来解决这个问题。
1 | public interface Serializable { |
尽管我们还是要往 DemoClass_1 的构造函数中,传入包含序列化和反序列化的 Serialization 实现类,但是,我们依赖的 Serializable 接口只包含序列化操作,DemoClass_1 无法使用 Serialization 类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的 “依赖有限接口”的要求 。
上面的代码实现思路,也体现了 “基于接口而非实现编程” 的设计原则,结合迪米特法则,我们可以总结出一条新的设计原则,那就是 “基于最小接口而非最大实现编程”。
新的设计模式和设计原则是怎么创造出来的,实际上,就是在大量的实践中,针对开发痛点总结归纳出来的套路。







