香雨站

 找回密码
 立即注册
搜索
热搜: 活动 交友 discuz
查看: 104|回复: 1

Java-面向对象设计七大基本原则

[复制链接]

5

主题

6

帖子

16

积分

新手上路

Rank: 1

积分
16
发表于 2023-1-14 14:14:23 | 显示全部楼层 |阅读模式
单一职责


什么是职责

在 The Single Responsibility Principle(SRP) 中职责的定义为“变动的原因”(A reason for change)
如果你有多个动机去修改一个类,那么这个类就有多个职责。这可能比较难理解,因为我们通常把一组职责放在一起思考,下面来看一个具体的例子。下面是一个 Modem(调制解调器或者叫猫)的接口
interface Modem {
    public void dial(String pno);
    public void hangup();
    public void send(char c);
    public char recv();
}
上面这个猫的接口中存在两个职责:第一个是管理连接(dial和hangup);第二个是数据传输(send和recv)。这两个职责应该被分开,因为 :

  • 它们没有共同点,而且通常会因为不同的原因被修改;
  • 调用它们的代码通常属于应用的不同部分,而这部分代码也会因为不同的原因被修改。
下面是一个 Modem 优化后的设计:


通过拆分猫的接口,我们可以在应用的其他部分将猫的设计分开来对待。虽然我们又在猫的实现中(Modem Implementation)将这两部分职责重新耦合在一起,但是除了初始化猫的代码以外,在使用面向接口编程的原则后,其他代码并不需要依赖于猫的实现。
SRP 是最简单的一个面向对象设计原则,但也是最难做正确的一个,因为我们习惯于将职责合并,而不是将它们分开来。找到并且拆分这些职责正是软件设计真正需要做的事情。

小结

核心思想:应该有且仅有一个原因引起类的变更
好处:类的复杂度降低、可读性提高、可维护性提高、扩展性提高、降低了变更引起的风险。
需注意:单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可以度量的,因项目和环境而异。

开闭原则

开闭原则的英文是 Open Closed Principle,缩写为 OCP。
开闭原则说的是:软件实体(模块、类、函数等等)应该对扩展是开放的,对修改是关闭的

  • 对扩展是开放的,意味着软件实体的行为是可扩展的,当需求变更的时候,可以对模块进行扩展,使其满足需求变更的要求。
  • 对修改是关闭的,意味着当对软件实体进行扩展的时候,不需要改动当前的软件实体;不需要修改代码;对于已经完成的类文件不需要重新编辑;对于已经编译打包好的模块,不需要再重新编译。
两者结合起来表述为:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
这里我们以出售电脑为例,首先定义一个顶层接口 Computer,然后定义两个实现类,华硕电脑与苹果 Mac,类层次结构如下图所示:


上面是我们一开始的需求,但是随着软件发布运行,我们的需求不可能一成不变,肯定要接轨市场。假设现在是双十一,华硕笔记本电脑需要搞促销活动。那么我们的代码肯定要添加新的功能。可能有些刚入职的新人会在原有的代码上做改动,这肯定不符合开闭原则,虽然这种做法最直接,也最简单,但是绝大部分项目中,一个功能的实现远比想像要复杂的多,我们在原有的代码中进行修改,其风险远比扩展和实现一个方法要大的多。正确的做法可以这样:


我们实现一个关于折扣的子类,其中包含一个关于折扣的方法,这方法相当于一个扩展方法。可以看到这个子类是 AsusComputer 的,那为什么不把他设计成一个共用的折扣类呢,比如 DiscountComputer,所有实现类都继承这个折扣类。这是因为每种实现类的折扣方案可能是不一样的。所以我们最好能把它作为每个实现类的子类单独实现。如果你能确保你的业务中的新功能能兼容所有相关联的需求你也可以共用一个。

小结

核心思想:尽量通过扩展软件实体来解决需求变化,而不是通过修改已有的代码来完成变化。
通俗来讲:一个软件产品在生命周期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计的时候尽量适应这些变化,以提高项目的稳定性和灵活性。

里氏替换原则

