面向对象设计原则和创建SOLID应用的5个方法

标签: 基础技术 设计原理 | 发表时间:2014-04-04 00:00 | 作者:何 佳妮
出处:http://www.importnew.com

最近我听到了很多关于函数式编程(FP),受之启发我觉得也应该关注面向对象编程(OOP)和面向对象设计(OOD),因为在设计系统时这些仍然非常重要。

我们将以SOLID原则为起点开始我们的旅程。SOLID原则是类级别的,面向对象的设计理念,它们与测试工具一起帮你改进腐坏的代码。SOLID由程序员们最喜欢的大叔 Robert C. Martin(Bob大叔)提出,它其实是五个其他缩略词的组合——SRP, OCP, LSP, ISP, DIP,我会在下面有更深入的介绍。最重要的是,SOLID原则使你的软件变得更有价值。

呃,这个代码有坏味道…

代码腐坏

1.当应用程序代码大量腐坏,开发人员会发现代码越来越难以维护、臃肿。那么如何识别将来的代码腐坏?这些迹象可能表明将要代码腐坏:

  • 僵化——小的变化导致整个系统重建。

  • 脆弱——一个模块的变化导致其他不相关模块不正常运行。想象一个汽车系统,改变电台的功能会影响到窗户的使用。

  • 固定——一个模块的内部组件不能被抽取并且在新环境重用。比如一个应用程序的登录模块不能在完全不同的系统中使用,那么这个模块是固定的,这是由于各模块之间的耦合和依赖造成的。改进的策略是从低层次的细节,比如特定的数据库,UI实现(Web,桌面),特殊框架等解耦核心抽象。

  • 粘性——代码构建和测试很难执行,并且要花费很长时间运行,甚至小的变化有很高的成本,并且要求在多个位置/层次修改。

用户期望从他们所用的软件之外得到一些价值。一个应用程序的价值在于它能否帮助用户将一些事情做得更好,增加生产力或者时间或金钱,在“浪费”上有所节省。人们通常付出金钱来换取价值高的软件。

但是用户从伟大的软件得到了次要价值。我想要谈谈这个价值,因为这也是人们在谈论软件价值时最先想到的:功能。

如果软件完成了用户需求的同时没有崩溃和延迟,那么这个软件的次要价值就高。软件满足了用户的当前需求,用户就获得了次要价值。但是,用户需求经常变化,软件提供的功能和用户需求很容易不同步,这导致了价值降低。为了保持次要价值高,软件必须能够跟上用户不断变化的需求。所以在这里我们来谈谈软件的首要价值,它必须能够容忍和有助于正在进行的变化。

试想一下,你的软件目前可以满足用户的需求,但是实在是很难改变,改变成本很高。那么,由于应用程序的不灵活性以及其盈利能力可能降低,用户会不高兴。

现在试想其他的软件开始时次要价值低,但是它可以容易且廉价地改变。盈利能力持续上升,用户也越来越高兴。


那么什么是SOLID原则?

单一职责原则(SRP)

单一职责原则(Single Responsibility Principle,SRP)指出,一个类发生变化的原因不应该超过一个。这意味着代码中每个类,或者类似的结构只有一个功能。

在类中的一切都与该单一目的有关,即内聚性。这并不是说类只应该含有一个方法或属性。

类中可以包括很多成员,只要它们与单一的职责有关。当类改变的一个原因出现时,类的多个成员可能多需要修改。也可能多个类将需要更新。

下面的代码有多少职责?

class Employee {
  public Pay calculatePay() {...}
  public void save() {...}
  public String describeEmployee() {...}
}  

正确答案是3个。

在一个类中混合了1)支付的计算逻辑,2)数据库逻辑,3)描述逻辑。如果你将多个职责结合在一个类中,可能很难实现修改一部分时不会破坏其他部分。混合职责也使这个类难以理解,测试,降低了内聚性。修改它的最简单方法是将这个类分割为三个不同的相互分离的类,每个类仅仅有一个职责:数据库访问,支付计算和描述。


开闭原则(OCP)

