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

关注微信号:javalearns   随时随地学Java

或扫一扫

随时随地学Java

  最近我听到了很多关于函数式编程(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)指出,一个类发生变化的原因不应该超过一个。这意味着代码中每个类,或者类似的结构只有一个功能。

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

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

  下面的代码有多少职责?

1
2
3
4
5
class Employee {
   public Pay calculatePay() {...}
   public void save() {...}
   public String describeEmployee() {...}
}

  正确答案是3个。

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

  开闭原则(OCP)

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

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

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

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

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

1
2
3
4
5
6
7
8
9
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原则。

1
2
3
4
5
6
Payment p;
if (credit)
   p = acceptCredit(total);
else
   p = acceptCash(total);
receipt.addPayment(p);

  更好的解决方案是:

1
2
3
4
5
6
7
8
9
10
11
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方法,在长方形中宽高可以单独修改。

1
2
3
4
5
6
7
8
9
public class Rectangle {
   private double height;
   private double width;
  
   public double area();
  
   public void setHeight( double height);
   public void setWidth( double width);
}

  以上代码违反了LSP。

1
2
3
4
5
6
7
8
9
10
public class Square extends Rectangle { 
   public void setHeight(<span id= "4_nwp" style= "width: auto; height: auto; float: none;" ><a id= "4_nwl" href= "http://cpro.baidu.com/cpro/ui/uijs.php?adclass=0&app_id=0&c=news&cf=1001&ch=0&di=128&fv=18&is_app=0&jk=ee939bb5d35f14b9&k=double&k0=double&kdi0=0&luki=5&mcpm=0&n=10&p=baidu&q=06011078_cpr&rb=0&rs=1&seller_id=1&sid=b9145fd3b59b93ee&ssp2=1&stid=9&t=tpclicked3_hc&td=1922429&tu=u1922429&u=http%3A%2F%2Fwww%2Eadmin10000%2Ecom%2Fdocument%2F4194%2Ehtml&urlid=0" target= "_blank" mpid= "4" style= "text-decoration: none;" ><span style= "color:#0000ff;font-size:14px;width:auto;height:auto;float:none;" > double </span></a></span> 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接口每个功能依赖了它不需要,但是被其他功能需要的方法,这正是我们想要避免的。

1
2
3
4
5
6
7
8
9
10
11
public interface Messenger {
   askForCard();
   tellInvalidCard();
   askForPin();
   tellInvalidPin();
   tellCardWasSiezed();
   askForAccount();
   tellNotEnoughMoneyInAccount();
   tellAmountDeposited();
   tellBalance();
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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接口的设备正确地工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 依赖反转原则 依赖抽象而不是具体实现。

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


关注微信号:javalearns   随时随地学Java

或扫一扫

随时随地学Java


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值