里氏替换原则由 Barbara Liskov 提出,这个原则很明显,Java 的多态或者 C++ 的虚函数本身就允许把指向基类的指针或引用,在调用其方法或函数的时候,调用实际类型的方法或函数。
里氏替换原则的内容可以描述为: “派生类(子类)对象可以在程式中代替其基类(超类)对象。

继承

优点

  • 提高代码的重用性,子类拥有父类的方法和属性。
  • 提高代码的可扩展性,子类可形似于父类,但异于父类,保留自我的特性。
缺点(侵入性、不够灵活、高耦合)

  • 继承是侵入性的,只要继承就必须拥有父类的所有方法和属性,在一定程度上约束了子类,降低了代码的灵活性。
  • 增加了耦合,当父类的常量、变量或者方法被修改了,需要考虑子类的修改,所以一旦父类有了变动,很可能会造成非常糟糕的结果,要重构大量的代码。

为什么需要里氏替换原则

任何基类可以出现的地方,子类一定可以出现。里氏替换原则是继承复用的基石,只有当衍生类可以替换基类,软件单位的功能不受到影响时,即基类随便怎么改动子类都不受此影响,那么基类才能真正被复用。
因为继承带来的侵入性,增加了耦合性,也降低了代码灵活性,父类修改代码,子类也会受到影响,此时就需要里氏替换原则。
我们来看一个简单的例子:Circle 和 Square 继承了基类 Shape,然后在应用的方法中,根据输入 Shape 对象类型进行判断,根据对象类型选择不同的绘图函数将图形画出来。
void drawShape(Shape shape) {

    if (shape.type == Shape.Circle ) {
        drawCircle((Circle) shape);
    } else if (shape.type == Shape.Square) {
        drawSquare((Square) shape);
    } else {
        ……
    }
}这种写法的代码既常见又糟糕,它同时违反了开闭原则和里氏替换原则。

  • 首先看到这样的 if/else 代码,就可以判断违反了(我们刚刚在上个部分讲过的)开闭原则:当增加新的 Shape 类型的时候,必须修改这个方法,增加 else if 代码。
  • 其次也因为同样的原因违反了里氏替换原则:当增加新的 Shape 类型的时候,如果没有修改这个方法,没有增加 else if 代码,那么这个新类型就无法替换基类 Shape。
要解决这个问题其实也很简单,只需要在基类 Shape 中定义 draw 方法,所有 Shape 的子类,Circle、Square 都实现这个方法就可以了:
public abstract Shape{
  public abstract void draw();
}上面那段 drawShape() 代码也就可以变得更简单:
void drawShape(Shape shape) {
  shape.draw();
}这段代码既满足开闭原则:增加新的类型不需要修改任何代码。也满足里氏替换原则:在使用基类的这个方法中,可以用子类替换,程序正常运行。

注意事项


  • 子类必须实现父类的抽象方法,但不得重写(覆盖)父类的非抽象(已实现)方法。
  • 子类中可以增加自己特有的方法。
  • 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

小结

核心思想:在使用基类的的地方可以任意使用其子类,能保证子类完美替换基类。
通俗来讲:只要父类能出现的地方子类就能出现。反之,父类则未必能胜任。
好处:增强程序的健壮性,即使增加了子类,原有的子类还可以继续运行。
需注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系 采用依赖、聚合、组合等关系代替继承。

接口隔离原则

什么是接口隔离原则

接口对于Java开发者来说都不陌生,它几乎存在于每一个Java程序中,是抽象的代名词。在讲接口隔离原则之前,先说说接口,接口分为以下两种:

  • 实例接口(Object Interface))
    在 Java 中声明一个类,然后用 new 关键字产生一个实例,是对一个类型的事物的描述,这就是一种接口。或许我们乍一看会有点懵,怎么和我们原来学习的接口不一样呢,其实我们这样想,我们都知道,在 Java 中有一个 Class 类,表示正在运行的类和接口,换句话说每一个正在运行时的类或接口都是 Class 类的对象,这是一种向上的抽象。接口是一种更为抽象的定义,类是一类相同事物的描述集合,那为什么不可以抽象为一个接口呢?
  • 类接口(Class Interface)
    这就是我们经常使用的用 interface 定义的接口
