[关闭]
@qidiandasheng 2021-01-04T10:46:16.000000Z 字数 25353 阅读 1416

面向对象之六大原则(😁)

架构


六大原则介绍

其实我们常说的是五大设计原则,简称SOLID原则,是除了下面迪米特法则外其他原则首字母的缩写合成的名字。

缩写 英文名称 中文名称
SRP Single Responsibility Principle 单一职责原则
OCP Open Close Principle 开闭原则
LSP Liskov Substitution Principle 里氏替换原则
LoD Law of Demeter ( Least Knowledge Principle) 迪米特法则(最少知道原则)
ISP Interface Segregation Principle 接口分离原则
DIP Dependency Inversion Principle 依赖倒置原则

下载 (1).jpeg-1469.1kB

单一职责原则

理解:不同的类具备不同的职责,各司其职。如果发现有一个类拥有了两种职责,那么就要问一个问题:可以将这个类分成两个类吗?如果真的有必要,那就分开,千万不要让一个类干的事情太多。

总结:一个类只承担一个职责

开闭原则

理解:类、模块、函数,可以去扩展,但不要去修改。如果要修改代码,尽量用继承或组合的方式来扩展类的功能,而不是直接修改类的代码。当然,如果能保证对整个架构不会产生任何影响,那就没必要搞的那么复杂,直接改这个类吧。

总结:对软件实体的改动,最好用扩展而非修改的方式。

里氏替换原则

理解:一个对象在其出现的任何地方,都可以用子类实例做替换,并且不会导致程序的错误。换句话说,当子类可以在任意地方替换基类且软件功能不受影响时,这种继承关系的建模才是合理的。

总结:子类可以扩展父类的方法,但不应该复写父类的方法。

接口隔离原则

理解:一个类实现的接口中,包含了它不需要的方法。将接口拆分成更小和更具体的接口,有助于解耦,从而更容易重构、更改。

总结:对象不应被强迫依赖它不使用的方法。

依赖倒置原则

理解:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。

总结:面向接口编程,提取出事务的本质和共性。

迪米特法则

理解:一个对象对另一个对象了解得越多,那么它们之间的耦合性也就越强,当修改其中一个对象时,对另一个对象造成的影响也就越大。

总结:一个对象应该对其他对象保持最少的了解,实现低耦合、高内聚。

组合/聚合复用原则

理解:合成/聚合复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象的委派达到复用已有功能的目的。它的设计原则是:要尽量使用合成/聚合,尽量不要使用继承。

总结:就是说要少用继承,多用合成关系来实现。

高内聚、松耦合

“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓松耦合指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动也不会或者很少导致依赖类的代码改动。

其实以上的一些原则都是为了实现代码的高内聚、松耦合。

单一职责原则(Single Responsibility Principle)

定义

一个类只允许有一个职责,即只有一个导致该类变更的原因。

定义的解读

优点

如果类与方法的职责划分得很清晰,不但可以提高代码的可读性,更实际性地更降低了程序出错的风险,因为清晰的代码会让bug无处藏身,也有利于bug的追踪,也就是降低了程序的维护成本。

单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性可读性可维护性

代码讲解

需求点

初始需求:需要创造一个员工类,这个类有员工的一些基本信息。

新需求:增加两个方法:

判定员工在今年是否升职
计算员工的薪水

不好的设计

  1. //================== Employee.h ==================
  2. @interface Employee : NSObject
  3. //============ 初始需求 ============
  4. @property (nonatomic, copy) NSString *name; //员工姓名
  5. @property (nonatomic, copy) NSString *address; //员工住址
  6. @property (nonatomic, copy) NSString *employeeID; //员工ID
  7. //============ 新需求 ============
  8. //计算薪水
  9. - (double)calculateSalary;
  10. //今年是否晋升
  11. - (BOOL)willGetPromotionThisYear;
  12. @end

由上面的代码可以看出:

新需求的做法看似没有问题,因为都是和员工有关的,但却违反了单一职责原则:因为这两个方法并不是员工本身的职责。

而上面的设计将本来不属于员工自己的职责强加进了员工类里面,而这个类的设计初衷(原始职责)就是单纯地保留员工的一些信息而已。因此这么做就是给这个类引入了新的职责,故此设计违反了单一职责原则。

我们可以简单想象一下这么做的后果是什么:如果员工的晋升机制变了,或者税收政策等影响员工工资的因素变了,我们还需要修改当前这个类。

那么怎么做才能不违反单一职责原则呢? 我们需要将这两个方法(责任)分离出去,让本应该处理这类任务的类来处理。

较好的设计

我们保留员工类的基本信息:

  1. //================== Employee.h ==================
  2. @interface Employee : NSObject
  3. //初始需求
  4. @property (nonatomic, copy) NSString *name;
  5. @property (nonatomic, copy) NSString *address;
  6. @property (nonatomic, copy) NSString *employeeID;

接着创建新的会计部门类:

  1. //================== FinancialApartment.h ==================
  2. #import "Employee.h"
  3. //会计部门类
  4. @interface FinancialApartment : NSObject
  5. //计算薪水
  6. - (double)calculateSalary:(Employee *)employee;
  7. @end

和人事部门类:

  1. //================== HRApartment.h ==================
  2. #import "Employee.h"
  3. //人事部门类
  4. @interface HRApartment : NSObject
  5. //今年是否晋升
  6. - (BOOL)willGetPromotionThisYear:(Employee*)employee;
  7. @end

