第5章:可複用性的軟體構建方法 5.2面向複用的構造

啥也博士發表於2019-01-19

大綱

設計可複用的類

  • 繼承和重寫
  • 過載(Overloading)
  • 引數多型和泛型程式設計
  • 行為子型別與Liskov替換原則
  • 組合與委託

設計可複用庫與框架

  • API和庫 – 框架
  • Java集合框架(一個例子)

設計可複用的類

在OOP中設計可複用的類
封裝和資訊隱藏
繼承和重寫
多型性,子型別和過載
泛型程式設計
行為子型別和Liskov替代原則(LSP)
組合與委託

(1)行為子型別和Liskov替換原則(LSP)

行為子型別
子型別多型性:客戶端程式碼可以統一處理不同種類的物件。 子型別多型:客戶端可用統一的方式處理不同型別的物件

  • 如果Cat的型別是Animal的一個子型別,那麼只要使用Animal型別的表示式,就可以使用Cat型別的表示式。

假設q(x)是T型別物件x可證明的性質,那麼對於S型別的物件y,q(y)應該是可證明的,其中S是T的一個子型別。 – Barbara Liskov

Java編譯器執行的規則(靜態型別檢查)

  • 子類可以新增,但不能刪除方法
  • 具體類必須實現所有未定義的方法
  • 重寫方法必須返回相同的型別或子型別
  • 重寫方法必須接受相同的引數型別

重寫方法不會丟擲額外的異常

也適用於指定的行為(方法):

  • 相同或更強的不變數
  • 相同或較弱的先決條件
  • 相同或更強的後置條件

Liskov替代原則(LSP)

LSP是一種特定的子型別關係定義,稱為強行為子型別化
在程式語言中,LSP依賴於以下限制:

  • 先決條件不能在子型別中加強。前置條件不能強化
  • 後置條件在子型別中不能被削弱。後置條件不能弱化
  • 超型別的不變式必須儲存在一個子型別中。不變數要保持
  • 子型別中方法引數的變換。子型別方法引數:逆變
  • 子型別中返回型別的協邊。子型別方法的返回值:協變
  • 子型別的方法不應引發新的異常,除非這些異常本身是超型別方法丟擲的異常的子型別。 異常型別:協變(這將在第7-2節討論)

Covariance (協變)

父型別到子型別:
越來越具體specific
返回值型別:不變或變得更具體
異常的型別:也是如此

Contravariance (反協變、逆變)

父型別到子型別:
越來越具體specific
引數型別:要相反的變化,要不變或越來越抽象

從邏輯上講,它被稱為子型別中方法引數的逆變。
這在Java中實際上是不允許的,因為它會使過載規則複雜化。

協變和反協變

陣列是協變的:根據Java的子型別規則,T []型別的陣列可能包含T型別的元素或T的任何子型別。
在執行時,Java知道這個陣列實際上是作為一個整數陣列例項化的,它只是簡單地通過Number []型別的引用來訪問。

  • 區分:物件的型別與引用的型別

考慮泛型中的LSP

泛型是型別不變的

  • ArrayList <String>是List <String>的子型別
  • List <String>不是List <Object>的子型別

編譯完成後,編譯器會丟棄型別引數的型別資訊; 因此這種型別的資訊在執行時不可用。
這個過程被稱為型別擦除
泛型不是協變的。

什麼是型別擦除?

型別擦除:如果型別引數是無界的,則將泛型型別中的所有型別引數替換為它們的邊界或物件。 因此,生成的位元組碼只包含普通的類,介面和方法。

泛型中的萬用字元

無界萬用字元型別使用萬用字元(?)指定,例如List <?>。

  • 這被稱為未知型別的列表。

有兩種情況,無界萬用字元是一種有用的方法:

  • 如果您正在編寫可以使用Object類中提供的功能實現的方法。
  • 程式碼使用泛型類中不依賴於型別引數的方法。 例如,List.size或List.clear。 事實上,Class <?>經常被使用,因為Class <T>中的大多數方法不依賴於T.

下限萬用字元:<? super A>
上限萬用字元:<? extends A>

考慮具有萬用字元的泛型的LSP

List<Number>是List<?>的一個子類
List<Number> 是List<? extends Object>的一個子類
List<Object>是List<? super String>的一個子類

(2)委託和組合

Interface Comparator<T>
int compare(T o1,T o2):比較它的兩個引數的順序。

  • 一個比較函式,它對某些物件集合進行總排序。
  • 可以將比較器傳遞給排序方法(如Collections.sort或Arrays.sort),以便精確控制排序順序。 比較器也可以用來控制某些資料結構(例如排序集合或排序對映)的順序,或者為沒有自然排序的物件集合提供排序。

