物件導向設計的六大設計原則(附 Demo & UML類圖)

J_Knight_發表於2018-09-10

學習初衷與講解方式

筆者想在 iOS 從業第三年結束之前系統學習一下關於設計模式方面的知識。而在學習設計模式之前,覺得更有必要先學習物件導向設計(OOD:Object Oriented Design)的幾大設計原則,為後面設計模式的學習打下基礎。

本篇分享的就是筆者近階段學習和總結的物件導向設計的六個設計原則:

縮寫 英文名稱 中文名稱
SRP Single Responsibility Principle 單一職責原則
OCP Open Close Principle 開閉原則
LSP Liskov Substitution Principle 里氏替換原則
LoD Law of Demeter ( Least Knowledge Principle) 迪米特法則(最少知道原則)
ISP Interface Segregation Principle 介面分離原則
DIP Dependency Inversion Principle 依賴倒置原則

注意,通常所說的SOLID(上方表格縮寫的首字母,從上到下)設計原則沒有包含本篇介紹的迪米特法則,而只有其他五項。另外,本篇不包含合成/聚合複用原則(CARP),因為筆者認為該原則沒有其他六個原則典型,而且在實踐中也不容易違背。有興趣的同學可以自行查資料學習。

在下一章節筆者將分別講解這些設計原則,講解的方式是將概念與程式碼及其對應的UML 類圖結合起來講解的方式。

程式碼的語言使用的是筆者最熟悉的Objective-C語言。雖然是一個比較小眾的語言,但是因為有 UML 類圖的幫助,而且主流的面嚮物件語言關於類,介面(Objective-C裡面是協議)的使用在形式上類似,所以筆者相信語言的小眾不會對知識的理解產生太大的阻力。

另外,在每個設計模式的講解裡,筆者會首先描述一個應用場景(需求點),接著用兩種設計的程式碼來進行對比講解:先提供相對不好的設計的程式碼,再提供相對好的設計的程式碼。而且兩種程式碼都會附上標準的 UML 類圖來進行更形象地對比,幫助大家來理解。同時也可以幫助不瞭解 UML 類圖的讀者先簡單熟悉一下 UML 類圖的語法。

六大設計原則

本篇講解六大設計原則的順序大致按照難易程式排列。在這裡最先講解開閉原則,因為其在理解上比較簡單,而且也是其他設計原則的基石。

注意:

  1. 六個原則的講解所用的例子之間並沒有關聯,所以閱讀順序可以按照讀者的喜好來定。
  2. Java語言裡的介面在Objective-C裡面叫做協議。雖然Demo是用Objective-C寫的,但是因為協議的叫法比較小眾,故後面一律用介面代替協議這個說法。

原則一:開閉原則(Open Close Principle)

定義

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

即:一個軟體實體如類、模組和函式應該對擴充套件開放,對修改關閉。

定義的解讀

  • 用抽象構建框架,用實現擴充套件細節。
  • 不以改動原有類的方式來實現新需求,而是應該以實現事先抽象出來的介面(或具體類繼承抽象類)的方式來實現。

優點

實踐開閉原則的優點在於可以在不改動原有程式碼的前提下給程式擴充套件功能。增加了程式的可擴充套件性,同時也降低了程式的維護成本。

程式碼講解

下面通過一個簡單的關於線上課程的例子講解一下開閉原則的實踐。

需求點

設計一個線上課程類:

由於教學資源有限,開始的時候只有類似於部落格的,通過文字講解的課程。 但是隨著教學資源的增多,後來增加了視訊課程,音訊課程以及直播課程。

先來看一下不好的設計:

不好的設計

最開始的文字課程類:

//================== Course.h ==================

@interface Course : NSObject

@property (nonatomic, copy) NSString *courseTitle;         //課程名稱
@property (nonatomic, copy) NSString *courseIntroduction;  //課程介紹
@property (nonatomic, copy) NSString *teacherName;         //講師姓名
@property (nonatomic, copy) NSString *content;             //課程內容

@end
複製程式碼

Course類宣告瞭最初的線上課程所需要包含的資料:

  • 課程名稱
  • 課程介紹
  • 講師姓名
  • 文字內容

接著按照上面所說的需求變更:增加了視訊,音訊,直播課程:

//================== Course.h ==================

@interface Course : NSObject

@property (nonatomic, copy) NSString *courseTitle;         //課程名稱
@property (nonatomic, copy) NSString *courseIntroduction;  //課程介紹
@property (nonatomic, copy) NSString *teacherName;         //講師姓名
@property (nonatomic, copy) NSString *content;             //文字內容


//新需求:視訊課程
@property (nonatomic, copy) NSString *videoUrl;

//新需求:音訊課程
@property (nonatomic, copy) NSString *audioUrl;

//新需求:直播課程
@property (nonatomic, copy) NSString *liveUrl;

@end
複製程式碼

三種新增的課程都在原Course類中新增了對應的url。也就是每次新增一個新的型別的課程,都在原有Course類裡面修改:新增這種課程需要的資料。

這就導致:我們從Course類例項化的視訊課程物件會包含並不屬於自己的資料:audioUrlliveUrl:這樣就造成了冗餘,視訊課程物件並不是純粹的視訊課程物件,它包含了音訊地址,直播地址等成員。

很顯然,這個設計不是一個好的設計,因為(對應上面兩段敘述):

  1. 隨著需求的增加,需要反覆修改之前建立的類。
  2. 給新增的類造成了不必要的冗餘。

之所以會造成上述兩個缺陷,是因為該設計沒有遵循對修改關閉,對擴充套件開放的開閉原則,而是反其道而行之:開放修改,而且不給擴充套件提供便利。

難麼怎麼做可以遵循開閉原則呢?下面看一下遵循開閉原則的較好的設計:

較好的設計

首先在Course類中僅僅保留所有課程都含有的資料:

//================== Course.h ==================

@interface Course : NSObject

@property (nonatomic, copy) NSString *courseTitle;         //課程名稱
@property (nonatomic, copy) NSString *courseIntroduction;  //課程介紹
@property (nonatomic, copy) NSString *teacherName;         //講師姓名
複製程式碼

