面向对象设计原则

前言

在面向对象的软件设计中,只有尽量降低各个模块之间的耦合度,才能提高代码的复用率,系统的可维护性、可扩展性才能提高。面向对象的软件设计中,有23种经典的设计模式,是一套前人代码设计经验的总结,如果把设计模式比作武功招式,那么设计原则就好比是内功心法。常用的设计原则有七个,本文将具体介绍单一职责原则。

设计原则简介

  • 单一职责原则:专注降低类的复杂度,实现类要职责单一;
  • 开放关闭原则:所有面向对象原则的核心,设计要对扩展开发,对修改关闭;
  • 里式替换原则:实现开放关闭原则的重要方式之一,设计不要破坏继承关系; (只要有父类出现的地方,都可以用子类来替换)
  • 依赖倒置原则:系统抽象化的具体实现,要求面向接口编程,是面向对象设计的主要实现机制之一;
  • 接口隔离原则:要求接口的方法尽量少,接口尽量细化;
  • 迪米特法则:降低系统的耦合度,使一个模块的修改尽量少的影响其他模块,扩展会相对容易;
  • 组合复用原则:在软件设计中,尽量使用组合/聚合而不是继承达到代码复用的目的。

这些设计原则并不说我们一定要遵循他们来进行设计,而是根据我们的实际情况去怎么去选择使用他们,来让我们的程序做的更加的完善。

单一职责原则

就一个类而言,应该仅有一个引起它变化的原因,通俗的说,就是一个类只负责一项职责。此原则的核心就是解耦和增强内聚性。

如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计。

优点

(1)降低类的复杂度;

(2)提高类的可读性,提高系统的可维护性;

(3)降低变更引起的风险(降低对其他功能的影响)。


链接:https://juejin.cn/post/6960839149699989512

 

开放关闭原则

简介

开放封闭原则是这样表述的:

软件实体(类、模块、函数)应该对扩展开放对修改封闭

这个说法是 Bertrand Meyer 在其著作《面向对象软件构造》(Object-Oriented Software Construction)中提出来的,它给软件设计提出了一个极高的要求:不修改代码。

或许你想问,不修改原有代码,那我怎么实现新的需求呢?答案就是靠扩展。用更通俗的话来解释,就是新需求应该用新代码实现。

开放封闭原则向我们描述的是一个结果,就是我们可以不修改原有代码而仅凭扩展就完成新功能。但是,这个结果的前提是要在软件内部留好扩展点,而这正是需要我们去设计的地方。因为每一个扩展点都是一个需要设计的模型

解释

举个例子,假如我们正在开发一个酒店预订系统,针对不同的用户,我们需要计算出不同的房价。比如,普通用户是全价,金卡是 8 折,银卡是 9 折,代码写出来可能是这样的:

class HotelService {
  public double getRoomPrice(final User user, final Room room) {
    double price = room.getPrice();
    if (user.getLevel() == Level.GOLD) {
      return price * 0.8;
    }
    
    if (user.getLevel() == Level.SILVER) {
      return price * 0.9;
    }
    
    return price;
  }
}

 

这时,新的需求来了,要增加白金卡会员,给出 75 折的优惠,如法炮制的写法应该是这样的:

class HotelService {
  public double getRoomPrice(final User user, final Room room) {
    double price = room.getPrice();
    if (user.getLevel() == UserLevel.GOLD) {
      return price * 0.8;
    }
    
    if (user.getLevel() == UserLevel.SILVER) {
      return price * 0.9;
    }
    
    if (user.getLevel() == UserLevel.PLATINUM) {
      return price * 0.75;
    }
    
    return price;
  }
}

显然,这种做法就是修改代码的做法,每增加一个新的类型就要修改一次代码。但是,一个有各种级别用户的酒店系统肯定不只是房价有区别,提供的服务也可能有区别。可想而知,每增加一个用户级别,我们要改的代码就漫山遍野。

那应该怎么办呢?我们应该考虑如何把它设计成一个可以扩展的模型。在这个例子里面,既然每次要增加的是用户级别,而且各种服务的差异都体现在用户级别上,我们就需要一个用户级别的模型。在前面的代码里,用户级别只是一个简单的枚举,我们可以给它丰富一下:

interface UserLevel {
  double getRoomPrice(Room room);
}

class GoldUserLevel implements UserLevel {
  public double getRoomPrice(final Room room) {
    return room.getPrice() * 0.8;
  }
}

class SilverUserLevel implements UserLevel {
  public double getRoomPrice(final Room room) {
    return room.getPrice() * 0.9;
  }
}

我们原来的代码就可以变成这样:

class HotelService {
  public double getRoomPrice(final User user, final Room room) {
    return user.getRoomPrice(room);
  }
}

class User {
  private UserLevel level;
  ...
  
  public double getRoomPrice(final Room room) {
    return level.getRoomPrice(room);
  }
}

 

这样一来,再增加白金用户,我们只要写一个新的类就好了:

class PlatinumUserLevel implements UserLevel {
  public double getRoomPrice(final Room room) {
    return room.getPrice() * 0.75;
  }
}

 

之所以我们可以这么做,是因为我们在代码里留好了扩展点:UserLevel。在这里,我们把原来的只支持枚举值的 UserLevel 升级成了一个有行为的 UserLevel。

经过这番改造,HotelService 的 getRoomPrice 这个方法就稳定了下来,我们就不需要根据用户级别不断地调整这个方法了。至此,我们就拥有了一个稳定的构造块,可以在后期的工作中把它当做一个稳定的模块来使用。

当然,在这个例子里,这个方法是比较简单的。而在实际的项目中,业务方法都会比较复杂。