开闭原则(Open-Closed Principle,OCP)指出:类应该对扩展开放但对修改关闭。“对扩展开放”指的是设计类时要考虑到新需求提出时类可以增加新的功能。“对修改关闭”指的是一旦一个类开发完成,除了改正bug就不再修改它。

这个原则的两个部分似乎是对立的。但是,如果正确地设计类和他们的依赖关系,就可以增加功能而不修改已有的源代码。

通常来说可以通过依赖关系的抽象实现开闭原则,比如接口或抽象类而不是具体类。通过创建新的类实现接口来增加功能。

在项目中应用OCP原则可以限制代码的更改,一旦代码完成,测试和调试之后就很少再去更改。这减少了给现有代码引入新bug的风险,增强软件的灵活性。

为依赖关系使用接口的另一个作用是减少耦合和增加灵活性。

void checkOut(Receipt receipt) {
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  Payment p = acceptCash(total);
  receipt.addPayment(p);
}

那么增加信用卡支持该怎么做?你 可能像下面的增加 if语句,但这违反OCP原则。

Payment p;
if (credit)
  p = acceptCredit(total);
else
  p = acceptCash(total);
receipt.addPayment(p);

更好的解决方案是:

public interface PaymentMethod {void acceptPayment(Money total);}
 
void checkOut(Receipt receipt, PaymentMethod pm) {
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  Payment p = pm.acceptPayment(total);
  receipt.addPayment(p);
}

这儿有一个小秘密:OCP仅仅用于即将到来的变化可预见的情况,那么只有类似的变化已经发生时应用它。所以,首先做最简单的事情,然后判断会有什么变化,就能更加准确地预见将来的变化。

这意味着等待用户做出改变,然后使用抽象应对将来的类似变化。


里氏替换原则(LSP)

里氏替换原则(Liskov Substitution Principle,LSP)适用于继承层次结构,指出设计类时客户端依赖的父类可以被子类替代,而客户端无须了解这个变化。

因此,所有的子类必须按照和他们父类相同方式操作。子类的特定功能可能不同,但是必须符合父类的预期行为。要成为真正的行为子类型,子类必须不仅要实现父类的方法和属性,也要符合其隐含行为。

一般来说,如果父类型的一个子类型做了一些父类型的客户没有预期的事情,那这就违反LSP。比如一个派生类抛出了父类没有抛出的异常,或者派生类有些不能预期的副作用。基本上派生类永远不应该比父类做更少的事情。

一个违反LSP的典型例子是Square类派生于Rectangle类。Square类总是假定宽度与高度相等。如果一个正方形对象用于期望一个长方形的上下文中,可能会出现意外行为,因为一个正方形的宽高不能(或者说不应该)被独立修改。

解决这个问题并不容易:如果修改Square类的setter方法,使它们保持正方形不变(即保持宽高相等),那么这些方法将弱化(违反)Rectangle类setter方法,在长方形中宽高可以单独修改。

public class Rectangle {
  private double height;
  private double width;
 
  public double area();
 
  public void setHeight(double height);
  public void setWidth(double width);
}

以上代码违反了LSP。

public class Square extends Rectangle {  
  public void setHeight(double height) {
    super.setHeight(height);
    super.setWidth(height);
  }
 
  public void setWidth(double width) {
    setHeight(width);
  }
}

违反LSP导致不明确的行为。不明确的行为意味着它在开发过程中运行良好但在产品中出现问题,或者要花费几个星期调试每天只出现一次的bug,或者不得不查阅数百兆日志找出什么地方发生错误。


接口隔离原则(ISP)

接口隔离原则(Interface Segregation Principle)指出客户不应该被强迫依赖于他们不使用的接口。当我们使用非内聚的接口时,ISP指导我们创建多个较小的内聚度高的接口。

当你应用ISP时,类和他们的依赖使用紧密集中的接口通信,最大限度地减少了对未使用成员的依赖,并相应地降低耦合度。小接口更容易实现,提升了灵活性和重用的可能性。由于很少的类共享这些接口,为响应接口的变化而需要变化的类数量降低,增加了鲁棒性。