接著,針對文字課程,視訊課程,音訊課程,直播課程這三種新型的課程採用繼承Course類的方式。而且繼承後,新增自己獨有的資料:

文字課程類:

//================== TextCourse.h ==================

@interface TextCourse : Course

@property (nonatomic, copy) NSString *content;             //文字內容

@end
複製程式碼

視訊課程類:

//================== VideoCourse.h ==================

@interface VideoCourse : Course

@property (nonatomic, copy) NSString *videoUrl;            //視訊地址

@end
複製程式碼

音訊課程類:

//================== AudioCourse.h ==================

@interface AudioCourse : Course

@property (nonatomic, copy) NSString *audioUrl;            //音訊地址

@end
複製程式碼

直播課程類:

//================== LiveCourse.h ==================

@interface LiveCourse : Course

@property (nonatomic, copy) NSString *liveUrl;             //直播地址

@end
複製程式碼

這樣一來,上面的兩個問題都得到了解決:

  1. 隨著課程型別的增加,不需要反覆修改最初的父類(Course),只需要新建一個繼承於它的子類並在子類中新增僅屬於該子類的資料(或行為)即可。
  2. 因為各種課程獨有的資料(或行為)都被分散到了不同的課程子類裡,所以每個子類的資料(或行為)沒有任何冗餘。

而且對於第二點:或許今後的視訊課程可以有高清地址,視訊加速功能。而這些功能只需要在VideoCourse類裡新增即可,因為它們都是視訊課程所獨有的。同樣地,直播課程後面還可以支援線上問答功能,也可以僅加在LiveCourse裡面。

我們可以看到,正是由於最初程式設計合理,所以對後面需求的增加才會處理得很好。

下面來看一下這兩個設計的UML 類圖,可以更形象地看出兩種設計上的區別:

UML 類圖對比

未實踐開閉原則:

未實踐開閉原則

實踐了開閉原則:

實踐了開閉原則

在實踐了開閉原則的 UML 類圖中,四個課程類繼承了Course類並新增了自己獨有的屬性。(在 UML 類圖中:實線空心三角箭頭代表繼承關係:由子類指向其父類)

如何實踐

為了更好地實踐開閉原則,在設計之初就要想清楚在該場景裡哪些資料(或行為)是一定不變(或很難再改變)的,哪些是很容易變動的。將後者抽象成介面或抽象方法,以便於在將來通過創造具體的實現應對不同的需求。

原則二:單一職責原則(Single Responsibility Principle)

定義

A class should have a single responsibility, where a responsibility is nothing but a reason to change.

即:一個類只允許有一個職責,即只有一個導致該類變更的原因。

定義的解讀

  • 類職責的變化往往就是導致類變化的原因:也就是說如果一個類具有多種職責,就會有多種導致這個類變化的原因,從而導致這個類的維護變得困難。

  • 往往在軟體開發中隨著需求的不斷增加,可能會給原來的類新增一些本來不屬於它的一些職責,從而違反了單一職責原則。如果我們發現當前類的職責不僅僅有一個,就應該將本來不屬於該類真正的職責分離出去。

  • 不僅僅是類,函式(方法)也要遵循單一職責原則,即:一個函式(方法)只做一件事情。如果發現一個函式(方法)裡面有不同的任務,則需要將不同的任務以另一個函式(方法)的形式分離出去。

優點

如果類與方法的職責劃分得很清晰,不但可以提高程式碼的可讀性,更實際性地更降低了程式出錯的風險,因為清晰的程式碼會讓bug無處藏身,也有利於bug的追蹤,也就是降低了程式的維護成本。

程式碼講解

單一職責原則的demo比較簡單,通過物件(屬性)的設計上講解已經足夠,不需要具體的客戶端呼叫。我們先看一下需求點:

需求點

初始需求:需要創造一個員工類,這個類有員工的一些基本資訊。

新需求:增加兩個方法:

  • 判定員工在今年是否升職
  • 計算員工的薪水

先來看一下不好的設計:

不好的設計

//================== Employee.h ==================

@interface Employee : NSObject

//============ 初始需求 ============
@property (nonatomic, copy) NSString *name;       //員工姓名
@property (nonatomic, copy) NSString *address;    //員工住址
@property (nonatomic, copy) NSString *employeeID; //員工ID
 
 
 
//============ 新需求 ============
//計算薪水
- (double)calculateSalary;

//今年是否晉升
- (BOOL)willGetPromotionThisYear;

@end
複製程式碼

由上面的程式碼可以看出:

  • 在初始需求下,我們建立了Employee這個員工類,並宣告瞭3個員工資訊的屬性:員工姓名,地址,員工ID。
  • 在新需求下,兩個方法直接加到了員工類裡面。

新需求的做法看似沒有問題,因為都是和員工有關的,但卻違反了單一職責原則:因為這兩個方法並不是員工本身的職責

  • calculateSalary這個方法的職責是屬於會計部門的:薪水的計算是會計部門負責。
  • willPromotionThisYear這個方法的職責是屬於人事部門的:考核與晉升機制是人事部門負責。

而上面的設計將本來不屬於員工自己的職責強加進了員工類裡面,而這個類的設計初衷(原始職責)就是單純地保留員工的一些資訊而已。因此這麼做就是給這個類引入了新的職責,故此設計違反了單一職責原則

我們可以簡單想象一下這麼做的後果是什麼:如果員工的晉升機制變了,或者稅收政策等影響員工工資的因素變了,我們還需要修改當前這個類。

那麼怎麼做才能不違反單一職責原則呢?- 我們需要將這兩個方法(責任)分離出去,讓本應該處理這類任務的類來處理。

較好的設計

我們保留員工類的基本資訊:

//================== Employee.h ==================

@interface Employee : NSObject

//初始需求
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *address;
@property (nonatomic, copy) NSString *employeeID;
複製程式碼

接著建立新的會計部門類:

//================== FinancialApartment.h ==================

#import "Employee.h"

//會計部門類
@interface FinancialApartment : NSObject

