架构师之路(5)---面向对象的设计原则

1 OO的设计原则
    采用面向对象的分析和设计思想,为我们分析和解决问题提供了一种全新的思维方式。我们在拿到需求之后(略去OOA,以后补全),接下来的问题就是:如何对系统进行面向对象的设计呢?
    按照软件工程的理论,面向对象的设计要解决的核心问题就是可维护性和可复用性,尤其是可维护性,它是影响软件生命周期重要因素。通常情况下,软件的维护成本远远大于初期开发成本。
    一个可维护性很差的软件设计,人们通常称之为“臭味”的,形成的原因主要有这么几个:过于僵硬、过于脆弱、复用率低或者黏度过高。相反,一个好的系统设计应该是灵活的、可扩展的、可复用的、可插拔的。在20世纪80到90年代,很多业内专家不断探索面向对象的软件设计方法,陆续提出了一些设计原则。这些设计原则能够显著地提高系统的可维护性和可复用性,成为了我们进行面向对象设计的指导原则:

1、单一职责原则SRP
    每一个类应该专注于做一件事情。

2、“开-闭”原则OCP
    每一个类应该是对扩展开放,对修改关闭。

3、 里氏代换原则LSP
    避免造成派生类的方法非法或退化,一个基类的用户应当不需要知道这个派生类。

4、 依赖倒转原则DIP
    用依赖于接口和抽象类来替代依赖容易变化的具体类。

5、 接口隔离原则ISP
    应当为客户提供尽可能小的接口,而不是提供大的接口。

其中,“开-闭”原则是面向对象的可复用设计的基石,其他设计原则(里氏代换原则、依赖倒转原则、合成/聚合复用原则、迪米特法则、接口隔离原则)是实现“开-闭”原则的手段和工具。
我会为大家一一进行讲解。

2 单一职责原则SRP(Single-Responsibility Principle)


2.1 什么是单一职责
    Robert C. Martin是面向对象设计领域的理论大师,他对单一职责原则的解释原文为:Each class should have one and only one reason to change。翻译成中文就是:一个类只能因为一个因素而改变。有的资料把单一职责原则解释为:“一个类应该只专注于做一件事”。这两个说法是本质上是等价的。如果一个类同时负责两件事情,那么这两件事情的变化都有可能引起它的变化。反之,如果仅有一个引起它变化的因素,那么这个类也就只能负责一件事情了。
    现实生活中也存在同样的情形:“一个人身兼数项彼此没有联系的职责,一旦其中一项发生变动,那么他可能需要重新安排所有的职责。所以,假如每一个人只承担一项职责的话,那就永远不会发生诸如此类的问题”。我们借鉴了现实生活中的这种思路,要求在设计类的时候,遵循单一职责原则。
    我们以计算器编程为例:
    在有些人眼里,计算器是一个整体,它只有一项职责就是负责计算,所以,他们把用户需求进行抽象后,最终设计为一个Calculator类。

