在软件开发的过程中,常常有这样一种现象:刚开始进行开发时,我们对开发的系统架构非常清晰,但是随着开发的深入,或者因为功能的增加,或者因为需求的变更,我们可能逐渐偏离原来的设计并且发现开发工作很难进行下去。最后软件即使发生最细微的变化也会带来灾难性的后果,有人把这时的软件比作“坏面包”或者“坏鸡蛋”。它们都说明了一个共同的问题——腐化的软件设计,这时软件设计的臭味就表现出来了。
常见的软件设计中的臭味有:
- 僵化性:软件难以修改。修改花费的代价巨大;
- 脆弱性:一个修改可能引发很多意想不到的问题;
- 顽固性:设计中包含了对其他系统有用的部分,但是把这部分从系统中分离出来所需要的努力和风险非常之大;
- 粘滞性:当面临修改时,开发人员有两类修改方法:一是保持设计;而是破坏设计(拼凑方法。当可以保持系统设计的方法比破坏设计的方法更难应用时,系统就有很高的粘滞性;
- 不必要的重复:是忽略抽象的结果;
- 不必要的复杂性:系统包含了当前没有用的组成部分;
- 晦涩性:模块难以理解,代码晦涩难懂。
软件为什么会腐化,简而言之就是因为没有遵循设计原则。
S.O.L.I.D 是面向对象编程(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)
从面向对象角度解释这个原则为:"引起类变化的因素永远不要多于一个"。
或者说 "一个类有且仅有一个职责"。这似乎不太好理解,简单来说就是:"对象(类)应该仅具有单一的功能"。
通常我们都说“低耦合,高内聚”。
在我看来,这里的"单一职责"就是我们通常所说的“高内聚”,即一个类只完成它应该完成的职责,不能推诿责任,也不可越殂代疱,不能成为无所不能的上帝类。
如果你的团队中实施宽松的“代码集体所有权”,在编码的过程中出现许多人同时修改(维护)同一个类的现象,而且成员之间的沟通不够及时,主动和畅通的话,那么时间一长,就很可能出现“承担过多职责”的上帝类。
这时,提炼基类/接口和提炼类重构将能帮助我们消除或减轻这种设计臭味。
Bad:
class UserSettings
{
private $user;
public function __construct($user)
{
$this->user = $user;
}
public function changeSettings($settings)
{
if ($this->verifyCredentials()) {
// ...
}
}
private function verifyCredentials()
{
// ...
}
}
Good:
class UserAuth
{
private $user;
public function __construct($user)
{
$this->user = $user;
}
public function verifyCredentials()
{
// ...
}
}
class UserSettings
{
private $user;
private $auth;
public function __construct($user)
{
$this->user = $user;
$this->auth = new UserAuth($user);
}
public function changeSettings($settings)
{
if ($this->auth->verifyCredentials()) {
// ...
}
}
}
二、开放封闭原则 (OCP)
从面向对象设计角度看,这个原则可以这么理解:"软件实体(类,模块,函数等等)应当对扩展开放,对修改闭合"。
通俗来讲,它意味着你(或者类的客户)应当能在不修改一个类的前提下扩展这个类的行为。在OOD里,对扩展开放意味着类或模块的行为能够改变,在需求变化时我们能以新的,不同的方式让模块改变,或者在新的应用中满足需求。
也就是说,对扩展是开放的,而对修改是封闭的。我们通常都说:向系统中增加功能时应该只是添加新代码,而应该尽量少的修改原代码。在我看来,这就是遵循开放封闭原则所能带来的效果。曾经在网上看到过这样一句话“哪里变化,封装哪里”。这其实就是说,我们要将系统中可能变化的地方封装起来,即对修改封闭。同时,为了应对系统需求(功能)的扩展,需要抽象!
例如我做了一个计算器接口,开始做了普通计算器,突然添加新需求,要再做一个程序员计算器,这时不应该修改普通计算器内部,应该使用面向接口编程,组合实现扩展,来看张图片:
那么,如何修改才能得到正确灵活的设计?
答案是:抽象!为服务器端的代码(类型)抽象出一个抽象基类(定义一组完成服务职责的最小接口)。
基本上,你抽象的东西是你系统的核心内容,如果你抽象得好,很可能增加一个新的服务器类型(扩展)只需要添加新类型(继承自AbstractServer即可)。
因此代码要尽可能以抽象(这里的AbstractServer)为依据,这会允许你扩展抽象事物,定义一个新的实现而不需要修改任何客户端代码。即”面向接口编程,不要面向实现编程“!
Bad:
abstract class Adapter
{
protected $name;
public function getName()
{
return $this->name;
}
}
class AjaxAdapter extends Adapter
{
public function __construct()
{
parent::__construct();
$this->name = 'ajaxAdapter';
}
}
class NodeAdapter extends Adapter
{
public function __construct()
{
parent::__construct();
$this->name = 'nodeAdapter';
}
}
class HttpRequester
{
private $adapter;
public function __construct($adapter)
{
$this->adapter = $adapter;
}
public function fetch($url)
{
$adapterName = $this->adapter->getName();
if ($adapterName === 'ajaxAdapter') {
return $this->makeAjaxCall($url);
} elseif ($adapterName === 'httpNodeAdapter') {
return $this->makeHttpCall($url);
}
}
private function makeAjaxCall($url)
{
// request and return promise
}
private function makeHttpCall($url)
{
// request and return promise
}
}
在上面的代码中,对于HttpRequester类中的fetch方法,如果我新增了一个新的xxxAdapter类并且要在fetch方法中用到的话,就需要在HttpRequester类中去修改类(如加上一个elseif 判断),而通过下面的代码,就可很好的解决这个问题。下面代码很好的说明了如何在不改变原有代码的情况下增加新功能。
Good:
interface Adapter
{
public function request($url);
}
class AjaxAdapter implements Adapter
{
public function request($url)
{
// request and return promise
}
}
class NodeAdapter implements Adapter
{
public function request($url)
{
// request and return promise
}
}
class HttpRequester
{
private $adapter;
public function __construct(Adapter $adapter)
{
$this->adapter = $adapter;
}
public function fetch($url)
{
return $this->adapter->request($url);
}
}
三、Liskov's 替换原则(里氏替换原则LSP)
Liskov's 替换原则意思是:"子类型必须能够替换它们的基类型。"或者换个说法:"使用基类引用的地方必须能使用继承类的对象而不必知道它。" 这个原则正是保证继承能够被正确使用的前提。通常我们都说,“优先使用组合(委托)而不是继承”或者说“只有在确定是 is-a 的关系时才能使用继承”,因为继承经常导致”紧耦合“的设计。
在基本的面向对象原则里,"继承"通常是"is a"的关系。如果"Developer" 是一个"SoftwareProfessional",那么"Developer"类应当继承"SoftwareProfessional"类。在类设计中"Is a"关系非常重要,但它容易冲昏头脑,导致使用错误的继承造成错误设计。
所有基类出现的地方都可以用派生类替换而不会程序产生错误。子类可以扩展父类的功能,但不能改变父类原有的功能。
例如机动车必须有轮胎和发动机,子类宝马和奔驰不应该改写没轮胎或者没发动机:
为什么LSP如此重要?
如果没有LSP,类继承就会混乱;如果子类作为一个参数传递给方法,将会出现未知行为;
如果没有LSP,适用与基类的单元测试将不能成功用于测试子类;
Bad:
class Rectangle
{
protected $width = 0;
protected $height = 0;
public function render($area)
{
// ...
}
public function setWidth($width)
{
$this->width = $width;
}
public function setHeight($height)
{
$this->height = $height;
}
public function getArea()
{
return $this->width * $this->height;
}
}
class Square extends Rectangle
{
public function setWidth($width)
{
$this->width = $this->height = $width;
}
public function setHeight(height)
{
$this->width = $this->height = $height;
}
}
function renderLargeRectangles($rectangles)
{
foreach ($rectangles as $rectangle) {
$rectangle->setWidth(4);
$rectangle->setHeight(5);
$area = $rectangle->getArea(); // BAD: Will return 25 for Square. Should be 20.
$rectangle->render($area);
}
}
$rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles($rectangles);
Good:
abstract class Shape
{
protected $width = 0;
protected $height = 0;
abstract public function getArea();
public function render($area)
{
// ...
}
}
class Rectangle extends Shape
{
public function setWidth($width)
{
$this->width = $width;
}
public function setHeight($height)
{
$this->height = $height;
}
public function getArea()
{
return $this->width * $this->height;
}
}
class Square extends Shape
{
private $length = 0;
public function setLength($length)
{
$this->length = $length;
}
public function getArea()
{
return pow($this->length, 2);
}
}
function renderLargeRectangles($rectangles)
{
foreach ($rectangles as $rectangle) {
if ($rectangle instanceof Square) {
$rectangle->setLength(5);
} elseif ($rectangle instanceof Rectangle) {
$rectangle->setWidth(4);
$rectangle->setHeight(5);
}
$area = $rectangle->getArea();
$rectangle->render($area);
}
}
$shapes = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles($shapes);
四、接口分离原则(ISP)
这个原则的意思是"客户端不应该被迫依赖于它们不用的接口。" 也就是说,一个接口或者类应该拥有尽可能少的行为(那么,什么叫尽可能少?就是少到恰好能完成它自身的职责),这也是保证“软件系统模块的粒度尽可能少,以达到高度可重用的目的。
接口包含太多的方法会降低其可用性,像这种包含了无用方法的"胖接口"会增加类之间的耦合。如果一个类想实现该接口,那么它需要实现所有的方法,尽管有些对它来说可能完全没用,所以这样做会在系统中引入不必要的复杂度,降低代码的可维护性或鲁棒性。
接口分离原则确保实现的接口有它们共同的职责,它们是明确的,易理解的,可复用的.
因此,如果我们想要获得可重用的方案,就应当遵循接口分离原则,把接口定义成仅包含必要的部分,以便在任何需要该接口功能的地方复用这个接口。
有一个清晰的例子来说明示范这条原则。当一个类需要一个大量的设置项,为了方便不会要求客户端去设置大量的选项,因为在通常他们不需要所有的设置项。使设置项可选有助于我们避免产生"胖接口"
Bad:
interface Employee
{
public function work();
public function eat();
}
class Human implements Employee
{
public function work()
{
// ....working
}
public function eat()
{
// ...... eating in lunch break
}
}
class Robot implements Employee
{
public function work()
{
//.... working much more
}
public function eat()
{
//.... robot can't eat, but it must implement this method
}
}
上面的代码中,Robot类并不需要eat()这个方法,但是实现了Emplyee接口,于是只能实现所有的方法了,这使得Robot实现了它并不需要的方法。所以在这里应该对Emplyee接口进行拆分,正确的代码如下:
Good:
interface Workable
{
public function work();
}
interface Feedable
{
public function eat();
}
interface Employee extends Feedable, Workable
{
}
class Human implements Employee
{
public function work()
{
// ....working
}
public function eat()
{
//.... eating in lunch break
}
}
// robot can only work
class Robot implements Workable
{
public function work()
{
// ....working
}
}
五、依赖倒置原则(DIP)
这个原则的意思是:高层模块不应该依赖底层模块,两者都应该依赖其抽象。其实又是”面向接口编程,不要面向实现编程“的内在要求。
看一个例子:垃圾收集器不管垃圾是什么类型,要是垃圾就行
这个原则细分下来,可以化为五点,如下:
(1).高层模块不要依赖低层模块;
(2).高层和低层模块都要依赖于抽象;
(3).抽象不要依赖于具体实现;
(4).具体实现要依赖于抽象;
(5).抽象和接口使模块之间的依赖分离。
我们考虑一个现实中的例子,来看看依赖倒置原则给我们软件带来的好处。
你的汽车是由很多如引擎,车轮,空调和其它等部件组成,对吗?
注意:这里的 Car 就是高层模块;它依赖于抽象接口IToyotaEngine 和 IEighteenInchWheel.
而具体的引擎FifteenHundredCCEngine 属于底层模块,也依赖于抽象接口IToyotaEngine ;
具体的车轮 EighteenInchWheelWithAlloy同样属于底层模块,也依赖于抽象接口IEighteenInchWheel。
上面Car类有两个属性(引擎和车轮列表),它们都是抽象类型(接口)。引擎和车轮是可插拔的,因为汽车能接受任何实现了声明接口的对象,并且Car类不需要做任何改动。
对于上例,我们可以做出如下总结:
一个对象只承担一种责任,所有服务接口只通过它来执行这种任务。
程序实体,比如类和对象,向扩展行为开放,向修改行为关闭。
子类应该可以用来替代它所继承的类。
一个类对另一个类的依赖应该限制在最小化的接口上。
依赖抽象层(接口),而不是具体类。
Bad:
class Employee
{
public function work()
{
// ....working
}
}
class Robot extends Employee
{
public function work()
{
//.... working much more
}
}
class Manager
{
private $employee;
public function __construct(Employee $employee)
{
$this->employee = $employee;
}
public function manage()
{
$this->employee->work();
}
}
Good:
interface Employee
{
public function work();
}
class Human implements Employee
{
public function work()
{
// ....working
}
}
class Robot implements Employee
{
public function work()
{
//.... working much more
}
}
class Manager
{
private $employee;
public function __construct(Employee $employee)
{
$this->employee = $employee;
}
public function manage()
{
$this->employee->work();
}
}
六、合成复用原则(CRP)
合成复用原则(Composite Reuse Principle,CRP),即优先使用委托而不是继承来重用已用功能(代码)。循序这一原则通常也是避免触犯里氏替换原则所要求的。
七、迪米特法则(LoD / LKP)
迪米特法则(Law Of Demeter)又称最小知识原则(Least Knowledge Principle, LKP)。意思是一个对象应当对其它对象有尽量好的了解,即应该保持对象间有尽量少的相互作用是,使得对象(类)具有好的独立性,可测试性,也就易于维护。
关于“迪米特法则”的其它表述还有:只与你的朋友们通信,不要与“陌生人”说话。
设计模式中的Facade模式和Mediator模式就是使用了这一原则,降低模块间的耦合。