这里插一句,接口隔离原则中所说的接口并不是狭意的在 Java 中用 interface 定义的接口,而是一种更为宽泛的概念,可以是接口,抽象类或者实体类。
接口隔离原则定义如下:

  • 客户端不应该依赖它不需要的接口
  • 类间的依赖关系应该建立在最小的接口上
接口隔离原则的英文是 SInterface Segregation Principle,缩写为 ISP。这个原则是说:客户端不应该强迫依赖它不需要的接口。也就是说:客户端不应该依赖它不需要的接口;类间的依赖关系应该建立在最小的接口上
我们在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。举一个简单的例子:
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 {
  // ...省略实现代码...
}删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。
参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。

小结

核心思想:类间的依赖关系应该建立在最小的接口上
通俗来讲:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。

注意事项

接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情为依赖接口的类定制服务。只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。

使用原则


  • 根据接口隔离原则拆分接口时,首先必须满足单一职责原则

    没有哪个设计可以十全十美的考虑到所有的设计原则,有些设计原则之间就可能出现冲突,就如同单一职责原则和接口隔离原则,一个考虑的是接口的职责的单一性,一个考虑的是方法设计的专业性(尽可能的少),必然是会出现冲突。在出现冲突时,尽量以单一职责为主,当然这也要考虑具体的情况。
  • 提高高内聚

    提高接口,类,模块的处理能力,减少对外的交互。比如你给杀手提交了一个订单,要求他在一周之内杀一个人,一周后杀手完成了任务,这种不讲条件完成任务的表现就是高内聚。具体来说就是:要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险就越小,也有利于降低成本。
  • 定制服务

    单独为一个个体提供优良服务(只提供访问者需要的方法)。
  • 接口设计要有限度

依赖倒置原则

依赖倒置原则的英文是 Dependency Inversion Principle,缩写为 DIP。依赖倒置原则说的是:高层模块不依赖低层模块,它们共同依赖同一个抽象,这个抽象接口通常是由高层模块定义,低层模块实现同时抽象不要依赖具体实现细节,具体实现细节依赖抽象。高层模块就是调用端,低层模块就是具体实现类,抽象就是指接口或抽象类,细节就是实现类
来看一个简单的例子:假设我们要设计一个很简单的程序,将键盘的输入输出到打印机上。一个简单的设计的程序结构图如下所示。


上面的设计中有三个模块,Copy 模块调用 Read Keyboard 模块来读取输出,然后 Copy 调用 Write Printer 模块输出字符。Read Keyboard 和 Write Printe r是两个下层模块,并且很容易被复用。
然而我们的 Copy 模块却不能被复用于任何不包含键盘和打印机的场景中,而 Copy 恰恰是这个程序的业务逻辑所在的模块,也是我们最希望能够复用的。
比如,我们还希望将键盘的输入,复制到磁盘文件。我们当然希望复用 Copy 模块,而事实上,Copy 依赖于键盘和打印机,缺一不可,所以不能被复用。我们也可以往 Copy 中增加一个 if 条件来支持新的磁盘文件输出,但是这就违背了开闭原则,最终随着功能的变多,代码将变得不可维护。
这个例子中的问题其实是高层级的模块(Copy模块)依赖于层级的模块(Read Keyboard 和 Write Printer);如果能够找到一个让 Copy 独立于它所控制的底层级模块的方法,那么我们可以自由地复用这个 Copy 模块。下图就是一种依赖反转的解决方案。


在这个新的设计中,我们的 Copy 模块有一个抽象的 Reader 和一个抽象的 Writer。Copy 不再直接依赖于具体的实现,不管有几个 Reader 或 Writer 的实现,我们都不需要修改 Copy。

小结

核心思想:高层模块不应该依赖底层模块,二者都该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象;
通俗来讲:依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,互不影响,实现模块间的松耦合。
好处:依赖倒置的好处在小型项目中很难体现出来。但在大中型项目中可以减少需求变化引起的工作量。使并行开发更友好。

