從面向對象解讀設計思想
從面向對象解讀設計思想
作者:哲思
時間:2021.8.30
GitHub:zhe-si (哲思) (github.com)
前言
很早就想總結一下自己對面向對象的理解,借這次公開課梳理了一下思路,並在之後撰寫成本文。
對於面向對象概念性的介紹與理解當前網上已經有很多了,但卻很少有人能講出怎樣用好面向對象,也就是如何用面向對象的思想設計出好的程式。所以本文將側重「設計」二字來講述這個問題。
當然,本文只是我參照當下所學和做的項目產生的認識,可能隨著見識的提升和技術的發展,推翻一些當下所寫。但是,其中對設計的思考,想必是走向更高位置的必經之路。
註:本文舉例所用的程式碼統一使用Kotlin,一種包含諸多高級特性、可代替Java並能夠編譯成諸多類型的產物、已經成為Android官方推薦的高級語言。
1.什麼是面向對象
首先,給大家一個思考題。
小明是一個志存高遠的程式設計師。一天,由於業務需要,他想要在原有數據類型Number的基礎上拓展兩個新的子數據類型A與B,但操作時需要統一使用父類型Number進行操作,同時需要支援調用順序無關的相加(add)的方法(假設相加邏輯為
A.numA + B.numB
,相加結果始終為C類型)。小明的設計之魂湧上心頭,打算不光要實現,還要實現一個更靈活、易拓展的設計,但沒有什麼好的思路,你能幫幫他嗎?
1.1.面向對象的含義
從小明的問題回過頭,我們開門見山的給出面向對象編程的定義:
面向對象編程就是將事物抽象成對象,針對對象所持有的數據和與之相關的行為進行編程。
想要了解這個概念,就不得不從老生常談的編程範式的歷史講起。
當電腦世界初開的時候,世界上只有低級語言,即機器語言和彙編語言。這種語言,從電腦的角度,一步步告訴電腦它該先做什麼,再做什麼。而我們需要把我們實際的問題轉化為電腦的基本模型:存儲器、運算器、控制器、輸入/輸出設備,也就是把什麼數據存起來,什麼數據和什麼數據取出來做運算。我們把這種編程方式叫做指令式編程。
後來,人們為了讓程式語言更符合人的理解,所以將最能描述事物本質同時又足夠抽象的數學概念引入其中,我們可以像解數學題一樣定義變數、對變數相加減(此處的變數指用一個標識符代指一個數據)、甚至定義函數來表示一個通用操作過程。這樣,我們就可以通過數學去描述現實事物,並將事物的發展轉化為一步步的運算過程。我們把這種編程方式叫作過程式編程,也算指令式編程的一種延伸。
在編寫程式的過程中,人們發現編程的本質就是處理數據,也就是數據和操作(對數據的處理)。而二者有著非常明顯的對應關係,一組相關的數據,總是對應一組相關的操作。而這樣的組合,便滿足了我們生活中對於絕大多數事物(也就是對象)的描述。我們將現實中的事物對應程式中的對象,讓程式的運行變成對象與對象間的交互。對象成為程式中的基本單元,將一類對象相關的數據和數據對應的操作封裝到一起作為類,而對象則是該類的一個具體實例,這便是面向對象編程。
編程的發展史便是不斷抽象來讓編程符合人的認知和事物的本質。包括之後出現的函數式編程、響應式編程,都是如此。但之後的編程範式都沒有完全逃脫面向對象的思想,同時都是在一些具體場景下的產物。世界是由事物組成的,這已經符合了我們對世界基本的認知,這也是面向對象一直經久不衰的原因。
1.2.面向對象的三大特徵
這裡要首先強調一個概念:類型。面向對象將一切看成對象,通過類去描述對象,這裡的類,在程式中,就是類型。我們將一類對象定義為一種類型,並在類型中聲明屬性和方法(這些都是該類型的特徵)。可以說,面向對象編程,從電腦角度來說,就是面向類型編程!
接下來,我們將細說面向對象的概念。而面向對象的三大特徵則是對其概念最好的描述:封裝、繼承、多態。
三者可以說從三個層面對面向對象進行了描述。封裝是面向對象最基本的表現,繼承是面向對象最核心的行為,多態是面向對象最重要的能力。
1.2.1.封裝
封裝:將不需要外部看到的數據和對應方法放到類內,外部不可見,只暴露外部需要看到的數據和方法。
這是面向對象的初衷和最基本的表現,將相關的數據放到一起,將數據對應的方法放到一起,實現了高內聚。
同時,進行資訊隱藏,將內部數據和邏輯隱藏到類的內部,只讓外部看到這個類的外部表現對應的數據和操作,實現了低耦合。
舉個經典的例子:
狗
屬性 行為 名字、顏色、尾巴長短 吃飯、叫、尾巴長不長
class Dog(
val name: String,
val color: String,
private val tailLength: Double
) {
private val description: String
get() = "${color}色、尾巴${tailLength}厘米的狗${name}"
fun eat() {
println(description + "正在吃飯")
}
fun shout() {
println(description + "正在叫:汪汪汪!")
}
fun isTailLong(): Boolean {
return tailLength > 15
}
}
fun main() {
// 一個狗的實例對象
val dog1 = Dog("dog1", "黑白", 12.5)
// 狗暴露出的外部資訊與行為
println(dog1.name)
println(dog1.color)
dog1.eat()
dog1.shout()
println(dog1.isTailLong())
}
在這裡狗的屬性與行為都被封裝到Dog
類中。
當前場景下外界不需要了解狗的尾巴具體是多長,所以將尾巴具體長度的資訊隱藏,而暴露判斷尾巴長不長的方法。同時對內部實現所需的狗的自我描述description
也進行隱藏,只能通過對外暴露的行為間接訪問。
這樣,外部可以通過Dog來訪問狗的各種外在資訊與行為,同時也看不到內部具體的實現。
本例中,封裝的是一個實體類,將一個實體相關的數據和方法放到一個類中。但如果只是這樣,實現的方法有很多種,稱不上使用了面對對象,因為現實事物都有一個很重要的描述方式:依據特徵去分類。
1.2.2.繼承
繼承:依據相關類的共有特徵進行層級分類,具體類包含抽象類(它的上一層分類)的特徵,二者是一種「is-a」的關係。
這是面向對象最核心的行為與標誌。子類繼承父類,表示子類「is-a」父類,子類從父類得到子類共有的方法,並進行個性化實現與拓展,是一種父類別下的具體類別,有著父類包含的特徵,也可以擁有自己獨有的特徵。而父類是一組相關子類共同特徵的集合,可以從抽象層面代指子類。
比如以下的例子,
狗、猩猩、貓、兔子,都是(is-a)動物,「動物」是那些具體動物的上一級分類(當然,這裡還可以說它們都是哺乳動物,這分類的依據,需要根據需求和實際情況而定),包含了具體動物在「動物」這個抽象層面的共同特徵。
動物
屬性 行為 名字、顏色 吃飯、叫
abstract class Animal(
val name: String,
val color: String
) {
// description是通用的內部特徵,但會隨著不同的實例而變化,所以將會改變的子類個性化特徵描述otherDescription與子類型名typeName抽象出來,讓子類實現
protected abstract val otherDescription: String
protected abstract val typeName: String
protected val description: String
get() = "${color}色${otherDescription}的${typeName}${name}"
abstract fun eat()
abstract fun shout()
}
於是,我們將狗的抽象特徵提取到動物抽象類中,
class Dog(
name: String,
color: String,
private val tailLength: Double
): Animal(name, color) {
override val otherDescription = "、尾巴${tailLength}厘米"
override val typeName = "狗"
override fun eat() {
println(description + "正在吃飯")
}
override fun shout() {
println(description + "正在叫:汪汪汪!")
}
fun isTailLong(): Boolean {
return tailLength > 15
}
}
並引入新的動物類別:猩猩。它也是動物的一種,包含動物的特徵。
class Orangutan(
name: String,
color: String,
): Animal(name, color) {
override val otherDescription = ""
override val typeName = "猩猩"
override fun eat() {
println(description + "正在吃飯")
}
override fun shout() {
println(description + "正在叫:嗷嗷~!")
}
}
我們雖然提取了狗和猩猩的抽象特徵「動物」,但當我們直接需要狗或者猩猩對象時,二者的外在表現沒有任何區別。我們可以調用它們的抽象特徵和特有特徵。
// main()中
// 當我們需要狗的時候,直接實例化一隻狗,可以調用它的抽象特徵(如:name、eat等)以及特有特徵(isTailLong)
println("**************** 1 *******************")
val dog1 = Dog("dog1", "黑白", 12.5)
println(dog1.name)
println(dog1.color)
dog1.eat()
dog1.shout()
println(dog1.isTailLong())
// 需要猩猩也是同理
println("**************** 2 *******************")
val orangutan1 = Orangutan("orangutan1", "黑")
println(orangutan1.name)
orangutan1.eat()
orangutan1.shout()
// 輸出
**************** 1 *******************
dog1
黑白
黑白色、尾巴12.5厘米的狗dog1正在吃飯
黑白色、尾巴12.5厘米的狗dog1正在叫:汪汪汪!
false
**************** 2 *******************
orangutan1
黑色的猩猩orangutan1正在吃飯
黑色的猩猩orangutan1正在叫:嗷嗷~!
但是當我們只需要關注動物的抽象特徵、不關心具體動物的特有特徵時,可以用「動物」這個抽象類別去統一代指和對待。從抽象層面,狗、猩猩都是動物。
// 當我們只需要所有的動物,不需要區分是狗還是猩猩,則可以用父類去統一代指具體類,並調用其抽象的共有特徵(但這些抽象特徵的具體表現不同)
println("**************** 3 *******************")
val animals = listOf(dog1, orangutan1, Dog("dog2", "白", 15.2), Orangutan("orangutan2", "棕"))
for (animal in animals) {
println(animal.name)
println(animal.color)
animal.eat()
animal.shout()
println()
}
// 輸出
**************** 3 *******************
dog1
黑白
黑白色、尾巴12.5厘米的狗dog1正在吃飯
黑白色、尾巴12.5厘米的狗dog1正在叫:汪汪汪!
orangutan1
黑
黑色的猩猩orangutan1正在吃飯
黑色的猩猩orangutan1正在叫:嗷嗷~!
dog2
白
白色、尾巴15.2厘米的狗dog2正在吃飯
白色、尾巴15.2厘米的狗dog2正在叫:汪汪汪!
orangutan2
棕
棕色的猩猩orangutan2正在吃飯
棕色的猩猩orangutan2正在叫:嗷嗷~!
1.2.3.多態
多態:相同的特徵,在不同情況下有不同的表現。
這是面向對象最重要的能力,也是它靈活、易拓展和復用的原因。多態本身的內涵非常寬泛,有重載多態、子類型多態、參數多態、結構多態、行多態等。從面向對象角度,最常用的是子類型多態。但不管是那種多態,都符合以上的定義,都可以在調用相同的特徵後產生不同的表現。
比如,重載多態,通過重載函數(函數本身即可理解為一種能力或特徵,放到類中,即該類型的特徵),調用時使用不同的參數(類別、個數)進而得到不同的表現。
class Number(val num: Int) {
fun add(number: Number): Int {
return num + number.num
}
fun add(number: Int): Int {
return number + num
}
}
fun main() {
val n1 = Number(5)
println(n1.add(6))
println(n1.add(Number(6)))
}
而子類型多態,在繼承的例子中已有表現,Animal
父類指代不同的子類型(狗、猩猩)時,雖然一視同仁的調用了共有的特徵animal.eat()
和animal.shout()
,但卻產生了不同的表現,如狗的「汪汪」叫和猩猩的「嗷嗷」叫。
這(子類型多態)是通過定義具體子類型,並調用抽象父類型的共有特徵實現的多態。父類型聲明了一組類型的共有特徵,但不一定直接實現,可以延遲到子類型去實現,進而基於子類型不同的實現方式產生不同的表現(多態)。而這種多態的實現方式,是基於繼承實現的。
由於在講面向對象,所以以下我們所說的多態都特指子類型多態,如果描述其他類型多態,會具體說明。
1.3.面向對象的思想
上面已經說過,三大特徵是從三個層面去描述面向對象。封裝從程式碼手段層面將相關的數據和對應的操作集中放到一起,讓程式聚合成類和對象的基本單元;繼承從核心行為層面,給予了類聚合相關特徵、靈活分類的能力;多態從表現和結果層面,描述了基於這種分類所帶來的好處,即可拓展性和可復用性。
好了,讀到這裡,想必大家對面對對象的基本概念和想法有了初步的理解,這些知識是當前網上比較「流行」的內容,也足夠大家去面試或回答本科課堂的問題(甚至比較自信的說,算是比較透徹的了 😎)。
但我想問,你真的理解了面向對象了嗎?或者說,你能用好面向對象並寫出好的設計嗎?再或者說,你已經有了足夠好的想法,去解決本節一開始提出的思考題嗎?
我想你也看到了,這不是結束,只是這篇文章的開始。面向對象的思想博大精深,也不斷與時俱進,需要豐富的經驗與知識儲備,更需要我們不斷的探索。
我們直接給出思考題比較基本和經典的解法,也希望大家能在繼續閱讀的過程中,發現當前解法在不同場景(或拓展場景)的問題,併產生自己新的想法:
// 解法: 二次分派
typealias C = Int
abstract class Number {
abstract fun add(number: Number): C
abstract fun add(a: A): C
abstract fun add(b: B): C
}
class A(private val numA: C): Number() {
override fun add(number: Number): C {
return number.add(this)
}
override fun add(a: A): C {
return a.numA + numA
}
override fun add(b: B): C {
return b.numB + numA
}
}
class B(val numB: C): Number() {
override fun add(number: Number): C {
return number.add(this)
}
override fun add(a: A): C {
return a.add(this)
}
override fun add(b: B): C {
return b.numB + numB
}
}
fun main() {
val n1: Number = A(3)
val n2: Number = B(4)
println(n1.add(n2))
println(n2.add(n1))
}
// 輸出
// 7
// 7
那麼,面向對象的思想到底是什麼?
面向對象是一種語言無關、技術無關的一種編程範式,強調編程時思考問題的角度與方法。
我們回過頭再去看面向對象的定義:面向對象編程就是將事物抽象成對象,針對對象所持有的數據和與之相關的行為進行編程。
簡單來說,我們要分析目標數據和操作,將世界合理的抽象為有層級分類的對象結構,每個對象結構是一組數據和與之相關的操作的集合,通過對象的創建、運行、交互和銷毀實現一個程式。
這裡的對象,不光包括以上例子中的實體類對象(如:Animal、Dog等),還包括一些抽象的對象(如:行為、消息、演算法、狀態等)。
這裡的關鍵點就是對世界的抽象與分類,難點則有很多,如:對象的交互方式、銷毀的時機等。由此也可以看出來,對目標事物本質的思考與理解才是最重要的,有了不同的抽象與分類形式,進而才會有其他問題(如:交互方式)的產生。
對世界進行抽象與分類的過程,也是認知事物相同與不同的過程。「道生一,一生二,二生三,三生萬物。」可以說,正因為不同,因為變化,才產生了世界上的萬物!所以想要描述好世界上的事物,就必須描述好事物的變化規律。
2.面向「變化」編程
在我看來,良好的設計最重要、也是最主要的就是面向「變化」編程。
處理好了變化,不光擁有了良好的拓展性,更提升了復用性、可讀性、穩定性、可測試性、兼容性等諸多指標,甚至性能也會有所提升。
2.1.什麼是變化?
那麼,什麼是「變化」?
變化就是一組可以獨立改變的事物。
具體來講,從抽象類衍生出各種有著特有特徵的具體類就是變化;產生了(或可能產生)新的事物進而需要拓展新的類就是變化;在不同的情況下事物需要有不同的表現就是變化。這三種變化,總結起來,就是:衍生細節、拓展種類、封裝差異。
2.1.1.衍生細節
小明開了一家快餐店,由於小明隨便賣了點吃的(速食麵),所以大家來了都招呼小明:老闆,來份吃的。
後來,小明拓展業務,也做起了手抓餅。由於有的顧客想吃速食麵,有的想吃手抓餅,而「吃的」是二者的統稱,無法區分二者,所以之後顧客進門就說:老闆,來份速食麵/手抓餅。或者說:老闆,吃的中來份速食麵/手抓餅。當然,也有一些不拘小節的顧客,進門就說:老闆,隨便來份吃的。
在這個例子中:
初期,由於沒有或不需要那麼多細節,大家不關係與區分具體種類,所以用統稱去描述。
後期,發生了變化,由於業務細化,內容變得豐富起來,細節變多,這個統稱無法去描述與區分那些不同的細節,需要用更具體的類別去描述所需,而具體的類別自然也被包括在籠統的類別中。當然,也存在一些不關心這些細節的場景與需要,而直接用統稱去描述它們。
這就是對細節的衍生。從一開始簡單的業務需要,到後來逐漸細化業務,區分細節。對應的,就是一開始的抽象類(也許一開始不認為它是抽象類),到後來從抽象類衍生的、可以區分細節的具體類。
而這樣從抽象類衍生具體類,非常自然的應對了這種細節衍生的變化。不但解決了新的要區分細節的需要,還不破壞原本對抽象事物的使用。
2.1.2.拓展種類
小紅也開了一個快餐店,她的拿手絕活是牛肉包子,吸引許多人慕名來吃,每個進來都招呼小紅:老闆,來個牛肉包子。
後來,小紅也拓展業務,想到自己牛肉包子好吃,自己做別的包子估計也不錯,於是出了新的豬肉包子。每個進來賣豬肉包子的顧客可以仿照賣牛肉包子招呼小紅:老闆,來個豬肉包子。
在這個例子中:
初期,業務場景單一,只有一個種類。
後期,發生了變化,由於業務拓展,新的類似業務產生,有了新的種類。同時,由於二者的相似性,拓展時如果完全重新拓展,成本較高。由此,將這類業務的共同特徵提取,作為公共的抽象業務(抽象類),基於此抽象業務去拓展新的業務。
這就是對種類的拓展。從一開始單一的業務需要,到後來逐漸拓展業務,添加業務種類。對應的,就是一開始只有一個具體類別,到後來需要高效拓展,提取公共業務封裝到抽象類中,並基於抽象類去拓展新的具體類別。
這樣提取抽象類並拓展其他具體類,非常統一、高效的應對了這種種類拓展的變化。不但解決了創建新類別的要求,還支援原有公共邏輯與介面的復用。
這裡可能會有同學困惑,兩個例子都是現有一個具體類,後來需要產生新類,為啥一個是衍生細節、一個是拓展種類呢?
首先,請注意衍生細節在初期的抽象類指沒有或不需要那麼多細節,本例子講速食麵雖是一個具體類,但在當前不區分細節的場景下,是當作一個抽象類——「吃的」去處理的。當然,有一些場景,可能一開始就是只需要抽象類(如:一開始我認為長得像貓和老鼠中的傑瑞的都是老鼠,後來才知道老鼠還分倉鼠、田鼠等具體種類,甚至其下還有更具體的種類)。
而拓展種類,需要抽象類是因為需要提取公共邏輯與介面,實現復用。
所以雖然看上去都是實現抽象類和具體類,但是二者出發點不同,抽象類和具體類的概念出現的順序也不同。
2.1.3.封裝差異
小明的快餐店不景氣,快要交不起店鋪租用費了。他想起老朋友小紅的快餐店的包子十分火爆,想去投靠小紅。
小紅十分善良,同意了小明的請求。兩人一合計,打算將兩個快餐店合成一個,同時不分你我,一起經營,併合並了菜單。
菜單:
吃的:速食麵、手抓餅、包子(牛肉包子、豬肉包子)
在這個例子中:
當前,存在兩個相似業務(兩人的快餐店),雖然存在許多實現上的不同(一個是小明的,一個是小紅的,菜單也不同),但大體業務形式是相似的(都支援顧客點餐)。
既然是相似的業務,外部就希望可以統一的看待它們,不希望自己處理這些不同。而這些不同,就是差異,就是變化。
為了統一對待相似業務,可以提取它們的宏觀公共業務邏輯與表現介面,封裝為總體業務的類。而實現上的不同,則交由實現介面(抽象類)的具體類去實現。
這就是對差異的封裝。從一開始有多個相似的業務,到後來需要統一對待,隱藏差異。對應的,就是一開始有多個業務邏輯類,到後來提取相同的宏觀公共業務邏輯與介面作為總體業務類,而變化的具體實現則延遲到實現對應介面的具體類中去實現。
這樣提取提取相似的業務邏輯與表現、封裝變化的具體實現的方式,對外透明且清晰的應對了這種存在差異而導致的變化。不但讓差異對外透明,而且精簡了重複的邏輯,同時支援具體實現的復用。
2.2.怎麼面向變化編程
面向變化編程的過程,也是封裝變化、調整結構支援變化的過程。
在什麼是變化的章節,我們講到了三種常見變化,在實際問題中,我們不一定每次都要分清這是什麼變化,因為變化的表現千變萬化,一個變化可能所屬多個變化的種類,難以明確分類,更難以一一歸納。但不要害怕,以上三種變化從大的方面基本涵蓋了變化的情形,其他大多是它們的變種。其次,上面的三種變化是給出了三種典型情況,希望以此為例,說明變化是什麼以及解決變化的通用方法。
2.2.1.封裝變化
面向變化編程的第一步就是封裝變化。
封裝變化有三個重點:1. 讓變化對外界透明;2. 將變化縮小為它本質的樣子;3. 將變化的影響範圍縮到最小。
-
將變化封裝的結果就是變化對外界透明。
對變化的細節感興趣的,我們允許它們感知到變化;對於更多對變化對細節不感興趣的,我們要讓變化可以像一類事物去對待,而將變化的實現延遲到具體類中實現,減少外界對變化的了解。這不但減輕了外界處理變化、了解變化細節的複雜度與工作量,降低了程式碼複雜度,同時也將變化的影響控制在了內部範圍,便於查找與修改。
-
將變化封裝的難點就是將變化縮小到它本質的樣子。
換句話說,就是找出變化的本質,並將其封裝,不要將沒有變化的部分也封裝其內。找出變化的本質可是一個技術活,需要你切實了解到底是什麼引發了變化,撥開應用場景對變化的層層封裝,將真正的變化抽象出來。當我們將本質的變化封裝起來,也就最大限度的復用了程式碼,減少了每次變化的程式碼體積,同時邏輯也將更加清晰,好處自然不言而喻。
-
將變化的影響範圍縮到最小,是封裝變化的重要目的。
除了將變化的部分透明的封裝到內部,同時要減少對外暴露的表現形式,讓行為與職責更加單一明確。而在變化的類內部,變化的部分影響的範圍也應儘可能的縮小。最好,變化的類就是變化本身。減少變化的影響範圍,本身也反映了對變化的理解程度以及對變化的抽象能力。當我們將變化的影響範圍縮到最小,我們也就儘可能的掌控了變化。
2.2.2.支援變化
面向變化編程的核心一步,就是調整變化的封裝結構,支援更多的變化。
變化雖帶來複雜性,但更是發展與進步的標誌。項目只應該因為業務而終止,而不應該是程式碼無法繼續維護和拓展而結束。面對變化,不但不應該逃避,我們反而應該去擁抱變化。
當我們已經通過封裝變化將變化儘可能集中,接下來,我們就可以讓變化在此處支援拓展,並在不同的情形下選擇不同的具體表現。而支援變化的方法,無外乎還是提取公共特徵與相同的抽象業務形式(介面),並將不同的實現延遲到具體類。但困難的是如何對這些特徵及介面進行抽象,讓其足夠清晰且靈活。
同時,在實現的過程中,大家也在儘可能的不讓新的變化影響原本的邏輯,這樣做可以讓變化更靈活、易拓展與復用。而方法還是將變化儘可能的封裝,將變化的邏輯更集中的放到具體的實現類中,讓變化可以自描述。當然,外界如果想使用一個新的類別,就必須使用新類別的聲明。想要優化這個問題,除了將新類別的引用封裝到單個地方來隱藏其他地方的顯示引用外,還可以通過程式碼生成、依賴注入等方式隱式引用。
2.3.在面向對象中面對變化
藏了這麼長時間,終於講到了如何用面向對象去解決變化問題 😁。在什麼是變化章節中,我們講了變化就是在原本的業務基礎上產生了新的業務需求,主要有三種情況:衍生細節、拓展種類、封裝差異;在怎麼面向變化編程章節中,我們講了要對變化進行抽象、封裝和拓展。但大家有沒有一種莫名的熟悉感?這不就是我們在面向對象中學到的東西嗎?
2.3.1.用面向對象衍生細節
在什麼是變化:衍生細節的例子中,從抽象類中衍生細節,產生具體類,不就是繼承嗎?這種變化體現了繼承允許一種類別可以在其基礎上去完成個性化的實現與拓展的能力。而由於指向不同的具體類,在使用抽象類的統一調用中,產生不同的表現(表現不同的細節),就是多態呀。
將該例子實現為程式碼,並針對怎麼面向變化編程所講進行具體說明,如下:
原本小明的快餐店,賣吃的,不需要那麼多細節,
class Food() {
fun sellFood() {
println("老闆,來份吃的")
}
}
class FastFoodShopXM {
val food = Food()
fun orderFood() {
food.sellFood()
}
}
後來,細化了業務,具體區分了「吃的」的種類,
open class Food {
open fun sellFood() {
// 此處省略隨便點了點吃的之後一系列過程
println("老闆,隨便來電吃的") // 這裡代表對某個具體類的封裝,也可以將該邏輯是現在外部,不提供本默認實現,同時給orderFood隨機傳入一個具體類別
}
}
class InstantNoodles: Food() {
override fun sellFood() {
// 此處省略點了速食麵之後一系列過程
println("老闆,來份速食麵")
}
}
class HandCake: Food() {
override fun sellFood() {
// 此處省略點了手抓餅之後一系列過程
println("老闆,來份手抓餅")
}
}
// 對原FastFoodShopXM進行調整,使支援新的場景
class FastFoodShopXM {
// 點餐
fun orderFood(food: Food) {
food.sellFood()
}
}
fun main() {
val fastFoodShopXM = FastFoodShopXM()
// 顧客點不同的食物
fastFoodShopXM.orderFood(InstantNoodles())
fastFoodShopXM.orderFood(HandCake())
fastFoodShopXM.orderFood(Food())
}
// 老闆,來份速食麵
// 老闆,來份手抓餅
// 老闆,隨便來電吃的
此例子中,變化是從一個抽象種類衍生包含更多細節的具體種類,對應原來的Food,到後來更具體的InstantNoodles和HandCake。
-
封裝變化:對外部透明
我們將這些變化的細節通過繼承封裝到具體類中,而抽象類Food中的sellFood作為公共特徵與介面,讓外部可見,而變化的具體實現(點具體食物及之後的所有操作)封裝到具體類中,對外部透明。
-
封裝變化:將變化縮小為它本質的樣子
這裡我們可以進行如下實現:
open class FastFoodShopXMTest { open fun orderFood() { // 這裡還包括一些點餐的其他邏輯 println("老闆,隨便來電吃的") } } class FastFoodShopXMTestInstantNoodles: FastFoodShopXMTest() { override fun orderFood() { // 這裡還包括一些點餐的其他邏輯 println("老闆,來份速食麵") } } class FastFoodShopXMTestHandCake: FastFoodShopXMTest() { override fun orderFood() { // 這裡還包括一些點餐的其他邏輯 println("老闆,來份手抓餅") } } fun main() { FastFoodShopXMTest().orderFood() FastFoodShopXMTestInstantNoodles().orderFood() FastFoodShopXMTestHandCake().orderFood() } // 老闆,隨便來電吃的 // 老闆,來份速食麵 // 老闆,來份手抓餅
這裡,我們將變化的類擴大到了快餐店,同樣實現了需求。但先不說有多個快餐店分別對應某種食物很不符合正常邏輯(這只是一個明顯的例子),在orderFood中也並不是只包括與食物相關的邏輯(如點餐收銀),而這部分邏輯是相似且可復用的。可以想像,這部分邏輯通過複製粘貼的方式重複出現在各個實現類中,極大增加了程式碼複雜度(這裡還要單獨覆蓋測試等,因為你不能跟測試說這幾段程式碼是一樣的,所以測試覆蓋率可以直接翻倍)。甚至一些不熟悉程式碼的同學還會自己重複實現一遍,而讓這段邏輯的正確性更難以保證。
這裡,當然也有解決方案,那就是將其他邏輯提取封裝到抽象類中,並在子類中復用(但誰能保證子類一定調用已經按照正確順序調用這些邏輯方法?)。甚至使用模版方法模式,將流程中與食物相關的邏輯封裝為抽象方法,交由具體類去實現(這裡,不感覺邏輯已經更複雜了嗎?而且與食物相關 (某個單獨流程) 的程式碼也無法單獨復用。當流程中變化的東西開始增加,你要寫多少類才能覆蓋所有情況?)。解決問題的辦法肯定不止一條,但如果要引入大量程式碼來解決問題,為什麼不一開始就不讓問題發生?
對變化本身小而精巧的描述,是其復用性提升的關鍵,而我們可以靈活復用的單位,自然是對象或類。
通過傳遞參數讓復用單位從類下降到對象也是一個實用的技巧。(但本文暫不具體討論)
-
封裝變化:將變化的影響範圍縮到最小
本例中,我們將變化本身(從抽象食物衍生出具體食物)封裝為一組類,同時在Food中只對外暴露一個sellFood介面,外部只用也只可以調用它,相比於暴露出sellFood內部的多個具體步驟,雖然少了一些使用的靈活性(請問:這些靈活性是該場景下有必要的嗎?),但卻讓外面了解了變化最少的細節,減少了變化對外的影響,也讓變化的拓展更靈活豐富。
-
支援變化
本例中,我們提取公共特徵與相同的抽象業務形式(sellFood)到抽象類Food,並將不同的實現延遲到具體類,同時每個類都自描述了與其變化的具體實現相關的邏輯,無需外界為其補充描述,拓展時對外部影響小(基本無需修改外部程式碼),對繼續變化支援較好。
2.3.2.用面向對象拓展種類
在什麼是變化:拓展種類的例子中,為了拓展新的種類,提取包含公共特徵和介面的抽象類,作為拓展的基準,來拓展新類。面向對象通過繼承實現從抽象類拓展新類,通過繼承表現出來的多態作為拓展的子類變化的部分的不同表現。
將該例子實現為程式碼,並針對怎麼面向變化編程所講進行簡要說明(詳細說明請見用面向對象衍生細節,與此類似),如下:
原本小紅的快餐店只有牛肉包子,
class BeefBun {
fun sellBun() {
println("老闆,來份牛肉包子")
}
}
class FastFoodShopXH {
fun orderFood(beefBun: BeefBun) {
beefBun.sellBun()
}
}
後來小紅拓展了包子業務,提取包子的抽象類,並新增了豬肉包子,
abstract class Bun() {
abstract fun sellBun()
}
class BeefBun: Bun() {
override fun sellBun() {
println("老闆,來份牛肉包子")
}
}
class PorkBun: Bun() {
override fun sellBun() {
println("老闆,來份豬肉包子")
}
}
class FastFoodShopXH {
fun orderFood(bun: Bun) {
bun.sellBun()
}
}
fun main() {
val fastFoodShopXH = FastFoodShopXH()
fastFoodShopXH.orderFood(BeefBun())
fastFoodShopXH.orderFood(PorkBun())
}
// 老闆,來份牛肉包子
// 老闆,來份豬肉包子
本例中,通過提取公共特徵與介面到抽象類Bun,我們將拓展種類的變化單獨封裝到每個具體子類中,變化對外部透明(外部統一使用Bun操作具體的包子類),同時讓變化(不同的包子種類)自描述,減少對不同包子種類對外部對影響,對繼續變化(增加新的包子種類)成本較小。
2.3.3.用面向對象封裝差異
在什麼是變化:封裝差異的例子中,需要對外部隱藏差異,而在不同情況下有不同的表現的要求,不就是多態嗎?而在面對對象中想要實現多態,就要使用繼承機制,繼承是面對對象實現多態的方法。
將該例子實現為程式碼,並針對怎麼面向變化編程所講進行簡要說明(詳細說明請見用面向對象衍生細節,與此類似),如下:
小明和小紅的快餐店在以上程式碼的基礎上,合併為一個快餐店,並封裝菜單新的層級類別以及小明和小紅分別對幾種食物的製作過程,
open class Food {
open fun sellFood() {
println("老闆,隨便來電吃的")
}
}
class InstantNoodles: Food() {
override fun sellFood() {
println("老闆,來份速食麵。(小明製作)")
}
}
class HandCake: Food() {
override fun sellFood() {
println("老闆,來份手抓餅。(小明製作)")
}
}
abstract class Bun: Food() {
final override fun sellFood() {
sellBun()
}
abstract fun sellBun()
}
class BeefBun: Bun() {
override fun sellBun() {
println("老闆,來份牛肉包子。(小紅製作)")
}
}
class PorkBun: Bun() {
override fun sellBun() {
println("老闆,來份豬肉包子。(小紅製作)")
}
}
class FastFoodShop {
fun orderFood(food: Food) {
food.sellFood()
}
}
fun main() {
val fastFoodShop = FastFoodShop()
fastFoodShop.orderFood(BeefBun())
fastFoodShop.orderFood(InstantNoodles())
fastFoodShop.orderFood(PorkBun())
fastFoodShop.orderFood(HandCake())
fastFoodShop.orderFood(Food())
}
// 老闆,來份牛肉包子。(小紅製作)
// 老闆,來份速食麵。(小明製作)
// 老闆,來份豬肉包子。(小紅製作)
// 老闆,來份手抓餅。(小明製作)
// 老闆,隨便來電吃的
本例中,我們通過繼承實現了一組不斷變化的食物類別族,允許我們通過抽象的類別統一對待它的子類別。它們在不同層級上提取並復用了公共的特徵與介面,並表現了具體食物種類、製作過程的多態。由此,我們通過面向對象封裝了差異。
通過以上三個例子,我們可以很清楚的發現,我們就在使用封裝和繼承,其表現是透明和多態,通過這種抽象與實現去封裝變化。而變化,自然是程式碼中最不穩定也是最需要復用的部分。我們準確封裝好變化,每個模組都是穩定的部分,那不就是高內聚嗎?模組中的變化準確封裝,可以獨立靈活的變化,且對外暴露的變化介面最小,那其他模組自然沒有耦合之說,自然實現了低耦合。
當然,面向對象不是封裝變化唯一的途徑,甚至不能說最好(因為在不同場景下有不同情況),但絕對是適用性最廣且相性極好的方式。但是,在實際情況下,我們也要根據情況靈活選擇合適的方式去面向變化編程。注意,核心是面向變化編程,面向對象只是一種手段。
3.解讀面向對象的六大原則
首先,直接給出面向對象的六大原則:
- 里氏替換原則(Liskov Substitution Principle)
- 單一職責原則(Single Responsibility Principle)
- 開閉原則(Open Closed Principle)
- 迪米特法則(Law of Demeter),又叫「最少知道法則」
- 介面隔離原則(Interface Segregation Principle)
- 依賴倒置原則(Dependence Inversion Principle)
可以說,以上六大原則,都是面對對象為了更好的服務於「面向變化編程」的。
3.1.里氏替換原則
里氏替換原則:如果對每一個類型為S的對象o1,都有類型為T的對象o2,使得以T定義的所有程式P在所有的對象o1都代替o2時,程式P的行為沒有發生變化,那麼類型S是類型T的子類型。
簡單來說:子類永遠可以替換父類,且不會造成錯誤、異常
這是一個用來規範繼承的原則,該原則包含四層含義:1. 子類必須完全實現父類的方法;2. 子類可以有自己的實現;3. 覆蓋或實現父類的方法時,輸入參數可以放大;4. 覆蓋或實現父類的方法時,返回值可以縮小。
通過該原則,繼承標準的實現了一個「is-a」語義,讓父類是子類的公共特徵與介面的集合,可以從抽象層面代指子類。也是通過這樣一個標準的抽象與實現的語義,才可以讓我們將變化的抽象公共特徵提取,並交由子類去實現變化的細節。
3.2.單一職責原則
單一職責原則:一個類應該有且僅有一個原因引起類的變更
單一職責原則要求一個介面或一個類只能有一個原因引起變化,也就是一個介面或者類只能有一個職責,它就負責一件事情。
從定義上,我們就可以看出,這是在控制變化。在面向變化編程中我們講到,我們要封裝變化,讓變化本身最小,同時讓變化對外暴露的介面最小。可以說,每一個變化,就對應一個類,二者是一對一的關係,變化本身是獨立的。而一個抽象類則對應了一組相關的變化。
讓變化本身最小,就是在告訴我們每個對變化的封裝只包含一個變化,且不包含與變化無關的邏輯部分。
讓變化對外暴露的介面最小,則是在說抽象變化的宏觀表現,將每個完整而獨立的功能當成一個整體。簡單來說,就是多個相同層面的功能不能放到一起,儘可能暴露更抽象層面的表現介面。
3.3.開閉原則
開閉原則:類應該對拓展開放,對修改關閉
對拓展開放,對修改關閉,不正是面向變化的編程方法——支援變化的表述?
我們通過提取抽象的公共特徵與介面作為對外表現介面來屏蔽外部對內部變化的感知,通過繼承實現包含不同細節的具體類進行拓展,通過儘可能的自描述與其他優化方法降低外部對具體類的引用。
3.4.最少知道原則
最少知道法則:一個對象應該對其他對象有最少的了解
最少知道原則讓我們儘可能少的允許直接引用某個對象,同時儘可能少的給外部(包括可以直接引用的「朋友」)暴露自己的細節。
在面向變化編程中,要求封裝變化,每個變化都對應一個類,每個變化都只在有此變化的類中被引用。同時,針對變化良好的封裝要求我們控制變化對外部的影響,只提供更抽象的表現介面。由此,我們要暴露變化本身最宏觀的表現,讓外部了解變化最少的細節。
3.5.依賴倒置原則
依賴倒置原則:要依賴抽象,不要依賴具體的類
類之間的依賴關係也是變化之間的依賴關係。該原則要求我們面向介面編程,而不是面向實現。換句話說,是要求我們依賴變化的抽象類(包含了變化的公共特徵與抽象表現介面),而不是包含變化細節的具體類。
本原則,正是我們對變化的處理方法。面向變化編程要求我們在外部關心的是變化的抽象表現,而變化的細節全部封裝到內部。這不但是封裝變化的要求,同時也是支援變化的基礎。
3.6.介面隔離原則
介面隔離原則:不應該依賴它不需要的介面,類間的依賴關係應該建立在最小的介面上
該原則要求我們明確真正獨立改變的變化到底是什麼。這就對應我們封裝變化的關鍵之一:將變化縮小為它本質的樣子。
通過切實了解到底是什麼引發了變化,進而確定每組變化的公共特徵與抽象外在表現,才能保證變化的獨立性和靈活性,才能保證項目的可拓展和程式碼的可復用性。
4.解讀設計模式
在這裡,先澄清,將設計模式放到這裡,並不是說設計模式屬於面對對象的一部分。二者是並行的,但設計模式在標準實現中用了面向對象,同時符合良好的面向對象設計的思想(也就是符合面向變化編程的思想。但注意,面向變化編程也是面向對象並行的,可見面向變化編程末尾總結部分),所以經常將設計模式當成面對對象的進階知識。
4.1.解讀「多用組合,少用繼承」
在具體解讀設計模式之前,先問大家一個問題:你們知道為啥要「多用組合,少用繼承」嗎?
這個問題,直接關係到大家最這句話本身內涵的理解。
從事物的關係層面理解,組合是「has-a」關係,而繼承是「is-a」關係。在我看來,事物與事物有以下五種關係:無關係、「use-a」、「has-a」、「implement-a」、「is-a」,嚴格意義上,「implement-a」是「is-a」在純介面情況下的一種特殊情形。而事物與事物的關係,就直接回答了我要用哪種選擇。
如:策略模式,將一組演算法封裝(演算法就是其變化的細節),而演算法/策略相對於當前使用它的主體,就是一種「has-a」的關係,使用的是組合。而裝飾者模式,不管是裝飾器還是被裝飾的基本實現類,都是抽象類,雖然裝飾類可以不斷組合實現這個抽象類的具體類,但不管套了多少層,相對於抽象類,都是「is-a」關係,使用的是繼承;而裝飾類相對於被裝飾對象,則是一種「has-a」關係,使用的是組合。
從封裝變化的方法角度講,二者則沒有區別,只是看問題的粒度不同。支援用組合,是因為可以它可以細粒度的動態改變,表現出多態。但這種變化,不也是繼承變化的抽象類實現的?話又說回來了,面向對象是通過繼承實現的多態。而用繼承,在使用它的層面,不也是組合嗎?所以,區分二者,其實是在強調封裝變化的粒度問題,也就是封裝變化講到的:將變化縮小到它本質的樣子,而具體的例子,也在用面向對象衍生細節的封裝變化:將變化縮小為它本質的樣子中講到。
4.2.解讀「工廠」
工廠有三種:簡單工廠(不算一個設計模式,而是一個設計習慣)、工廠方法和抽象工廠。
簡單工廠,可以根據一些條件去有選擇的生產(實例化病返回)一個產品(具體類)。這就是在封裝變化,更具體來說,就是封裝對變化的具體引用,並將工廠作為了解具體變化的唯一位置。由此,可以更好的支援變化。
工廠方法,可以在不同的實現類中決定實際生產(實例化並返回)的產品(具體類),將具體類的引用與選擇延遲到子類中。這也是一種變相的對變化的具體引用的封裝,向外部透明具體的變化。
抽象工廠,可以定義一組生產相關產品的方法,基於此產生不同「風格」的工廠,每個工廠可以生產該「風格」的一組相關產品。這可以說是對工廠方法的拓展,工廠方法只能生產一種抽象種類的產品,抽象工廠可以生產一組抽象種類的產品。其目的,也是對變化(成組統一的變化)的封裝。
4.3.解讀「觀察者模式」
觀察者模式是最常用的設計模式之一,通過訂閱和發布的形式實現一個消息的通知與同步,是一種一對多的依賴關係。
那觀察者模式是在處理哪類問題?或者說,是在處理什麼變化?
顧名思義,這是在處理觀察者的變化。對某個發布的主題訂閱的觀察者是不斷在變化的,數量不定,具體類型不定。但只要實現了抽象觀察者類,向被觀察者註冊,就可以收到對應的消息。抽象的觀察者類,就是觀察者的變化的公共特徵和抽象對外介面的集合,並基於此產生了與「觀察」相關的一組具體變化。
4.4.解讀設計模式
設計模式還有很多,以上只是舉了幾個例子。設計模式的準則就是:1)中意於組合而不是繼承,2)依賴於介面而不是實現,3)高內聚,低耦合。而這些也正是面向變化編程的描述。每一個設計模式都有對應的使用場景,每一種場景描述的正是一種常見變化(單例模式亦可看作一種特殊的變化:限制變化,只可產生一個實例)。而設計模式應對這些常見變化的方法,亦是處理這些變化的經典方法,是好的面向變化編程的範例。
5.從需求中來,到需求中去
從一開始講面對對象的基本概念與思想,到後來提出面向「變化」編程、詳細講解如何面向「變化」編程、如何通過面對對象實現面向「變化」編程,再到後來通過面向「變化」的視角解讀面向對象的六大原則與設計模式,來說明與論證面向「變化」的地位,我們已經從淺入深的講解與論證了基於面向「變化」的良好面向對象設計方法的定義、方法與作用,但有一點沒有說明——那就是怎麼發現「變化」。
如何發現「變化」可謂是面向變化編程的重中之重。如何找到「變化」,如何找准「變化」的本質,如何確定變化最抽象的外在表現,直接關係到我們能否成功封裝「變化」,能否良好的封裝「變化」,能否讓項目可以繼續靈活變化。
5.1.從哪裡發現「變化」?
要問一切的變化從哪裡來,那自然是從需求中來!
要問一切的變化到哪裡去,那一定是到需求中去!
可以說,需求,即是變化、是項目本身的目的與意義!
那從哪裡發現「變化」?那當然是從需求中發現變化。
面向對象編程就是如此,它並非出自本能或技術本身,而是需要我們先理解需求,再確定有哪些類與對象、對象與對象之間有哪些交互,最後進行實現。
而面向變化編程則進一步要求我們,不光要分析需求中的基本元素及交互,同時要找到需求中的變化。
程式碼是對應需求的,程式碼中對變化的封裝即是對需求中的變化進行封裝,程式碼中支援新的變化即是支援新的需求中的變化。我們找到變化、封裝變化、支援變化,都是為了更好的應對需求。
5.2.如何發現「變化」?
那麼,如何在需求中發現「變化」呢?
首先,是去熟悉、理解需求,知道需求有什麼、什麼是重點、怎麼變化的(需求的發展與變更歷史)。
接下來,關注需求中頻繁修改、新增、刪除的部分,確定它們的範圍、修改的原因和目的。
最後,依據變化的基本類別,對需求中的變化進行定性分析,並進一步迭代其範圍,最終確定需求中的變化。
5.3.哪裡才是真正的變化?
讀完了如何發現「變化」,我想絕大多數同學都還是一頭霧水。
沒錯,如果光了解方法,我也不清楚哪裡才是真正的變化。發現「變化」沒有什麼捷徑或竅門(至少我沒找到),只有繼續去熟悉業務、熟悉需求。想來也是這個道理,一個業務不熟的程式猿連完成一個普通業務需求都很陌生緩慢,更不要說去設計項目了。一個好的架構師想必不但有著豐富的架構知識與經驗,也一定對項目有著無與倫比的熟悉度與見解。
但誰都是慢慢才了解業務的,需要一個熟悉的過程,並在這個過程中不斷去理解業務,尋找真正的變化。在這裡,溫馨提示,不要著急,不要看到需求中比較直觀的改變就認為它是真正的變化。請冷靜分析、沉著思考是不是背後還有更本質的變化形式。當然,如果有把握,就放心大膽的封裝你找到的變化吧。因為除了神,沒有人能找到變化的最本質形式。或者最本質,那可能就是「一」,已經沒有實際的參考價值了,而對變化相對最合適的解讀,需要你在抽象與實際間去平衡。反覆迭代對變化的封裝,也是封裝變化重要的方法與必經的步驟。而許多小的重構與優化,也往往可以促使發現更大的重構機會。
6.面向對象的泥沼
6.1.面對對象的過度崇拜
面向對象已經火了很多個年頭,並作為編程教學內容、面試與工作的必考知識而廣為流傳。但作為一名程式猿,總發現身邊或網上存在對面對對象的過度崇拜、對設計模式的過度崇拜,一言不合,就各種方法、模式往上堆,甚至過度設計而不知。
面對對象固然是一個解決現實中問題的一個普適性良好的編程範式與思想,但絕非任何情形都是好的選擇,而連編程範式或思想都不算、只適用於一些特定場合的設計模式就更是這樣了。
在面向「變化」編程章節所講的快餐店的例子,變化的部分是食物的種類與製作,也可以說是菜單。那麼,不說更複雜的情況,完全可以通過一個配置文件去實現菜單的描述,通過一個菜單類去封裝對配置文件的讀取與解析,拓展性與靈活性遠高於面向對象去封裝的同時,程式碼也更加簡潔清晰。(當然,這裡我不是在自黑上面所講的面向對象的例子,就像例子中的注釋所寫,其中省略了很多涉及多個對象的複雜操作步驟。隨著複雜度的增加,就去權衡這種面向數據的編程方式和面向對象的利弊了)
當然,有人「誇」面對對象,自然有人黑面向對象,把它說的好像一無是處。我的觀點,是不信謠,不傳謠。同時,我對面向對象的思想十分認可,認為這是對實際生活非常好的描述方式。
6.2.面向對象只是好的解決方案的一部分
在快餐店的「面向數據」實現方式中可以看到,此處主要用了一個文件表格去封裝食物變化的差異。但眼尖的同學可能已經看到,這裡是用類去封裝配置文件的讀取與解析的。
在前面講過,一個類,就對應一個變化。那這裡封裝的是什麼變化呢?是對不同配置文件的解析方式。由此看出,面對對象可以廣泛的出現在編程的各種場合,而在很多時候,面對對象是好的解決方案的一部分,但也只是一部分。
6.3.學會設計本身
想要設計出好的解決方案,就要學會設計本身,而不能局限於任何一個編程範式中,什麼面向過程、面向對象、函數式編程、響應式編程等等,都平等對待,按需使用。而好的設計最核心、也是最主要的,就是面向「變化」編程。而想要找到「變化」,就要回到具體的需求中去尋找答案。
面向對象本就是抽象的思想,而設計的路更是玄而又玄,學習的路還有很長,需要足夠的知識與經驗積累,也需要有好的項目機遇。希望每一個小夥伴都能在編寫更好的程式碼的道路上不斷砥礪前行!