如果你的ADT需要比較大小,或者要放入Collections或Arrays進行排序,可實現Comparator介面並重寫compare()函式。

該介面對每個實現它的類的物件進行總排序。

這種順序被稱為類的自然順序,類的compareTo方法被稱為其自然比較方法。
另一種方法:讓你的ADT實現Comparable介面,然後重寫compareTo()方法
與使用Comparator的區別:不需要構建新的Comparator類,比較程式碼放在ADT內部。

委託

委託只是當一個物件依賴另一個物件來實現其功能的某個子集時(一個實體將某個事物傳遞給另一個實體)
委派/委託:一個物件請求另一個物件的功能

  • 例如分揀機正在委託比較器的功能

委派是複用的一種常見形式

  • 分揀機可以重複使用任意的排序順序
  • 比較器可以重複使用需要比較整數的任意客戶端程式碼

委託可以被描述為在實體之間共享程式碼和資料的低階機制。

  • 顯式委託:將傳送物件傳遞給接收物件
  • 隱式委託:由語言的成員查詢規則

委託模式是實施委託的一種軟體設計模式,雖然這個術語也用於鬆散地進行諮詢或轉發。

委託依賴於動態繫結,因為它要求給定的方法呼叫可以在執行時呼叫不同的程式碼段。
處理

  • 接收者物件將操作委託給Delegate物件
  • 接收者物件確保客戶端不會濫用委託物件。

委託與繼承

繼承:通過新操作擴充套件基類或重寫操作。
委託:捕獲操作並將其傳送給另一個物件。
許多設計模式使用繼承和委派的組合。

將繼承替換為委派

問題:你有一個只使用其超類的一部分方法的子類(或者它不可能繼承超類資料)。
解決方案:建立一個欄位並在其中放入一個超類物件,將方法委託給超類物件,並消除繼承。
實質上,這種重構拆分了兩個類,並使超類成為子類的幫助者,而不是其父類。

  • 代替繼承所有的超類方法,子類將只有必要的方法來委派給超類物件的方法。
  • 一個類不包含從超類繼承的任何不需要的方法。

合成繼承原則

或稱為合成複用原則(CRP)

  • 類應該通過它們的組合(通過包含實現所需功能的其他類的例項)實現多型行為和程式碼複用,而不是從基類或父類繼承。
  • 最好組合一個物件可以做的事(has_a)而不是擴充套件它(is_a)。

委託可以被看作是在物件層次上的複用機制,而繼承是類層次上的複用機制。
“委託”發生在objet層面,而“繼承”發生在類層面

合成繼承原則

組合繼承的實現通常始於建立代表系統必須展現的行為的各種介面。
實現已識別的介面的類將根據需要構建並新增到業務域類中。
這樣,系統行為就沒有繼承地實現了。
使用介面定義不同側面的行為
介面之間通過擴充套件實現行為的擴充套件(介面組合)
類實現組合介面

委託的型別

使用(A使用B)
組合/聚合(A擁有B)
關聯(A有B)
這種分類是根據被委託者和委託者之間的“耦合程度”。

(1)依賴:臨時性的委託

使用類的最簡單形式是呼叫它的方法;
這兩種類別之間的關係形式被稱為“uses-a”關係,其中一個類使用另一個類而不實際地將其作為屬性。 例如,它可能是一個引數或在方法中本地使用。
依賴關係:物件需要其他物件(供應商)實施的臨時關係。

(2)關聯:永久性的委託

關聯:物件類之間的一種持久關係,它允許一個物件例項使另一個物件代表它執行一個動作。

  • has_a:一個類有另一個作為屬性/例項變數
  • 這種關係是結構性的,因為它指定一種物件與另一種物件相連,並不代表行為。

(3)組成:更強的委託

組合是一種將簡單物件或資料型別組合成更復雜的物件的方法。

  • is_part_of:一個類有另一個作為屬性/例項變數
  • 實現了一個物件包含另一個物件。

(4)聚合

聚合:物件存在於另一個之外,在外部建立,所以它作為引數傳遞給構造者。

  • has_a

組合(Composition)與聚合(Aggregation)

在組合中,當擁有的物件被破壞時,被包含的物件也被破壞。

  • 一所大學擁有多個部門,每個部門都有一批教授。 如果大學關閉,部門將不復存在,但這些部門的教授將繼續存在。