基本上,这里的教训是“不要依赖你不需要的东西”。下面是例子:

想象一个ATM取款机,通过一个屏幕显示我们想要的不同信息。你会如何解决显示不同信息的问题?我们使用SRP,OCP和LSP想出一个方案,但是这个系统仍然很难维护。这是为什么?

想象ATM的所有者想要添加仅在取款功能出现的一条信息,“ATM机将在您取款时收取一些费用,您同意吗”。你会如何解决?

可能你会给Messenger接口增加一个方法并使用这个方法完成。但是这会导致重新编译这个接口的所有使用者,几乎所有的系统需要重新部署,这直接违反了OCP。让代码腐坏开始了!

这里出现了这样的情形:对于取款功能的改变导致其他全部非相关功能也变化,我们现在知道这并不是我们想要的。这是怎么回事?

其实,这里是向后依赖在作怪,使用了该Messenger接口每个功能依赖了它不需要,但是被其他功能需要的方法,这正是我们想要避免的。

public interface Messenger {
  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin();
  tellCardWasSiezed();
  askForAccount();
  tellNotEnoughMoneyInAccount();
  tellAmountDeposited();
  tellBalance();
}

相反,将Messenger接口分割,不同的ATM功能依赖于分离的Messenger。

public interface LoginMessenger {
  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin(); 
}
 
public interface WithdrawalMessenger {
  tellNotEnoughMoneyInAccount();
  askForFeeConfirmation();
}
 
publc class EnglishMessenger implements LoginMessenger, WithdrawalMessenger {
  ...   
}

依赖反转原则(DIP)

依赖反转原则(Dependency Inversion Principle,DIP)指出高层次模块不应该依赖于低层次模块;他们应该依赖于抽象。第二,抽象不应该依赖于细节;细节依赖于抽象。方法是将类孤立在依赖于抽象形成的边界后面。如果在那些抽象后面所有的细节发生变化,那我们的类仍然安全。这有助于保持低耦合,使设计更容易改变。DIP也允许我们做单独测试,比如作为系统插件的数据库等细节。

例子:一个程序依赖于Reader和Writer接口,Keyboard和Printer作为依赖于这些抽象的细节实现了这些接口。CharCopier是依赖于Reader和Writer实现类的低层细节,可以传入任何实现了Reader和Writer接口的设备正确地工作。

public interface Reader { char getchar(); }
public interface Writer { void putchar(char c)}
 
class CharCopier {
 
  void copy(Reader reader, Writer writer) {
    int c;
    while ((c = reader.getchar()) != EOF) {
      writer.putchar();
    }
  }
}
 
public Keyboard implements Reader {...}
public Printer implements Writer {...}

最后的问题——使用SOLID

我想SOLID原则是你的工具箱里很有价值的工具。在设计下一个功能或者应用时他们就应该在你的脑海中。正如Bob大叔在他那不朽的帖子中总结的:

SRP 单一职责原则 一个类有且只有一个更改的原因。
OCP 开闭原则 能够不更改类而扩展类的行为。
LSP 里氏替换原则 派生类可以被他们的父类所替换。
ISP 接口隔离原则 使用客户端特定的细粒度接口。
DIP 依赖反转原则 依赖抽象而不是具体实现。

而且,将这些原则应用在项目中。

可能感兴趣的文章

相关 [面向对象 设计 原则] 推荐:

面向对象设计的设计原则

- - 博客园_知识库
  在设计面向对象的程序的时,模式不是一定要套的,但是有一些原则最好是遵守. 这些原则已知的有七个,包括:开闭原则、里氏代换原则、依赖倒转原则、合成/聚合复用原则、迪米特法则、接口隔离原则,单一职责原则. Martin引入了SOLID的说法,包括了其中的五个原则. 另外两个,这里把他们算成额外的两个规则.

面向对象设计原则:不要STUPID,坚持GRASP和SOLID

