面試官:「談談面向對象的特性」
碼農:「封裝」、「繼承」和「多態」
面試官:能具體說一下嗎?
碼農:「封裝」隱藏了某一方法的具體運行步驟,取而代之的是通過消息傳遞機制發送消息。「繼承」即子類繼承父類,子類比原本的類(稱為父類)要更加具體化。這意味著我們只需要將相同的代碼寫一次。而「多態」可以使同一類型的對象對同一消息會做出不同的響應。
上面是一個普通的面試場景。這麼回答是否正確呢?
你有沒有想過何謂「特性」?
「特性」指某事物所特有的性質!
那麼問題來了,「封裝」、「繼承」和「多態」是面向對象所特有的嗎?
- Java是面嚮對象語言
- C是面向過程語言
- Go是面向類型的語言
- Clojure是函數式語言
這四種範式的語言都支持「封裝」、「繼承」和「多態」嗎?
我們通過例子來驗證四種不同範式的語言是否能實現「封裝」、「繼承」和「多態」!
封裝
先來看Java:
- Java是通過類來進行封裝,將相關的方法和屬性封裝到了同一個類中
- 通過訪問權限控制符來控制訪問權限。
public class Person {
private String name;
public String say(String someThing) {
...
}
}
而C則是:
- 通過方法來進行封裝,將具體的過程封裝到一個個方法中
- 通過頭文件來隱藏具體的細節
struct Person;
void say(struct Person *p);
相對於Java來說,C的封裝性實際更好!因為Person裡的結構都被隱藏了!
對Go語言來說,乍看之下像是以函數進行封裝的,但是實際上在Go語言中函數也是一種類型。所以可以說Go語言是以類型來進行封裝的。
func say(){
fmt.Println("Hello")
}
func main() {
a := say
a()
}
而Clojure則主要以函數的形式進行封裝。
(defn say []
(println "Hello"))
可以看出來,四種語言都支持封裝,只是封裝的方式不同而已。
繼承
再來看繼承,繼承實際就是代碼複用。
繼承本身是與類或命名空間無關的獨立功能。只不過面嚮對象語言將繼承綁定到了類層面上,而面嚮對象語言是比較普遍的語言,一直強調繼承,所以當我們說繼承的時候,默認就是在說基於類的繼承。
Java是基於類的繼承。也就是說子類可以複用父類定義的非私有屬性和方法。
class Man extends Person {
...
}
C語言可以通過指針來複用。和下面Go語言比較類似,Go語言相對更簡單。而C則更像是奇技淫巧!
struct Person {
char* name;
}
struct Man {
char* name;
int age;
}
struct Man* m = malloc(sizeof(struct Man));
m->name = "Man";
m->age = 20;
struct Person* p = (struct Person*) m; // Man可以轉換為Person
而Go語言則是通過匿名字段來實現繼承。即一個類型可以通過匿名字段複用另一個類型的字段或函數。
type Person struct {
name string
}
type Man struct {
...
Person // 引入Person內的字段
}
- Man通過直接在定義中引入Person,就可以複用Person中的字段
- 在Man中,既可以通過this.Person.name來訪問name字段,也可以直接通過this.name來訪問
- 如果Man中也有name這個字段,則通過this.name訪問的則是Man的name,Person裡的name被覆蓋了
- 此方案對函數也適用
對於Clojure來說,則是通過高階函數來實現代碼的複用。只需要將需要複用的函數作為參數傳遞給另一個函數即可。
; 複用的打印函數
(defn say [v]
(println "This is " v))
; 打印This is Man
(defn man [s]
(s "Man"))
; 打印This is Women
(defn women [s]
(s "Women"))
同時Clojure可以基於Ad-hoc來實現繼承,這是基於symbol或keyword的繼承,適用範圍比基於類的繼承廣泛。
(derive ::man ::person)
(isa? ::man ::person) ;; true
可以看出,四種語言也都能實現繼承。
多態
多態實際是對行為的動態選擇。
Java通過父類引用指向子類對象來實現多態。
Person p = new Man();
p.say();
p = new Woman();
p.say();
C語言的多態則是由函數指針來實現的。
struct Person{
void (* say)( void ); //指向參數為空、返回值為空的函數的指針
}
void man_say( void ){
printf("Hello Man\n");
}
void woman_say( void ){
printf("Hello Woman\n");
}
...
p->say = man_say;
p.say(); // Hello Man
p->say = woman_say;
p.say(); // Hello Woman
Go語言通過interface來實現多態。這裡的interface和Java裡的interface不是一個概念
; 定義interface
type Person interface {
say()
}
type Man struct {}
type Women struct {}
func (this Man) say() {
fmt.Println("Man")
}
func (this Women) area() {
fmt.Println("Women")
}
func main() {
m := Man{}
w := Women{}
exec(m) // Man say
exec(w) // Women say
}
func exec(a Person) {
a.say()
}
- Man和Women並沒有像在Java裡一樣實現了interface,而是定義了和在Person裡相同的方法
- exec函數接收參數為interface
Clojure除了可以通過高階函數來實現多態(上面的例子就是高階函數的例子)。還可以通過「多重方法」來實現多態。
(defmulti say (fn [t] t))
(defmethod run
:Man
[t]
(println "Man"))
(defmethod run
:Women
[t]
(println "Women"))
(rsay :Man) ; 打印Man,結合Ad-hoc,可以實現類似Java的多態
四種語言同樣都能實現多態。
問題的解決
從上面的對比可知,「封裝」、「繼承」和「多態」並不是面向對象所特有的!
那麼當我們說「面向XX編程時,我們實際在說什麼呢」?
我們從解決問題的方式來回答這個問題!
對於一些很簡單的問題,我們一般可以直接得到解決方案。例如:1+1等於幾?
但是對於比較複雜的問題,我們不能直接得到解決方案。例如:雞兔同籠問題,有若干只雞兔同在一個籠子裡,從上面數,有35個頭,從下面數,有94只腳。問籠中各有多少隻雞和兔?
對於這類問題,我們的一般做法就是先對問題進行抽象建模,然後再針對抽象來尋找解決方案。
對應到軟件開發來說,對於真實環境的問題,我們先通過編程技術對其抽象建模,然後再解決這些抽象問題,繼而解決實際的問題。這裡的抽象方式就是:「封裝」、「繼承」、「多態」!而無論是基於類的實現、還是基於類型的實現、還是基於函數或方法的,都是抽象的具體實現。
現在再回到問題:當我們說「面向XX編程時,我們實際在說什麼呢」?
實際就是,使用不同的抽象方式,來解決問題。
抽象方式:只是不同,沒有優劣
無論是面向對象編程還是函數式編程亦或面向過程編程,只是抽象方式的差異,而抽象方式的不同導致瞭解決問題方式的差異。
- 面向對象將現實抽象為一個個的對象,以及對象間的通信來解決問題。
- 函數式編程將現實抽象為有限的數據結構,以及一個個的對這些數據結構進行操作的函數,通過函數對數據結構的操作,以及函數間的調用來解決問題。
- 面向過程編程將現實抽象為一個個數據結構和過程方法,通過方法組合調用以及對數據結構的操作來解決問題。
每種抽象方式都既有優點也有缺點。沒有完美的抽象方法。
例如,對於面向對象來說,可以很方便的自定義類,也就是增加類型,但是很難在不修改已定義代碼的前提下,為既有的具體類實現一套既有的抽象方法(稱為表達式問題)。而相對的,函數式編程可以很方便的增加操作(也就是函數),但是很難增加一個適應各種既有操作的類型。
舉個例子,在Java裡,String這個類在1.6之前是沒有isEmpty這個方法的,如果我們想判斷一個字符串是否為空,我們只能使用工具類或者就是等著官方提供,而理想的方法應該是“abc”.isEmpty()。雖然現代化的語言都提供了各種手段來解決這個問題,像Ruby這類動態語言可以通過猴子補丁來實現;Scala可以通過隱式轉換實現;Kotlin可以通過intern方法實現。但這本身是面向對象這種抽象方式所需要面對的問題。
對於函數式語言來說,比如上面提到的Clojure,它如果要新增一個類似的函數,直接編寫一個對應的函數就可以了,因為它的數據結構都實現了統一的接口:Collection,Sequence,Associative,Indexed,Stack,Set,Sorted等。這使得一組函數可以應用到Set,List,Vector,Map。但是相應的,如果你要增加一個數據結構,那就需要實現上面所有的接口,難度可想而知了。
抽象程度與維護成本正相關
面向對象相較於其它抽象方式的優勢可能就是粒度相對較大,相對的較易理解。
這就像組裝電腦一樣:
- 面向對象就像是將電腦拆分成了主板、CPU、顯卡、機箱等,你稍微學一學就可以組裝了。
- 而函數式編程就像將電腦拆成了一個個的元器件。你既需要學習相關知識,還得將這些元器件組裝起來。難度可想而知了。但是組合方式和自由度則比面向對象好得多。
抽象度越高,也就越難理解。但是抽象度越高,適應性就越強,代碼相對就越簡潔。
抽象程度越高,相應的抽象粒度就更細。抽象粒度越細,靈活性就更好,但也導致了維護難度越大。
總結
編程在思想!編程範式只是輔助你思考的工具!不要被編程範式所限制!你需要考慮的是「我該如何使用XX編程範式實現XXX?」,而不是「XX編程範式能實現什麼?」
每種抽象方式都有各自的優缺點。為了取長補短,每種編程範式都有自己的最佳實踐。這些最佳實踐被收集整理,成為了套路或模式。
例如面向對象裡的:
- 複用代碼優先考慮組合而不是繼承
- 為多態而繼承
- 設計原則
- 23種設計模式
- ...
參考資料
- 架構整潔之道
- Wiki:https://zh.wikipedia.org/wiki/%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1