C#版本:
class Calculator{
public String calculate() {
Console.Write("Please input the first number:");
       String strNum1 = Console.ReadLine();

Console.Write(Please input the operator:");
String strOpr= Console.ReadLine();

Console.Write("Please input the second number:");
       String strNum2 = Console.ReadLine();

       String strResult = "";
       if (strOpr == "+"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) + Convert.ToDouble(strNum2));
}
else if (strOpr == "-"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) - Convert.ToDouble(strNum2));
}
else if (strOpr == "*"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) * Convert.ToDouble(strNum2));
}
else if (strOpr == "/"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) / Convert.ToDouble(strNum2));
}

        Console.WriteLine("The result is " + strResult);
    }    
}
Java版本:
class Calculator{
public String calculate() {
try{
  BufferedReader objBuffRead = new BufferedReader(new InputStreamReader(System.in));  
  System.out.println("Please input the first number:");
  String strNum1 = objBuffRead.readLine();
  System.out.println("Please input the operator:");
  String strOpr = objBuffRead.readLine();
  System.out.println("Please input the second number:");
  String strNum2 = objBuffRead.readLine();
   
  String strResult = "";
  if (strOpr.equals("+")){
strResult = Double.toString(Double.parseDouble(strNum1) + Double.parseDouble(strNum2));
  }
  else if (strOpr.equals("-")){
strResult = Double.toString(Double.parseDouble(strNum1) - Double.parseDouble(strNum2));
  }
  else if (strOpr.equals("*")){
strResult = Double.toString(Double.parseDouble(strNum1) * Double.parseDouble(strNum2));
  }
  else if (strOpr.equals("/")){
strResult = Double.toString(Double.parseDouble(strNum1) / Double.parseDouble(strNum2));
  }
else{
   System.out.println("An error occured!");
   return;
  }
  
  System.out.println("The result is " + strResult);
 }catch(Exception err){
  System.out.println("An error occured!");
 };
也有人提出了不同的看法,他们认为:计算器应该是一个外设和一个处理器的组合,除了计算职责以外,还要有输入输出的职责。按照他们的思路,计算器被拆分为两个类: Appearance和Processor。
class Appearance{
public int displayInput(String &strNum1,String &strOpr, String &strNum2) {
Console.Write("Please input the first number:");
       strNum1 = Console.ReadLine();

Console.Write(Please input the operator:");
strOpr= Console.ReadLine();

Console.Write("Please input the second number:");
       strNum2 = Console.ReadLine();
      
return 0;
}
public String displayOutput(String strResult) {
Console.WriteLine("The result is " + strResult);
    }
}
class Processor{
public String calculate(String strNum1,String strOpr, String strNum2){
       String strResult = "";
       if (strOpr == "+"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) + Convert.ToDouble(strNum2));
}
else if (strOpr == "-"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) - Convert.ToDouble(strNum2));
}
else if (strOpr == "*"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) * Convert.ToDouble(strNum2));
}
else if (strOpr == "/"){
          strResult = Convert.ToString(Convert.ToDouble(strNum1) / Convert.ToDouble(strNum2));
}
}
return strResult;
}

为什么这么做呢?因为外壳和处理器是两个职责,是两件事情,而且都是很容易发生需求变动的因素,所以把它们放到一个类中,违背了单一职责原则。
    比如,用户可能对计算器提出以下要求:
    第一,目前已经实现了“加法”、“减法”、“乘法”和“除法”,以后还可能出现“乘方”、“开方”等很多运算。
    第二,现在人机界面太简单了,还可能做个Windows计算器风格的界面或者Mac计算器风格的界面。

    所以,把一个类Calculator 拆分为两个类Appearance和Processor,就更容易应对这两类需求变化。如果界面发生变动,那么就去修改Appearance类;如果处理器发生变动,那么就去修改Processor类。
    我们再举一个邮件的例子。我们平常收到的邮件内容,看起来就是一个带有某种格式的文本文件。实际上,一封邮件的内容是有两部分组成:邮件头和邮件体。电子邮件的编码都要求符合RFC822标准。
我们对邮件的设计,有两种方式:
第一种设计方式:
interface IEmail {
    public void setSender(String sender);
    public void setReceiver(String receiver);
    public void setContent(String content);
}

