- S - Single-responsibility Principle(单一职责原则)
- O - Open-closed Principle(开闭原则)
- L - Liskov substitution principle(里式替换原则)
- I - Interface segregation principle(接口隔离原则)
- D - Dependency Inversion principle Conclusion(依赖反转原则)
使用solid原则,可以使代码易于维护、扩展、测试和重构。
总的来说,刚开始看可能不好掌握,但是随着持续的使用和思考,这些原则将会成为你的一部分。
而且了解这5个原则后,再去看开源库,会发现很有优秀开源库都默默遵守这些规则。
一、单一职责原则(Single responsibility principle)
一个类只负责一项职责。
举例:假如有一个图形数组,计算数组中图形的总面积。
1. 圆形类
#import "BXCircle.h"
@implementation BXCircle
- (instancetype)initWithRadius:(CGFloat)radius
{
self = [super init];
if (self) {
self.radius = radius;
}
return self;
}
@end
2. 正方形类
#import "BXSquare.h"
- (instancetype)initWithLength:(CGFloat)length
{
self = [super init];
if (self) {
self.length = length;
}
return self;
}
@end
3. 计算面积总和的类
#import "BXAreaCalculator.h"
- (instancetype)initWithShapes:(NSArray *)shapes
{
self = [super init];
if (self) {
self.shapes = shapes;
}
return self;
}
- (CGFloat)sumOfShapes
{
CGFloat sum = 0.0;
for (id shape in _shapes) {
if ([shape isKindOfClass:[BXCircle class]]) {
CGFloat radius = (BXCircle *)shape.radius;
sum = sum + radius * radius * M_PI;
} else if ([shape isKindOfClass:[BXSquare class]]) {
CGFloat length = (BXSquare *)shape.lenght;
sum = sum + length * length;
}
}
return sum;
}
- (NSString *)output
{
CGFloat sumOfShapes = [self sumOfShapes];
return [NSString stringWithFormat:@"面积总和为%f", sumOfShapes];
}
@end
实例化BXAreaCalculator类,传进去一个图形的数组,调用outPut方法就能输出结果。
面积总和为8
然后后面来了个需求:需要用json,html的形式输出结果
此时,BXAreaCalculator类就负责了两个职责:1. 计算面积总和;2. 输出各种形式。
这时就与单一职责原则相违背了。BXAreaCalculator类应该只关心面积总和的计算,而不应该关心输出json还是html。
有两种修改方法:
- 直接在BXAreaCalculator类中添加各种输出方法。(违背SRP原则)
- 新建一个专门负责输出的类,在其中添加各种输出方法。(遵循SRP原则)
所以,我再创建一个BXSumCalculatorOutputter类,专门负责输出各种形式。
#import "BXSumCalculatorOutputter.h"
@implementation BXSumCalculatorOutputter
- (instancetype)initWithAreas:(CGFloat)areas
{
self = [super init];
if (self) {
_areas = areas;
}
return self;
}
#pragma mark - public methods
- (id)JSON
{
// 输出json
}
- (id)HTML
{
// 输出HTML
}
- (id)JADE
{
// 输出JADE
}
- (id)XML
{
// 输出XML
}
@end
以上所举的这个例子太简单了,它只有几个方法,所以,无论是在代码级别上违反单一职责原则,还是在方法级别上违反,都不会造成太大的影响。实际应用中的类都要复杂的多,一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。
遵循单一职责原的优点有:
- 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多
- 提高类的可读性,提高系统的可维护性
- 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
二、开闭原则(Open-closed Principle)
This simply means that a class should be easily extendable without modifying the class itself. Let's take a look at the AreaCalculator class, especially it's sum method.
类应该能在不修改类本身的情况下轻松扩展
现在看一下BXAreaCalculator类的计算面积总和的方法,sumOfShapes方法:
- (CGFloat)sumOfShapes
{
CGFloat sum = 0.0;
for (id shape in _shapes) {
if ([shape isKindOfClass:[BXCircle class]]) {
CGFloat radius = (BXCircle *)shape.radius;
sum = sum + radius * radius * M_PI;
} else if ([shape isKindOfClass:[BXSquare class]]) {
CGFloat length = (BXSquare *)shape.lenght;
sum = sum + length * length;
}
}
return sum;
}
如果想要添加其他图形,例如长方形,梯形等等,就要扩展sumOfShapes,在其中添加大量的if else逻辑。
应该将计算每个图形面积的逻辑从方法中移除,放到他自身的图形类中。
#import "BXCircle.h"
@implementation BXCircle
- (instancetype)initWithRadius:(CGFloat)radius
{
self = [super init];
if (self) {
self.radius = radius;
}
return self;
}
- (CGFloat)area
{
return self.radius * self.radius * M_PI;
}
@end
#import "BXSquare.h"
- (instancetype)initWithLength:(CGFloat)length
{
self = [super init];
if (self) {
self.length = length;
}
return self;
}
- (CGFloat)area
{
return self.radius * self.radius * M_PI;
}
@end
这样一来,BXAreaCalculator中计算总和的方法就会很简单
- (CGFloat)sumOfShapes
{
// 这里简单这样写
CGFloat sum = 0.0;
for (id shape in _shapes) {
SEL areaFunction = NSSelectorFromString(@"area");
CGFloat area = ((CGFloat(*)(id,SEL))objc_msgSend)(shape, areaFunction);
sum = sum + area;
}
return sum;
}
如果再创建长方形、梯形,就不需要修改BXAreaCalculator中的代码了。
但是又出现一个新的问题:我们怎么知道传给BXAreaCalculator的数组中对象是图形类、有area方法呢?
编写接口是solid一个重要部分
我们可以创建一个协议,让所有的图形类都去实现它。
#import <Foundation/Foundation.h>
@protocol BXShapeInterface <NSObject>
@required
- (CGFloat)area;
@end
BXCircle和BXSquare都实现这个协议
@interface BXCircle : NSObject <BXShapeInterface>
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithRadius:(CGFloat)radius NS_DESIGNATED_INITIALIZER;
@end
@interface BXSquare : NSObject <BXShapeInterface>
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithLength:(CGFloat)length NS_DESIGNATED_INITIALIZER;
@end
然后规定BXAreaCalculator中传进来的shapes数组中的元素都遵守这个协议
- (instancetype)initWithShapes:(NSArray<id<BXShapeInterface>> *)shapes
{
self = [super init];
if (self) {
_shapes = shapes;
}
return self;
}
开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
三、里氏替换原则(Open-closed Principle)
Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
对于 class T 的实例对象 x,可以使q(x)成立;那么,对于class T 的子类 classS 的实例对象 y,也要是q(y)成立。(意思是要兼容子类???)
意思是说所有的子类/派生类都应该可以替代他的父类/基类。
肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑。其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。
所有引用基类的地方必须能透明地使用其子类的对象。
当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
如果我们想实现 求体积和 的 BXVolumeCalculator类,继承自 BXAreaCalculator类。
- (NSArray *)sumOfShapes
{
return @[@3, @5, @2];
}
返回的是一个数组,而不是一个CGFloat类型。所以之后output的时候就会出现bug。
- (CGFloat)sumOfShapes
{
return @6;
}
这个例子举得不太好,简而言之就是在继承父类后,不要影响子类具备的父类原有的功能。
在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
看上去很不可思议,因为我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?
后果就是:你写的代码出问题的几率将会大大增加。
四、接口隔离原则
A client should never be forced to implement an interface that it doesn't use or clients shouldn't be forced to depend on methods they do not use.
客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。
未遵循接口隔离原则:
这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。
接口拆分:
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
采用接口隔离原则对接口进行约束时,要注意以下几点:
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
五、依赖倒置原则
Entities must depend on abstractions not on concretions. It states that the high level module must not depend on the low level module, but they should depend on abstractions.
高层模块不应该直接依赖具体的底层模块。两者都应该依赖其抽象,而不是去依赖具体细节。细节应该依赖抽象。
定义:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
问题由来:类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
解决方案:将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。
依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:
class Book{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下:
class Newspaper{
public String getContent(){
return "林书豪38+7领导尼克斯击败湖人……";
}
}
这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。假如以后需求换成杂志呢?换成网页呢?还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:
interface IReader{
public String getContent();
}
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:
class Newspaper implements IReader {
public String getContent(){
return "林书豪17+9助尼克斯击败老鹰……";
}
}
class Book implements IReader{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
mother.narrate(new Newspaper());
}
}
运行结果:
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Mother类与Book类直接耦合时,Mother类必须等Book类编码完成后才可以进行编码,因为Mother类依赖于Book类。修改后的程序则可以同时开工,互不影响,因为Mother与Book类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。现在很流行的TDD开发模式就是依赖倒置原则最成功的应用。
传递依赖关系有三种方式,以上的例子中使用的方法是接口传递,另外还有两种传递方式:构造方法传递和setter方法传递,相信用过Spring框架的,对依赖的传递方式一定不会陌生。
在实际编程中,我们一般需要做到如下3点:
- 低层模块尽量都要有抽象类或接口,或者两者都有。
- 变量的声明类型尽量是抽象类或接口。
- 使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
六、总结
说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。
图中的每一条维度各代表一项原则,我们依据对这项原则的遵守程度在维度上画一个点,则如果对这项原则遵守的合理的话,这个点应该落在红色的同心圆内部;如果遵守的差,点将会在小圆内部;如果过度遵守,点将会落在大圆外部。一个良好的设计体现在图中,应该是六个顶点都在同心圆中的六边形。
在上图中,设计1、设计2属于良好的设计,他们对六项原则的遵守程度都在合理的范围内;设计3、设计4设计虽然有些不足,但也基本可以接受;设计5则严重不足,对各项原则都没有很好的遵守;而设计6则遵守过渡了,设计5和设计6都是迫切需要重构的设计。