- - CSDN博客架构设计推荐文章
不要STUPID,坚持GRASP和SOLID. 有人可能会说:这是描述设计原则的一个专业术语,由我们可爱的代码整洁之道传教者鲍勃(罗伯特C. 马丁)大叔提出,是一组用于指导我们如何写出“好代码”的原则. 在编程界充满了这样由单词首字母组成的缩略词. 其它类似的例子还有DRY(Don’t Repeat Yourself.

面向对象设计原则和创建SOLID应用的5个方法

- - ImportNew
最近我听到了很多关于函数式编程(FP),受之启发我觉得也应该关注面向对象编程(OOP)和面向对象设计(OOD),因为在设计系统时这些仍然非常重要. 我们将以SOLID原则为起点开始我们的旅程. SOLID原则是类级别的,面向对象的设计理念,它们与测试工具一起帮你改进腐坏的代码. SOLID由程序员们最喜欢的大叔 Robert C.

Java程序员应了解的10个面向对象设计原则

- - ITeye博客
众所周知,Java编程最基本的原则就是要追求高内聚和低耦合的解决方案和代码模块设计. 查看Apache和Sun的开放源代码能帮助你发现其他Java设计原则在这些代码中的实际运用. Java Development Kit则遵循以下模式:BorderFactory类中的工厂模式、Runtime类中的单件模式.

面向对象编程的五大原则

- - 非技术 - ITeye博客
       高层的实现不应该依赖底层,(父类可以替换掉任何子类),具体说就是我们要针对接口抽象来编程,不要针对实现来编程,这样程序才能解耦. 一、“开-闭”原则(Open-Closed Principle,OCP). 1.1 “开-闭”原则的定义及优点. 1)定义:一个软件实体应当对扩展开放,对修改关闭( Software entities should be open for extension,but closed for modification.).

面向对象设计模式的核心法则

- tonytech - Michael`s blog
有本经典的书叫《设计模式》,讲了经典的21种设计模式,建议大家都看看. 就一个类而言,应该仅有一个引起它变化的原因. 如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力. 这种耦合会导致脆弱的设计,当变化发生时,设计会遭到意想不到的破坏. 软件设计真正要做的许多内容,就是发现职责并把那些职责互相分离.

JavaScript 游戏中的面向对象的设计

- - 博客 - 伯乐在线
来源: IBM developerworks. 简介: 从程序角度考虑,许多 JavaScript 都基于循环和大量的 if/else 语句. 在本文中,我们可了解一种更聪明的做法 — 在 JavaScript 游戏中使用面向对象来设计. 本文将概述原型继承和使用 JavaScript 实现基本的面向对象的编程 (OOP).

精通面向对象分析与设计的秘诀

- - 移动开发 - ITeye博客
精通面向对象分析与设计的秘诀. 面向对象设计的一个重要经验:. 谁拥有数据,谁就对外提供操作这些数据的方法,. 谁具有这项属性,谁就提供操作这些属性的方法. 这个案例有三个对象:Person,Blackboard,Circle. 有一个动作画圆,画圆这个动作可以封装成一个方法draw(),那么,这个draw()方法应该封装在那个类中呢.

Android设计原则

- - 所有文章 - UCD大社区
译者按: 在 iOS HIG已经强大经典了N年之后,Android终于推出了一套比较系统的 HIG(大概是为了配合Android 4.0 Ice Cream Sandwich). 仔细比较两套HIG的“设计原则”部分,发现完全是截然不同的两种风格. iOS HIG走的是更专业型的路线,描述严谨且有不少的专业词汇(比如Metaphors、Consistency之类的).

面向对象的Shell脚本

- jejer - 酷壳 - CoolShell.cn
还记得以前那个用算素数的正则表达式吗. 编程这个世界太有趣了,总是能看到一些即别出心裁的东西. 你有没有想过在写Shell脚本的时候可以把你的变量和函数放到一个类中. 不要以为这不可能,这不,我在网上又看到了一个把Shell脚本整成面向对象的东西. Shell本来是不支持的,需要自己做点东西,能搞出这个事事的人真的是hacker啊.