程序员该有的艺术气质—SOLID原则

时间:2022-03-11 15:25:28

      昨天Amazon在我们学校有场讲座,主要是跟我们分享一些经验:作为一个程序员该有的编码硬技术和工作软技术。当然他们也有来我们学校招聘人才的目的,想要吸引我们去他们公司,于我而言,这么牛逼的一个公司我当然特别想去啦,这场讲座我是必听无疑,的确学到了一些知识。没有过实际项目的在校生往往不能写出漂亮的代码(包括编码规范、注释、设计模式、性能等等),因为他们更多注重的正确性,只要结果出来了且是正确的,那么就是好代码,孰不知这样的代码拿给公司的项目经理看,他们会是多么的痛苦啊!呵呵,不说了,作为在校生,我首先得学习编码规范,这个每个公司有每个公司自己的一套规则,就不说了;其次是代码的设计模式,这个太大了,需要不断学习和积累。众所周知,Java编程最基本的原则就是要追求高内聚和低耦合的解决方案和代码模块设计,在这里我先学习下讲座上讲的程序员该有的艺术气质—SOLID原则。

      S.O.L.I.D是面向对象设计和编程(OOD&OOP)中几个重要编码原则(Programming Priciple)的首字母缩写。

SRP The Single Responsibility Principle 单一责任原则
OCP The Open Closed Principle 开放封闭原则
LSP The Liskov Substitution Principle 里氏替换原则
ISP The Interface Segregation Principle 接口分离原则
DIP The Dependency Inversion Principle 依赖倒置原则

 

1. 单一责任原则(SRP)
      当需要修改某个类的时候原因有且只有一个。换句话说就是让一个类只做一种类型责任,当这个类需要承当其他类型的责任的时候,就需要分解这个类。 类被修改的几率很大,因此应该专注于单一的功能。如果你把多个功能放在同一个类中,功能之间就形成了关联,改变其中一个功能,有可能中止另一个功能,这时就需要新一轮的测试来避免可能出现的问题,非常耗时耗力。

示例:

新建一个Rectangle类,该类包含两个方法,一个用于把矩形绘制在屏幕上,一个方法用于计算矩形的面积。如图

 程序员该有的艺术气质—SOLID原则

Rectangle类违反了SRP原则。Rectangle类具有两个职责,如果其中一个改变,会影响到两个应用程序的变化。

一个好的设计是把两个职责分离出来放在两个不同的类中,这样任何一个变化都不会影响到其他的应用程序。


2. 开放封闭原则(OCP)
软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。这个原则是诸多面向对象编程原则中最抽象、最难理解的一个。

(1)通过增加代码来扩展功能,而不是修改已经存在的代码。
(2)若客户模块和服务模块遵循同一个接口来设计,则客户模块可以不关心服务模块的类型,服务模块可以方便扩展服务(代码)。
(3)OCP支持替换的服务,而不用修改客户模块。

示例:

public boolean sendByEmail(String addr, String title, String content) {

}

public boolean sendBySMS(String addr, String content) {

}

// 在其它地方调用上述方法发送信息
sendByEmail(addr, title, content);
sendBySMS(addr, content);

如果现在又多了一种发送信息的方式,比如可以通过QQ发送信息,那么不仅需要增加一个方法sendByQQ(),还需要在调用它的地方进行修改,违反了OCP原则,更好的方式是

抽象出一个Send接口,里面有个send()方法,然后让SendByEmail和SendBySMS去实现它既可。这样即使多了一个通过QQ发送的请求,那么只要再添加一个SendByQQ实现类实现Send接口既可。这样就不需要修改已有的接口定义和已实现类,很好的遵循了OCP原则。

 

3. 里氏替换原则(LSP)

当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系

客户模块不应关心服务模块的是如何工作的;同样的接口模块之间,可以在不知道服务模块代码的情况下,进行替换。即接口或父类出现的地方,实现接口的类或子类可以代入。

示例:

public class Rectangle {
private double width;
private double height;

public void setWidth(double value) {
this.width = value;
}

public double getWidth() {
return this.width;
}

public void setHeight(double value) {
this.width = value;
}

public double getHeight() {
return this.height;
}

public double Area() {
return this.width*this.height;
}
}

public class Square extends Rectangle {

/* 由于父类Rectangle在设计时没有考虑将来会被Square继承,所以父类中字段width和height都被设成private,在子类Square中就只能调用父类的属性来set/get,具体省略 */
}

// 测试
void TestRectangle(Rectangle r) {
r.Weight
=10;
r.Height
=20;
Assert.AreEqual(
10,r.Weight);
Assert.AreEqual(
200,r.Area);
}

// 运行良好
Rectangle r = new Rectangle ();
TestRectangle(r);

