35 | 程式設計範式遊記(6)- 面向對象程式設計
陳皓 2018-01-30

前面我們談了函數式程式設計,函數式程式設計總結起來就是把一些功能或邏輯代碼通過函數拼裝方式來組織的玩法。這其中涉及最多的是函數,也就是程式設計中的代碼邏輯。但我們知道,代碼中還是需要處理資料的,這些就是所謂的“狀态”,函數式程式設計需要我們寫出無狀态的代碼。
而這天下并不存在沒有狀态沒有資料的代碼,如果函數式程式設計不處理狀态這些東西,那麼,狀态會放在什麼地方呢?總是需要一個地方放這些資料的。
對于狀态和資料的處理,我們有必要提一下“面向對象程式設計”(Object-oriented programming,縮寫為 OOP)這個程式設計範式了。我們知道,面向對象的程式設計有三大特性:封裝、繼承和多态。
面向對象程式設計是一種具有對象概念的程式程式設計範型,同時也是一種程式開發的抽象方針。它可能包含資料、屬性、代碼與方法。對象則指的是類的執行個體。它将對象作為程式的基本單元,将程式和資料封裝其中,以提高軟體的可重用性、靈活性和可擴充性,對象裡的程式可以通路及修改對象相關聯的資料。在面向對象程式設計裡,計算機程式會被設計成彼此相關的對象。
面向對象程式設計可以看作一種在程式中包含各種獨立而又互相調用的對象的思想,這與傳統的思想剛好相反:傳統的程式設計主張将程式看作一系列函數的集合,或者直接就是一系列對計算機下達的指令。面向對象程式設計中的每一個對象都應該能夠接受資料、處理資料并将資料傳達給其它對象,是以它們都可以被看作一個小型的“機器”,即對象。
目前已經被證明的是,面向對象程式設計推廣了程式的靈活性和可維護性,并且在大型項目設計中廣為應用。此外,支援者聲稱面向對象程式設計要比以往的做法更加便于學習,因為它能夠讓人們更簡單地設計并維護程式,使得程式更加便于分析、設計、了解。
現在,幾乎所有的主流語言都支援面向對象,比如:Common Lisp、Python、C++、Objective-C、Smalltalk、Delphi、Java、Swift、C#、Perl、Ruby 與 PHP 等。
說起面向對象,就不得不提由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合作出版的《設計模式:可複用面向對象軟體的基礎》(Design Patterns - Elements of Reusable Object-Oriented Software)一書,在此書中共收錄了 23 種設計模式。
這本書的 23 個經典的設計模式,基本上就是說了兩個面向對象的核心理念。
- "Program to an ‘interface’, not an ‘implementation’."
- 使用者不需要知道資料類型、結構、算法的細節。
- 使用者不需要知道實作細節,隻需要知道提供的接口。
- 利于抽象、封裝、動态綁定、多态。
- 符合面向對象的特質和理念。
- "Favor ‘object composition’ over ‘class inheritance’."
- 繼承需要給子類暴露一些父類的設計和實作細節。
- 父類實作的改變會造成子類也需要改變。
- 我們以為繼承主要是為了代碼重用,但實際上在子類中需要重新實作很多父類的方法。
- 繼承更多的應該是為了多态。
示例一:拼裝對象
好,我們先來看一個示例,假設我們有如下的描述:
- 四個物體:木頭桌子、木頭椅子、塑膠桌子、塑膠椅子
- 四個屬性:燃點、密度、價格、重量
那麼,我們怎麼用面向對象的方式來設計我們的類呢?
參看下圖:
- 圖的左邊是“材質類” Material。其屬性有燃點和密度。
- 圖的右邊是“家具類” Furniture。其屬性有價格和體積。
- 在 Furniture 中耦合了 Material。而具體的 Material 是 Wood 還是 Plastic,這是在構造對象的時候注入到 Furniture 裡就好了。
- 這樣,在家具類中,通過材料的密度屬性和家具的體積屬性就可以計算出重量屬性。
這樣設計的優點顯而易見,它能和現實世界相對應起來。而且,材料類是可以重用的。這個模式也表現了面向對象的拼裝資料的另一個精髓——喜歡組合,而不是繼承。這個模式在設計模式裡叫“橋接模式”。
和函數式程式設計來比較,函數式強調動詞,而面向對象強調名詞,面向對象更多的關注接口間的關系,而通過多态來适配不同的具體實作。
示例二:拼裝功能
再來看一個示例。我們的需求是:處理電商系統中的訂單,處理訂單有一個關鍵的動作就是計算訂單的價格。有的訂單需要打折,有的則不打折。
在進行面向對象程式設計時,假設我們用 Java 語言,我們需要先寫一個接口——
BillingStrategy
,其中就是一個方法:
GetActPrice(double rawPrice)
,輸入一個原始的價格,輸出一個根據相應的政策計算出來的價格。
interface BillingStrategy { |
public double GetActPrice(double rawPrice); |
} |
複制代碼
這個接口很簡單,隻是對接口的抽象,而與實作無關。現在我們需要對這個接口進行實作。
// Normal billing strategy (unchanged price) |
class NormalStrategy implements BillingStrategy { |
@Override |
public double GetActPrice(double rawPrice) { |
return rawPrice; |
} |
} |
// Strategy for Happy hour (50% discount) |
class HappyHourStrategy implements BillingStrategy { |
@Override |
public double GetActPrice(double rawPrice) { |
return rawPrice * 0.5; |
} |
} |
複制代碼
上面的代碼實作了兩個政策,一個是不打折的:
NormalStrategy
,一個是打了 5 折的:
HappyHourStrategy
。
于是,我們先封裝訂單項
OrderItem
,其包含了每個商品的原始價格和數量,以及計算價格的政策。
class OrderItem { |
public String Name; |
public double Price; |
public int Quantity; |
public BillingStrategy Strategy; |
public OrderItem(String name, double price, int quantity, BillingStrategy strategy) { |
this.Name = name; |
this.Price = price; |
this.Quantity = quantity; |
this.Strategy = strategy; |
} |
} |
複制代碼
然後,在我們的訂單類——
Order
中封裝了
OrderItem
的清單,即商品清單。并在操作訂單添加購買商品時,加入一個計算價格的
BillingStrategy
。
class Order { |
private List<OrderItem> orderItems = new ArrayList<OrderItem>(); |
private BillingStrategy strategy = new NormalStrategy(); |
public void Add(String name, double price, int quantity, BillingStrategy strategy) { |
orderItems.add(new OrderItem(name, price, quantity, strategy)); |
} |
// Payment of bill |
public void PayBill() { |
double sum = 0; |
for (OrderItem item : orderItems) { |
actPrice = item.Strategy.GetActPrice(item.price * item.quantity); |
sum += actPrice; |
System.out.println("%s -- %f(%d) - %f", |
item.name, item.price, item.quantity, actPrice); |
} |
System.out.println("Total due: " + sum); |
} |
} |
複制代碼
最終,我們在
PayBill()
函數中,把整個訂單的價格明細和總價列印出來。
在上面這個示例中,可以看到,我把定價政策和訂單處理的流程分開了。這麼做的好處是,我們可以随時給不同的商品注入不同的價格計算政策,這樣一來就有很高的靈活度了。剩下的事就交給我們的營運人員來配置不同的商品使用什麼樣的價格計算政策了。
注意,現實社會中,訂單價格計算會比這個事複雜得多,比如:有會員價,有打折卡,還有商品的打包價等,而且還可以疊加不同的政策(疊加政策用前面說的函數式的 pipeline 或 decorator 就可以實作)。我們這裡隻是為了說明面向對象程式設計範式,是以故意簡單化了。
其實,這個設計模式叫——政策模式。我認為,這是設計模式中最為經典的模式了,其充分展現了面向對象程式設計的方式。
示例三:資源管理
先看一段代碼:
mutex m; |
void foo() { |
m.lock(); |
Func(); |
if ( ! everythingOk() ) return; |
... |
... |
m.unlock(); |
} |
複制代碼
可以看到,上面這段代碼是有問題的,原因是,那個
if
語句傳回時沒有把鎖給 unlock 掉,這會導緻鎖沒有被釋放。如果我們要把代碼寫對,需要在 return 前 unlock 一下。
mutex m; |
void foo() { |
m.lock(); |
Func(); |
if ( ! everythingOk() ) { |
m.unlock(); |
return; |
} |
... |
... |
m.unlock(); |
} |
複制代碼
但是,在所有的函數退出的地方都要加上
m.unlock();
語句,這會讓我們很難維護代碼。于是可以使用面向對象的程式設計模式,我們先設計一個代理類。
class lock_guard { |
private: |
mutex &_m; |
public: |
lock_guard(mutex &m):_m(m) { _m.lock(); } |
~lock_guard() { _m.unlock(); } |
}; |
複制代碼
然後,我們的代碼就可以這樣寫了:
mutex m; |
void foo() { |
lock_guard guard(m); |
Func(); |
if ( ! everythingOk() ) { |
return; |
} |
... |
... |
} |
複制代碼
這個技術叫 RAII(Resource Acquisition Is Initialization), 是 C++ 中的一個利用了面向對象的技術。這個設計模式叫“代理模式”。我們可以把一些控制資源配置設定和釋放的邏輯交給這些代理類,然後,隻需要關注業務邏輯代碼了。而且,在我們的業務邏輯代碼中,減少了這些和業務邏輯不相關的程式控制的代碼。
從上面的代碼中,我們可以看到下面幾個面向對象的事情。
- 我們使用接口抽象了具體的實作類。
- 然後其它類耦合的是接口而不是實作類。這就是多态,其增加了程式的可擴充性。
- 因為這就是接口程式設計,所謂接口也就是一種“協定”,就像 HTTP 協定一樣。浏覽器和後端的程式都依賴于這一種協定,而不是具體實作(如果是依賴具體實作,那麼浏覽器就要依賴後端的程式設計語言或中間件了,這就太惡心了)。于是,浏覽器和後端的程式就完全解開依賴關系,而去依賴于一個标準的協定。
- 這就是面向對象的程式設計範式的精髓!同樣也是 IoC/DIP(控制反轉 / 依賴倒置)的本質。
IoC 控制反轉
關于 IoC 的的概念提出來已經很多年了,其被用于一種面向對象的設計。我在這裡再簡單地回顧一下這個概念。我先談技術,再說管理。
話說,我們有一個開關要控制一個燈的開和關這兩個動作,最常見也是最沒有技術含量的實作會是這個樣子:
然後,有一天,我們發現需要對燈泡擴充一下,于是做了個抽象類:
但是,如果有一天,我們發現這個開關可能還要控制别的不單單是燈泡的東西,就會發現這個開關耦合了燈泡這種類别,非常不利于擴充,于是反轉控制出現了。
就像現實世界一樣,造開關的工廠根本不關心要控制的東西是什麼,它隻做一個開關應該做好的事,就是把電接通,把電斷開(不管是手動的,還是聲控的,還是光控,還是遙控的)。而我們造的各種各樣的燈泡(不管是日光燈,白熾燈)的工廠也不關心你用什麼樣的開關,反正我隻管把燈的電源接口給做出來。然後,開關廠和電燈廠依賴于一個标準的通電和斷電的接口。于是産生了 IoC 控制反轉,如下圖。
所謂控制反轉的意思是,開關從以前裝置的專用開關,轉變到了控制電源的開關,而以前的裝置要反過來依賴于開關廠聲明的電源連接配接接口。隻要符合開關廠定義的電源連接配接的接口,這個開關可以控制所有符合這個電源連接配接接口的裝置。也就是說,開關從依賴裝置這種情況,變成了,裝置反過來依賴于開關所定義的接口。
這樣的例子在生活中太多見了。比如說:
- 錢就是一個很好的例子。以前大家都是“以物易物”,是以,在各種物品之前都需要相應的“交易政策”,比如:一頭羊換 2 袋米,一袋米換一斤豬後腿肉……這種換算太複雜了。于是,“錢”就出來了,所謂“錢”,其實就是一種交易協定,所有的商品都依賴這個協定,而不用再互相依賴了。于是整個世界的運作就簡單了很多。
- 在交易的過程中,賣家向買家賣東西,一手交錢一手交貨,是以,基本上來說賣家和買家必需強耦合(必需見面)。這個時候,銀行出來做擔保,買家把錢先墊到銀行,銀行讓賣家發貨,買家驗貨後,銀行再把錢打給賣家。這就是反轉控制。買賣雙方把對對方的直接依賴和控制,反轉到了讓對方來依賴一個标準的交易模型的接口。股票交易也是一樣的,證交所就是買賣雙方的标準交易模型接口。
-
上面這個例子,可能還不明顯,再舉一個例子。海爾公司作為一個電器制商需要把自己的商品分銷到全國各地,但是發現,不同的分銷管道有不同的玩法,于是派出了各種銷售代表玩不同的玩法。随着管道越來越多,發現,每增加一個管道就要新增一批人和一個新的流程,嚴重耦合并依賴各管道商的玩法。
實在受不了了,于是制定業務标準,開發分銷資訊化系統,隻有符合這個标準的管道商才能成為海爾的分銷商。讓各個管道商反過來依賴自己标準。反轉了控制,倒置了依賴。
這個思維方式其實還深遠地影響了很多東西,比如我們的系統架構。
-
雲計算平台中有很多的雲産品線。一些底層服務的開發團隊隻管開發底層的技術,然後什麼也不管了,就交給上層的開發人員。上層開發人員在底層團隊開發出來的産品上面開發各種管理這個底層資源的東西,比如:生産底層資源的業務,底層資源的控制台,底層資源的監控系統。
然而,随着接入的資源越來越多,上層為各個雲資源控制生産,開發控制台和監控的團隊,完全幹不過來了。這個時候依賴倒置和反轉控制又可以解決問題了。為了有統一體驗,各個雲産品線需要遵從一定的協定或規範來開發。比如,每個雲産品團隊需要按照标準定義相關資源的生命周期管理,提供控制台,接入整體監控系統,通過标準的協定開發控制系統。
-
集中式處理電子商務訂單的流程。各個垂直業務線都需要通過這個平台來處理自己的交易業務,但是垂直業務線上的個性化需求太多。于是,這個技術平台開始發現,對來自各個業務方的需求應接不暇,各種變态需求嚴重幹擾系統,各種技術決策越來越不好做,導緻需求排期排不過來。
這個時候,也可以使用依賴倒置和反轉控制的思想來解決問題:開發一個插件模型、工作流引擎和 Pub/Sub 系統,讓業務方的個性化需求支援以插件的方式插入訂單流程中。業務方自己的資料存在自己的庫中,業務邏輯也不要侵入系統,并可以使用工作流引擎或 Pub/Sub 的協定标準來自己定義工作流的各個步驟(甚至把工作流引擎的各個步驟的 decider 交給各個業務方自行處理)。
讓各個業務方來依賴于标準插件和工作流接口,反轉控制,讓它們來控制系統,依賴倒置,讓它們來依賴标準。
上面這些我想說什麼?我想說的是:
- 我們每天都在标準化和定制化中糾結。我們痛苦于哪些應該是平台要做的,哪些應該要甩出去的。
- 這裡面會出現大量的與業務無關的軟體或中間件,包括協定、資料、接口……
- 通過面向對象的這些方式,我們可以通過抽象來解耦,通過中間件來解耦,這樣可以降低軟體的複雜度。
總而言之,我們就是想通過一種标準來讓業務更為規範。
小結
不過,我們也需要知道面向對象的優缺點。
優點
- 能和真實的世界交相輝映,符合人的直覺。
- 面向對象和資料庫模型設計類型,更多地關注對象間的模型設計。
- 強調于“名詞”而不是“動詞”,更多地關注對象和對象間的接口。
- 根據業務的特征形成一個個高内聚的對象,有效地分離了抽象和具體實作,增強了可重用性和可擴充性。
- 擁有大量非常優秀的設計原則和設計模式。
- S.O.L.I.D(單一功能、開閉原則、裡氏替換、接口隔離以及依賴反轉,是面向對象設計的五個基本原則)、IoC/DIP……
缺點
- 代碼都需要附着在一個類上,從一側面上說,其鼓勵了類型。
- 代碼需要通過對象來達到抽象的效果,導緻了相當厚重的“代碼粘合層”。
- 因為太多的封裝以及對狀态的鼓勵,導緻了大量不透明并在并發下出現很多問題。
還是好多人并不是喜歡面向對象,尤其是喜歡函數式和泛型那些人,似乎都是非常讨厭面向對象的。
通過對象來達到抽象結果,把代碼分散在不同的類裡面,然後,要讓它們執行起來,就需要把這些類粘合起來。是以,它另外一方面鼓勵相當厚重的代碼黏合層(代碼黏合層就是把代碼黏合到這裡面)。
在 Java 裡有很多注入方式,像 Spring 那些注入,鼓勵黏合,導緻了大量的封裝,完全不知道裡面在幹什麼事情。而且封裝屏蔽了細節,具體發生啥事你還不知道。這些都是面向對象不太好的地方。
以下是《程式設計範式遊記》系列文章的目錄,友善你了解這一系列内容的全貌。這一系列文章中代碼量很大,很難用音頻展現出來,是以沒有錄制音頻,還望諒解。
轉載于:https://www.cnblogs.com/zhangfengshi/p/11104174.html