go 學習筆記之go是不是面向對象語言是否支持面對對象編程?

  • 2019 年 10 月 3 日
  • 筆記

go-oop-about-oop-elephant.jpg

面向對象編程風格深受廣大開發者喜歡,尤其是以 C++, Java 為典型代表的編程語言大行其道,十分流行!

有意思的是這兩中語言幾乎毫無意外都來源於 C 語言,卻不同於 C 的面向過程編程,這種面向對象的編程風格給開發者帶來了極大的便利性,解放了勞動,松耦合,高內聚也成為設計的標準,從而讓我們能夠更加愉快地複製粘貼,做代碼的搬運工,很多第三方工具開箱即用,語義明確,職責清晰,這都是面向對象編程的好處!

Go 語言也是來源於 C 語言,不知道你是否也會好奇 Go 語言是否支持面向對象這種編程風格呢?

準確的說,Go 既支持面向對象編程又不是面向對象語言!

是也不是,難道像是薛定諤的貓一樣具有不確定性?

其實這個答案是官方的回答,並不是我個人憑空杜撰而來的,如需了解詳情可參考 Is Go an object-oriented language?

go-oop-about-schrodinger-cat.png

為什麼這麼說呢?

Go 支持封裝,卻不支持繼承和多態,所以嚴格按照面向對象規範來說, Go 語言不是面向對象的編程語言.

但是,Go 提供的接口是一種非常簡單上手且更加通用的方式,雖然和其他主流的編程語言表現形式上略有不同,甚至不能實現多態,但 Go 的接口不僅僅適用於結構體,也可以適用於任何數據類型,這無疑是很靈活的!

爭議性比較大的當屬繼承,由於沒有任何關鍵字支持繼承特性,因此是找不到繼承的痕迹.雖然的確存在着某些方式可以將類型嵌入到其他類型中以實現子類化,但那卻不是真正的繼承.

所以說,Go 既支持面向對象的編程風格又不完全是面向對象的編程語言.

如果換個角度看問題的話,正是由於沒有繼承特性使得Go 相對於面向對象編程語言更加輕量化,不妨想一想繼承的特性,子類和父類的關係,單繼承還是多繼承,訪問控制權限等問題吧!

go-oop-about-go-lovely.png

如果按照面向對象的編程規範,實現封裝特性的那部分應該是類和對象,但這種概念與實現語言的關鍵字class 是密不可分的,然而 Go 並沒有 class 關鍵字而是 C 語言家族的 struct 關鍵字,所以叫做類或對象也不是十分貼切,所以下面的講解過程還是採用結構體吧!

如何定義結構體

stuct 關鍵字聲明結構體,屬性之間回車換行.

比如下面示例中定義了動態數組結構體,接下來的示例中都會以動態數組結構體作為演示對象.

type MyDynamicArray struct {      ptr *[]int      len int      cap int  }

Go 語言中定義對象的多屬性時使用直接換行方式而不是分號來分隔?為什麼和其他主流的編程語言不呢?

對於習慣分號結尾的開發者可能一時並不習慣 Go 的這種語法,於是決定探索一下 Go 的編程規範!

go-oop-about-redundant-semicolon.png

如果手動添加分號的話,編輯器則會提示分號重複,所以猜想是可能是Go編譯器已經自動添加了分號,並將分號作為語句聲明的分隔符,手動添加分號後,Go 不管不顧還是添加了分號,於是就有了上述的報錯.

這樣做有什麼好處呢?

自己添加分號和編譯器無條件添加分號結果不都是一樣的嗎,更何況其他主流的編程語言都是手動添加分號的啊!

存在多個屬性時直接換行而不是添加分號作為分隔符,對於從未接觸過編程語言的小白來說,可能會省事兒,但是對於已有編程經驗的開發者來說,卻需要特別記住不能添加分號,這一點確實有些鬧騰!

如果多個屬性全部寫在一行時,沒有換行符我看你還怎麼區分,此時用逗號分隔還是用分號分隔呢?