在聚合中,這不一定是正確的。

  • 大學可以被看作是一個部門的組合,而部門則擁有一批教授。 一位教授可以在一個以上的部門工作,但一個部門不能成為多個大學的一部分。

設計系統級可複用的庫和框架

實際中的庫和框架

定義關鍵抽象及其介面
定義物件互動和不變數
定義控制流程
提供體系結構指導
提供預設值
之所以庫和框架被稱為系統層面的複用,是因為它們不僅定義了1個可複用的介面/類,而是將某個完整系統中的所有可複用的介面/類都實現出來,並且定義了這些類之間的互動關係,呼叫關係,從而形成了系統整體的“架構”。

更多條款

API:應用程式程式設計介面,庫或框架的介面
客戶端:使用API的程式碼
外掛:定製框架的客戶端程式碼
擴充套件點:框架內預留的“空白”,開發者開發出符合介面要求的程式碼(即外掛),框架可呼叫,從而相當於開發者擴充套件了框架的功能
協議:API和客戶端之間預期的互動順序
回撥:框架呼叫來訪問定製功能的外掛方法
生命週期方法:根據協議和外掛狀態按順序呼叫的回撥方法

(1)API設計

為什麼API設計很重要?

如果你程式設計,你是一個API設計師,並且API可以是你最大的資產之一

  • 好的程式碼是模組化的
  • 每個模組都有一個API
  • 使用者大量投資:收購,寫作,學習
  • 根據API思考改進程式碼質量
  • 成功的公共API捕捉使用者

也可以是你最大的責任

  • 糟糕的API可能會導致無盡的支援呼叫流
  • 可以抑制前進的能力

公共API是永遠的

  • 有一個機會讓它正確
  • 一旦模組擁有使用者,就不能隨意更改API

(1)API應該做一件事,做得好

功能應該很容易解釋

  • 如果名稱很難,那通常是一個不好的跡象
  • 好名字推動發展
  • 適合分解和合並模組

(2)API應該儘可能小,但不能更小

API應該滿足其要求

  • 功能,類別,方法,引數等
  • 你可以隨時新增,但你永遠不能刪除

尋找一個很好的功率重量比

(3)實施不應該影響API

API中的實施細節是有害的

  • 迷惑使用者
  • 禁止改變執行的自由

請注意什麼是實施細節

  • 不要過分指定方法的行為

例如:不要指定雜湊函式

  • 所有調整引數都是可疑的

不要讓實現細節“洩露”到API中

  • 序列化表單,丟擲異常

儘量減少一切的可達性(資訊隱藏)

  • 讓班級成員儘可能私人化
  • 公共班級不應該有公共領域

(4)檔案事宜

記錄每個類,介面,方法,建構函式,引數和異常

  • 類:什麼是例項
  • 方法:方法和客戶之間的契約

先決條件,後置條件,副作用

  • 引數:指示單位,表格,所有權

檔案執行緒安全
如果類是可變的,則記錄狀態空間

重複使用比說要容易得多。 這樣做需要良好的設計和非常好的文件。 即使我們看到良好的設計(這仍然不常見),如果沒有良好的文件,我們也不會看到元件被複用。 – D. L. Parnas軟體老化,ICSE 1994

(5)考慮績效後果

不好的決定會限制效能

  • 使型別變化
  • 提供建構函式而不是靜態工廠
  • 使用實現型別而不是介面

不要扭曲API來獲得效能

  • 潛在的效能問題將得到解決,但頭痛將永遠伴隨著你

良好的設計通常與良好的效能相吻合
糟糕的API決策的效能影響可能是真實且永久的

  • Component.getSize()返回Dimension,但Dimension是可變的,因此每個getSize呼叫都必須分配Dimension,導致數百萬無用的物件分配

(6)API必須與平臺和平共存

習慣做什麼

  • 遵守標準的命名約定
  • 避免過時的引數和返回型別
  • 模仿核心API和語言中的模式

利用API友好功能

  • 泛型,可變引數,列舉,函式介面

瞭解並避免API陷阱和陷阱

  • 終結器,公共靜態最終陣列等。

不要音譯API

(7)類設計

最小化可變性:除非有充分的理由否則類應該是不可變的

  • 優點:簡單,執行緒安全,可重複使用
  • 缺點:為每個值分開物件
  • 如果可變,保持狀態空間小,定義明確。

只有子類才有意義:子類化會影響替代性(LSP)

  • 除非存在某種關係,否則不要繼承。 否則,請使用委託或組合。
  • 不要為了複用實現而繼承子類。
  • 繼承違反封裝,子類對超類的實現細節很敏感