通过创建了两个分别专门处理薪水和晋升的部门,会计部门和人事部门的类:FinancialApartmentHRApartment,把两个任务(责任)分离了出去,让本该处理这些职责的类来处理这些职责。

这样一来,不仅仅在此次新需求中满足了单一职责原则,以后如果还要增加人事部门和会计部门处理的任务,就可以直接在这两个类里面添加即可。

UML 类图对比

未实践单一职责原则:
导出图片Thu Apr 09 2020 15_57_38 GMT+0800 (中国标准时间).png-140kB

实践了单一职责原则:
导出图片Thu Apr 09 2020 15_57_50 GMT+0800 (中国标准时间).png-159.4kB

如何实践

对于上面的员工类的例子,或许是因为我们先入为主,知道一个公司的合理组织架构,觉得这么设计理所当然。但是在实际开发中,我们很容易会将不同的责任揉在一起,这点还是需要开发者注意的。

不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。

比如下面这个用户信息类,如果这个app比较简单地址那部分只是简单的展示,那么放到用户类里面没问题。但如果app后面做了电商业务,需要地址这部分拆分出来独立为物流信息类会更合适。再后面如果公司内部多个app统一账户系统,也就是用户一个账号可以在公司内部的所有产品中登录,我们则可以把emailtelephone独立出来为身份认证信息类会更合适。

  1. public class UserInfo {
  2. private long userId;
  3. private String username;
  4. private String email;
  5. private String telephone;
  6. private long createTime;
  7. private long lastLoginTime;
  8. private String avatarUrl;
  9. private String provinceOfAddress; // 省
  10. private String cityOfAddress; // 市
  11. private String regionOfAddress; // 区
  12. private String detailedAddress; // 详细地址
  13. // ... 省略其他属性和方法...
  14. }

一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

开闭原则(Open Close Principle)

定义

一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。

添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修 改”;在细代码粒度下,可能又被认定为“扩展”。

定义的解读

优点

实践开闭原则的优点在于可以在不改动原有代码的前提下给程序扩展功能。增加了程序的可扩展性,同时也降低了程序的维护成本。

代码讲解

需求点

设计一个在线课程类:

由于教学资源有限,开始的时候只有类似于博客的,通过文字讲解的课程。 但是随着教学资源的增多,后来增加了视频课程,音频课程以及直播课程。

不好的设计

最开始的文字课程类:

  1. //================== Course.h ==================
  2. @interface Course : NSObject
  3. @property (nonatomic, copy) NSString *courseTitle; //课程名称
  4. @property (nonatomic, copy) NSString *courseIntroduction; //课程介绍
  5. @property (nonatomic, copy) NSString *teacherName; //讲师姓名
  6. @property (nonatomic, copy) NSString *content; //课程内容
  7. @end

Course类声明了最初的在线课程所需要包含的数据:

接着按照上面所说的需求变更:增加了视频,音频,直播课程:

  1. //================== Course.h ==================
  2. @interface Course : NSObject
  3. @property (nonatomic, copy) NSString *courseTitle; //课程名称
  4. @property (nonatomic, copy) NSString *courseIntroduction; //课程介绍
  5. @property (nonatomic, copy) NSString *teacherName; //讲师姓名
  6. @property (nonatomic, copy) NSString *content; //文字内容
  7. //新需求:视频课程
  8. @property (nonatomic, copy) NSString *videoUrl;
  9. //新需求:音频课程
  10. @property (nonatomic, copy) NSString *audioUrl;
  11. //新需求:直播课程
  12. @property (nonatomic, copy) NSString *liveUrl;
  13. @end

三种新增的课程都在原Course类中添加了对应的url。也就是每次添加一个新的类型的课程,都在原有Course类里面修改:新增这种课程需要的数据。

这就导致:我们从Course类实例化的视频课程对象会包含并不属于自己的数据:audioUrlliveUrl:这样就造成了冗余,视频课程对象并不是纯粹的视频课程对象,它包含了音频地址,直播地址等成员。

很显然,这个设计不是一个好的设计,因为(对应上面两段叙述):

之所以会造成上述两个缺陷,是因为该设计没有遵循对修改关闭,对扩展开放的开闭原则,而是反其道而行之:开放修改,而且不给扩展提供便利。

较好的设计

首先在Course类中仅仅保留所有课程都含有的数据:

  1. //================== Course.h ==================
  2. @interface Course : NSObject
  3. @property (nonatomic, copy) NSString *courseTitle; //课程名称
  4. @property (nonatomic, copy) NSString *courseIntroduction; //课程介绍
  5. @property (nonatomic, copy) NSString *teacherName; //讲师姓名

接着,针对文字课程,视频课程,音频课程,直播课程这三种新型的课程采用继承Course类的方式。而且继承后,添加自己独有的数据:

文字课程类:

  1. //================== VideoCourse.h ==================
  2. @interface VideoCourse : Course
  3. @property (nonatomic, copy) NSString *videoUrl; //视频地址
  4. @end

音频课程类:

  1. //================== AudioCourse.h ==================
  2. @interface AudioCourse : Course
  3. @property (nonatomic, copy) NSString *audioUrl; //音频地址
  4. @end

直播课程类:

  1. //================== LiveCourse.h ==================
  2. @interface LiveCourse : Course
  3. @property (nonatomic, copy) NSString *liveUrl; //直播地址
  4. @end