//計算薪水
- (double)calculateSalary:(Employee *)employee;

@end
複製程式碼

人事部門類:

//================== HRApartment.h ==================

#import "Employee.h"

//人事部門類
@interface HRApartment : NSObject

//今年是否晉升
- (BOOL)willGetPromotionThisYear:(Employee*)employee;

@end
複製程式碼

通過建立了兩個分別專門處理薪水和晉升的部門,會計部門和人事部門的類:FinancialApartmentHRApartment,把兩個任務(責任)分離了出去,讓本該處理這些職責的類來處理這些職責。

這樣一來,不僅僅在此次新需求中滿足了單一職責原則,以後如果還要增加人事部門和會計部門處理的任務,就可以直接在這兩個類裡面新增即可。

下面來看一下這兩個設計的UML 類圖,可以更形象地看出兩種設計上的區別:

UML 類圖對比

未實踐單一職責原則:

未實踐單一職責原則

實踐了單一職責原則:

實踐了單一職責原則

可以看到,在實踐了單一職責原則的 UML 類圖中,不屬於Employee的兩個職責被分類了FinancialApartment類 和 HRApartment類。(在 UML 類圖中,虛線箭頭表示依賴關係,常用在方法引數等,由依賴方指向被依賴方)

上面說過除了類要遵循單一職責設計原則之外,在函式(方法)的設計上也要遵循單一職責的設計原則。因函式(方法)的單一職責原則理解起來比較容易,故在這裡就不提供Demo和UML 類圖了。

可以簡單舉一個例子:

APP的預設導航欄的樣式是這樣的:

  • 白色底
  • 黑色標題
  • 底部有陰影

那麼建立預設導航欄的虛擬碼可能是這樣子的:

//預設樣式的導航欄
- (void)createDefaultNavigationBarWithTitle:(NSString *)title{
    
    //create white color background view
    
    //create black color title
    
    //create shadow bottom
}
複製程式碼

現在我們可以用這個方法統一建立預設的導航欄了。 但是過不久又有新的需求來了,有的頁面的導航欄需要做成透明的,因此需要一個透明樣式的導航欄:

  • 透明底
  • 白色標題
  • 底部無陰影

針對這個需求,我們可以新增一個方法:

//透明樣式的導航欄
- (void)createTransParentNavigationBarWithTitle:(NSString *)title{
    
    //create transparent color background view
    
    //create white color title
}
複製程式碼

看出問題來了麼?在這兩個方法裡面,創造background view和 title color title的方法的差別僅僅是顏色不同而已,而其他部分的程式碼是重複的。 因此我們應該將這兩個方法抽出來:

//根據傳入的顏色引數設定導航欄的背景色
- (void)createBackgroundViewWithColor:(UIColor)color;

//根據傳入的標題字串和顏色引數設定標題
- (void)createTitlewWithColorWithTitle:(NSString *)title color:(UIColor)color;
複製程式碼

而且上面的製造陰影的部分也可以作為方法抽出來:

- (void)createShadowBottom;
複製程式碼

這樣一來,原來的兩個方法可以寫成:

//預設樣式的導航欄
- (void)createDefaultNavigationBarWithTitle:(NSString *)title{
    
    //設定白色背景
    [self createBackgroundViewWithColor:[UIColor whiteColor]];
    
    //設定黑色標題
    [self createTitlewWithColorWithTitle:title color:[UIColor blackColor]];
    
    //設定底部陰影
    [self createShadowBottom];
}


//透明樣式的導航欄
- (void)createTransParentNavigationBarWithTitle:(NSString *)title{
    
    //設定透明背景
    [self createBackgroundViewWithColor:[UIColor clearColor]];
    
    //設定白色標題
    [self createTitlewWithColorWithTitle:title color:[UIColor whiteColor]];
}
複製程式碼

而且我們也可以將裡面的方法拿出來在外面呼叫也可以:

設定預設樣式的導航欄:

//設定白色背景
[navigationBar createBackgroundViewWithColor:[UIColor whiteColor]];

//設定黑色標題
[navigationBar createTitlewWithColorWithTitle:title color:[UIColor blackColor]];

//設定陰影
[navigationBar createShadowBottom];
複製程式碼

設定透明樣式的導航欄:

//設定透明色背景
[navigationBar createBackgroundViewWithColor:[UIColor clearColor]];

//設定白色標題
[navigationBar createTitlewWithColorWithTitle:title color:[UIColor whiteColor]];
複製程式碼

這樣一來,無論寫在一個大方法裡面呼叫或是分別在外面呼叫,都能很清楚地看到導航欄的每個元素是如何生成的,因為每個職責都分配到了一個單獨的方法裡面。而且還有一個好處是,透明導航欄如果遇到淺色背景的話,使用白色字型不如使用黑色字型好,所以遇到這種情況我們可以在createTitlewWithColorWithTitle:color:方法裡面傳入黑色色值。 而且今後可能還會有更多的導航欄樣式,那麼我們只需要分別改變傳入的色值即可,不需要有大量的重複程式碼了,修改起來也很方便。

如何實踐

對於上面的員工類的例子,或許是因為我們先入為主,知道一個公司的合理組織架構,覺得這麼設計理所當然。但是在實際開發中,我們很容易會將不同的責任揉在一起,這點還是需要開發者注意的。

原則三:依賴倒置原則(Dependency Inversion Principle)

定義

  • Depend upon Abstractions. Do not depend upon concretions.
  • Abstractions should not depend upon details. Details should depend upon abstractions
  • High-level modules should not depend on low-level modules. Both should depend on abstractions.

即:

  • 依賴抽象,而不是依賴實現。
  • 抽象不應該依賴細節;細節應該依賴抽象。
  • 高層模組不能依賴低層模組,二者都應該依賴抽象。

定義解讀

  • 針對介面程式設計,而不是針對實現程式設計。
  • 儘量不要從具體的類派生,而是以繼承抽象類或實現介面來實現。
  • 關於高層模組與低層模組的劃分可以按照決策能力的高低進行劃分。業務層自然就處於上層模組,邏輯層和資料層自然就歸類為底層。

