理解设计原则
一、单一原则
单一职责原则(Single Responsibility Principle)简称SRP,它要求一个类或模块应该只负责一个特定的功能。这有助于降低类之间的耦合度,提高代码的可读性和可维护性。一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
1. 如何判断类的职责是否足够单一?
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
类中的代码行数、函数或者属性过多;
类依赖的其他类过多,或者依赖类的其他类过多;
私有方法过多;
比较难给类起一个合适的名字;
类中大量的方法都是集中操作类中的某几个属性。
2. 类的职责是否设计得越单一越好?
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
二、开闭原则
开闭原则(Open Closed Principle),简写为 OCP。当我们需要添加一个新的功能时,应该在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
开闭原则的核心思想是要尽量减少对现有代码的修改,以降低修改带来的风险和影响。在实际开发过程中,完全不修改代码是不现实的。当需求变更或者发现代码中的错误时,修改代码是正常的。然而,开闭原则鼓励我们通过设计更好的代码结构,使得在添加新功能或者扩展系统时,尽量减少对现有代码的修改。
当我们遵循开闭原则时,其目的是为了让我们的代码更容易维护、更具可复用性,同时降低了引入新缺陷的风险。但是,在某些情况下,遵循开闭原则可能会导致过度设计,增加代码的复杂性。因此,在实际开发中,我们应该根据实际需求和预期的变化来平衡遵循开闭原则的程度。
写代码不是为了设计而设计,脱离需求谈设计都是耍流氓,有些场景,比如项目的使用频率不高,修改的可能性很低,或者代码本来就很简单使用了设计模式可能会增加开发难度,提升开发成本,反而得不偿失。
1. 如何做到“对扩展开放、修改关闭”?
开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“黄金标准”,为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
作为一名“工程师”,而非码农,应该思考一些问题:
要写的这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
我们还要识别出代码可变部分和不可变部分,要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
我们可以采用以下策略和设计模式:
抽象与封装:通过定义接口或抽象类来封装变化的部分,将共性行为抽象出来。当需要添加新功能时,只需要实现接口或继承抽象类,而不需要修改现有代码。
组合/聚合:使用组合或聚合的方式,将多个不同功能模块组合在一起,形成一个更大的系统。当需要扩展功能时,只需要添加新的组件,而不需要修改现有的组件。
使用依赖注入:
使用设计模式:设计模式是针对某些特定问题的通用解决方案。很多设计模式都是为了支持“对扩展开放、修改关闭”的原则。例如,策略模式、工厂模式、装饰器模式等,都是为了实现这个原则。
使用事件和回调:通过事件驱动和回调函数,可以让系统在运行时根据需要动态地添加或修改功能,而无需修改现有代码。
使用插件机制:通过插件机制,可以允许第三方开发者为系统添加新功能,而无需修改系统的核心代码。这种机制常用于框架和大型软件系统中。
需要注意的是,遵循开闭原则并不意味着永远不能修改代码。在实际开发过程中,完全不修改代码是不现实的。开闭原则的目标是要尽量降低修改代码带来的风险和影响,提高代码的可维护性和可复用性。在实际开发中,我们应该根据项目需求和预期的变化来平衡遵循开闭原则的程度。
三、里氏替换原则
里式替换原则(Liskov Substitution Principle),缩写为 LSP🫣🫣,最早是在 1986 年由 Barbara Liskov 提出:使用基类引用指针的函数必须能够在不知情的情况下使用派生类的对象。
翻译过来:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。
多态:是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。
里式替换:是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
1. 哪些代码明显违背了 LSP?
子类覆盖或修改了基类的方法:当子类覆盖或修改基类的方法时,可能导致子类无法替换基类的实例而不引起问题。这违反了LSP,会导致代码变得脆弱和不易维护。
子类违反了基类的约束条件:当子类违反了基类中定义的约束条件(如输入、输出或异常等),也会违反LSP。
子类与基类之间缺乏"is-a"关系:如果一个类继承自另一个类,仅仅因为它们具有部分相似性,而不是完全的"is-a"关系,那么这种继承关系可能不满足LSP。
为了避免违反LSP,我们需要在设计和实现过程中注意以下几点:
确保子类和基类之间存在真正的"is-a"关系。
遵循其他设计原则,如单一职责原则、开闭原则和依赖倒置原则。
四、接口隔离原则
接口隔离原则(Interface Segregation Principle),缩写为 ISP。客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
接口隔离原则有以下几个要点:
将一个大的、通用的接口拆分成多个专用的接口。这样可以降低类之间的耦合度,提高代码的可维护性和可读性。
为每个接口定义一个独立的职责。这样可以确保接口的粒度适当,同时也有助于遵循单一职责原则。
在定义接口时,要考虑到客户端的实际需求。客户端不应该被迫实现无关的接口方法。
一个接口只定义一个方法确实可以满足接口隔离原则,但这并不是一个绝对的标准。在设计接口时,我们需要权衡接口的粒度和实际需求。过度拆分接口可能导致过多的单方法接口,这会增加代码的复杂性,降低可读性和可维护性。关键在于确保接口的职责清晰且单一,以便客户端只需依赖它们真正需要的接口。
单一职责原则针对的是模块、类、接口的设计。 接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。 它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
五、依赖倒置原则
依赖倒置原则(Dependency Inversion Principle),简称 DIP。这个原则强调要依赖于抽象而不是具体实现。遵循这个原则可以使系统的设计更加灵活、可扩展和可维护。
依赖倒置原则有两个关键点:
高层模块不应该依赖于低层模块,它们都应该依赖于抽象。
抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
说人话:在依赖倒置原则(Dependency Inversion Principle, DIP)中,我们需要改变依赖关系的方向,使得高层模块和低层模块都依赖于抽象,而不是高层模块直接依赖于低层模块。这样一来,依赖关系就从直接依赖具体实现“反过来”依赖抽象了。
1.如何理解抽象?
当我们在讨论依赖倒置原则中的抽象时,绝对不能仅仅把他理解为一个接口。抽象的目的是将关注点从具体实现转移到概念和行为,使得我们在设计和编写代码时能够更加关注问题的本质。通过使用抽象,我们可以创建更加灵活、可扩展和可维护的系统。
事实上抽象是一个很广泛的概念,它可以包括接口、抽象类以及由大量接口,抽象类和实现组成的更高层次的模块。通过将系统分解为更小的、可复用的组件,我们可以实现更高层次的抽象。这些组件可以独立地进行替换和扩展,从而使整个系统更加灵活。
2.如何理解高层模块和底层模块?
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。
在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟控制反转类似。拿 Tomcat 这个 Servlet 容器作为例子来解释:从业务代码上讲,controller要依赖service的接口而不是实现,service实现要依赖dao层的接口而不是实现,调用者要依赖被调用者的接口而不是实现。
Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Sevlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。这样做的好处就是tomcat中可以运行任何实现了servlet规范的应用程序,同时我们编写的servlet实现(web)工程也可以运行在不同的web服务器中。
3. IOC容器
依赖倒置的目的是,低层模块可以随时替换,以提高代码的可扩展性。在spring中实现这个很简单的,我们只需要向容器中注入特定的bean就能切换具体实现。
控制反转是一种软件设计原则,它将传统的控制流程颠倒过来,将控制权交给一个中心化的容器或框架。
依赖注入是指不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
通过控制翻转和依赖注入结合,我们只要保证依赖抽象而不是实现,就能很轻松的替换实现。如给容器注入一个MySQL的数据,则所有依赖数据源的部分会自动使用MySQL,如果想替换数据源则仅仅需要给容器注入一个新的数据源就好了。
六、KISS 原则
KISS 原则(Keep It Simple, Stupid)。KISS 原则强调保持代码简单,易于理解和维护。在编写代码时,应避免使用复杂的逻辑、算法和技术,尽量保持代码简洁明了。这样可以提高代码的可读性、可维护性和可测试性。目标是要在保证性能和功能的前提下,尽量简化代码实现。
KISS 原则算是一个万金油类型的设计原则,可以应用在很多场景中。它不仅经常用来指导软件开发,还经常用来指导更加广泛的系统设计、产品设计等,比如,冰箱、建筑、iPhone 手机的设计等等。
代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
1、代码逻辑复杂就违背 KISS 原则吗?
并非所有的复杂代码逻辑都违背了KISS原则。KISS原则强调的是保持代码简洁、易于理解和维护。有时候,为了实现某个功能,确实需要一定程度的复杂逻辑。关键在于如何尽可能地让代码简单和清晰。 遵循KISS原则的复杂代码应当具备以下特点:
模块化:将复杂的代码逻辑拆分成多个简单、独立的模块,每个模块负责一个特定的功能。这有助于降低代码的复杂度,提高代码的可读性和可维护性。
清晰的命名:为变量、方法、类等使用清晰、有意义的命名,以便于其他人(或未来的你)阅读和理解代码。
注释和文档:为复杂的代码逻辑编写清晰、详细的注释和文档,解释代码的作用和实现原理。这有助于其他人(或未来的你)更容易地理解和维护代码。
避免不必要的复杂度:尽量避免引入不必要的复杂性,如使用过于复杂的算法或数据结构。在实现功能的同时,要考虑代码的简洁性和可读性。
总之,遵循KISS原则并不意味着代码必须简单到极致。相反,它鼓励我们在实现功能的同时,尽量保持代码的简洁、易于理解和维护。一个良好的开发者应当在实际项目中找到适当的平衡点,既满足功能需求,又不过分增加代码的复杂度。
2、如何写出满足 KISS 原则的代码?
不要使用同事可能不懂的技术来实现代码。如一些编程语言中过于高级的语法等。
不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。(个人学习,造轮子还是很有必要,生产环境还是不冒险背锅的好)
不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
代码是否足够简单是一个挺主观的评判。同样的代码,有的人觉得简单,有的人觉得不够简单。而往往自己编写的代码,自己都会觉得够简单。所以,评判代码是否简单,还有一个很有效的间接方法,那就是 code review。如果在 code review 的时候,有人对你的代码有很多疑问,那就说明写的代码有可能不够“简单”,需要优化。
七、 DRY 原则
DRY 原则(Don't Repeat Yourself),DRY 原则强调避免代码重复,尽量将相似的代码和逻辑提取到共享的方法、类或模块中。遵循 DRY 原则可以减少代码的冗余和重复,提高代码的复用性和可维护性。当需要修改某个功能时,只需修改对应的共享代码,而无需在多处进行相同的修改。这有助于降低维护成本,提高开发效率。
这条看似简单的原则,实则很难把控,设计原则不是1+1,有些代码在有些场景下就是符合某些原则的,在其他场景下就是不符合的,我们学习了DRY原则,不能狭隘的理解。不是说只要两段代码长得一样,那就是违反 DRY 原则,同时有些看似不重复的代码也有可能会违反DRY原则。
1.只要两段代码长得一样,那就是违反 DRY 原则吗?
不一定。DRY 原则的核心思想是减少重复代码,以提高代码的可维护性、可读性和可重用性。DRY原则的核心思想是避免重复的代码,尽可能将重复的代码封装成可重用的模块、函数、类等,提高代码的复用性,降低代码的耦合性。然而,并不是所有看起来相似的代码都违反了 DRY 原则。在某些情况下,重复的代码片段可能具有完全不同的逻辑含义,因此将它们合并可能会导致误解。
在评估是否违反了 DRY 原则时,需要考虑以下几点:
逻辑一致性:如果两段代码的逻辑和功能是一致的,那么将它们合并为一个方法或类是有意义的。如果它们实际上是在执行不同的任务,那么合并它们可能导致难以理解的代码。
可维护性:如果将两段看似相同的代码合并可能导致难以维护的代码(例如,增加了过多的条件判断),那么保留一些重复可能是更好的选择。
变更影响:考虑未来的需求变更。如果两段看似相同的代码很可能在未来分别发生变化,那么将它们合并可能导致更多的维护负担。在这种情况下,保留一些重复代码可能是更实际的选择。
总之,判断是否违反了 DRY 原则需要权衡多个因素。关键在于寻找适当的平衡点,以提高代码质量,同时确保可维护性和可读性。
八、迪米特法则
迪米特法则(Law of Demeter, LoD),又称最少知识原则(Least Knowledge Principle, LKP)。它的核心思想是:一个对象应该尽量少地了解其他对象,降低对象之间的耦合度,从而提高代码的可维护性和可扩展性。
两个类之间尽量不要直接依赖,如果必须依赖,最好只依赖必要的接口。迪米特法则的主要指导原则如下:
类和类之间尽量不直接依赖。
有依赖关系的类之间,尽量只依赖必要的接口。
1.辩证思考与灵活应用
在实际工作中,确实需要在不同的设计原则之间进行权衡。迪米特法则(Law of Demeter,LoD)是一种有助于降低类之间耦合度的原则,但过度地应用迪米特法则可能导致代码变得复杂和难以维护。
避免过度封装:尽管迪米特法则强调类之间的低耦合,但是过度封装可能导致系统变得难以理解和维护。当一个类需要访问另一个类的属性或方法时,我们应该权衡封装的成本和收益,而不是盲目地遵循迪米特法则。
拒绝过度解耦:在实际项目中,过度解耦可能导致大量的中间层和传递性调用。当一个类需要访问另一个类的方法时,如果引入大量的中间层会导致系统变得复杂和低效,那么我们应该考虑放宽迪米特法则的约束。
与其他设计原则和模式相结合:在实际项目中,我们应该灵活地将迪米特法则与其他设计原则(如单一职责原则、开闭原则等)和设计模式(如外观模式、代理模式等)相结合。这样可以使我们在降低耦合度的同时,保持代码的可读性、可维护性和可扩展性。
考虑实际需求和场景:在应用迪米特法则时,我们应该关注实际的需求和场景。如果一个项目的需求和场景较为简单,那么过度地应用迪米特法则可能导致不必要的开发成本。相反,如果一个项目的需求和场景较为复杂,那么遵循迪米特法则可能有助于提高系统的稳定性和可维护性。
总结
职责单一,类和接口的粒度要适中,类要有内聚性(单一职责,接口隔离)
面向抽象编程,要依赖抽象,而不依赖具体,要解耦合(开闭原则、依赖倒置、迪米特法则)
类和类之间的关联要紧凑,只依赖必要的接口(迪米特法则)
组合优于继承,继承后最好不要重写,如果重写不要采用一些手段禁用方法(里氏替换原则)
不要写重复代码,代码实现(在保证功能和性能的前提下)最好简单一点,多用大家常用的库,最好不要造轮子,代码禁止花里胡哨,编写好的注释和文档,使用好的命名规范。(KISS . DRY) 可读性 安全性
评论区