设计模式之七大基本原则
做什么事都需要遵循一些准则,设计模式也不例外。我们在设计一些设计模式时,一般遵循如下七项基本原则,它们分别是:
- 单一职责原则 (Single Responsibility Principle)
- 开放-关闭原则 (Open-Closed Principle)
- 里氏替换原则 (Liskov Substitution Principle)
- 依赖倒转原则 (Dependence Inversion Principle)
- 接口隔离原则 (Interface Segregation Principle)
- 迪米特法则(Law Of Demeter)
- 组合/聚合复用原则 (Composite/Aggregate Reuse Principle)
1.单一职责原则 SRP
单一职责原则表示一个模块的组成元素之间的功能相关性。从软件变化的角度来看,就一个类而言,应该仅有一个让它变化的原因;通俗地说,即一个类只负责一项职责。
假设某个类 P 负责两个不同的职责,职责 P1 和 职责 P2,那么当职责 P1 需求发生改变而需要修改类 P,有可能会导致原来运行正常的职责 P2 功能发生故障。
我们假设一个场景:
有一个动物类,它会呼吸空气,用一个类描述动物呼吸这个场景:
class Animal{
public void breathe(String animal){
System.out.println(animal + "呼吸空气");
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
}
}
在后来发现新问题,并不是所有的动物都需要呼吸空气,比如鱼需要呼吸水,修改时如果遵循单一职责原则的话,那么需要将 Animal 类进行拆分为陆生类和水生动物类,代码如下:
class Terrestrial{
public void breathe(String animal){
System.out.println(animal + "呼吸空气");
}
}
class Aquatic{
public void breathe(String animal){
System.out.println(animal + "呼吸水");
}
}
public class Client{
public static void main(String[] args){
Terrestrial terrestrial = new Terrestrial();
terrestrial.breathe("牛");
terrestrial.breathe("羊");
terrestrial.breathe("猪");
Aquatic aquatic = new Aquatic();
aquatic.breathe("鱼");
}
}
在实际工作中,如果这样修改的话开销是很大的,除了将原来的 Animal 类分解为 Terrestrial 类和 Aquatic 类以外还需要修改客户端,而直接修改类 Animal 类来达到目的虽然违背了单一职责原则,但是花销却小的多,代码如下:
class Animal{
public void breathe(String animal){
if("鱼".equals(animal)){
System.out.println(animal + "呼吸水");
}else{
System.out.println(animal + "呼吸空气");
}
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
animal.breathe("鱼");
}
}
可以看得出,这样的修改显然简便了许多,但是却存在着隐患,如果有一天有需要加入某类动物不需要呼吸,那么就要修改 Animal 类的 breathe 方法,而对原有代码的修改可能会对其他相关功能带来风险,也许有一天你会发现输出结果变成了:"牛呼吸水" 了,这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却最大的。
另外还有一种修改方式:
class Animal{
public void breathe(String animal){
System.out.println(animal + "呼吸空气");
}
public void breathe2(String animal){
System.out.println(animal + "呼吸水");
}
}
public class Client{
public static void main(String[] args){
Animal animal = new Animal();
animal.breathe("牛");
animal.breathe("羊");
animal.breathe("猪");
animal.breathe2("鱼");
}
}
可以看出,这种修改方式没有改动原来的代码,而是在类中新加了一个方法,这样虽然违背了单一职责原则,但是它并没有修改原来已存在的代码,不会对原本已存在的功能造成影响。
那么在实际编程中,需要根据实际情况来确定使用哪种方式,只有逻辑足够简单,才可以在代码级别上违背单一职责原则。
总结:
- SRP 是一个简单又直观的原则,但是在实际编码的过程中很难将它恰当地运用,需要结合实际情况进行运用。
- 单一职责原则可以降低类的复杂度,一个类仅负责一项职责,其逻辑肯定要比负责多项职责简单。
- 提高了代码的可读性,提高系统的可维护性。
2. 开放-关闭原则 OCP
**开放-关闭原则表示软件实体 (类、模块、函数等等) 应该是可以被扩展的,但是不可被修改。(Open for extension, close for modification)
**
如果一个软件能够满足 OCP 原则,那么它将有两项优点:
- 能够扩展已存在的系统,能够提供新的功能满足新的需求,因此该软件有着很强的适应性和灵活性。
- 已存在的模块,特别是那些重要的抽象模块,不需要被修改,那么该软件就有很强的稳定性和持久性。
举个简单例子,这里有个生产电脑的公司,根据输入的类型,生产出不同的电脑,代码如下:
interface Computer {}
class Macbook implements Computer {}
class Surface implements Computer {}
class Factory {
public Computer produceComputer(String type) {
Computer c = null;
if(type.equals("macbook")){
c = new Macbook();
}else if(type.equals("surface")){
c = new Surface();
}
return c;
}
}
显然上面的代码违背了开放 - 关闭原则,如果需要添加新的电脑产品,那么修改 produceComputer 原本已有的方法,正确的方式如下:
interface Computer {}
class Macbook implements Computer {}
class Surface implements Computer {}
interface Factory {
public Computer produceComputer();
}
class AppleFactory implements Factory {
public Computer produceComputer() {
return new Macbook();
}
}
class MSFactory implements Factory {
public Computer produceComputer() {
return new Surface();
}
}
正确的方式应该是将 Factory 抽象成接口,让具体的工厂(如苹果工厂,微软工厂)去实现它,生产它们公司相应的产品,这样写有利于扩展,如果这是需要新增加戴尔工厂生产戴尔电脑,我们仅仅需要创建新的电脑类和新的工厂类,而不需要去修改已经写好的代码。
总结:
- OCP 可以具有良好的可扩展性,可维护性。
- 不可能让一个系统的所有模块都满足 OCP 原则,我们能做到的是尽可能地不要修改已经写好的代码,已有的功能,而是去扩展它。
3. 里氏替换原则 LSP
在编程中常常会遇到这样的问题:有一功能 P1, 由类 A 完成,现需要将功能 P1 进行扩展,扩展后的功能为 P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。
里氏替换原则告诉我们,当使用继承时候,类 B 继承类 A 时,除添加新的方法完成新增功能 P2,尽量不要修改父类方法预期的行为。
里氏替换原则的重点在不影响原功能,而不是不覆盖原方法。
继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
举个例子,我们需要完成一个两数相减的功能:
class A{
public int func1(int a, int b){
return a-b;
}
}
后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:
- 两数相减
- 两数相加,然后再加100
由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:
class B extends A{
public int func1(int a, int b){
return a+b;
}
public int func2(int a, int b){
return func1(a,b)+100;
}
}
我们发现原来原本运行正常的相减功能发生了错误,原因就是类 B 在给方法起名时无意中重写了父类的方法,造成了所有运行相减功能的代码全部调用了类 B 重写后的方法,造成原来运行正常的功能出现了错误。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是这样往往也增加了重写父类方法所带来的风险。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。
4. 依赖倒转原则 DIP
定义:高层模块不应该依赖低层模块,二者都应该于抽象。进一步说,抽象不应该依赖于细节,细节应该依赖于抽象。
举个例子, 某天产品经理需要添加新的功能,该功能需要操作数据库,一般负责封装数据库操作的和处理业务逻辑分别由不同的程序员编写。
封装数据库操作可认为低层模块,而处理业务逻辑可认为高层模块,那么如果处理业务逻辑需要等到封装数据库操作的代码写完的话才能添加的话讲会严重拖垮项目的进度。
正确的做法应该是处理业务逻辑的程序员提供一个封装好数据库操作的抽象接口,交给低层模块的程序员去编写,这样双方可以单独编写而互不影响。
依赖倒转原则的核心思想就是面向接口编程,思考下面这样一个场景:母亲给孩子讲故事,只要给她一本书,她就可照着书给孩子讲故事了。代码如下:
class Book{
public String getContent(){
return "这是一个有趣的故事";
}
}
class Mother{
public void say(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.say(new Book());
}
}
假如有一天,给的是一份报纸,而不是一本书,让这个母亲讲下报纸上的故事,报纸的代码如下:
class Newspaper{
public String getContent(){
return "这个一则重要的新闻";
}
}
然而这个母亲却办不到,应该她只会读书,这太不可思议,只是将书换成报纸,居然需要修改 Mother 类才能读,假如以后需要换成了杂志呢?原因是 Mother 和 Book 之间的耦合度太高了,必须降低他们的耦合度才行。
我们可以引入一个抽象接口 IReader 读物,让书和报纸去实现这个接口,那么无论提供什么样的读物,该母亲都能读。代码如下:
interface IReader{
public String getContent();
}
class Newspaper implements IReader {
public String getContent(){
return "这个一则重要的新闻";
}
}
class Book implements IReader{
public String getContent(){
return "这是一个有趣的故事";
}
}
class Mother{
public void say(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.say(new Book());
mother.say(new Newspaper());
}
}
这样修改之后,以后无论提供什么样的读物,只要去实现了 IReader 接口之后就可以被母亲读。实际情况中,代表高层模块的 Mother 类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒转原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
**依赖倒转原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒转。
**
5. 接口隔离原则 ISP
接口隔离原则,其 "隔离" 并不是准备的翻译,真正的意图是 “分离” 接口(的功能)
接口隔离原则强调:**客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
**
我们先来看一张图:
从图中可以看出,类 A 依赖于 接口 I 中的方法 1,2,3 ,类 B 是对类 A 的具体实现。类 C 依赖接口 I 中的方法 1,4,5,类 D 是对类 C 的具体实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。
用代码表示:
interface I {
public void method1();
public void method2();
public void method3();
public void method4();
public void method5();
}
class A{
public void depend1(I i){
i.method1();
}
public void depend2(I i){
i.method2();
}
public void depend3(I i){
i.method3();
}
}
class B implements I{
// 类 B 只需要实现方法 1,2, 3,而其它方法它并不需要,但是也需要实现
public void method1() {
System.out.println("类 B 实现接口 I 的方法 1");
}
public void method2() {
System.out.println("类 B 实现接口 I 的方法 2");
}
public void method3() {
System.out.println("类 B 实现接口 I 的方法 3");
}
public void method4() {}
public void method5() {}
}
class C{
public void depend1(I i){
i.method1();
}
public void depend2(I i){
i.method4();
}
public void depend3(I i){
i.method5();
}
}
class D implements I{
// 类 D 只需要实现方法 1,4,5,而其它方法它并不需要,但是也需要实现
public void method1() {
System.out.println("类 D 实现接口 I 的方法 1");
}
public void method2() {}
public void method3() {}
public void method4() {
System.out.println("类 D 实现接口 I 的方法 4");
}
public void method5() {
System.out.println("类 D 实现接口 I 的方法 5");
}
}
public class Client{
public static void main(String[] args){
A a = new A();
a.depend1(new B());
a.depend2(new B());
a.depend3(new B());
C c = new C();
c.depend1(new D());
c.depend2(new D());
c.depend3(new D());
}
}
可以看出,如果接口定义的过于臃肿,只要接口中出现的方法,不管依赖于它的类是否需要该方法,实现类都必须去实现这些方法,这就不符合接口隔离原则,如果想符合接口隔离原则,就必须对接口 I 如下图进行拆分:
代码可修改为如下:
interface I1 {
public void method1();
}
interface I2 {
public void method2();
public void method3();
}
interface I3 {
public void method4();
public void method5();
}
class A{
public void depend1(I1 i){
i.method1();
}
public void depend2(I2 i){
i.method2();
}
public void depend3(I2 i){
i.method3();
}
}
class B implements I1, I2{
public void method1() {
System.out.println("类 B 实现接口 I1 的方法 1");
}
public void method2() {
System.out.println("类 B 实现接口 I2 的方法 2");
}
public void method3() {
System.out.println("类 B 实现接口 I2 的方法 3");
}
}
class C{
public void depend1(I1 i){
i.method1();
}
public void depend2(I3 i){
i.method4();
}
public void depend3(I3 i){
i.method5();
}
}
class D implements I1, I3{
public void method1() {
System.out.println("类 D 实现接口 I1 的方法 1");
}
public void method4() {
System.out.println("类 D 实现接口 I3 的方法 4");
}
public void method5() {
System.out.println("类 D 实现接口 I3 的方法 5");
}
}
总结:
- 接口隔离原则的思想在于建立单一接口,尽可能地去细化接口,接口中的方法尽可能少
- 但是凡事都要有个度,如果接口设计过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
6. 迪米特法则 LOD
迪米特法则又称为 最少知道原则,它表示一个对象应该对其它对象保持最少的了解。通俗来说就是,只与直接的朋友通信。
首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。
对于被依赖的类来说,无论逻辑多么复杂,都尽量的将逻辑封装在类的内部,对外提供 public 方法,不对泄漏任何信息。
举个例子,家人探望犯人
- 家人:家人只与犯人是亲人,但是不认识他的狱友
public class Family {
public void visitPrisoner(Prisoners prisoners) {
Inmates inmates = prisoners.helpEachOther();
imates.weAreFriend();
}
}
- 犯人:犯人与家人是亲人,犯人与狱友是朋友
public class Prisoners {
private Inmates inmates = new Inmates();
public Inmates helpEachOther() {
System.out.println("家人说:你和狱友之间应该互相帮助...");
return inmates;
}
}
- 狱友: 犯人与狱友是朋友,但是不认识他的家人
public class Inmates {
public void weAreFriend() {
System.out.println("狱友说:我们是狱友...");
}
}
- 场景类:发生在监狱里
public class Prison {
public static void main(String args[])
{
Family family = new Family();
family.visitPrisoner(new Prisoners());
}
}
运行结果会发现:
家人说:你和狱友之间应该互相帮助...
狱友说:我们是狱友...
家人和狱友显然是不认识的,且监狱只允许家人探望犯人,而不是随便谁都可以见面的,这里家人和狱友有了沟通显然是违背了迪米特法则,因为在 Inmates 这个类作为局部变量出现在了 Family 类中的方法里,而他们不认识,不能够跟直接通信,迪米特法则告诉我们只与直接的朋友通信。所以上述的代码可以改为:
public class Family {
//家人探望犯人
public void visitPrisoner(Prisoners prisoners) {
System.out.print("家人说:");
prisoners.helpEachOther();
}
}
public class Prisoners {
private Inmates inmates = new Inmates();
public Inmates helpEachOther() {
System.out.println("犯人和狱友之间应该互相帮助...");
System.out.print("犯人说:");
inmates.weAreFriend();
return inmates;
}
}
public class Inmates {
public void weAreFriend() {
System.out.println("我们是狱友...");
}
}
public class Prison {
public static void main(String args[]) {
Family family = new Family();
family.visitPrisoner(new Prisoners());
}
}
运行结果
家人说:犯人和狱友之间应该互相帮助...
犯人说:我们是狱友...
这样家人和狱友就分开了,但是也表达了家人希望狱友能跟犯人互相帮助的意愿。也就是两个类通过第三个类实现信息传递, 而家人和狱友却没有直接通信。
7. 组合/聚合复用原则 CRP
组合/聚合复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分; 新的对象通过向这些对象的委派达到复用已有功能的目的。
在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生了改变,则子类的实现也不得不改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。于是就提出了组合/聚合复用原则,也就是在实际开发设计中,尽量使用组合/聚合,不要使用类继承。
举个简单的例子,在某家公司里的员工分为经理,工作者和销售者。如果画成 UML 图可以表示为:
但是这样违背了组合聚合复用原则,继承会将 Employee 类中的方法暴露给子类。如果要遵守组合聚合复用原则,可以将其改为:
这样做降低了类与类之间的耦合度,Employee 类的变化对其它类造成的影响相对较少。
总结:
- 总体说来,组合/聚合复用原则告诉我们:组合或者聚合好过于继承。
- 聚合组合是一种 “黑箱” 复用,因为细节对象的内容对客户端来说是不可见的。
本文来自博客园,作者:迷糊桃,转载请注明原文链接:https://www.cnblogs.com/mihutao/p/16814488.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!