優點

通過抽象來搭建框架,建立類和類的關聯,以減少類間的耦合性。而且以抽象搭建的系統要比以具體實現搭建的系統更加穩定,擴充套件性更高,同時也便於維護。

程式碼講解

下面通過一個模擬專案開發的例子來講解依賴倒置原則。

需求點

實現下面這樣的需求:

用程式碼模擬一個實際專案開發的場景:前端和後端開發人員開發同一個專案。

不好的設計

首先生成兩個類,分別對應前端和後端開發者:

前端開發者:

//================== FrondEndDeveloper.h ==================

@interface FrondEndDeveloper : NSObject

- (void)writeJavaScriptCode;

@end



//================== FrondEndDeveloper.m ==================

@implementation FrondEndDeveloper

- (void)writeJavaScriptCode{
    NSLog(@"Write JavaScript code");
}

@end
複製程式碼

後端開發者:

//================== BackEndDeveloper.h ==================

@interface BackEndDeveloper : NSObject

- (void)writeJavaCode;

@end



//================== BackEndDeveloper.m ==================

@implementation BackEndDeveloper

- (void)writeJavaCode{
    NSLog(@"Write Java code");
}
@end
複製程式碼

這兩個開發者分別對外提供了自己開發的方法:writeJavaScriptCodewriteJavaCode

接著建立一個Project類:

//================== Project.h ==================

@interface Project : NSObject

//構造方法,傳入開發者的陣列
- (instancetype)initWithDevelopers:(NSArray *)developers;

//開始開發
- (void)startDeveloping;

@end



//================== Project.m ==================

#import "Project.h"
#import "FrondEndDeveloper.h"
#import "BackEndDeveloper.h"

@implementation Project
{
    NSArray *_developers;
}


- (instancetype)initWithDevelopers:(NSArray *)developers{
    
    if (self = [super init]) {
        _developers = developers;
    }
    return self;
}



- (void)startDeveloping{
    
    [_developers enumerateObjectsUsingBlock:^(id  _Nonnull developer, NSUInteger idx, BOOL * _Nonnull stop) {
        
        if ([developer isKindOfClass:[FrondEndDeveloper class]]) {
            
            [developer writeJavaScriptCode];
            
        }else if ([developer isKindOfClass:[BackEndDeveloper class]]){
            
            [developer writeJavaCode];
            
        }else{
            //no such developer
        }
    }];
}

@end
複製程式碼

Project類中,我們首先通過一個構造器方法,將開發者的陣列傳入project的例項物件。然後在開始開發的方法startDeveloping裡面,遍歷陣列並判斷元素型別的方式讓不同型別的開發者呼叫和自己對應的函式。

思考一下,這樣的設計有什麼問題?

問題一:

假如後臺的開發語言改成了GO語言,那麼上述程式碼需要改動兩個地方:

  • BackEndDeveloper:需要向外提供一個writeGolangCode方法。
  • Project類的startDeveloping方法裡面需要將BackEndDeveloper類的writeJavaCode改成writeGolangCode

問題二:

假如後期老闆要求做移動端的APP(需要iOS和安卓的開發者),那麼上述程式碼仍然需要改動兩個地方:

  • 還需要給Project類的構造器方法裡面傳入IOSDeveloperAndroidDeveloper兩個類。而且按照現有的設計,還要分別向外部提供writeSwiftCodewriteKotlinCode
  • Project類的startDeveloping方法裡面需要再多兩個elseif判斷,專門判斷IOSDeveloperAndroidDeveloper這兩個類。

開發安卓的程式碼也可以用Java,但是為了和後臺的開發程式碼區分一下,這裡用了同樣可以開發安卓的Kotlin語言。

很顯然,在這兩種假設的場景下,高層模組(Project)都依賴了低層模組(BackEndDeveloper)的改動,因此上述設計不符合依賴倒置原則

那麼該如何設計才可以符合依賴倒置原則呢?

答案是將開發者寫程式碼的方法抽象出來,讓Project類不再依賴所有低層的開發者類的具體實現,而是依賴抽象。而且從下至上,所有底層的開發者類也都依賴這個抽象,通過實現這個抽象來做自己的任務

這個抽象可以用介面,也可以用抽象類的方式來做,在這裡筆者用使用介面的方式進行講解:

較好的設計

首先,建立一個介面,介面裡面有一個寫程式碼的方法writeCode

//================== DeveloperProtocol.h ==================

@protocol DeveloperProtocol <NSObject>

- (void)writeCode;

@end
複製程式碼

然後,讓前端程式設計師和後端程式設計師類實現這個介面(遵循這個協議)並按照自己的方式實現:

前端程式設計師類:

//================== FrondEndDeveloper.h ==================

@interface FrondEndDeveloper : NSObject<DeveloperProtocol>
@end



//================== FrondEndDeveloper.m ==================

@implementation FrondEndDeveloper

- (void)writeCode{
    NSLog(@"Write JavaScript code");
}
@end
複製程式碼

後端程式設計師類:

//================== BackEndDeveloper.h ==================

@interface BackEndDeveloper : NSObject<DeveloperProtocol>
@end



//================== BackEndDeveloper.m ==================
@implementation BackEndDeveloper

- (void)writeCode{
    NSLog(@"Write Java code");
}
@end
複製程式碼

最後我們看一下新設計後的Project類:

//================== Project.h ==================

#import "DeveloperProtocol.h"

@interface Project : NSObject

//只需傳入遵循DeveloperProtocol的物件陣列即可
- (instancetype)initWithDevelopers:(NSArray <id <DeveloperProtocol>>*)developers;

//開始開發
- (void)startDeveloping;

@end


//================== Project.m ==================

#import "FrondEndDeveloper.h"
#import "BackEndDeveloper.h"

@implementation Project
{
    NSArray <id <DeveloperProtocol>>* _developers;
}


- (instancetype)initWithDevelopers:(NSArray <id <DeveloperProtocol>>*)developers{
    
    if (self = [super init]) {
        _developers = developers;
    }
    return self;
    
}