go-oop-about-semicolon-or-new-line.png

首先空格肯定是不能分隔多個屬性的,因此嘗試分號或者逗號是否可以.

根據提示說需要分號或者新的換行符,而換行符是標準形式,所以接下來試一下分號能不能分隔?

go-oop-about-semicolon-in-one-line.png

編輯器此時沒有報錯或警告信息,因此在一行上多個屬性之間應該用分號分隔,也就是說 Go 編譯器識別多個屬性仍然是同其他主流的編程語言一樣,使用分號分隔,而開發者卻不能用!

go-oop-about-semicolon-fire-by-official.jpeg

類似於上述的規則記憶很簡單,驗證也比較容易,難點在於理解為什麼?

Go 為什麼會這麼設計?或者說如何理解這種設計思路所代表的語義?

Go 作為一門新的編程語言,不僅體現在具體的語法差異上,更重要的是編程思想的特殊性.

正如面向對象中的接口概念一樣,設計者只需要定義抽象的行為並不用關心行為的具體實現.

如果我們也採用這種思路來理解不同的編程語言,那麼就能透過現象看本質了,否則真的很容易陷入語法細節上,進而可能忽略了背後的核心思想.

其實關於結構體的多屬性分隔符問題上,實際上不論採用什麼作為分隔符都行,哪怕就是一個逗號,句號都行,只要能讓編譯器識別到這是不同的屬性就行.

由於大多數主流的編程語言一般採用分號作為分隔符,開發者需要手動編寫分隔號以供編譯器識別,而 Go 語言卻不這麼認為,算了吧,直接換行,我一樣可以識別出來(儘管底層 Go 編譯器進行編譯時仍然是採用分號表示換行的)!

go-oop-about-semicolon-ninja.png

添加或者不添加分號,對於開發者而言,僅僅是一種分隔多個屬性的標誌而已,如果能不添加就能實現,那為什麼還要添加呢?

go-oop-about-semicolon-ok.png

是什麼,為什麼和怎麼樣是三個基本問題,如果是簡單學習了解的話,學會是什麼和怎麼樣就已經足夠了,但是這樣一來學着學着難免會陷入各自為政的場面,也就是說各個編程語言之間沒有任何關係,每一種語言都是獨立存在的?!

世界語言千千萬,編程語言也不少,學了新語言卻沒有利用舊語言,那學習新語言時和純小白有何差異?

學到是學會了,可惜卻對舊語言沒什麼幫助並沒有加深舊語言的理解,只是單純的學習一種全新的語言罷了.

語言是演變創造出來的,不是空中樓閣,是建立在已有體系下逐漸發展演變而來,任何新語言都能或多或少找到舊語言的影子.

所以何不嘗試一下,弄清楚新語言設計的初衷和以及設計時所面臨的問題,然後再看該語言是如何解決問題的,解決的過程稱之為實現細節,我想這種方式應該是一種比較好的學習方式吧!

雖然無法身處語言設計時環境,也不一定明白語言設計時所面臨的挑戰,但先問嘗試着問一下為什麼,不這麼設計行不行諸如此類的問題,應該是一種不錯的開端.

所以接下來的文章都會採用語義性分析的角度,嘗試理解 Go語言背後的設計初衷,同時以大量的輔助性的測試驗證猜想,不再是簡單的知識羅列整理過程,當然必要的知識歸納還是很重要的,這一點自然也不會放棄.

go-oop-about-go-cheer.png

現在動態數組已經定義完畢,也就是作為設計者的工作暫時告一段落,那作為使用者,如何使用我們的動態數組呢?

按照面向對象的說法,由類創造出對象的過程叫做實例化,然而我們已經知道 Go 並不是完全的面向對象語言,因此為了儘可能避免用面向對象的專業術語去稱呼 Go 的實現細節,我們暫時可以將其理解為結構體類型和結構體變量的關係,以後隨着學習的深入,可能會對這部分有更加深刻的認識.