class Email implements IEmail { 
    public void setSender(String sender) {// set sender; } 
    public void setReceiver(String receiver) {// set receiver; } 
    public void setContent(String content) {// set content; }
}

这个设计是有问题的,因为邮件头和邮件体都有变化的可能性。
1、邮件头的每一个域的编码,可能是BASE64,也可能是QP,而且域的数量也不固定。
2、邮件体中封装的邮件内容可能是PlainText类型,也可能是HTML类型,甚至于流媒体。
所谓第一种设计方式违背了单一职责原则,里面封装了两种可能引起变化的原因。
我们依照单一职责原则,对其进行改进后,变为第二种设计方式:
interface IEmail {
  public void setSender(String sender);
  public void setReceiver(String receiver);
  public void setContent(IContent content);
}

interface IContent {
  public String getAsString();
}

class Email implements IEmail {
  public void setSender(String sender) {// set sender; } 
  public void setReceiver(String receiver) {// set receiver; } 
  public void setContent(IContent content) {// set content; }
}

    我们在软件项目中也经常会遇到这样的案例,比如当当网的网上购书系统,这是一个典型的B2C电子商务系统。对顾客购买的书籍最终计费的时候,订单的总价是购书费和运费的总和。当当网对于运费的计算分为多种:
1、购物满20元的国内普通快递/平邮订单,运费为2元/单;不满20元的国内普通快递/平邮订单,运费为5元/单;
2、使用礼券/当当荣誉顾客卡支付的订单,运费为5元/单;
这是当当网目前的运费规则是,不难推断,他们在不同时期还会根据实际情况进行调整。
所以,如果你设计的系统把订单总价全部封装为一个类Fee,就违背了单一职责原则,推荐的做法就是把运费单独封装为一个类Freight。

2.2 单一职责原则给我们带来了什么

    我们设计的每一个类都要负责一定的职责,否则它就没有存在的必要,但是设计一个类的时候,不能让它分担过多的职责,过多互不相关或者相关性不强的职责集中在一个类中就会造成高耦合、代码僵化的问题,每一个职责的变化都可能引起类的变化。因此,我们应该把不同的职责分散到不同的类中。
    单一职责的原则不仅是应用类的设计,同样适应于更高层面的系统分层设计,比如利用MVC对系统进行的分层。我们在实际的软件系统中,通常采用N层结构:数据层专门负责数据持久化,处理和存储(数据库、文件、LDAP等等数据源)的交互;业务逻辑层专门负责业务处理规则;表现层(又叫展现层、表示层)专门负责用户界面。这样设计的N层结构的系统就具有很强的可维护性,一个层的变化不会对其他层造成影响。
    几乎所有的脚本语言,在发展的初期阶段往往是违背单一职责原则的,Asp,Jsp,Php无一例外,甚至于很多人现在依然在这么用,他们的理由往往都是认为这样运行效率高。我个人认为这只是开发者的不良使用习惯造成的,不愿意去改变,或者说根本还不具有面向对象的思维方式。其实,这种做法根本不会提高运行效率,而且后期根本无法维护。
我们是不是百分之百能够做到单一职责原则?这个问题也是比较难以回答,单一职责原则是一个大的原则,属于战略层面的概念,我们只能说是要靠近这个原则。实际操作中,类设计时的职责划分和类粒度的确定也不是一件很简单的事情,需要设计者经验的积累和对需求的仔细分析。

2.3 单一职责原则的使用
    单一职责原则的尺度如何掌握呢?我怎么能知道该拆分还是不应该拆分呢?原则也很简单:需求导向,即需求决定设计。如果你所需要的计算器,永远都没有外设和处理器变动的可能性,那么就应该把它抽象为一个整体的计算器;如果你所需要的计算器,外壳和处理器都有可能发生变动,那么就必须把它拆离为外设和处理器。
    我们在使用单一职责原则的时候,牢记以下几点:
第一:一个设计合理的类,应该仅有一个可以引起它变化的原因,即单一职责,如果有多个原因可以引起它的变化,就必须进行分离,将一个类拆分为多个类;
第二:在没有需求变化征兆的情况下,不要使用单一职责原则对类进行拆分,因为这么做,你的设计系统中类的粒度会变得太小,由一堆细小颗粒组成的系统会变得很复杂;
第三:在需求能够预计或实际发生变化时,就应该使用单一职责原则来重构代码。
    有经验的设计师、架构师对可能出现的需求变化很敏感,设计之前会尽量想到系统所有可能的行为,对系统所有可能发生变化的部分进行评估,对每一个可变的因素都单独进行封装。关于类粒度的问题,过犹不及都不好,需要根据实际情况进行评估。

 -------------------------------------------------------------

后记:最近看了一个“现场说法”的电视节目,着实有意思。说是最近有两个偷车大盗被我警方抓获。这俩大盗都是贼中高手,非常了得,不过,他们却有着不同的成长路线。
     其中一个大盗,苦心钻研开锁技术,专门去香港学习先进技术,前后花了一百多万,非常舍得投资,回来后屡屡得手。另一个大盗,就比较狡猾,整天到商场的停车场,跟随着宝马、奔驰的车主,在车主购物的时候,伺机偷去车钥匙,然后从停车场把车开走,案发的时候案值达到了千万。呵呵,看来干什么事,都得找到关键所在。

猜你喜欢

转载自blog.csdn.net/wanghao72214/article/details/3976996