當我們說面向XX編程時,我們實際在說什麼?

面試官:「談談面向對象的特性」

碼農:「封裝」、「繼承」和「多態」

面試官:能具體說一下嗎?

碼農:「封裝」隱藏了某一方法的具體運行步驟,取而代之的是通過消息傳遞機制發送消息。「繼承」即子類繼承父類,子類比原本的類(稱為父類)要更加具體化。這意味著我們只需要將相同的代碼寫一次。而「多態」可以使同一類型的對象對同一消息會做出不同的響應。


上面是一個普通的面試場景。這麼回答是否正確呢?

你有沒有想過何謂「特性」?

「特性」指某事物所特有的性質

那麼問題來了,「封裝」、「繼承」和「多態」是面向對象所特有的嗎

  • 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等於幾?

當我們說面向XX編程時,我們實際在說什麼?

但是對於比較複雜的問題,我們不能直接得到解決方案。例如:雞兔同籠問題,有若干只雞兔同在一個籠子裡,從上面數,有35個頭,從下面數,有94只腳。問籠中各有多少隻雞和兔?

對於這類問題,我們的一般做法就是先對問題進行抽象建模,然後再針對抽象來尋找解決方案。

當我們說面向XX編程時,我們實際在說什麼?

對應到軟件開發來說,對於真實環境的問題,我們先通過編程技術對其抽象建模,然後再解決這些抽象問題,繼而解決實際的問題。這裡的抽象方式就是:「封裝」、「繼承」、「多態」!而無論是基於類的實現、還是基於類型的實現、還是基於函數或方法的,都是抽象的具體實現。

當我們說面向XX編程時,我們實際在說什麼?

現在再回到問題:當我們說「面向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

相關推薦

推薦中...