- (void)startDeveloping{
    
    //每次迴圈,直接向物件傳送writeCode方法即可,不需要判斷
    [_developers enumerateObjectsUsingBlock:^(id<DeveloperProtocol>  _Nonnull developer, NSUInteger idx, BOOL * _Nonnull stop) {
        
        [developer writeCode];
    }];
    
}

@end
複製程式碼

新的Project的構造方法只需傳入遵循DeveloperProtocol協議的物件構成的陣列即可。這樣也比較符合現實中的需求:只需要會寫程式碼就可以加入到專案中。

而新的startDeveloping方法裡:每次迴圈,直接向當前物件傳送writeCode方法即可,不需要對程式設計師的型別做判斷。因為這個物件一定是遵循DeveloperProtocol介面的,而遵循該介面的物件一定會實現writeCode方法(就算不實現也不會引起重大錯誤)。

現在新的設計接受完了,我們通過上面假設的兩個情況來和之前的設計做個對比:

假設1:後臺的開發語言改成了GO語言

在這種情況下,只需更改BackEndDeveloper類裡面對於DeveloperProtocol介面的writeCode方法的實現即可:

//================== BackEndDeveloper.m ==================
@implementation BackEndDeveloper

- (void)writeCode{

    //Old:
    //NSLog(@"Write Java code");
    
    //New:
    NSLog(@"Write Golang code");
}
@end
複製程式碼

而在Project裡面不需要修改任何程式碼,因為Project類只依賴了介面方法WriteCode,沒有依賴其具體的實現。

我們接著看一下第二個假設:

假設2:後期老闆要求做移動端的APP(需要iOS和安卓的開發者)

在這個新場景下,我們只需要將新建立的兩個開發者類:IOSDeveloperAndroidDeveloper分別實現DeveloperProtocol介面的writeCode方法即可。

同樣,Project的介面和實現程式碼都不用修改:客戶端只需要在Project的構建方法的陣列引數裡面新增這兩個新類的例項即可,不需要在startDeveloping方法裡面新增型別判斷,原因同上。

我們可以看到,新設計很好地在高層類(Project)與低層類(各種developer類)中間加了一層抽象,解除了二者在舊設計中的耦合,使得在低層類中的改動沒有影響到高層類。

同樣是抽象,新設計同樣也可以用抽象類的方式:建立一個Developer的抽象類並提供一個writeCode方法,讓不同的開發者類繼承與它並按照自己的方式實現writeCode方法。這樣一來,在Project類的構造方法就是傳入已Developer型別為元素的陣列了。有興趣的小夥伴可以自己實現一下~

下面來看一下這兩個設計的UML 類圖,可以更形象地看出兩種設計上的區別:

UML 類圖對比

未實踐依賴倒置原則:

未實踐依賴倒置原則

實踐了依賴倒置原則:

實踐了依賴倒置原則

在實踐了依賴倒置原則的 UML 類圖中,我們可以看到Project僅僅依賴於新的介面;而且低層的FrondEndDevelopeBackEndDevelope類按照自己的方式實現了這個介面:通過介面解除了原有的依賴。(在 UML 類圖中,虛線三角箭頭表示介面實線,由實現方指向介面)

如何實踐

今後在處理高低層模組(類)互動的情景時,儘量將二者的依賴通過抽象的方式解除掉,實現方式可以是通過介面也可以是抽象類的方式。

原則四:介面分離原則(Interface Segregation Principle)

定義

Many client specific interfaces are better than one general purpose interface.

即:多個特定的客戶端介面要好於一個通用性的總介面。

定義解讀

  • 客戶端不應該依賴它不需要實現的介面。
  • 不建立龐大臃腫的介面,應儘量細化介面,介面中的方法應該儘量少。

需要注意的是:介面的粒度也不能太小。如果過小,則會造成介面數量過多,使設計複雜化。

優點

避免同一個介面裡麵包含不同類職責的方法,介面責任劃分更加明確,符合高內聚低耦合的思想。

程式碼講解

下面通過一個餐廳服務的例子講解一下介面分離原則。

需求點

現在的餐廳除了提供傳統的店內服務,多數也都支援網上下單,網上支付功能。寫一些介面方法來涵蓋餐廳的所有的下單及支付功能。

不好的設計

//================== RestaurantProtocol.h ==================

@protocol RestaurantProtocol <NSObject>

- (void)placeOnlineOrder;         //下訂單:online
- (void)placeTelephoneOrder;      //下訂單:通過電話
- (void)placeWalkInCustomerOrder; //下訂單:在店裡

- (void)payOnline;                //支付訂單:online
- (void)payInPerson;              //支付訂單:在店裡支付

@end
複製程式碼

在這裡宣告瞭一個介面,它包含了下單和支付的幾種方式:

  • 下單:

    • online下單
    • 電話下單
    • 店裡下單(店內服務)
  • 支付

    • online支付(適用於online下單和電話下單的顧客)
    • 店裡支付(店內服務)

這裡先不討論電話下單的顧客是用online支付還是店內支付。

對應的,我們有三種下單方式的顧客:

1.online下單,online支付的顧客

//================== OnlineClient.h ==================

#import "RestaurantProtocol.h"

@interface OnlineClient : NSObject<RestaurantProtocol>
@end



//================== OnlineClient.m ==================

@implementation OnlineClient

- (void)placeOnlineOrder{
    NSLog(@"place on line order");
}

- (void)placeTelephoneOrder{
    //not necessarily
}

- (void)placeWalkInCustomerOrder{
    //not necessarily
}

- (void)payOnline{
    NSLog(@"pay on line");
}

- (void)payInPerson{
    //not necessarily
}
@end

複製程式碼

2.電話下單,online支付的顧客

//================== TelephoneClient.h ==================

#import "RestaurantProtocol.h"

@interface TelephoneClient : NSObject<RestaurantProtocol>
@end



//================== TelephoneClient.m ==================

@implementation TelephoneClient

- (void)placeOnlineOrder{
    //not necessarily
}