迪米特法则



什么是迪米特法则

迪米特法则(Law of Demeter )又叫做最少知识原则,也就是说,一个对象应当对其他对象尽可能少的了解。不和陌生人说话。英文简写为: LoD。
迪米特法则最初是用来作为面向对象的系统设计风格的一种法则,于1987年秋天由 lan holland 在美国东北大学为一个叫做迪米特的项目设计提出的。

迪米特法则的模式与意义

迪米特法则可以简单说成:talk only to your immediate friends。
对于OOD来说,又被解释为下面几种方式:
一个软件实体应当尽可能少的与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
迪米特法则的初衷在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。
迪米特法则不希望类之间建立直接的联系。如果真的有需要建立联系,也希望能通过它的友元类来转达。因此,应用迪米特法则有可能造成的一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互调用关系——这在一定程度上增
加了系统的复杂度。

狭义的迪米特法则
如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中一个类需要调用另一类的某一个方法的话,可以通过第三者转发这个调用。
这么看不太形象,我们来举个例子,和陌生人说话,甲和朋友认识,朋友和陌生人认识,而甲和陌生人不认识,这时甲可以直接和朋友说话,朋友可以直接和陌生人说话,而如果甲想和陌生人说话,就必须通过朋友

UML 类图

模式一


这样的方式呢,看上去陌生人的实例是通过朋友来创建了,但还是不行,因为甲中包含的陌生人的引用,甲还是和陌生人直接关联上了,所以,不符合迪米特法则,我们要的是甲和陌生人没有一丁点直接关系
模式二



这种方式,甲和陌生人之间就没有了任何直接联系,这样就避免了甲和陌生人的耦合度过高。当然还有一种更好的方式,与依赖倒转原则结合,为陌生人创建一个接口。
模式三


这样的方式,和甲直接通信的是陌生人的抽象父类,和具体陌生人没有直接关系,所以符合迪米特法则。

小结

缺点

  • 在系统里造出大量的小方法,这些方法仅仅是传递间接的调用,与系统的商务逻辑无关。
  • 遵循类之间的迪米特法则会是一个系统的局部设计简化,因为每一个局部都不会和远距离的对象有直接的关联。但是,这也会造成系统的不同模块之间的通信效率降低,也会使系统的不同模块之间不容易协调。
应用实例

  • 外观模式
  • 中介者模式

合成复用原则

合成复用原则的定义

合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)。它要求在软件复用时,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。

实现

合成复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。
下面以汽车分类管理程序为例来介绍合成复用原则的应用。
分析:汽车按“动力源”划分可分为汽油汽车、电动汽车等;按“颜色”划分可分为白色汽车、黑色汽车和红色汽车等。如果同时考虑这两种分类,其组合就很多。下图所示是用继承关系实现的汽车分类的类图。


小结

通常类的复用分为继承复用和合成复用两种,继承复用虽然有简单和易实现的优点,但它也存在以下缺点。

  • 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用。
  • 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
  • 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点。

  • 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
  • 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
  • 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。

参考文章

SOLID:面向对象设计的五个基本原则
设计模式六大原则(二):里氏替换原则 - 杨冠标 - 博客园
面向对象六大原则--接口隔离原则_皮皮的雅客-CSDN博客_接口隔离原则
面向对象五大原则-----迪米特法则 - 高压锅里的小白 - 博客园
合成复用原则--面向对象设计原则
回复

使用道具 举报

2

主题

9

帖子

9

积分

新手上路

Rank: 1

积分
9
发表于 2023-1-14 14:15:02 | 显示全部楼层
这篇文章总体上写的很好,但是有一处明显的错误,就是在聚合/组合原则部分,类图实际上是描述继承,而非组合。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Archiver|手机版|小黑屋|香雨站

GMT+8, 2025-7-4 00:57 , Processed in 0.076631 second(s), 18 queries .

Powered by Discuz! X3.4

© 2001-2013 Comsenz Inc.. 技术支持 by 巅峰设计

快速回复 返回顶部 返回列表