面向对象设计原则和创建SOLID应用的5个方法
最近我听到了很多关于函数式编程(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 | 依赖反转原则 | 依赖抽象而不是具体实现。 |
而且,将这些原则应用在项目中。