func TestMyDynamicArray(t *testing.T){      var arr MyDynamicArray        // {<nil> 0 0}      t.Log(arr)  }

上述寫法並沒有特殊強調過,完全是用前幾篇文章中已經介紹過的語法規則實現的,var arr MyDynamicArray 表示聲明類型為 MyDynamicArray 的變量 arr ,此時直接打印該變量的值,得到的是 {<nil> 0 0}.

後兩個值都是 0,自然很好理解,因為在講解 Go 語言中的變量時我們就已經介紹過,Go 的變量類型默認初始化都有相應的零值,int 類型的 len cap 屬性自然就是 0,而 ptr *[]int 是指向數組的指針,所以是 nil.

等等,有點不對勁,這裡有個設計錯誤,明明叫做動態數組結果內部卻是切片,這算怎麼回事?

先修正這個錯誤再說,由此可見,一時粗心影響多麼惡劣以至於語義都變了,容我先改正過來!

go-oop-about-myDynamicArray-array-size-with-cap.png

我們知道要使用數組必須指定數組的初始化長度,第一感覺是使用 cap 表示的容量來初始化 *[cap]int 數組,然而並不可以,編輯器提示說必須使用整型數字.

雖然 capint 類型的變量,但內部數組 [cap]int 並不能識別這種方式,可能是因為這兩個變量時一塊聲明的,cap[cap]int 都是變量,無法分配.

那如果指定初始化長度應該指定多少呢,如果是 0 的話,語義上正確但和實際使用情況不符合,因為這樣一來內部數組根據就沒辦法插入了!

go-oop-about-myDynamicArray-array-size-with-zero.png

所以數組的初始化長度不能為零,這樣解決了無法操作數組的問題,但語義上又不正確了,因此這種情況下需要維護兩個變量 lencap 的值來確保語義和邏輯正確,其中 len 表示真正的數組個數,cap 表示內部數組實際分配的長度,由於這兩個變量至關重要,不應該被調用者隨意修改,最多只能查看變量的值,所以必須提供一種機制保護變量的值.

接下來,我們嘗試用函數封裝的思路來完成這種需求,代碼實現如下:

type MyDynamicArray struct {      ptr *[10]int      len int      cap int  }    func TestMyDynamicArray(t *testing.T){      var myDynamicArray MyDynamicArray        t.Log(myDynamicArray)        myDynamicArray.len = 0      myDynamicArray.cap = 10      var arr [10]int      myDynamicArray.ptr = &arr        t.Log(myDynamicArray)      t.Log(*myDynamicArray.ptr)  }

var myDynamicArray MyDynamicArray 聲明結構體變量後並設置了結構體的基本屬性,然後操作了內部數組,實現了數組的訪問修改.

go-oop-about-myDynamicArray-array-size-with-init.png

然而,我們犯了一個典型的錯誤,調用者不應該關注實現細節,這不是一個封裝該乾的事!

具體實現細節應該由設計者完成,將有關數據封裝成一個整體對外提供相應的接口,這樣調用者才能安全方便地調用.

第一步,先將與內部數組相關的兩個變量進行封裝,對外僅提供訪問接口不提供設置接口,防止調用者隨意修改.

很顯然這部分應該是函數來實現,於是乎有了下面的改造過程.

go-oop-about-myDynamicArray-method-inner.png

很遺憾,編輯器直接報錯: 必須是類型名稱或是指向類型名稱的指針.

函數不可以放在結構體內,這一點倒是像極了 C 家族,但是 Java 這種衍生家族會覺得不可思議,不管怎麼說,這意味着結構體只能定義結構而不能定義行為!

那我們就把函數移動到結構體外部吧,可是我們定義的函數名叫做 len,而系統也有 len 函數,此時能否正常運行呢?讓我們拭目以待,眼見為實.

go-oop-about-myDynamicArray-method-len.png

除了函數本身報錯外,函數內部的 len 也報錯了,是因為此時的函數和結構體尚未建立起任何聯繫,怎麼可能訪問到 len 屬性呢,不報錯才怪呢!

解決這個問題很簡單,直接將結構體的指針傳遞給 len 函數不就好了,這樣一來函數內部就可以訪問到結構體的屬性了.

go-oop-about-myDynamicArray-method-len-with-args.png

從設計的角度上來講,確實解決了函數定義的問題,但是使用者調用函數時的使用方法看起來和面向對象的寫法有些不一樣.

func TestMyDynamicArray(t *testing.T) {      var myDynamicArray MyDynamicArray        t.Log(myDynamicArray)        myDynamicArray.len = 0      myDynamicArray.cap = 10      var arr [10]int      myDynamicArray.ptr = &arr        t.Log(myDynamicArray)      t.Log(*myDynamicArray.ptr)        (*myDynamicArray.ptr)[0] = 1      t.Log(*myDynamicArray.ptr)        t.Log(len(&myDynamicArray))  }

面向對象的方法中一般都是通過點操作符 . 訪問屬性或方法的,而我們實現的屬性訪問是 . 但方法卻是典型的函數調用形式?這看起來明顯不像是方法嘛!

為了讓普通函數看起來像是面向對象中的方法,Go 做了下面的改變,通過將當前結構體的變量聲明移動到函數名前面,從而實現類似於面向對象語言中的 thisself 的效果.

func len(myArr *MyDynamicArray) int {      return myArr.len  }

go-oop-about-myDynamicArray-method-len-ahead-name.png

此時方法名和參數返回值又報錯了,根據提示說函數名和字段名不能相同?

真的又是一件神奇的事情,難不成 Go 無法區分函數和字段?這就不得而知了.

那我們只好修改函數名,改成面向對象中喜聞樂見的方法命名規則,如下:

func (myArr *MyDynamicArray) GetLen() int {      return myArr.len  }

簡單說一下 Go 的訪問性規則,大寫字母開頭的表示公開的 public 權限,小寫字母開頭表示私有的 private 權限,Go 只有這兩類權限,都是針對包 package 而言,以後會再細說,現在先這麼理解就行了.

按照實驗得到的方法規則,繼續完善其他方法,補充 GetCapIsEmpty 等方法.

現在我們已經解決了私有變量的訪問性問題,對於初始化的邏輯還沒有處理,一般來說,初始化邏輯可以放到構造函數中執行,那 Go 是否支持構造函數呢,以及怎麼才能觸發構造函數?

go-oop-about-myDynamicArray-constuct.png

嘗試按照其他主流的編程語言中構造函數的寫法來編寫 Go 的構造函數 , 沒想到 Go 編譯器直接報錯了,提示重新定義了 MyDynamicArray 類型,以至於影響了其餘部分!

如果修改方法名稱的話,理論上可以解決報錯問題,但是這並不是構造函數的樣子了,難不成 Go 不支持構造函數嗎?

此時,面向對象形式的構造函數轉變成自定義函數實現的構造函數,更加準確的說法,這是一種類似於工廠模式實現的構造函數方式.

func NewMyDynamicArray() *MyDynamicArray {      var myDynamicArray MyDynamicArray      return &myDynamicArray  }

難道 Go 語言真的不支持構造函數?

至於是否支持構造函數或者說應該如何支持構造函數,真相不得而知,隨着學習的深入,相信以後一定會有明確的答案,這裡簡單表達一下個人看法.

首先我們知道 Go 的結構體中只能定義數據,而結構體的方法肯定是在結構體外定義的,為了符合面向對象的使用習慣,也就是通過實例對象的點操作符來訪問方法,Go 的方法只能是函數的變體,即普通函數中關於指向結構體變量的聲明部分轉移到函數名前面來實現方法,這種由函數轉變成為方法的模式也符合 Go 一貫的命名規則: 向來是按照人的思維習慣命名,先有輸入再有輸出等邏輯.

結構體的方法從語法和語義的兩個維度上支持了面向對象規範,那麼構造函數想要實現面向對象應該如何做呢?

構造函數正如其名應該是函數,而不是方法,方法由指向自身的參數,這一點構造函數不應該有,否則都有實例對象了還構造毛線啊?

既然構造函數是普通函數,那麼按照面向對象的命名習慣,方法名應該是結構體名,然而真的操作了,編輯器直接就報錯了,所以這不符合面向對象的命名習慣!

如此一來,構造函數的名稱可能並不是結構體類型的名稱,有可能是其他特殊的名稱,最好這個名稱能夠見名知義且具備實例化對象時自動調用的能力.

當然這個名稱依賴於 Go 的設計者如何命名,這裡靠猜測是很難猜對的,否則我就是設計者了啊!

除此之外,還有另外一種可能,那就是 Go 並沒有構造函數,想要實現構造函數的邏輯只能另闢蹊徑.

這麼說有沒有什麼靠譜的依據呢?

我想大概是有的,構造函數雖然提供了自動初始化能力,但是如果真的在構造函數中加入複雜的初始化邏輯,無疑會增大以後出錯的排查難度並給使用者帶來一定的閱讀障礙,所以說一定程度上,構造函數很有可能被濫用了!

那是否就意味着不需要構造函數了呢?

也不能這麼說,構造函數除了基本的變量初始化以及簡單的邏輯外,在實際編程中還是有一定用途的,為了避免濫用而直接禁用,多少有點飲鴆止渴的感覺吧?

因此,個人的看法是應該可以保留構造函數這種初始化邏輯,也可以換一種思路去實現,或者乾脆直接放棄構造函數轉而由編譯器自動實現構造函數,正如編譯器可以自動添加多字段之間的分號那樣.

如果開發者真的有構造函數的需求,通過工廠模式或者單例模式等手段總是可以定製結構體初始化的邏輯,所以放棄也未嘗不可!

最後,以上這些純屬個人猜想,目前並不知道 Go 是否存在構造函數,有了解的人,還請明確告訴我答案,個人傾向於不存在構造函數,最多只提供類似於構造函數初始化的邏輯!

現在,我們已經封裝了結構體的數據,定義了結構體的方法以及實現了結構體的工廠函數.那麼接下來讓我們繼續完善動態數組,實現數組的基本操作.

func NewMyDynamicArray() *MyDynamicArray {      var myDynamicArray MyDynamicArray        myDynamicArray.len = 0      myDynamicArray.cap = 10      var arr [10]int      myDynamicArray.ptr = &arr        return &myDynamicArray  }    func TestMyDynamicArray(t *testing.T) {      myDynamicArray := NewMyDynamicArray()        t.Log(myDynamicArray)  }

首先將測試用例中的邏輯提取到工廠函數中,默認無參的工廠函數初始化的內部數組長度為 10 ,後續再考慮調用者指定以及實現動態數組等功能,暫時先實現最基本的功能.

初始化的內部數組均是零值,因此需要首先提供給外界能夠添加的接口,實現如下:

  func (myArr *MyDynamicArray) Add(index, value int) {      if myArr.len == myArr.cap {          return      }        if index < 0 || index > myArr.len {          return      }        for i := myArr.len - 1; i >= index; i-- {          (*myArr.ptr)[i+1] = (*myArr.ptr)[i]      }        (*myArr.ptr)[index] = value      myArr.len++  }

由於默認的初始化工廠函數暫時是固定長度的數組,因此新增元素其實是操作固定長度的數組,不過這並不妨礙後續實現動態數組部分.

為了操作方便,再提供插入頭部和插入尾部兩種接口,可以基於動態數組實現比較高級的數據結構.

func (myArr *MyDynamicArray) AddLast(value int) {      myArr.Add(myArr.len, value)  }    func (myArr *MyDynamicArray) AddFirst(value int) {      myArr.Add(0, value)  }

為了方便測試動態數組的算法是否正確,因此提供打印方法查看數組結構.

go-oop-about-myDynamicArray-print.png

由此可見,打印方法顯示的數據結構和真實的結構體數據是一樣的,接下來我們就比較有信心繼續封裝動態數組了!

func (myArr *MyDynamicArray) Set(index, value int) {      if index < 0 || index >= myArr.len {          return      }        (*myArr.ptr)[index] = value  }    func (myArr *MyDynamicArray) Get(index int) int {      if index < 0 || index >= myArr.len {          return -1      }        return (*myArr.ptr)[index]  }

這兩個接口更加簡單,更新數組指定索引的元素以及根據索引查詢數組的值.

接下來讓我們開始測試一下動態數組的全部接口吧!

go-oop-about-myDynamicArray-test.png

動態數組暫時告一段落,不知道你是否好奇為什麼以動態數組為例講解面向對象?

其實主要是為了驗證上一篇文章中的猜想,也就是切片和數組的到底是什麼關係?

我覺得切片的底層是數組,只不過語法層面提供了支持以至於看不出數組的影子,仙子阿既然學習了面向對象,那麼就用面向對象的方式實現下切片的功能,雖然無法模擬語法級別的實現,但是功能特性完全是可以模仿的啊!

下面還是梳理總結一下本文的只要知識點吧,也就是封裝的實現.

如何封裝結構體

之所以稱之為結構體是因為 Go 的關鍵字是 struct 而不是 class,也是面向對象編程風格中唯一支持的特性,繼承和多態都不支持,到時候另開文章細說.

結構體是對數據進行封裝所使用的手段,結構體內只能定義數據而不能定義方法,這些數據有時候被稱為字段,有時候叫做屬性或者乾脆叫做變量,至於什麼叫法不是特別重要,如何命名和所處的環境語義有關.

type MyDynamicArray struct {      ptr *[10]int      len int      cap int  }

這種結構體內就有三個變量,變量之間直接換行進行分隔而不是分號並換行的形式,剛開始覺得有些怪,不過編輯器一般都很智能,假如習慣性添加了分號,會提示你進行刪除,所以語法細節上不必在意.

結構體內不支持編寫函數,僅支持數據結構,這樣就意味着數據和行為是分開的,兩者之間的關聯是比較弱的.

func (myArr *MyDynamicArray) IsEmpty() bool {      return myArr.len == 0  }

這種方式的函數和普通函數略有不同,將包含結構體變量的參數提前到函數名前面,語義上也比較明確,表示的是結構體的函數,為了和普通函數有所區別,這種函數稱之為方法.

其實,單純地就實現功能上看,方法和函數並沒有什麼不同,無外乎調用者的使用方式不一樣罷了!

func IsEmpty(myArr *MyDynamicArray) bool {      return myArr.len == 0  }

之所以是這種設計方式,一方面體現了函數的重要性,畢竟是 Go 語言中的一等公民嘛!

另一方面是為了實現面向對象的語法習慣,不論屬性還是方法,統統用點 . 操作符進行調用.

官方的文檔中將這種結構體參數稱之為接收者,因為數據和行為是弱關聯的,這裡的接收者充當的就是關聯數據的作用,接收者顧名思義就是接受數據的人,那發送數據的人又是誰呢?

不言而喻,發送者應該是調用者傳遞的結構體實例對象,結構體變量將數據結構發送給接收者方法,從而數據和行為聯繫在一起了.

func TestMyDynamicArray(t *testing.T) {      myDynamicArray := NewMyDynamicArray()        fmt.Println(myDynamicArray.IsEmpty())  }

好了,以上就是面向對象初體驗中的全部部分,僅僅是這麼一小部分卻耗費我整整三天的時間了,想說換種思維不簡單,寫好一篇文章也不容易啊!

下篇文章中將繼續介紹面向對象的封裝特性,講解更多乾貨,如果覺得本文對你有所幫助,歡迎轉發評論,感覺你的閱讀!

雪之夢技術驛站.png