- (void)placeTelephoneOrder{
    NSLog(@"place telephone order");
}

- (void)placeWalkInCustomerOrder{
    //not necessarily
}

- (void)payOnline{
    NSLog(@"pay on line");
}

- (void)payInPerson{
    //not necessarily
}

@end
複製程式碼

3.在店裡下單並支付的顧客:

//================== WalkinClient.h ==================

#import "RestaurantProtocol.h"

@interface WalkinClient : NSObject<RestaurantProtocol>
@end



//================== WalkinClient.m ==================

@implementation WalkinClient

- (void)placeOnlineOrder{
   //not necessarily
}

- (void)placeTelephoneOrder{
    //not necessarily
}

- (void)placeWalkInCustomerOrder{
    NSLog(@"place walk in customer order");
}

- (void)payOnline{
   //not necessarily
}

- (void)payInPerson{
    NSLog(@"pay in person");
}

@end
複製程式碼

我們發現,並不是所有顧客都必須要實現RestaurantProtocol裡面的所有方法。由於介面方法的設計造成了冗餘,因此該設計不符合介面隔離原則

注意,Objective-C中的協議可以通過@optional關鍵字設定不需要必須實現的方法,該特性不與介面分離原則衝突:只要屬於同一類責任的介面,都可以放入同一介面中。

那麼如何做才符合介面隔離原則呢?我們來看一下較好的設計。

較好的設計

要符合介面隔離原則,只需要將不同型別的介面分離出來即可。我們將原來的RestaurantProtocol介面拆分成兩個介面:下單介面和支付介面。

下單介面:

//================== RestaurantPlaceOrderProtocol.h ==================

@protocol RestaurantPlaceOrderProtocol <NSObject>

- (void)placeOrder;

@end
複製程式碼

支付介面:

//================== RestaurantPaymentProtocol.h ==================

@protocol RestaurantPaymentProtocol <NSObject>

- (void)payOrder;

@end
複製程式碼

現在有了下單介面和支付介面,我們就可以讓不同的客戶來以自己的方式實現下單和支付操作了:

首先建立一個所有客戶的父類,來遵循這個兩個介面:

//================== Client.h ==================

#import "RestaurantPlaceOrderProtocol.h"
#import "RestaurantPaymentProtocol.h"

@interface Client : NSObject<RestaurantPlaceOrderProtocol,RestaurantPaymentProtocol>
@end
複製程式碼

接著另online下單,電話下單,店內下單的顧客繼承這個父類,分別實現這兩個介面的方法:

1.online下單,online支付的顧客

//================== OnlineClient.h ==================

#import "Client.h"
@interface OnlineClient : Client
@end



//================== OnlineClient.m ==================

@implementation OnlineClient

- (void)placeOrder{
    NSLog(@"place on line order");
}

- (void)payOrder{
    NSLog(@"pay on line");
}

@end
複製程式碼

2.電話下單,online支付的顧客

//================== TelephoneClient.h ==================
#import "Client.h"
@interface TelephoneClient : Client
@end



//================== TelephoneClient.m ==================
@implementation TelephoneClient

- (void)placeOrder{
    NSLog(@"place telephone order");
}

- (void)payOrder{
    NSLog(@"pay on line");
}

@end
複製程式碼

3.在店裡下單並支付顧客:

//================== WalkinClient.h ==================

#import "Client.h"
@interface WalkinClient : Client
@end



//================== WalkinClient.m ==================

@implementation WalkinClient

- (void)placeOrder{
    NSLog(@"place walk in customer order");
}

- (void)payOrder{
    NSLog(@"pay in person");
}

@end
複製程式碼

因為我們把不同職責的介面拆開,使得介面的責任更加清晰,簡潔明瞭。不同的客戶端可以根據自己的需求遵循所需要的介面來以自己的方式實現。

而且今後如果還有和下單或者支付相關的方法,也可以分別加入到各自的介面中,避免了介面的臃腫,同時也提高了程式的內聚性。

下面來看一下這兩個設計的UML 類圖,可以更形象地看出兩種設計上的區別:

UML 類圖對比

未實踐介面分離原則:

未實踐介面分離原則

實踐了介面分離原則:

實踐了介面分離原則

通過遵守介面分離原則,介面的設計變得更加簡潔,而且各種客戶類不需要實現自己不需要實現的介面。

如何實踐

在設計介面時,尤其是在向現有的介面新增方法時,我們需要仔細斟酌這些方法是否是處理同一類任務的:如果是則可以放在一起;如果不是則需要做拆分。

做iOS開發的朋友對UITableViewUITableViewDelegateUITableViewDataSource這兩個協議應該會非常熟悉。這兩個協議裡的方法都是與UITableView相關的,但iOS SDK的設計者卻把這些方法放在不同的兩個協議中。原因就是這兩個協議所包含的方法所處理的任務是不同的兩種:

  • UITableViewDelegate:含有的方法是UITableView的例項告知其代理一些點選事件的方法,即事件的傳遞,方向是從UITableView的例項到其代理。
  • UITableViewDataSource:含有的方法是UITableView的代理傳給UITableView一些必要資料供UITableView展示出來,即資料的傳遞,方向是從UITableView的代理到UITableView

很顯然,UITableView協議的設計者很好地實踐了介面分離的原則,值得我們大家學習。

原則五:迪米特法則(Law of Demeter)

定義

You only ask for objects which you directly need.

即:一個物件應該對儘可能少的物件有接觸,也就是隻接觸那些真正需要接觸的物件。

定義解讀

  • 迪米特法則也叫做最少知道原則(Least Know Principle), 一個類應該只和它的成員變數,方法的輸入,返回引數中的類作交流,而不應該引入其他的類(間接交流)。

優點

實踐迪米特法則可以良好地降低類與類之間的耦合,減少類與類之間的關聯程度,讓類與類之間的協作更加直接。

程式碼講解

下面通過一個簡單的關於汽車的例子來講解一下迪米特法則。

需求點

設計一個汽車類,包含汽車的品牌名稱,引擎等成員變數。提供一個方法返回引擎的品牌名稱。

不好的設計