// 现在两个Assert测试都失败了
Square s = new Square();
TestRectangle(s);

      LSP让我们得出一个非常重要的结论:一个模型,如果孤立地看,并不具有真正意义上的有效性,模型的有效性只能通过它的客户程序来表现。例如孤立地看Rectangle和Squre,它们时自相容的、有效的;但从对基类Rectangle做了合理假设的客户程序TestRectangle(Rectangle r)看,这个模型就有问题了。在考虑一个特定设计是否恰当时,不能完全孤立地来看这个解决方案,必须要根据该设计的使用者所作出的合理假设来审视它。

      目前也有一些技术可以支持我们将合理假设明确化,例如测试驱动开发(Test-Driven Development,TDD)和基于契约设计(Design by Contract,DBC)。但是有谁知道设计的使用者会作出什么样的合理假设呢?大多数这样的假设都很难预料。如果我们预测所有的假设的话,我们设计的 系统可能也会充满不必要的复杂性。推荐的做法是:只预测那些最明显的违反LSP的情况,而推迟对所有其他假设的预测,直到出现相关的脆弱性的臭味(Bad Smell)时,才去处理它们。我觉得这句话还不够直白,Martin Fowler的《Refactoring》一书中“Refused Bequest”(拒收的遗赠)描 述的更详尽:子类继承父类的methods和data,但子类仅仅只需要父类的部分Methods或data,而不是全部methods和data;当这 种情况出现时,就意味这我们的继承体系出现了问题。例如上面的Rectangle和Square,Square本身长和宽相等,几何学中用边长来表示边, 而Rectangle长和宽之分,直观地看,Square已经Refused了Rectangle的Bequest,让Square继承 Rectangle是一个不合理的设计。

      现在再回到面向对象的基本概念上,子类继承父类表达的是一种IS-A关系,IS-A关系这种用法被认为是面向对象分析(OOA)基本技术之一。但正方形的 的确确是一个长方形啊,难道它们之间不存在IS-A关系?关于这一点,《Java与模式》一书中的解释是:我们设计继承体系时,子类应该是可替代的父类的,是可替代关系,而不仅仅是IS-A的关系;而PPP一书中的解释是:从行为方式的角度来看,Square不是Rectangle,对象的行为方式才是软件真正所关注的问题;LSP清楚地指出,OOD中IS-A关系时就行为方式而言的,客户程序是可以对行为方式进行合理假设的。其实二者表达的是同一个意思。

 
4. 接口分离原则(ISP)

不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的接口比使用单一的总接口总要好。

客户模块不应该依赖大的接口,应该裁减为小的接口给客户模块使用,以减少依赖性。如Java中一个类实现多个接口,不同的接口给不用的客户模块使用,而不是提供给客户模块一个大的接口。

示例:

public interface Animal {
public void eat(); //

public void sleep(); //

public void crawl(); //

public void run(); //
}

public class Snake implements Animal {

public void eat() {

}

public void sleep() {

}

public void crawl() {

}

public void run(){

}

}

public class Rabit implements Animal {

public void eat() {

}

public void sleep() {

}

public void crawl() {

}

public void run(){

}

}

上面的例子,Snake并没有run的行为而Rabbit并没有crawl的行为,而这里它们却必须实现这样不必要的方法,更好的方法是crawl()和run()单独作为一个接口,这需要根据实际情况进行调整,反正不要把什么功能都放在一个大的接口里,而这些功能并不是每个继承该接口的类都所必须的。


5. 依赖注入或倒置原则(DIP)

1. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
2. 抽象不应该依赖于细节,细节应该依赖于抽象

这个设计原则的亮点在于任何被DI框架注入的类很容易用mock对象进行测试和维护,因为对象创建代码集中在框架中,客户端代码也不混乱。有很多方式可以实现依赖倒置,比如像AspectJ等的AOP(Aspect Oriented programming)框架使用的字节码技术,或Spring框架使用的代理等。

(1).高层模块不要依赖低层模块;
(2).高层和低层模块都要依赖于抽象;
(3).抽象不要依赖于具体实现; 
(4).具体实现要依赖于抽象;
(5).抽象和接口使模块之间的依赖分离

先让我们从宏观上来看下,举个例子,我们经常会用到宏观的一种体系结构模式--layer模式,通过层的概念分解和架构系统,比如常见得三层架构等。那么依赖关系应该是自上而下,也就是上层模块依赖于下层模块,而下层模块不依赖于上层,如下图所示。
程序员该有的艺术气质—SOLID原则
这应该还是比较容易理解的,因为越底层的模块相对就越稳定,改动也相对越少,而越上层跟需求耦合度越高,改动也会越频繁,所以自上而下的依赖关系使上层发生变更时,不会影响到下层,降低变更带来的风险,保证系统的稳定。
上面是立足在整体架构层的基础上的结果,再换个角度,从细节上再分析一下,这里我们暂时只关注UI和Service间的关系,如下面这样的依赖关系会有什么样的问题?
程序员该有的艺术气质—SOLID原则
第一,当需要追加提供一种新的Service时,我们不得不对UI层进行改动,增加了额外的工作。
第二,这种改动可能会影响到UI,带来风险。
第三,改动后,UI层和Logic层都必须重新再做Unit testing。

那么具体怎么优化依赖关系才能让模块或层间的耦合更低呢?想想前面讲的OCP原则吧,观点是类似的。
我们可以为Service追加一个抽象层,上层UI不依赖于Service的details,UI和Service同时依赖于这个Service的抽象层。如下图是我们的改进后的结果。
程序员该有的艺术气质—SOLID原则
这样改进后会有什么好处呢?
第一,Service进行扩展时,一般情况下不会影响到UI层,UI不需要改动。
第二,Service进行扩展时,UI层不需要再做Unit testing。
 
总结:
  1. 一个对象只承担一种责任,所有服务接口只通过它来执行这种任务。
  2. 程序实体,比如类和对象,向扩展行为开放,向修改行为关闭。
  3. 子类应该可以用来替代它所继承的类。
  4. 一个类对另一个类的依赖应该限制在最小化的接口上。
  5. 依赖抽象层(接口),而不是具体类。

      这几条原则是非常基础而且重要的面向对象设计原则。正是由于这些原则的基础性,理解、融汇贯通这些原则需要不少的经验和知识的积累。举的例子可能不太贴切也不太准确,反正理解了就行,以后去公司实习什么的一定要遵循这些原则,不能让自己写的代码让别人批的一无是处然后胎死腹中,当然还有其他的一些很重要的原则,我会在后面的时间里继续学习和分享!