面向对象设计原则
张三作为敲诈勒索系统的开发人员,他负责开发系统敲诈成功后受害者支付功能的后端服务。他的服务需要调用张飞开发的支付接口,来完成用户的支付请求。张三在开发时,使用了一个固定的支付接口地址,如下:
public class PaymentService {
private static final String PAYMENT_URL = "https://pay.example.com/api/v1/pay";
public void pay(Order order) {
// 调用支付接口,传入订单信息,获取支付结果
// 省略具体的逻辑
}
}
有一天,公司新来了个实习生,修改了导致支付接口,修改后接口有一些参数和返回值的不同,结果造成了线上的报错和异常,支付功能受到了严重的影响,数笔巨额诈骗款因此没有入账,张三团队因此扣光了所有工资。
这个故事就是一个因为没有遵守开闭原则导致线上报错的例子,刚入公司的你是否也干过同样的事情呢?
要解决上述问题,我们需要学习了解面向对象设计原则,掌握这些原则有诸多如下好处:
- 可以帮助你理解面向对象编程的思想和方法,掌握面向对象的三大特征:封装、继承和多态。
- 可以帮助你遵循一些通用的设计标准,避免一些常见的设计错误和缺陷,提高代码的质量和可读性。
- 可以帮助你使用设计模式来解决一些复杂的软件问题,设计模式是基于面向对象设计原则的一些成熟的解决方案,可以让你的代码更加优雅和高效。
- 可以帮助你提高你的创造力和创新能力,面向对象设计原则不是一成不变的,你可以根据不同的场景和需求,灵活地运用和改进这些原则,创造出更适合的设计。
单一职责原则
我们先来看《猫和老鼠》中的“万能指挥家”这一集中,因为汤姆的精彩表演。
在我们的代码中,如果我们将所有功能全部写到一个类中,那么随着业务功能的增加,这个类会变得特别臃肿,对于后续维护将变得十分麻烦。我曾经负责的一个系统就是这样,一个java文件有2万多行代码,各种功能在这个类中耦合在一起,又因为其是核心代码,所以改动特别多,我能想到一个词形容来形容就是“面目全非”。
单一职责原则(Single Responsibility Principle,SRP):最简单的面向对象设计原则,它用于控制类的粒度大小。一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。
开闭原则
场景思考
开头故事讲到实习生修改了支付接口,导致支付功能异常,那么如果根据开闭原则,我们应该如何去解决这个问题呢?首先我们不确定这个接口被哪些地方调用了,直接修改很可能导致某些调用方的功能异常,但是我们又需要修改这个接口功能,稳妥的做法是在继承旧接口的基础上,新起一个接口,这样既能使用旧接口功能,又能增加我们想要的功能,还不影响就有调用者,这个做法遵循的就是开闭原则。
开闭原则(Open Close Principle):相当重要的面向对象设计原则。软件实体应当对扩展开放,对修改关闭。
典型例子:所有的导入的包都是只能使用,继承或者重写方法,而不能直接修改它的源码。
里氏替换原则
场景思考
我有一部古董级的诺基亚,可以打电话,可以发短信,但是没法玩王者聊QQ。现在我想升级一下手机,重新买部智能手机小米12s,希望能够完美取代诺基亚的同时还能拥有新的功能。
这里诺基亚就是代码中的父类,小米12s就是子类,如果按照里氏替换原则,那么子类可以完全替代父类,那么我们就可以完美取代诺基亚了,换句话说所有能够使用父类功能的地方,我都能够使用子类代替而不出错,这便是遵守了里氏替换原则。
里氏替换原则(Liskov Substitution Principle,LSP):子类必须能够替代它们的基类而不引起程序错误。更简单地说,如果一个类是另一个类的子类,那么它应该能够在不改变程序正确性的前提下替代掉其父类。
为什么要遵守里氏替换原则:
保持一致性和可预测性: 遵守里氏替换原则有助于保持代码的一致性和可预测性。当一个子类能够替代其基类时,客户端代码可以对基类和子类使用相同的方式,而不会引起不一致和意外的结果。降低耦合性: 里氏替换原则有助于降低系统中各个类之间的耦合性。当子类可以替代其基类时,客户端代码不需要关心实际使用的是基类还是子类,从而减少了代码的依赖性。提高代码的可维护性: 遵守里氏替换原则使得代码更易于理解和维护。当代码遵循一致的替代关系时,修改和扩展系统变得更加容易,因为不会影响到客户端代码。支持多态性: 里氏替换原则是多态性的基础。多态性使得能够以一致的方式处理不同类型的对象,从而提高了代码的灵活性和可扩展性。促进代码的复用: 当子类能够替代其基类时,基类的功能和接口可以被多个子类共享和复用。这有助于避免代码的冗余,并使得系统更加模块化和可重用。支持开发人员的协作: 遵守里氏替换原则有助于不同开发人员之间的协作。当一个开发人员编写基类时,其他人员可以编写符合里氏替换原则的子类,而无需担心引入错误或破坏系统的正确性。
典型例子:在Java中,里氏替换原则通常应用在继承关系中。一个经典的例子是Java中的多态性,通过子类替代基类的方式。比如Java中的List和ArrayList类
依赖倒转原则
场景思考
想象一下,你每天都需要交通工具来上班,最初你可能使用的是自行车(低层模块)。当然,这对于短距离的通勤来说是个不错的选择。
现在,你决定要升级,你不再直接依赖于自行车,而是依赖于一种更通用的交通工具,比如汽车(抽象)。这样,无论你是骑自行车还是开汽车,你都能够轻松地完成通勤的任务,而不必担心每次都需要调整你的出行方式。
在这个例子中,抽象就是“交通工具”,而自行车和汽车是具体的实现。通过依赖倒转原则,你的通勤方式(高层模块)不再直接依赖于特定的交通工具,而是依赖于一个更抽象的概念,使得你可以更轻松地改变交通工具而不用改变通勤方式。
在软件开发中,这个原则的应用是通过使用接口和抽象类来定义高层模块所需的服务,而具体的实现则由低层模块提供。这样,高层模块不需要了解低层模块的具体实现,而是依赖于抽象。这种设计有助于减少系统的耦合性,提高代码的灵活性和可维护性。
依赖倒转原则(Dependence Inversion Principle):高层模块不应依赖于底层模块,它们都应该依赖抽象。抽象不应依赖于细节,细节应该依赖于抽象。
典型例子: spring框架中的依赖注入(DI)和面向切面编程(AOP)
接口隔离原则
场景思考
想象一下你去购物,你可能会去一个百货商场,商场里有各种各样的商品。你有一个购物篮子,这个篮子就是一个接口,它定义了你可以往里面放置的物品。这个接口(购物篮子)只包含了你需要的功能,比如放入商品和取出商品。
现在,假设商场的管理人员决定让你也负责记录商品的库存信息,以及商品的价格变化等等。于是,他们把一个庞大而复杂的“商场管理”接口交给了你,里面包含了所有与商品有关的操作,比如记录库存、更新价格、生成销售报告等等。
这时候,问题就来了。你只是一个普通的购物者,你并不需要关心商品的库存、价格等管理操作,你只想简单地往购物篮子里放入和取出商品。但是商场强迫你去依赖一个庞大而复杂的接口,这就违反了接口隔离原则。
按照接口隔离原则,接口应该是小而专注的,不应该包含不相关的功能。在上述例子中,商场可以将购物篮子的功能和商品管理的功能分开,让购物者只关心购物篮子的接口,而商品管理则由专门的类来负责。
通过这个例子,我们可以理解接口隔离原则的核心思想:一个接口应该只包含使用者所需的方法,而不应该强迫使用者去依赖它们不需要的方法。这有助于降低类之间的耦合性,使系统更加灵活和易于维护。
接口隔离原则(Interface Segregation Principle, ISP):实际上是对接口的细化。客户端不应依赖那些它不需要的接口。
典型例子: Java中的java.util.Collection接口。
Collection 接口表示一组对象的集合,但这个接口非常庞大,包含了很多方法,例如add、remove、size等。然而,并不是所有的集合类都需要实现所有这些方法,因为不同类型的集合可能有不同的需求。
为了遵循接口隔离原则,Java在Collection 接口之上构建了更小、更专注的接口,例如 java.util.List、java.util.Set、java.util.Queue等。这些接口继承自 Collection 接口,但每个接口都专注于一组相关的操作。
举例来说,List 接口继承了 Collection 接口,但它添加了一些特定于列表的方法,比如 get(int index)、set(int index, E element) 等。这使得实现 List 接口的类只需要关注列表相关的操作,而不必实现 Collection 接口中所有的方法。
合成复用原则
合成复用原则(Composition Over Inheritance,COI)的核心思想是通过组合(composition)而不是继承(inheritance)来实现代码的复用。这个原则的目标是尽量使用对象组合,而不是通过继承来达到代码复用的目的。让我们通过一个生活中的例子来理解这个原则。
假设你想要制作一台新的音响系统。一开始你可能会考虑继承,创建一个 SuperSoundSystem 类,然后派生出 GoodSoundSystem(额外的音效功能) 和 AdditionalSoundSystem(更多的音量控制选项) 类。每个子类都会有一些特定的功能,比如 GoodSoundSystem 可能有额外的音效,而 AdditionalSoundSystem 可能有更多的音量控制选项。
// 使用继承
class SuperSoundSystem {
// 通用音响功能
}
class GoodSoundSystem extends SuperSoundSystem {
// 额外的音效功能
}
class AdditionalSoundSystem extends GoodSoundSystem {
// 更多的音量控制选项
}
然而,这种继承的方法可能会导致一些问题。比如,如果你想要一个同时拥有 GoodSoundSystem 和 AdditionalSoundSystem 功能的音响系统怎么办?你不能同时继承两个类。这就是合成复用原则的应用时机。
使用合成复用原则,你可以将每个音响系统的功能拆分成独立的类,并通过组合来实现复用。例如,你可以有一个通用的 SoundSystem 类,然后用不同的组件来装配不同的功能。
class SoundSystem {
// 通用音响功能
}
class ExtraSoundEffects {
// 额外的音效功能
}
class VolumeControl {
// 更多的音量控制选项
}
然后,你可以通过将这些组件组合在一起来创建不同级别的音响系统:
SoundSystem basicSoundSystem = new SoundSystem();
SoundSystem goodSoundSystem = new SoundSystem();
goodSoundSystem.add(new ExtraSoundEffects());
SoundSystem AdditionalSoundSystem = new SoundSystem();
AdditionalSoundSystem.add(new ExtraSoundEffects());
AdditionalSoundSystem.add(new VolumeControl());
这样,你可以更加灵活地组合各种功能,而不用通过继承层次来创建各种不同的子类。通过合成复用,你可以更好地应对系统的变化和需求的变更,提高了系统的灵活性和可维护性。
合成复用原则(Composite Reuse Principle): 核心就是委派。优先使用对象组合,而不是通过继承来达到复用的目的。
迪米特法则
场景思考
假设你去一家餐馆吃饭。按照迪米特法则,你与服务员交互的方式应该是简单而直接的,而不是去直接与厨房的厨师、收银员等进行交流。
如果你需要点菜,你不会直接跟厨师说:“我想要一份意大利面,里面要有意大利香肠和番茄酱。” 而是通过服务员告诉他们你的需求。
这样,你与餐馆的其他部分(厨房、收银台等)保持了松耦合。你只需与服务员交互,而不需要直接了解和操作餐馆的内部工作流程。
在软件设计中,这就意味着一个对象应该尽量避免直接访问其他对象的内部细节,而是通过中介者或者代理的方式进行间接的交互。这样可以降低系统的耦合度,提高系统的灵活性,使得每个对象只需关注自己的职责。
总的来说,迪米特法则鼓励设计师将系统划分成松耦合的模块,每个模块只需要与其直接的朋友(直接的成员)进行交流,而不需要深入了解其他模块的内部情况。这有助于提高代码的可维护性和可扩展性。
迪米特法则(Law of Demeter): 又称最少知识原则,是对程序内部数据交互的限制。每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
参考资料:柏码 - 让每一行代码都闪耀智慧的光芒!、ChatGPT
