解耦,面向对象编程,DRY原则,Single Responsibility Principle,Open/Closed Principle,Law of Demeter,Dependency Inversion Principle

石头 2016-11-28 23:02:25



 

解耦合手段之二:Single Responsibility Principle

在面向对象的方法中,Robert C. Martin引入了Single Responsibility Principle(SRP),即单一职责原则。就是说:所有的对象都应该有单一的职责,它提供的所有的服务也都仅围绕着这个职责。用Uncle Bob自己的话来说就是:“永远不要让一个类存在多个改变的理由”(There should never be more than one reason for a class to change.)

解耦合手段之二:Single Responsibility Principle 

在面向对象的方法中,Robert C. Martin引入了Single Responsibility Principle(SRP),即单一职责原则。就是说:所有的对象都应该有单一的职责,它提供的所有的服务也都仅围绕着这个职责。用Uncle Bob自己的话来说就是:“永远不要让一个类存在多个改变的理由”(There should never be more than one reason for a class to change.

多个职责挤在一个类当中就是耦合。例如一个矩形类即要负责几何计算,又要负责在图形界面中画出这个矩形,这就违反了SRP。为什么这样耦合性就高了呢?因为类内部的“内聚性”差了。如果一个类里面所有的方法都用到了这个类所有的成员变量,我们说这个类的内聚性是最高的。如果是一些方法用到了一半的成员变量,而另一些方法用到了另一半,那么这显然是两个不相干的职责被挤到了一个类里,这个类的内聚性是最差的。遵守SRP原则就能够使我们提高类的内聚性,从而降低系统的耦合性。
那么怎么来定义职责呢?Uncle Bob的定义就是“改变的理由”。如果你能想到一个类存在多个使其改变的原因,那么这个类就存在多个职责。例如上面提到的矩形类,要改变图形界面中矩形的外观要改动它,要改变矩形的几何运算也要改到它。因此这个矩形类要被拆成两个。
这样就会使得每个类都很小,是的,就是要类很小。可能有人会担心这么多很小的类会不会用起来很麻烦。用个Uncle Bob的例子吧:你是希望工具箱里只有几个大抽屉,所有的工具都放在一起,还是希望有很多小抽屉,上面都标好了里面工具的名称?
即使不是面向对象的语言,我们还是可以使用面向对象的思想,把数据和它所对应的操作关联起来。函数,类,模块以至于整个系统,这些不同层面代表了对业务逻辑不同层面的抽象。SRP从职责,或者是“改变的理由”的角度为类的抽象粒度提供的判断的标准。
最后提前说一下,所谓SRP只不过是伟大的Separation of Concerns原则的一个引伸原则,SoC才是一切解耦合的不二法门。等我把SoC的引伸原则一个一个的列举完了,再来谈它自己吧。

 

解耦合手段之三:Open/Closed Principle

这又是一个面向对向的方法。Open/Closed Principle,可以译成“开/关原则”,“开-闭原则”或者“开放封闭原则”,是说“一个软件实体应当对扩展开放,对修改关闭( Software entities should be open for extension,but closed for modification.)。”这里所说的“实体”可以是函数、类、模块等等。

 

解耦合手段之三:Open/Closed Principle

这又是一个面向对向的方法。Open/Closed Principle,可以译成“开/关原则”,“开-闭原则”或者“开放封闭原则”,是说“一个软件实体应当对扩展开放,对修改关闭( Software entities should be open for extension,but closed for modification.)。”这里所说的“实体”可以是函数、类、模块等等。
举个我所见过的比较差的例子吧。我们做的产品是由很多单板组成的分布式系统,为了适应不断提高的数据传输要求,其接口板经常要更新换代,不断升级。每次有新的接口板硬件以后,这块板上的几个程序模块都需要进行修改,甚至其它单板上的程序也要改动很多处。这种改动往往是在函数级别上的。系统的这种高耦合性给我们带来了很多麻烦,即费时又容易出问题。显然,我们的系统设计一定程度上违反了OCP,因为这个系统的模块都对修改开放,对扩展封闭。如果遵守了OCP,如果一段程序自身的功能没有BUG的话是不会被打开修改的,并且如果接口没有发生变化也不会对接口两端都进行改动的。
还是在Uncle Bob的著作《Agile Software Development Principles, Patterns, and Practice》(PPP)中有对于这个原则的详细描述。实现OCP原则,往往要用到继承和抽象接口。这篇文章 是和《PPP》中的内容一样的。
通常如果我们只是为了满足眼前的功能所写出的代码是不会自然而然的满足OCP和前面提到的SRP的,这就需要我们不断的对代码进行重构(Refactoring)。Martin Fowler在他的著作《重构-改善即有代码的设计》(Refactoring Improving the Design of Existing Code)一书中讲到了许多“Bad smells of code”——代码中的坏味道。如果你“闻”到你的代码中有他提到的“发散式变化(divergent change)”或者“散弹式修改(shotgun surgery)”那么你的代码可能违反了SRP或者OCP,需要重构。


解耦合手段之四:Law of Demeter

迪米特法则(Law of Demeter),又称“最少知识原则”(Principle of Least Knowledge),也是主要针对面向对象思想的,可以简单的概括为“talk only to your immediate friends”。

 

解耦合手段之四:Law of Demeter

迪米特法则(Law of Demeter),又称“最少知识原则”(Principle of Least Knowledge),也是主要针对面向对象思想的,可以简单的概括为“talk only to your immediate friends”。

具体来说,在面向对象的方法中,一个方法“M”和一个对象“O”只可以调用以下几种对象的方法:
1. O自己
2. M的参数
3. 在M中创建的对象
4. O的直接组件对象

Law of Demeter来源于1987年荷兰大学的一个叫做Demeter的项目。Craig Larman把Law of Demeter又称作“不要和陌生人说话”。在《程序员修炼之道》中讲LoD的那一章叫作“解耦合与迪米特法则”。可以看出迪米特法则是非常流行的解耦合手段之一。关于迪米特法则有一些很形象的比喻:
如果你想让你的狗狗跑的话,你会对狗狗说还是对四条狗腿说?

如果你去店里买东西,你会把钱交给店员,还是会把钱包交给店员让他自己拿?
让店员自己从钱包里拿钱?这听起来有点荒唐,不过在我们的代码里这几乎是见怪不怪的事情了:
class Clerk {
Store store;
void SellGoodsTo(Client client)
{
money = client.GetWallet().GetMoney();//店员自己从钱包里拿钱了!
store.ReceiveMoney(money);
}
};
在《Clean Code》一书中,作者找到了Apache framework中的一段违反了LoD的代码:
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
这么长的一串对其它对象的细节,以及细节的细节,细节的细节的细节……的调用,违反了迪米特法则,增加了耦合,使得代码结构复杂、僵化,难以扩展和维护。

在《重构》一书中的各种“Bad smells of code”中有一种“smell”叫做“Feature Envy”(依恋情结),形象的描述了一种违反了LoC的情况。Feature Envy就是说一个对象对其它对象的内容更有兴趣,也就是说老是羡慕别的对象的成员、结构或者功能,大老远的调用人家的东西。这样的结构显然是不合理的。我们的程序应该写得比较“害羞”。不能像前面例子中的那个不把自己当外人的店员一样,拿过客人的钱包自己把钱拿出来。“害羞”的程序只和自己最近的朋友交谈。这种情况下应该调整程序的结构,让那个对象自己拥有它羡慕的feature,或者使用合理的设计模式(例如Facade和Mediator)。

店员的例子如果是这样就会好一点:
money = client.GetMoney();//客户自己从钱包里拿钱

或者根本不用那么麻烦:

void SellGoods(Money money)
{
store.ReceiveMoney(money);
}

这一法则不仅仅局限于计算机领域,在其他领域也同样适用。据说美国人就在航天系统的设计中采用这一法则。

 

 

解耦合手段之五:Dependency Inversion Principle

Dependency Inversion,又被称为控制反转(Inversion of Control)。这种原则又被戏称为“好莱坞原则”——“Don’t call us, we’ll call you.”。Uncle Bob是这样描述DIP(Dependency Inversion Principle)的:

A. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。

B. 抽象不应该依赖于细节,细节应该依赖于抽象。

 

解耦合手段之五:Dependency Inversion Principle

通常,我们认为上层模块理所当然的依赖于下层模块的服务,例如业务模块对数据库的依赖,通信模块对网络的依赖。按照这种逻辑,如果要设计一款电视的话,也许会是这样:
class TVSet{
private Program program = LocateProgramFromRadiobBroadcast();
void Play() {
Show(Program);
}
};
因为电视依赖于节目源,所以电视自己创建了节目源,或者说自己来定位节目源。在上面的例子中是定位一个无线广播节目。但这样的电视有个问题,它只能播广播节目。生产厂商要对电视进行测试也要依赖于电视台的广播。因此,我们的电视通常都不是这样的,而是专门有接口,由使用者提供节目源:
class TVSet{
void Play(Program program) {
Show(Program);
}
};
这样,不论是测试用的简单信号发生器,还是DVD,电脑都可以通过电视播放了。
这种依赖关系的倒置被称为Dependency Inversion,又被称为控制反转(Inversion of Control)。这种原则又被戏称为“好莱坞原则”——“Don’t call us, we’ll call you.”。Uncle Bob是这样描述DIP(Dependency Inversion Principle)的:

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

依赖倒置使得系统的耦合性降低,模块的可重用性提高,并能提高系统的可测性和可用性。尤其是对单元测试有至关重要的作用,因为这样就可以用mock object来代替真实的对象。
实现依赖倒置的技术被称为“依赖注入”(Dependency Injection)。通常,当一个模块需要一种服务的时候,它要么直接持有对此服务的索引,要么通过一个服务定位器(ServiceLocator)来获得对此服务的索引。而采用依赖注入的方法,则是从外界传入对此服务的索引。例如通过传入参数,或者回调函数来设置服务。
Martin Fowler把依赖注入的方法分为三类:
  • Interface injection
  • Setter injection
  • Constructor injection
甚至有很多框架工具可以支持我们方便的在代码中实现Dependency Injection,例如google Guice。

 

解耦合手段之六:并发

一般的程序都是顺序执行的,在这种情况下,程序的执行和执行的时间是相互依赖的或者说是耦合的。当这种耦合成为一种阻碍时我们就需要并发(Concurrency)。

"Concurrency is a decoupling strategy. It helps us decouple what gets done from when it gets done."

–Clean Code, Robert C. Martin

 

解耦合手段之六:并发

什么?并发也是一种解耦合的手段么?
是的。
一般的程序都是顺序执行的,在这种情况下,程序的执行和执行的时间是相互依赖的或者说是耦合的。当这种耦合成为一种阻碍时我们就需要并发(Concurrency)。
"Concurrency is a decoupling strategy. It helps us decouple what gets done from when it gets done."
Clean Code, Robert C. Martin
一个很常见的错误观点是并发总是能够提高性能。这在大多数情况下,尤其是单核系统中往往是不成立的,就像1+1+1+1+1和1*4都等于4一样。
我能想象到的采用并发的理由总结一下有以下几点:
  • 解开what和when的耦合以后,程序的结构更容易被理解。例如用一个大的循环来处理所有用户的聊天请求,就不如用不同的线程来处理每个用户的请求那么清晰,而且也更方便扩展对用户的服务。
  • 需要提高响应输入的速度,或及时的输出中间结果。
  • 需要利用多核,多CPU,多个机算机甚至网络的计算能力。
并发就需要用到多线程或者多进程。而在多线程或多进程编程的时候我们往往又需要或不得不把其它耦合性带回到程序里去。例如对共享数据的操作需要互斥,有些业务逻辑要求有顺序,同时还要避免可能发生的死锁。因此,结果可能是我们引入了更多的耦合性。例如前面提到的聊天程序。因为每个请求都很简单,处理得很快,因此即使是上千用户一起使用单一线程的服务也是感觉不到有什么问题。然而采用多线程处理就要引入很多其它负载,反而可能降低性能或引入更多问题。但如果每个用户的服务内容很复杂,单一线程又会变得很难理解或很难处理。
这个时候,合理的选择串行和并发以及并发的手段就成为降低耦合性的关键。如果我们不得不选择并发,那么提高每个进程的内聚性仍是降低整体耦合性的有效手段。
现在有很多并发语言,例如传统的电信行业中使用的SDL,还有新的语言如Erlang, Stackless Python。它们往往会提供高效的进程调度机制,方便的进程间通信手段以及合理的互斥方法。但无论使用什么语言,在多么先进的框架下工作,并发编程都是非常复杂的,并且和串行的编程所需要用到的设计策略往往是完全不同的。

 

解耦合手段之七:Liskov代换原则

子类不应要求比基类更高的precondition。

子类不应弱化基类的postcondition。

解耦合手段之七:Liskov代换原则

周二,美国计算机协会宣布,来自麻省理工学院的女教授芭芭拉·利斯科夫(Barbara Liskov)获得本年度图灵奖以及25万美元的奖金,她的贡献是让计算机程序更加可靠、安全和易于使用。(新闻
Barbara Liskov 是美国第一位获得计算机博士学位的女性。她的研究为模块化编程和面向对向编程的产生奠定了基础。除此之外,Barbara的另一个为人们所熟知的供献是她定义了Liskov substitution principle ,我们且称之为“Liskov代换原则”。它为子类型化或者说面向对向中的“继承”定义了重要的原则。
Liskov代换原则的原文是挺难看懂的:
Let q(x) be a property provable about objectsx of typeT. Thenq(y) should be true for objectsy of typeS whereS is a subtype ofT.
幸好有Bob大叔做好事,早就写了一篇相对容易理解一点的文章来解释Liskov Substitution Principle:

使用指向基类(超类)的引用的函数,必须能够在不知道具体派生类(子类)对象类型的情况下使用它们。

举一个例子:
有一个基类Rectangle(矩形)。按常理来说,正方形(Square)也是一个矩形,那么Square类可以从Rectangle类继承而来。而事实是这样的么?假设有一个函数:
void g(Rectangle& r)
{
r.SetWidth(5);
r.SetHeight(4);
assert(r.GetWidth() * r.GetHeight()) == 20);
}
很显然,这个函数对于传入的基类Rectangle变量来讲是正确的。然而如果传如入的变量是Square类型就不正确了。因此,Square与Rectangle之间的继承关系违反了Liskov代换原则。
那么为什么会这样呢?从常识(或几何定义)的角度来看,正方形是一个矩形;然而从行为的角度看,正方形不是一个矩形!因为正方形的行为与矩形有所不同。如果一定要让正方形继承于矩形,那么正方形的行为必须修改矩形的行为。可以看出,这同时也违反了“开闭原则 ”。
Liskov代换原则与“契约式设计(Design by Contract)”也有很紧密的联系。在Design by Contract的模式下对于一个类的方法有precondition(先决条件)和postcondition(后置条件)两个概念。Precondition是为了使用这个方法而必须事先满足的条件;Postcondition是这个方法必须保证它被使用以后成立的条件。由Liskov代换原则我们可以得到:
.子类不应要求比基类更高的precondition。
.子类不应弱化基类的postcondition。
例如上面的Rectangle类里SetWidth方法的一个postcondition是矩形的Height不应发生变化。这个postcondition在Square中就不能满足。
发现中文维基中没有Liskov代换原则的条目,把它补上:

来源网页