Car類:


//================== Car.h ==================

@class GasEngine;

@interface Car : NSObject

//構造方法
- (instancetype)initWithEngine:(GasEngine *)engine;

//返回私有成員變數:引擎的例項
- (GasEngine *)usingEngine;

@end




//================== Car.m ==================

#import "Car.h"
#import "GasEngine.h"

@implementation Car
{
    GasEngine *_engine;
}

- (instancetype)initWithEngine:(GasEngine *)engine{
    
    self = [super init];
    
    if (self) {
        _engine = engine;
    }
    return self;
}

- (GasEngine *)usingEngine{
    
    return _engine;
}

@end
複製程式碼

從上面可以看出,Car的構造方法需要傳入一個引擎的例項物件。而且因為引擎的例項物件被賦到了Car物件的私有成員變數裡面。所以Car類給外部提供了一個返回引擎物件的方法:usingEngine

而這個引擎類GasEngine有一個品牌名稱的成員變數brandName

//================== GasEngine.h ==================
@interface GasEngine : NSObject

@property (nonatomic, copy) NSString *brandName;

@end

複製程式碼

這樣一來,客戶端就可以拿到引擎的品牌名稱了:

//================== Client.m ==================

#import "GasEngine.h"
#import "Car.h"

- (NSString *)findCarEngineBrandName:(Car *)car{

    GasEngine *engine = [car usingEngine];
    NSString *engineBrandName = engine.brandName;//獲取到了引擎的品牌名稱
    return engineBrandName;
}
複製程式碼

上面的設計完成了需求,但是卻違反了迪米特法則。原因是在客戶端的findCarEngineBrandName:中引入了和入參(Car)和返回值(NSString)無關的GasEngine物件。增加了客戶端與 GasEngine的耦合。而這個耦合顯然是不必要更是可以避免的。

接下來我們看一下如何設計可以避免這種耦合:

較好的設計

同樣是Car這個類,我們去掉原有的返回引擎物件的方法,而是增加一個直接返回引擎品牌名稱的方法:

//================== Car.h ==================

@class GasEngine;

@interface Car : NSObject

//構造方法
- (instancetype)initWithEngine:(GasEngine *)engine;

//直接返回引擎品牌名稱
- (NSString *)usingEngineBrandName;

@end


//================== Car.m ==================

#import "Car.h"
#import "GasEngine.h"

@implementation Car
{
    GasEngine *_engine;
}

- (instancetype)initWithEngine:(GasEngine *)engine{
    
    self = [super init];
    
    if (self) {
        _engine = engine;
    }
    return self;
}


- (NSString *)usingEngineBrandName{
    return _engine.brand;
}

@end
複製程式碼

因為直接usingEngineBrandName直接返回了引擎的品牌名稱,所以在客戶端裡面就可以直接拿到這個值,而不需要間接地通過原來的GasEngine例項來獲取。

我們看一下客戶端操作的變化:

//================== Client.m ==================

#import "Car.h"

- (NSString *)findCarEngineBrandName:(Car *)car{
    
    NSString *engineBrandName = [car usingEngineBrandName]; //直接獲取到了引擎的品牌名稱
    return engineBrandName;
}
複製程式碼

與之前的設計不同,在客戶端裡面,沒有引入GasEngine類,而是直接通過Car例項獲取到了需要的資料。

這樣設計的好處是,如果這輛車的引擎換成了電動引擎(原來的GasEngine類換成了ElectricEngine類),客戶端程式碼可以不做任何修改!因為它沒有引入任何引擎類,而是直接獲取了引擎的品牌名稱。

所以在這種情況下我們只需要修改Car類的usingEngineBrandName方法實現,將新引擎的品牌名稱返回即可。

下面來看一下這兩個設計的UML 類圖,可以更形象地看出兩種設計上的區別:

UML 類圖對比

未實踐迪米特法則:

未實踐迪米特法則

實踐了迪米特法則:

實踐了迪米特法則

很明顯,在實踐了迪米特法則的 UML 類圖裡面,沒有了ClientGasEngine的依賴,耦合性降低。

如何實踐

今後在做物件與物件之間互動的設計時,應該極力避免引出中間物件的情況(需要匯入其他物件的類):需要什麼物件直接返回即可,降低類之間的耦合度。

原則六:里氏替換原則(Liskov Substitution Principle)

定義

In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e. an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of the program (correctness, task performed, etc.)

即:所有引用基類的地方必須能透明地使用其子類的物件,也就是說子類物件可以替換其父類物件,而程式執行效果不變。

定義的解讀

在繼承體系中,子類中可以增加自己特有的方法,也可以實現父類的抽象方法,但是不能重寫父類的非抽象方法,否則該繼承關係就不是一個正確的繼承關係。

優點

可以檢驗繼承使用的正確性,約束繼承在使用上的泛濫。

程式碼講解

在這裡用一個簡單的長方形與正方形的例子講解一下里氏替換原則。

需求點

建立兩個類:長方形和正方形,都可以設定寬高(邊長),也可以輸出面積大小。

不好的設計

首先宣告一個長方形類,然後讓正方形類繼承於長方形。

長方形類:

//================== Rectangle.h ==================

@interface Rectangle : NSObject
{
@protected double _width;
@protected double _height;
}

//設定寬高
- (void)setWidth:(double)width;
- (void)setHeight:(double)height;

//獲取寬高
- (double)width;
- (double)height;

//獲取面積
- (double)getArea;

@end



//================== Rectangle.m ==================

@implementation Rectangle

- (void)setWidth:(double)width{
    _width = width;
}

- (void)setHeight:(double)height{
    _height = height;
}

- (double)width{
    return _width;
}

- (double)height{
    return _height;
}


- (double)getArea{
    return _width * _height;
}

@end
複製程式碼

正方形類:

//================== Square.h ==================

@interface Square : Rectangle
@end



//================== Square.m ==================

@implementation Square

- (void)setWidth:(double)width{
    
    _width = width;
    _height = width;
}

- (void)setHeight:(double)height{
    
    _width = height;
    _height = height;
}