(8)方法設計

不要讓客戶做任何模組可以做的事情

  • 客戶通常通過剪下和貼上,這是醜陋的,煩人的,錯誤的。

API應該快速失敗:儘快報告錯誤。 編譯時間最好 – 靜態型別,泛型。

  • 在執行時,第一個錯誤的方法呼叫是最好的
  • 方法應該是失敗原子的

以字串形式提供對所有可用資料的程式設計訪問。 否則,客戶端會解析字串,這對客戶來說很痛苦
過度謹慎。 通常最好使用不同的名稱。
使用適當的引數和返回型別。

  • 歡迎介面型別的類輸入靈活性,效能
  • 使用最具體的可能輸入引數型別,從而將錯誤從執行時移到編譯時間。

避免長引數列表。 三個或更少的引數是理想的。

  • 如果你必須使用很多引數呢?

避免需要特殊處理的返回值。 返回零長度陣列或空集合,不為null。

(2)框架設計

白盒和黑盒框架

白盒框架

  • 通過繼承和覆蓋方法進行擴充套件
  • 通用設計模式:模板方法
  • 子類具有主要方法,但對框架進行控制

黑盒框架

  • 通過實現外掛介面進行擴充套件
  • 通用設計模式:策略,觀察者
  • 外掛載入機制載入外掛並對框架進行控制

白盒與黑盒框架

白盒框架使用子類/子型別—繼承

  • 允許擴充套件每個非私有方法
  • 需要了解超類的實現
  • 一次只能有一個分機
  • 彙編在一起
  • 通常所謂的開發者框架

黑盒框架使用組合 – 委派/組合

  • 允許擴充套件在介面中顯示的功能
  • 只需要瞭解介面
  • 多個外掛
  • 通常提供更多的模組化
  • 可以單獨部署(.jar,.dll,…)
  • 通常稱為終端使用者框架,平臺

框架設計考慮

一旦設計好,改變的機會就很小
關鍵決策:將通用部件與可變部件分開

  • 你想解決什麼問題?

可能的問題:

  • 擴充套件點太少:限於狹窄的使用者類別
  • 延伸點過多:難以學習,速度緩慢
  • 太通用:很少複用價值

“最大限度地利用重複使用最小化”

典型的框架設計和實現

定義你的域名

  • 識別潛在的公共部分和可變部分
  • 設計和編寫示例外掛/應用程式

分解和實施通用部件為框架

為可變部分提供外掛介面和回撥機制

  • 在適當的地方使用眾所周知的設計原則和模式…

獲得大量的反饋,並迭代
這通常被稱為“域工程”。

進化設計:提取共同點

提取介面是進化設計中的一個新步驟:

  • 抽象類是從具體類中發現的
  • 介面是從抽象類中提取的

一旦架構穩定就開始

  • 從課堂上刪除非公開的方法
  • 將預設實現移動到實現介面的抽象類中

執行一個框架

一些框架可以自行執行

  • 例如 Eclipse

其他框架必須擴充套件才能執行

  • Swing,JUnit,MapReduce,Servlets

載入外掛的方法:

  • 客戶端寫入main(),建立一個外掛並將其傳遞給框架
  • Framework寫入main(),客戶端將plugin的名稱作為命令列引數或環境變數傳遞
  • Framework在一個神奇的位置查詢,然後配置檔案或.jar檔案被自動載入和處理。

(3)Java集合框架

什麼是收集和收集框架?

集合:對元素進行分組的物件
主要用途:資料儲存和檢索,以及資料傳輸

  • 熟悉的例子:java.util.Vector,java.util.Hashtable,Array

集合框架:一個統一的架構

  • 介面 – 實現獨立
  • 實現 – 可複用的資料結構
  • 演算法 – 可複用的功能

最著名的例子

  • C++標準模板庫(STL)
  • Java集合框架(JCF)

同步包裝(不是執行緒安全的!)

同步包裝:執行緒安全的新方法

  • 匿名實現,每個核心介面一個
  • 靜態工廠需要收集適當的型別
  • 如果通過包裝進行全部訪問,執行緒安全保證
  • 必須手動同步迭代

那時是新的; 現在已經老了!

  • 同步包裝很大程度上已經過時
  • 由同時收集而過時

總結

設計可複用的類

  • 繼承和重寫
  • 超載
  • 引數多型和泛型程式設計
  • 行為分類和Liskov替代原則(LSP)
  • 組成和委派

設計系統級可複用的庫和框架

  • API和庫
  • 框架
  • Java集合框架(一個例子)

相關文章