这样一来,上面的两个问题都得到了解决:

而且对于第二点:或许今后的视频课程可以有高清地址,视频加速功能。而这些功能只需要在VideoCourse类里添加即可,因为它们都是视频课程所独有的。同样地,直播课程后面还可以支持在线问答功能,也可以仅加在LiveCourse里面。

我们可以看到,正是由于最初程序设计合理,所以对后面需求的增加才会处理得很好。

UML 类图对比

未实践开闭原则:
导出图片Thu Apr 09 2020 11_54_07 GMT+0800 (中国标准时间).png-136.3kB

实践了开闭原则:
导出图片Thu Apr 09 2020 11_54_33 GMT+0800 (中国标准时间).png-198.7kB

在实践了开闭原则的 UML 类图中,四个课程类继承了Course类并添加了自己独有的属性。(在 UML 类图中:实线空心三角箭头代表继承关系:由子类指向其父类)

代码讲解二

以下Java代码就是简单的基于抽象多态依赖注入来实现开闭原则的例子:

  1. // 这一部分体现了抽象意识
  2. public interface MessageQueue { //... }
  3. public class KafkaMessageQueue implements MessageQueue { //... }
  4. public class RocketMQMessageQueue implements MessageQueue {//...}
  5. public interface MessageFromatter { //... }
  6. public class JsonMessageFromatter implements MessageFromatter {//...}
  7. public class ProtoBufMessageFromatter implements MessageFromatter {//...}
  8. public class Demo {
  9. private MessageQueue msgQueue; // 基于接口而非实现编程
  10. public Demo(MessageQueue msgQueue) { // 依赖注入
  11. this.msgQueue = msgQueue;
  12. }
  13. // msgFormatter:多态、依赖注入
  14. public void sendNotification(Notification notification, MessageFormatter msg){
  15. //...
  16. }
  17. }

如何实践

为了更好地实践开闭原则,在设计之初就要想清楚在该场景里哪些数据(或行为)是一定不变(或很难再改变)的,哪些是很容易变动的。将后者抽象成接口或抽象方法,以便于在将来通过创造具体的实现应对不同的需求。

里氏替换原则(Liskov Substitution Principle)

定义

定义的解读

在继承体系中,子类中可以增加自己特有的方法,也可以实现父类的抽象方法,但是重写父类的非抽象方法时需要慎重,否则该继承关系就不是一个正确的继承关系。

父类中已实现的方法其实是一种已定好的规范和契约,如果我们随意的修改了它,那么可能会带来意想不到的错误。

里式替换原则就是用来指导,继承关系中子类该如何设计的一个原则。理解里式替换原则,最核心的就是理解design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至 包括注释中所罗列的任何特殊说明。

多态和里式替换的区别

多态是一种特性、能力,里氏替换是一种原则、约定。

虽然多态和里氏替换不是一回事,但是里氏替换这个原则 需要多态这种能力才能实现。

里氏替换最重要的就是替换之后原本的功能一点不能少。

优点

可以检验继承使用的正确性,约束继承在使用上的泛滥。

代码讲解

需求点

实现一个计算机,能够简单的实现两数的相加

后面增加需求变成两数相加再加100。

不好的设计

父类实现了A+B的方法:

  1. //================== Calculator.h ==================
  2. @class Calculator;
  3. @interface Calculator : NSObject
  4. - (int)funcA:(int)A withB:(int)B;
  5. @end
  6. //================== Calculator.m ==================
  7. #import "Calculator.h"
  8. @implementation Calculator
  9. - (int)funcA:(int)A withB:(int)B{
  10. return A+B;
  11. }
  12. @end

子类实现,A+B+100的方法:

  1. //================== NewCalculator.h ==================
  2. @class NewCalculator;
  3. @interface NewCalculator : Calculator
  4. - (int)funcA:(int)A withB:(int)B;
  5. - (int)newfuncA:(int)A withB:(int)B;
  6. @end
  7. //================== NewCalculator.m ==================
  8. #import "NewCalculator.h"
  9. @implementation NewCalculator
  10. - (int)funcA:(int)A withB:(int)B{
  11. return A-B;
  12. }
  13. - (int)newfuncA:(int)A withB:(int)B{
  14. int c = [self funcA:A withB:B];
  15. return c+100;
  16. }
  17. @end
  1. NewCalculator *newCalculator = [NewCalculator new];
  2. [newCalculator newfuncA:50 withB:30];
  3. 输出结果为:120

上面的运行结果明显是错误的,实际应该为180。类NewCalculator继承自Calculator,后来需要增加新功能,类NewCalculator新写了一个方法,然后因为直接复写了父类Calculator里相加的方法,不小心写错成了相减,导致了整个程序出错。

上述子类就是因为违反里氏替换原则,直接复写了父类的方法,导致出现的问题。

较好的设计

这也并不是较好的设计,只是不复写了父类的方法,遵守了父类的规范和契约,遵守了里氏替换原则,减少了带来意想不到的错误的可能。

  1. //================== NewCalculator.h ==================
  2. @class NewCalculator;
  3. @interface NewCalculator : Calculator
  4. - (int)newfuncA:(int)A withB:(int)B;
  5. @end
  6. //================== NewCalculator.m ==================
  7. #import "NewCalculator.h"
  8. @implementation NewCalculator
  9. - (int)newfuncA:(int)A withB:(int)B{
  10. int c = [self funcA:A withB:B];
  11. return c+100;
  12. }
  13. @end

如何实践

里氏替换原则是对继承关系的一种检验:检验是否真正符合继承关系,以避免继承的滥用。因此,在使用继承之前,需要反复思考和确认该继承关系是否正确,或者当前的继承体系是否还可以支持后续的需求变更,如果无法支持,则需要及时重构,采用更好的方式来设计程序。

接口分离原则(Interface Segregation Principle)

定义

多个特定的客户端接口要好于一个通用性的总接口。

如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。

如果把“接口”理解为单个API接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。

如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

定义解读

需要注意的是:接口的粒度也不能太小。如果过小,则会造成接口数量过多,使设计复杂化。

接口隔离原则与单一职责原则的区别

单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。

接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

优点

避免同一个接口里面包含不同类职责的方法,接口责任划分更加明确,符合高内聚低耦合的思想。

代码讲解

需求点

现在的餐厅除了提供传统的店内服务,多数也都支持网上下单,网上支付功能。写一些接口方法来涵盖餐厅的所有的下单及支付功能。

不好的设计

  1. //================== RestaurantProtocol.h ==================
  2. @protocol RestaurantProtocol <NSObject>
  3. - (void)placeOnlineOrder; //下订单:online
  4. - (void)placeTelephoneOrder; //下订单:通过电话
  5. - (void)placeWalkInCustomerOrder; //下订单:在店里
  6. - (void)payOnline; //支付订单:online
  7. - (void)payInPerson; //支付订单:在店里支付
  8. @end

在这里声明了一个接口,它包含了下单和支付的几种方式:

下单:

支付:

对应的,我们有三种下单方式的顾客:

1.online下单,online支付的顾客

  1. //================== OnlineClient.h ==================
  2. #import "RestaurantProtocol.h"
  3. @interface OnlineClient : NSObject<RestaurantProtocol>
  4. @end
  5. //================== OnlineClient.m ==================
  6. @implementation OnlineClient
  7. - (void)placeOnlineOrder{
  8. NSLog(@"place on line order");
  9. }
  10. - (void)placeTelephoneOrder{
  11. //not necessarily
  12. }
  13. - (void)placeWalkInCustomerOrder{
  14. //not necessarily
  15. }
  16. - (void)payOnline{
  17. NSLog(@"pay on line");
  18. }
  19. - (void)payInPerson{
  20. //not necessarily
  21. }
  22. @end

2.电话下单,online支付的顾客

  1. //================== TelephoneClient.h ==================
  2. #import "RestaurantProtocol.h"
  3. @interface TelephoneClient : NSObject<RestaurantProtocol>
  4. @end
  5. //================== TelephoneClient.m ==================
  6. @implementation TelephoneClient
  7. - (void)placeOnlineOrder{
  8. //not necessarily
  9. }
  10. - (void)placeTelephoneOrder{
  11. NSLog(@"place telephone order");
  12. }
  13. - (void)placeWalkInCustomerOrder{
  14. //not necessarily
  15. }
  16. - (void)payOnline{
  17. NSLog(@"pay on line");
  18. }
  19. - (void)payInPerson{
  20. //not necessarily
  21. }
  22. @end

3.在店里下单并支付的顾客:

  1. //================== WalkinClient.h ==================
  2. #import "RestaurantProtocol.h"
  3. @interface WalkinClient : NSObject<RestaurantProtocol>
  4. @end
  5. //================== WalkinClient.m ==================
  6. @implementation WalkinClient
  7. - (void)placeOnlineOrder{
  8. //not necessarily
  9. }
  10. - (void)placeTelephoneOrder{
  11. //not necessarily
  12. }
  13. - (void)placeWalkInCustomerOrder{
  14. NSLog(@"place walk in customer order");
  15. }
  16. - (void)payOnline{
  17. //not necessarily
  18. }
  19. - (void)payInPerson{
  20. NSLog(@"pay in person");
  21. }
  22. @end

我们发现,并不是所有顾客都必须要实现RestaurantProtocol里面的所有方法。由于接口方法的设计造成了冗余,因此该设计不符合接口隔离原则。

较好的设计

要符合接口隔离原则,只需要将不同类型的接口分离出来即可。我们将原来的RestaurantProtocol接口拆分成两个接口:下单接口和支付接口。

下单接口:

  1. //================== RestaurantPlaceOrderProtocol.h ==================
  2. @protocol RestaurantPlaceOrderProtocol <NSObject>
  3. - (void)placeOrder;
  4. @end

支付接口:

  1. //================== RestaurantPaymentProtocol.h ==================
  2. @protocol RestaurantPaymentProtocol <NSObject>
  3. - (void)payOrder;
  4. @end

现在有了下单接口和支付接口,我们就可以让不同的客户来以自己的方式实现下单和支付操作了:

首先创建一个所有客户的父类,来遵循这个两个接口:

  1. //================== Client.h ==================
  2. #import "RestaurantPlaceOrderProtocol.h"
  3. #import "RestaurantPaymentProtocol.h"
  4. @interface Client : NSObject<RestaurantPlaceOrderProtocol,RestaurantPaymentProtocol>
  5. @end

接着另online下单,电话下单,店内下单的顾客继承这个父类,分别实现这两个接口的方法:

1.online下单,online支付的顾客

  1. //================== OnlineClient.h ==================
  2. #import "Client.h"
  3. @interface OnlineClient : Client
  4. @end
  5. //================== OnlineClient.m ==================
  6. @implementation OnlineClient
  7. - (void)placeOrder{
  8. NSLog(@"place on line order");
  9. }
  10. - (void)payOrder{
  11. NSLog(@"pay on line");
  12. }
  13. @end

2.电话下单,online支付的顾客

  1. //================== TelephoneClient.h ==================
  2. #import "Client.h"
  3. @interface TelephoneClient : Client
  4. @end
  5. //================== TelephoneClient.m ==================
  6. @implementation TelephoneClient
  7. - (void)placeOrder{
  8. NSLog(@"place telephone order");
  9. }
  10. - (void)payOrder{
  11. NSLog(@"pay on line");
  12. }
  13. @end

3.在店里下单并支付顾客:

  1. //================== WalkinClient.h ==================
  2. #import "Client.h"
  3. @interface WalkinClient : Client
  4. @end
  5. //================== WalkinClient.m ==================
  6. @implementation WalkinClient
  7. - (void)placeOrder{
  8. NSLog(@"place walk in customer order");
  9. }
  10. - (void)payOrder{
  11. NSLog(@"pay in person");
  12. }
  13. @end

因为我们把不同职责的接口拆开,使得接口的责任更加清晰,简洁明了。不同的客户端可以根据自己的需求遵循所需要的接口来以自己的方式实现。

而且今后如果还有和下单或者支付相关的方法,也可以分别加入到各自的接口中,避免了接口的臃肿,同时也提高了程序的内聚性。

UML 类图对比

未实践接口分离原则:
导出图片Thu Apr 09 2020 17_19_17 GMT+0800 (中国标准时间).png-273.8kB

实践了接口分离原则:
导出图片Thu Apr 09 2020 17_19_33 GMT+0800 (中国标准时间).png-231.3kB

通过遵守接口分离原则,接口的设计变得更加简洁,而且各种客户类不需要实现自己不需要实现的接口。

代码讲解二

下面是一个配置相关的代码,包括用户配置,设置中心配置,主题配置。有两个需求,一个需求是需要动态更新配置,但只有设置中心配置和主题配置可以动态更新。另一个是显示配置的需求,只有设置中心和用户配置可以显示。

这里我们把更新和显示这两个接口隔离(OC里的协议),调用方只依赖他需要的接口。

  1. //动态更新接口
  2. @protocol UpdateProtocol <NSObject>
  3. - (void)update;
  4. @end
  5. //显示配置接口
  6. @protocol ViewProtocol <NSObject>
  7. - (void)showConfig;
  8. @end
  1. //用户配置
  2. @interface UserConfig : NSObject<ViewProtocol>
  3. @end
  4. @implementation UserConfig
  5. - (void)showConfig{
  6. }
  7. @end
  8. //设置中心配置
  9. @interface SettingConfig : NSObject<UpdateProtocol,ViewProtocol>
  10. @end
  11. @implementation UserConfig
  12. - (void)showConfig{
  13. }
  14. - (void)update{
  15. }
  16. @end
  17. //主题配置
  18. @interface ThemeConfig : NSObject<UpdateProtocol>
  19. @end
  20. @implementation ThemeConfig
  21. - (void)update{
  22. }
  23. @end
  1. //更新中心
  2. @interface Updater : NSObject
  3. - (void)addUpdateConfig:(id<UpdateProtocol>)config;
  4. - (void)update;
  5. @end
  6. //显示中心
  7. @interface Viewer : NSObject
  8. - (void)addViewConfig:(id<ViewProtocol>)config;
  9. - (void)show;
  10. @end
  1. //调用
  2. - (void)viewDidLoad {
  3. [super viewDidLoad];
  4. UserConfig *userConfig = [UserConfig new];
  5. SettingConfig *settingConfig = [SettingConfig new];
  6. ThemeConfig *themeConfig = [ThemeConfig new];
  7. Updater *updater = [Updater new];
  8. [updater addUpdateConfig:settingConfig];
  9. [updater addUpdateConfig:themeConfig];
  10. [updater update];
  11. Viewer *viewer = [Viewer new];
  12. [viewer addViewConfig:userConfig];
  13. [viewer addViewConfig:settingConfig];
  14. [viewer show];
  15. }

如何实践

在设计接口时,尤其是在向现有的接口添加方法时,我们需要仔细斟酌这些方法是否是处理同一类任务的:如果是则可以放在一起;如果不是则需要做拆分。

做iOS开发的朋友对UITableView的UITableViewDelegateUITableViewDataSource这两个协议应该会非常熟悉。这两个协议里的方法都是与UITableView相关的,但iOS SDK的设计者却把这些方法放在不同的两个协议中。原因就是这两个协议所包含的方法所处理的任务是不同的两种:

很显然,UITableView协议的设计者很好地实践了接口分离的原则,值得我们大家学习。

依赖倒置原则(Dependency Inversion Principle)

定义

依赖倒置控制反转这个概念有点像,控制反转具体可以看一下这篇文章:控制反转(IOC)和依赖注入(DI)

定义解读

也就是主要使用了面向对象四大特性里的抽象特性。

优点

通过抽象来搭建框架,建立类和类的关联,以减少类间的耦合性。而且以抽象搭建的系统要比以具体实现搭建的系统更加稳定,扩展性更高,同时也便于维护。

例子说明

比如:

那么,这些如果写到代码里,将会是非常紧密的耦合,假如有一天我们不喜欢吃油泼面,或者无法吃到油泼面,则代码非常多的地方需要更改,因此可以进一步抽象:

还可以更进一步的抽象:

我们真正所需要的、依赖的,其实不是实际的类别与物件,而是它所拥有的功能。其实这就是依赖倒置原则DIP (Dependency Inversion Principle)

代码讲解

需求点

实现下面这样的需求:

用代码模拟一个实际项目开发的场景:前端和后端开发人员开发同一个项目。

不好的设计

首先生成两个类,分别对应前端和后端开发者:

前端开发者:

  1. //================== FrondEndDeveloper.h ==================
  2. @interface FrondEndDeveloper : NSObject
  3. - (void)writeJavaScriptCode;
  4. @end
  5. //================== FrondEndDeveloper.m ==================
  6. @implementation FrondEndDeveloper
  7. - (void)writeJavaScriptCode{
  8. NSLog(@"Write JavaScript code");
  9. }
  10. @end

后端开发者:

  1. //================== BackEndDeveloper.h ==================
  2. @interface BackEndDeveloper : NSObject
  3. - (void)writeJavaCode;
  4. @end
  5. //================== BackEndDeveloper.m ==================
  6. @implementation BackEndDeveloper
  7. - (void)writeJavaCode{
  8. NSLog(@"Write Java code");
  9. }
  10. @end

这两个开发者分别对外提供了自己开发的方法:writeJavaScriptCodewriteJavaCode

接着创建一个Project类:

  1. //================== Project.h ==================
  2. @interface Project : NSObject
  3. //构造方法,传入开发者的数组
  4. - (instancetype)initWithDevelopers:(NSArray *)developers;
  5. //开始开发
  6. - (void)startDeveloping;
  7. @end
  8. //================== Project.m ==================
  9. #import "Project.h"
  10. #import "FrondEndDeveloper.h"
  11. #import "BackEndDeveloper.h"
  12. @implementation Project
  13. {
  14. NSArray *_developers;
  15. }
  16. - (instancetype)initWithDevelopers:(NSArray *)developers{
  17. if (self = [super init]) {
  18. _developers = developers;
  19. }
  20. return self;
  21. }
  22. - (void)startDeveloping{
  23. [_developers enumerateObjectsUsingBlock:^(id _Nonnull developer, NSUInteger idx, BOOL * _Nonnull stop) {
  24. if ([developer isKindOfClass:[FrondEndDeveloper class]]) {
  25. [developer writeJavaScriptCode];
  26. }else if ([developer isKindOfClass:[BackEndDeveloper class]]){
  27. [developer writeJavaCode];
  28. }else{
  29. //no such developer
  30. }
  31. }];
  32. }
  33. @end

在Project类中,我们首先通过一个构造器方法,将开发者的数组传入project的实例对象。然后在开始开发的方法startDeveloping里面,遍历数组并判断元素类型的方式让不同类型的开发者调用和自己对应的函数。

思考一下,这样的设计有什么问题?

问题一:
假如后台的开发语言改成了GO语言,那么上述代码需要改动两个地方:

问题二:
假如后期老板要求做移动端的APP(需要iOS和安卓的开发者),那么上述代码仍然需要改动两个地方:

很显然,在这两种假设的场景下,高层模块(Project)都依赖了低层模块(BackEndDeveloper)的改动,因此上述设计不符合依赖倒置原则

那么该如何设计才可以符合依赖倒置原则呢?

答案是将开发者写代码的方法抽象出来,让Project类不再依赖所有低层的开发者类的具体实现,而是依赖抽象。而且从下至上,所有底层的开发者类也都依赖这个抽象,通过实现这个抽象来做自己的任务。

这个抽象可以用接口,也可以用抽象类的方式来做。

较好的设计

首先,创建一个接口,接口里面有一个写代码的方法writeCode:

  1. //================== DeveloperProtocol.h ==================
  2. @protocol DeveloperProtocol <NSObject>
  3. - (void)writeCode;
  4. @end

然后,让前端程序员和后端程序员类实现这个接口(遵循这个协议)并按照自己的方式实现:

  1. //================== 前端程序员类 ==================
  2. @interface FrondEndDeveloper : NSObject<DeveloperProtocol>
  3. @end
  4. @implementation FrondEndDeveloper
  5. - (void)writeCode{
  6. NSLog(@"Write JavaScript code");
  7. }
  8. @end
  9. //================== 后端程序员类 ==================
  10. @interface BackEndDeveloper : NSObject<DeveloperProtocol>
  11. @end
  12. @implementation BackEndDeveloper
  13. - (void)writeCode{
  14. NSLog(@"Write Java code");
  15. }
  16. @end

最后我们看一下新设计后的Project类:

  1. //================== Project.h ==================
  2. #import "DeveloperProtocol.h"
  3. @interface Project : NSObject
  4. //只需传入遵循DeveloperProtocol的对象数组即可
  5. - (instancetype)initWithDevelopers:(NSArray <id <DeveloperProtocol>>*)developers;
  6. //开始开发
  7. - (void)startDeveloping;
  8. @end
  9. //================== Project.m ==================
  10. #import "FrondEndDeveloper.h"
  11. #import "BackEndDeveloper.h"
  12. @implementation Project
  13. {
  14. NSArray <id <DeveloperProtocol>>* _developers;
  15. }
  16. - (instancetype)initWithDevelopers:(NSArray <id <DeveloperProtocol>>*)developers{
  17. if (self = [super init]) {
  18. _developers = developers;
  19. }
  20. return self;
  21. }
  22. - (void)startDeveloping{
  23. //每次循环,直接向对象发送writeCode方法即可,不需要判断
  24. [_developers enumerateObjectsUsingBlock:^(id<DeveloperProtocol> _Nonnull developer, NSUInteger idx, BOOL * _Nonnull stop) {
  25. [developer writeCode];
  26. }];
  27. }
  28. @end

新的Project的构造方法只需传入遵循DeveloperProtocol协议的对象构成的数组即可。这样也比较符合现实中的需求:只需要会写代码就可以加入到项目中。

而新的startDeveloping方法里:每次循环,直接向当前对象发送writeCode方法即可,不需要对程序员的类型做判断。因为这个对象一定是遵循DeveloperProtocol接口的,而遵循该接口的对象一定会实现writeCode方法(就算不实现也不会引起重大错误)。

现在新的设计接受完了,我们通过上面假设的两个情况来和之前的设计做个对比:

假设1:后台的开发语言改成了GO语言

在这种情况下,只需更改BackEndDeveloper类里面对于DeveloperProtocol接口的writeCode方法的实现即可:

  1. //================== BackEndDeveloper.m ==================
  2. @implementation BackEndDeveloper
  3. - (void)writeCode{
  4. //Old:
  5. //NSLog(@"Write Java code");
  6. //New:
  7. NSLog(@"Write Golang code");
  8. }
  9. @end

而在Project里面不需要修改任何代码,因为Project类只依赖了接口方法WriteCode,没有依赖其具体的实现。

假设2:后期老板要求做移动端的APP(需要iOS和安卓的开发者)

在这个新场景下,我们只需要将新创建的两个开发者类:IOSDeveloperAndroidDeveloper分别实现DeveloperProtocol接口的writeCode方法即可。

同样,Project的接口和实现代码都不用修改:客户端只需要在Project的构建方法的数组参数里面添加这两个新类的实例即可,不需要在startDeveloping方法里面添加类型判断,原因同上。

我们可以看到,新设计很好地在高层类(Project)与低层类(各种developer类)中间加了一层抽象,解除了二者在旧设计中的耦合,使得在低层类中的改动没有影响到高层类。

同样是抽象,新设计同样也可以用抽象类的方式:创建一个Developer的抽象类并提供一个writeCode方法,让不同的开发者类继承与它并按照自己的方式实现writeCode方法。这样一来,在Project类的构造方法就是传入已Developer类型为元素的数组了。

UML 类图对比

未实践依赖倒置原则:
导出图片Thu Apr 09 2020 16_38_18 GMT+0800 (中国标准时间).png-173.8kB

实践了依赖倒置原则:
导出图片Thu Apr 09 2020 16_38_34 GMT+0800 (中国标准时间).png-202.1kB

在实践了依赖倒置原则的 UML 类图中,我们可以看到Project仅仅依赖于新的接口;而且低层的FrondEndDevelopeBackEndDevelope类按照自己的方式实现了这个接口:通过接口解除了原有的依赖。(在 UML 类图中,虚线三角箭头表示接口实线,由实现方指向接口)

如何实践

今后在处理高低层模块(类)交互的情景时,尽量将二者的依赖通过抽象的方式解除掉,实现方式可以是通过接口也可以是抽象类的方式。

迪米特法则(Law of Demeter)

定义

一个对象应该对尽可能少的对象有接触,也就是只接触那些真正需要接触的对象。

定义解读

优点

实践迪米特法则可以良好地降低类与类之间的耦合,减少类与类之间的关联程度,让类与类之间的协作更加直接。

减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。

代码讲解

不该有直接依赖关系的类之间,不要有依赖

需求点

设计一个汽车类,包含汽车的品牌名称,引擎等成员变量。提供一个方法返回引擎的品牌名称。

不好的设计

Car类:

  1. //================== Car.h ==================
  2. @class GasEngine;
  3. @interface Car : NSObject
  4. //构造方法
  5. - (instancetype)initWithEngine:(GasEngine *)engine;
  6. //返回私有成员变量:引擎的实例
  7. - (GasEngine *)usingEngine;
  8. @end
  9. //================== Car.m ==================
  10. #import "Car.h"
  11. #import "GasEngine.h"
  12. @implementation Car
  13. {
  14. GasEngine *_engine;
  15. }
  16. - (instancetype)initWithEngine:(GasEngine *)engine{
  17. self = [super init];
  18. if (self) {
  19. _engine = engine;
  20. }
  21. return self;
  22. }
  23. - (GasEngine *)usingEngine{
  24. return _engine;
  25. }
  26. @end

从上面可以看出,Car的构造方法需要传入一个引擎的实例对象。而且因为引擎的实例对象被赋到了Car对象的私有成员变量里面。所以Car类给外部提供了一个返回引擎对象的方法:usingEngine

而这个引擎类GasEngine有一个品牌名称的成员变量brandName

  1. //================== GasEngine.h ==================
  2. @interface GasEngine : NSObject
  3. @property (nonatomic, copy) NSString *brandName;
  4. @end

这样一来,客户端就可以拿到引擎的品牌名称了:

  1. //================== Client.m ==================
  2. #import "GasEngine.h"
  3. #import "Car.h"
  4. - (NSString *)findCarEngineBrandName:(Car *)car{
  5. GasEngine *engine = [car usingEngine];
  6. NSString *engineBrandName = engine.brandName;//获取到了引擎的品牌名称
  7. return engineBrandName;
  8. }

上面的设计完成了需求,但是却违反了迪米特法则。原因是在客户端的findCarEngineBrandName:中引入了和入参(Car)和返回值(NSString)无关的GasEngine对象。增加了客户端与GasEngine的耦合。而这个耦合显然是不必要更是可以避免的。

较好的设计

同样是Car这个类,我们去掉原有的返回引擎对象的方法,而是增加一个直接返回引擎品牌名称的方法:

  1. //================== Car.h ==================
  2. @class GasEngine;
  3. @interface Car : NSObject
  4. //构造方法
  5. - (instancetype)initWithEngine:(GasEngine *)engine;
  6. //直接返回引擎品牌名称
  7. - (NSString *)usingEngineBrandName;
  8. @end
  9. //================== Car.m ==================
  10. #import "Car.h"
  11. #import "GasEngine.h"
  12. @implementation Car
  13. {
  14. GasEngine *_engine;
  15. }
  16. - (instancetype)initWithEngine:(GasEngine *)engine{
  17. self = [super init];
  18. if (self) {
  19. _engine = engine;
  20. }
  21. return self;
  22. }
  23. - (NSString *)usingEngineBrandName{
  24. return _engine.brand;
  25. }
  26. @end

因为直接usingEngineBrandName直接返回了引擎的品牌名称,所以在客户端里面就可以直接拿到这个值,而不需要间接地通过原来的GasEngine实例来获取。

我们看一下客户端操作的变化:

  1. //================== Client.m ==================
  2. #import "Car.h"
  3. - (NSString *)findCarEngineBrandName:(Car *)car{
  4. NSString *engineBrandName = [car usingEngineBrandName]; //直接获取到了引擎的品牌名称
  5. return engineBrandName;
  6. }

与之前的设计不同,在客户端里面,没有引入GasEngine类,而是直接通过Car实例获取到了需要的数据。

这样设计的好处是,如果这辆车的引擎换成了电动引擎(原来的GasEngine类换成了ElectricEngine类),客户端代码可以不做任何修改!因为它没有引入任何引擎类,而是直接获取了引擎的品牌名称。

所以在这种情况下我们只需要修改Car类的usingEngineBrandName方法实现,将新引擎的品牌名称返回即可。

UML 类图对比

未实践迪米特法则:
开闭原则.png-68.7kB

实践了迪米特法则:
导出图片Thu Apr 09 2020 18_16_57 GMT+0800 (中国标准时间).png-162.1kB

很明显,在实践了迪米特法则的 UML 类图里面,没有了Client对GasEngine的依赖,耦合性降低。

代码讲解二

有依赖关系的类之间,尽量只依赖必要的接口。

需求点

Serialization 类负责对象的序列化和反序列化。

不好的设计

  1. public class Serialization {
  2. public String serialize(Object object) {
  3. String serializedResult = ...;
  4. //...
  5. return serializedResult;
  6. }
  7. public Object deserialize(String str) {
  8. Object deserializedResult = ...;
  9. //...
  10. return deserializedResult;
  11. }
  12. }

单看这个类的设计,没有一点问题。不过,如果我们把它放到一定的应用场景里,那就还有继续优化的空间。假设在我们的项目中,有些类只用到了序列化操作,而另一些类只用到反序列化操作。那基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口。

  1. public class Serializer {
  2. public String serialize(Object object) {
  3. String serializedResult = ...;
  4. ...
  5. return serializedResult;
  6. }
  7. }
  8. public class Deserializer {
  9. public Object deserialize(String str) {
  10. Object deserializedResult = ...;
  11. ...
  12. return deserializedResult;
  13. }
  14. }

上面拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的 地方不至于过于分散。

较好的设计

  1. public interface Serializable {
  2. String serialize(Object object);
  3. }
  4. public interface Deserializable {
  5. Object deserialize(String text);
  6. }
  1. public class Serialization implements Serializable, Deserializable {
  2. @Override
  3. public String serialize(Object object) {
  4. String serializedResult = ...;
  5. ...
  6. return serializedResult;
  7. }
  8. @Override
  9. public Object deserialize(String str) {
  10. Object deserializedResult = ...;
  11. ...
  12. return deserializedResult;
  13. }
  14. }
  1. public class DemoClass_1 {
  2. private Serializable serializer;
  3. public Demo(Serializable serializer) {
  4. this.serializer = serializer;
  5. }
  6. //...
  7. }
  8. public class DemoClass_2 {
  9. private Deserializable deserializer;
  10. public Demo(Deserializable deserializer) {
  11. this.deserializer = deserializer;
  12. }
  13. //...
  14. }

上面我们通过抽象接口的方式来实现迪米特法则。尽管我们还是要往DemoClass_1的构造函数中,传入包含序列化和反序列化的Serialization实现类,但是,我们依赖的Serializable接口只包含序列化操作,DemoClass_1 无法使用 Serialization类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。

如何实践

今后在做对象与对象之间交互的设计时,应该极力避免引出中间对象的情况(需要导入其他对象的类):需要什么对象直接返回即可,降低类之间的耦合度。

参考

面向对象设计的六大设计原则(附 Demo & UML类图)
控制反转(IOC)和依赖注入(DI)

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注