链接:https://juejin.cn/post/6960852120367005726

 

里氏替换原则

链接:https://www.jianshu.com/p/cf9f3c7c0df5

https://juejin.cn/post/6961203475640221704

在学习java类的继承时,我们知道继承有一些优点

  1. 子类拥有父类的所有方法和属性,从而可以减少创建类的工作量。
  2. 提高了代码的重用性。
  3. 提高了代码的扩展性,子类不但拥有了父类的所有功能,还可以添加自己的功能。

但又有点也同样存在缺点

  1. 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法。
  2. 降低了代码的灵活性。因为继承时,父类会对子类有一种约束。
  3. 增强了耦合性。当需要对父类的代码进行修改时,必须考虑到对子类产生的影响。有时修改了一点点代码都有可能需要对打断程序进行重构。

如何扬长避短呢?方法是引入里氏替换原则

定义

  • 第一种定义,也是最正宗的定义:If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.
    如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。

  • 第二种定义:Functions that use pointers or references to base classes must be able to useobjects of derived classes without knowing it.
    所有引用基类的地方必须能透明地使用其子类的对象。

第二种定义比较通俗,容易理解:只要有父类出现的地方,都可以用子类来替代而且不会出现任何错误和异常。但是反过来则不行,有子类出现的地方,不能用其父类替代。

四层含义

里氏替换原则对继承进行了规则上的约束,这种约束主要体现在四个方面:

  1. 子类必须实现父类的抽象方法,但不得重写(覆盖)  父类的非抽象(已实现)方法
  2. 子类中可以增加自己特有的方法。
  3. 当子类重载父类的方法时(方法参数不一致),方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。  如——
    • 若 A类 f(Map() map), AA类  f(HashMap() map)  且在代码里AA替换了A,此时参数调用传入 HashMap类型(具体类型)的参数 mapParam仅执行AA类的方法; (父类不可以被子类替换)
    • 若 A类 f(HashMap() map),AA类  f(Map() map)  且在代码里AA替换了A,此时参数调用传入 HashMap类型(具体类型)的参数 mapParam仅执行A类的方法       (父类可以被子类替换
  4. 当子类的方法实现  父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。   如——
    •  abstract A类:Map f(); AA类:HashMap f();    HashMap可以替换Map类型
    • abstract A类:HashMap f(), AA类:Map f();  编译报错

依赖倒置原则

https://juejin.cn/post/6961568751720333342

谁依赖谁

依赖倒置原则(Dependency inversion principle,简称 DIP)是这样表述的:

高层模块不应依赖于低层模块,二者应依赖于抽象

抽象不应依赖于细节,细节应依赖于抽象。

学习这个原则,最重要的是要理解“倒置”,而要理解什么是“倒置”,就要先理解所谓的“正常依赖”是什么样的。

我们很自然地就会写出类似下面的这种代码:

 
class CriticalFeature {   // 高层模块
  private Step1 step1;
  private Step2 step2;
  ...
  
  void run() {
    // 执行第一步
    step1.execute();   // 低层模块
    // 执行第二步
    step2.execute();
    ...
  }
}

 

但是,这种未经审视的结构天然就有一个问题:高层模块会依赖于低层模块。在上面这段代码里,CriticalFeature 类就是高层类,Step1 和 Step2 就是低层模块,而且 Step1 和 Step2 通常都是具体类。虽然这是一种自然而然的写法,但是这种写法确实是有问题的。

在实际的项目中,代码经常会直接耦合在具体的实现上。比如,我们用 Kafka 做消息传递,我们就在代码里直接创建了一个 KafkaProducer 去发送消息。我们就可能会写出这样的代码:

 
class Handler {
  private KafkaProducer producer;
  
  void send() {
    ...
    Message message = ...;
    producer.send(new KafkaRecord<>("topic", message);
    ...
  }
}

也许你会问,我就是用了 Kafka 发消息,创建一个 KafkaProducer,这有什么问题吗?其实,我们需要站在长期的角度去看,什么东西是变的、什么东西是不变的。Kafka 虽然很好,但它并不是系统最核心的部分,我们在未来是可能把它换掉的。

你可能会想,这可是我实现的一个关键组件,我怎么可能会换掉它呢?软件设计需要关注长期、放眼长期,所有那些不在自己掌控之内的东西,都是有可能被替换的。其实,替换一个中间件是经常发生的。所以,依赖于一个可能会变的东西,从设计的角度看,并不是一个好的做法。那我们应该怎么做呢?这就轮到倒置登场了。

所谓倒置,就是把这种习惯性的做法倒过来,让高层模块不再依赖于低层模块。那要是这样的话,我们的功能又该如何完成呢?计算机行业中一句名言告诉了我们答案:

计算机科学中的所有问题都可以通过引入一个间接层得到解决。

是的,引入一个间接层。这个间接层指的就是 DIP 里所说的抽象。也就是说,这段代码里面缺少了一个模型,而这个模型就是这个低层模块在这个过程中所承担的角色。

依赖于抽象

抽象不应依赖于细节,细节应依赖于抽象。

其实,这个可以更简单地理解为一点:依赖于抽象,从这点出发,我们可以推导出一些更具体的指导编码的规则:

  • 任何变量都不应该指向一个具体类;
  • 任何类都不应继承自具体类;
  • 任何方法都不应该改写父类中已经实现的方法。

举个List 声明的例子,其实背后遵循的就是这里的第一条规则:

 
List<String> list = new ArrayList<>();

 


posted on 2023-08-25 15:03  gogoy  阅读(6)  评论(0编辑  收藏  举报

导航