@end
複製程式碼

可以看到,正方形類繼承了長方形類以後,為了保證邊長永遠是相等的,特意在兩個set方法裡面強制將寬和高都設定為傳入的值,也就是重寫了父類Rectangle的兩個set方法。但是里氏替換原則裡規定,子類不能重寫父類的方法,所以上面的設計是違反該原則的。

而且里氏替換原則原則裡面所屬:子類物件能夠替換父類物件,而程式執行效果不變。我們通過一個例子來看一下上面的設計是否符合:

在客戶端類寫一個方法:傳入一個Rectangle型別並返回它的面積:

- (double)calculateAreaOfRect:(Rectangle *)rect{
    return rect.getArea;
}
複製程式碼

我們先用Rectangle物件試一下:

Rectangle *rect = [[Rectangle alloc] init];
rect.width = 10;
rect.height = 20;
    
double rectArea = [self calculateAreaOfRect:rect];//output:200
複製程式碼

長寬分別設定為10,20以後,結果輸出200,沒有問題。

現在我們使用Rectange的子類Square的物件替換原來的Rectange物件,看一下結果如何:

Square *square = [[Square alloc] init];
square.width = 10;
square.height = 20;
    
double squareArea = [self calculateAreaOfRect:square];//output:400
複製程式碼

結果輸出為400,結果不一致,再次說明了上述設計不符合里氏替換原則,因為子類的物件square替換父類的物件rect以後,程式執行的結果變了。

不符合里氏替換原則就說明該繼承關係不是正確的繼承關係,也就是說正方形類不能繼承於長方形類,程式需要重新設計。

我們現在看一下比較好的設計。

較好的設計

既然正方形不能繼承於長方形,那麼是否可以讓二者都繼承於其他的父類呢?答案是可以的。

既然要繼承於其他的父類,它們這個父類肯定具備這兩種形狀共同的特點:有4個邊。那麼我們就定義一個四邊形的類:Quadrangle

//================== Quadrangle.h ==================

@interface Quadrangle : NSObject
{
@protected double _width;
@protected double _height;
}

- (void)setWidth:(double)width;
- (void)setHeight:(double)height;

- (double)width;
- (double)height;

- (double)getArea;
@end
複製程式碼

接著,讓Rectangle類和Square類繼承於它:

Rectangle類:

//================== Rectangle.h ==================

#import "Quadrangle.h"

@interface Rectangle : Quadrangle

@end



//================== Rectangle.m ==================

@implementation Rectangle

- (void)setWidth:(double)width{
    _width = width;
}

- (void)setHeight:(double)height{
    _height = height;
}

- (double)width{
    return _width;
}

- (double)height{
    return _height;
}


- (double)getArea{
    return _width * _height;
}

@end
複製程式碼

Square類:

//================== Square.h ==================

@interface Square : Quadrangle
{
    @protected double _sideLength;
}

-(void)setSideLength:(double)sideLength;

-(double)sideLength;

@end



//================== Square.m ==================

@implementation Square

-(void)setSideLength:(double)sideLength{    
    _sideLength = sideLength;
}

-(double)sideLength{
    return _sideLength;
}

- (void)setWidth:(double)width{
    _sideLength = width;
}

- (void)setHeight:(double)height{
    _sideLength = height;
}

- (double)width{
    return _sideLength;
}

- (double)height{
    return _sideLength;
}


- (double)getArea{
    return _sideLength * _sideLength;
}

@end
複製程式碼

我們可以看到,RectangeSquare類都以自己的方式實現了父類Quadrangle的公共方法。而且由於Square的特殊性,它也宣告瞭自己獨有的成員變數_sideLength以及其對應的公共方法。

注意,這裡RectangeSquare並不是重寫了其父類的公共方法,而是實現了其抽象方法。

下面來看一下這兩個設計的UML 類圖,可以更形象地看出兩種設計上的區別:

UML 類圖對比

未實踐里氏替換原則:

未實踐里氏替換原則

實踐了里氏替換原則:

實踐了里氏替換原則

如何實踐

里氏替換原則是對繼承關係的一種檢驗:檢驗是否真正符合繼承關係,以避免繼承的濫用。因此,在使用繼承之前,需要反覆思考和確認該繼承關係是否正確,或者當前的繼承體系是否還可以支援後續的需求變更,如果無法支援,則需要及時重構,採用更好的方式來設計程式。

最後的話

到這裡關於六大設計原則的講解已經結束了。本篇文章所展示的Demo和UML 類圖都在筆者維護的一個專門的GitHub庫中:object-oriented-design。想看Demo和UML圖的同學可以點選連結檢視。歡迎fork,更歡迎給出更多語言的不同例子的PR~ 而且後續還會新增關於設計模式的 程式碼和 UML 類圖。

關於這幾個設計原則還有最後一點需要強調的是: 設計原則是設計模式的基石,但是很難在使實際開發中的某個設計中全部都滿足這些設計原則。因此我們需要抓住具體設計場景的特殊性,有選擇地遵循最合適的設計原則。

本篇已同步到個人部落格:物件導向設計的六大設計原則(附 Demo 及 UML 類圖)


筆者在近期開通了個人公眾號,主要分享程式設計,讀書筆記,思考類的文章。

  • 程式設計類文章:包括筆者以前釋出的精選技術文章,以及後續釋出的技術文章(以原創為主),並且逐漸脫離 iOS 的內容,將側重點會轉移到提高程式設計能力的方向上。
  • 讀書筆記類文章:分享程式設計類思考類心理類職場類書籍的讀書筆記。
  • 思考類文章:分享筆者平時在技術上生活上的思考。

因為公眾號每天釋出的訊息數有限制,所以到目前為止還沒有將所有過去的精選文章都發布在公眾號上,後續會逐步釋出的。

而且因為各大部落格平臺的各種限制,後面還會在公眾號上釋出一些短小精幹,以小見大的乾貨文章哦~

掃下方的公眾號二維碼並點選關注,期待與您的共同成長~

物件導向設計的六大設計原則(附 Demo & UML類圖)

相關文章