雖然在上篇文章中,我們通過嘗試性學習探索了 Go 語言中關于面向對象的相關概念,更確切的說是關于封裝的基本概念以及相關實現.
但那還遠遠不夠,不能滿足于一條路,而是應該盡可能地多走幾條路,只有這樣才能為以后可能遇到的問題積攢下來經驗,所以這一節我們將繼續探索封裝.
何為探索性學習

通過現有知識加上思想規則指導不斷猜想假設逐步驗證的學習過程是探索性學習,這樣既有利于我們思考又能加深我們對新知識的理解,何樂而不為?
學習 Go 語言的過程越發覺得吃力,倒不是因為語法晦澀難懂而是因為語法習慣背后蘊藏的思維習慣差異性太大!
Go 語言相對于其他主流的編程語言來說是一種新語言,不僅體現在語法層面更重要的是實現思路的差異性.
尤其是對于已有其他編程經驗的開發者而言,這種體會更加深刻,原本可能覺得理所應當的事情到了 Go 語言這里基本上都變了模樣,很大程度上都換了一種思路去實現,這其實是一件好事,不同的思維碰撞才能促進思考進步,一成不變的話,談何創新發展?
在這里不得不感謝強大的 IDE 開發工具,沒有它我們就不能及時發現錯誤,正是這種快速試錯的體驗才給我們足夠的反饋,運用已有的編程經驗逐步接近 Go 語言編程的真相.
上篇文章中已經確定主線方向,基本上弄清楚了面向對象中的封裝概念以及實現,為了不遺漏任何可能重要的知識點,本文將繼續開放性探索,力爭講解清楚封裝的知識點.
如果這種學習的過程用走迷宮來比喻的話,一條道走到黑這種策略就是算法理論中的深度優先算法.如果邊走邊看,四處觀望周圍的風景就是廣度優先算法.
所以,聰明的你肯定已經猜到了,上文采用的正是深度優先算法而本文則采用廣度優先算法繼續探索封裝對象之旅!
定義結構體
結構體的定義方式只有一種,或者不存在簡化形式嗎?
個人覺得不會不存在簡化形式,當結構體存在多個字段,標準定義方式是合理使用的,但要是字段只有一個,仍然以標準形式定義結構體未免有種殺雞焉用牛刀的感覺.

所謂的結構體只不過是實現封裝的一種手段,當封裝的對象只有一個字段時,這個字段也就不存在字段名或者說這個唯一的字段名應該就可以由編譯器自動定義,因此字段名可以省略.
字段類型肯定是不可或缺的,這么想的話,對于封裝只有一個字段的對象來說,只需要考慮的是這個唯一字段的類型.
基于上述原因,個人覺得是這種猜想是合情合理的,但是按照已有的知識能否實現呢?
簡單起見,暫時先以上篇文章中關于動態數組的結構體聲明為例作為測試案例.

如果一定要從三個字段中選擇一個字段,那只能是保留內部數組,排除其余字段了,同時最終結果上可能實現不了動態數組的功能,語義上會有所欠缺,那就不論語義,只談技術!
由于只保留內部數組,動態數組就變成下面這樣.失去了動態數組的語義,命名上也做了改變,姑且稱之為 MyArray 吧!

很明顯,現在仍然是結構體的標準語法形式,請隨我一起思考一下如何簡化這種形式?
因為這種簡化形式的內部字段只有一個,所以字段名必須省略而字段類型可能不同,因此應該在簡化形式中只保留聲明內部字段類型的部分.

由于多個字段時才需要換行分隔,一個字段自然是不需要換行的,因此大括號也是沒必要存在的,這也是符合 Go 設計中盡可能精簡的情況下保證語義清晰的原則.
當然如果你問我是否真的有這個原則的話,我的回答是可能有也可能沒有.
因為我也不知道,只是近期學習 Go 語言的一種感覺,處處體現了這么一種哲學思想,也不用較真,只是個人看法.
type MyArray struct [10]int
現在這種形式應該可以算是只有一種字段的結構體的簡化形式,struct 語義上指明了 MyArray 是結構體,緊隨后面的 [10]int 語義上表示結構體的類型,整體上就是說 MyArray 結構體的類型是 [10]int .
現在讓我們在編輯器中測試一下,看一看 Go 的編譯會不會報錯,能否驗證我們的猜測呢?

很遺憾,IDE 編輯器告訴我們 [10]int 不合法,必須是類型或類型指針!
可 [10]int 確實是我們需要的類型啊,既然報錯也就是說Go 編譯器不支持這種簡化形式!
個人猜測可能是 struct 關鍵字不支持這種簡化形式,那就去掉這個關鍵字好了!

沒想到真的可以!
至少現在看來 Go 編譯器是支持簡化形式的,至于這種支持的形式和我們預期實現的語義是否一致,暫時還不好說,繼續做實驗探索吧!

通過聲明變量后直接打印,初步證明了我們這種簡化形式是可以正常工作的,輸出結果也是我們定義的內部數組!
接下來看一看能不能對這個所謂的內部數組進行操作呢?
這種簡化形式只有一個字段,只指明了字段的類型,沒有字段名,因而訪問該字段應該直接通過結構體變量訪問,不知道這種猜測是否正確,依舊做實驗來證明.

這一次猜想也得到了驗證,Go 編譯器就是通過結構體變量直接操作內部字段,看來我們離真相更進一步!
先別急著高興,將唯一的字段換成其他類型,多測試幾遍看看是否依然正常?

一番測試后并沒有報錯,很有可能這是 Go 所支持的結構體簡化形式,也和我們的預期一致.
關于結構體屬性的語法規則暫時沒有其他探索的新角度,接下來開始探索結構體的方法.
探索的過程中要盡可能的設身處地思考 Go 語言應該如何設計才能方便使用者,盡可能地把自己想象成 Go 語言的設計者!

結構體的簡化形式下可能并不支持方法,如果真的是這樣的話,這樣做也有一定道理.
首先就語法層面分析,為什么單字段的結構體不支持方法?
還記得我們想要簡化單字段結構體遇到的報錯提示嗎?
type MyArray struct [10]int
如果直接將單字段類型放到 struct 關鍵字后面,Go 編譯器就會報錯,當我們省略 struct 關鍵字時上述報錯自然就消失了.
從Go 編譯器的角度上來講,struct 是系統關鍵字,告訴編譯器只要遇到這個關鍵字就解析成結構體語法,現在沒有遇到 sruct 關鍵字也就意味著不是結構體語法.
這里關鍵字和結構體是一一對應關系,也就是充分必要條件,由關鍵字可以推測到結構體,由結構體也可以推測到關鍵字.
再回來看一看,我們的單字段結構體是怎么定義的呢?
type MyArray [10]int
因為沒有關鍵字 struct ,所以編譯器推斷 MyArray 不是結構體,既然不是結構體,也不能用結構體的接收者函數去定義方法.

所以這種方法就會報錯,由此可見 ,Go 語言如果真的不支持單字段結構體方法也有理可循.
然后我們再從語義的角度上解釋一下為什么不支持方法?
回到探索的初衷,當正在定義的結構體有多個字段時,應該按照標準寫法為每個字段指定字段的名稱和類型.
假如該字段有且只有一個時,再按照標準寫法定義當然可以,但也應該提供更加簡化的寫法.
只有一個字段的結構體,字段名稱是沒有意義的也是不應該出現的,因為完全可以用結構體變量所代替,此時這個結構體唯一有存在價值的就是字段的類型了!
字段類型包括內建類型和用戶自定義結構體類型,不論哪種類型,這種簡化形式的結構體的語義上完全可以由該結構體的字段類型所決定,所以簡化形式的結構體還需要方法嗎?
自然是不需要的!
字段類型可以由字段類型自己定義的,也能確保職責清晰,彼此分離!
綜上,個人覺得即便 Go 真的不支持單字段結構體的方法,背后的設計還是有章可循的,有理可依的!
上文中定義動態數組時,內部使用的數組是靜態數組,現在為了方便繼續探索方法,應該提供重載方法使其支持動態數組.

內部數組 arr 是靜態數組,應該提供可以讓外部調用者初始化指定數組的接口,按照已知的面向對象中關于方法的定義來重載方法.

初次嘗試方法的重載就遇到了問題,報錯提示該方法已聲明,所以說 Go 可能并不支持方法重載,這樣就有點麻煩了.
想要實現類似的功能要么通過定義不同的方法名,要么定義一個非常大的函數,接收最全的參數,再根據調用者參數進行對應的邏輯處理.
用慣了方法的重載,突然發現這種特性在 Go 語言中無法實現,頓時有點沮喪,和其他主流的面向對象語言差異性也太大了吧!
不支持構造函數,不支持方法重載,原來以為理所應當的特性并不理所應當.
還是先冷靜下來想一想,Go 為什么不支持方法重載呢?難不成和構造函數那樣,怕是濫用干脆禁用的邏輯?
因為我不是設計者,無法體會也不想猜測原因,但可以肯定的是,Go 語言是一門全新的語言,有著獨特的設計思路,不與眾人同!
吐槽時間結束,既然上了賊船就得一條道走到黑,不支持方法重載就換個函數名或者按參數名區分.

天啊擼,剛剛解決方法重載問題又冒出數組初始化不能是變量只能是常量表達式?
簡直不可思議!
既然數組初始化長度只是常量表達式,也就無法接收外部傳遞的容量 cap,沒有了容量只能接收長度 len ,而初始化內部數組長度又沒辦法確定了,兩個變量都無法對外暴露!
一切又回到原點,想要實現動態數組的功能只能靠具體的方法中去動態擴容和縮容,不能初始化指定長度了.
這樣的話,關于方法也是一條死路,停止探索.
聲明結構體
結構體定義基本已經探索完畢,除了發現一種單字段結構體的簡化形式外,暫時沒有新的發現.
再次回到使用者的角度上,聲明結構體有沒有其他方式呢?

這是變量的聲明方式,除了這種形式,還記得在學習 Go 的變量時曾經介紹過聲明并初始化變量方式,是否也適用于結構體變量呢?

編譯器沒有報錯,證明這種字面量形式也是適用的,不過空數據結構沒有太大的意義,怎么能初始化對應的結構呢?
和多字段結構體最為相似的數據結構莫過于映射 map 了!
回憶一下 map 如何進行字面量初始化的吧!

模仿這種結構看看能不能對結構體也這么初始化,果然就沒有那么順利!

我還沒定義,你就不行了?
IDE 編輯器提示字段名稱無效,結構體明明就有 len 字段啊,除非是沒有正確識別!
"len" 與 len 是不一樣的吧?
那就去掉雙引號 "" 直接使用字段名進行定義看看.

此時報錯消失了,成功解鎖一種新的隱藏技能.

除了這種指定字段名稱注入方式,能不能不指定字段名稱而是按照字段順序依次初始化?

借助編輯器可以看到確實是按照順序注入的,這樣的話,其實有點意思了,明明不支持構造函數,采用字面量實例化時卻看起來像構造函數的無參,有參數和全參形式?
可以預想到的是,這種全參注入的方式一定是嚴格按照定義順序相匹配的,當參數不全時可能按位插入也可能不支持,真相如何,一試便知!

事實上并不支持這種參數不全的形式,因此個人覺得要么無參要么全參要么指定初始化字段這三種語義上還是比較清楚的.
除了字面量的方式,Go 是否支持創建 slice 或 map 時所使用的 make 函數呢?

看樣子,make 函數并不支持創建結構體,至于為什么不支持,原因就不清楚了,也是個人的一個疑惑點.
既然 make 可以創建 slice ,map 這種內建類型,語義上就是用來創建類型的變量,而結構體也是一種類型,唯一的差別可能就是結構體大多是自定義類型而不是內建類型.
如果我來設計的話,可能會一統天下,因為語義上一致的功能只使用相同的關鍵字.
回到面向對象的傳統編程規范上,一般實例化對象用的是關鍵字 new,而 new 并不是 Go 中的關鍵字.
Go 語言中的函數是一等公民,正如剛才說的 make 也不是關鍵字,同樣是函數.

即便對于同一個目標,Go 也是有著自己的獨到見解!
new 不是以關鍵字形式出現而是以函數的身份登場,初步推測應該也具備實例化對象的能力吧?

難道 new 函數不能實例化對象?為什么報錯說賦值錯誤,難不成姿勢不對?
嚇得我趕緊看一下 new 的文檔注釋.

根據注釋說明,果然是使用姿勢不對,并不像其他的面向對象語言那樣可以重復賦值,Go 不支持這種形式,還是老老實實初始化聲明吧!

既然存在著兩種方式來實例化對象,那么總要看一下有什么區別.


這里簡單解釋下 t.Logf("%[1]T %[1]v", myDynamicArray) 語句是什么意思?
%[1]T 其實是 %T 的變體,%[1]v 也是 %v 的變體,仔細觀察的話就會發現占位符剛好都是同一個變量,這里也就是第一個參數,所以就用 [1] 替代了,再次體現了 Go 語言設計的簡潔性.
下面再舉一個簡單的例子加深印象,看仔細了哦!

%T 是打印變量的類型,應該是類型 type 的縮寫,v 應該是值 value 的縮寫.
解釋清楚了測試代碼的含義,再回頭看看測試結果,發現采用字面量方式得到的變量類型和 new 函數得到的變量類型明顯不同!
具體表現為 _struct.MyDynamicArray {0xc0000560f0 10 10} 是結構體類型,而 *_struct.MyDynamicArray &{0xc000056190 10 10} 是結構體類型的指針類型.
這種差異也是可以預期的差異,也是符合語義的差異.
字面量實例化的對象是值對象,而 new 實例化對象開辟了內存,返回的是實例對象到引用,正如其他編程語言的 new 關鍵字一樣,不是嗎?
既然說到了值對象和引用對象,再說一遍老生常談的問題,函數或者說方法傳遞時應該傳遞哪一種類型?
值傳遞還是引用傳遞
接下來的示例和動態數組并沒有什么關系,簡單起見,新開一個結構體叫做 Employee,順便回顧一下目前學到的封裝知識.

首先測試引用傳遞,這也是結構體常用的傳遞方式,行為表現上和其他的主流編程語言表現一致,方法內的修改會影響調用者的參數.


unsafe.Pointer(&e.Name) 是查看變量的內存地址,可以看出來調用前后的地址是同一個.


調用者發送的內存地址和接收者接收的內存地址不一樣,符合期望,值傳遞都是拷貝變量進行傳遞的嘛!
值類型還是引用類型的區分無需贅述,接下來請關注一個神奇的事情,方法的接收者是值類型,方法的調用者是不是一定要傳遞值類型呢?

方法的調用者分別傳遞值類型和引用類型,兩者均能正常工作,是不是很神奇,好像和方法的定義沒什么關系一樣!


雖然方法的接收者要求的是值類型,調用者傳遞的是值類型還是引用類型均可!

僅僅更改了方法接收者的類型,調用者不用做任何更改,依然可以正常運行!
這樣就很神奇了,方法的接受者不論是值類型還是指針類型,調用者既可以是值類型也可以是指針類型,為什么?
同樣的,基于語義進行分析,方法的設計者和調用者之間可以說是松耦合的,設計者的更改對于調用者來說沒有太大影響,這也就意味著以后設計者覺得用值類型接收參數不好,完全可以直接更改為指針類型而不用通知調用者調整邏輯!
這其實要歸功于 Go 語言到設計者很好的處理了值類型和指針類型的調用方式,不論是值類型還是引用類型,一律使用點操作符 . 調用方法,并不像有的語言指針類型是 -> 或 * 前綴才能調用指針類型的方法.
有所為有所不為,可能正是看到了這兩種調用方式帶來的差異性,Go 全部統一成點操作符了!
雖然形式上兩種調用方式是一樣的,但是設計方法或者函數時到底應該是值類型還是指針類型呢?
這里有三點建議可供參考:
- 如果接收者需要更改調用者的值,只能使用指針類型
- 如果參數本身非常大,拷貝參數比較占用內存,只能用指針類型
- 如果參數本身具有狀態,拷貝參數可能會影響對象的狀態,只能用指針類型
- 如果是內建類型或者比較小的結構體,完全可以忽略拷貝問題,推薦用值類型.
當然,實際情況可能還和業務相關,具體用什么類型還要自行判斷,萬一選用不當也不用擔心,更改一下參數類型就好了也不會影響調用者的代碼邏輯.
封裝后如何訪問
封裝問題基本上講解清楚了,一般來說,封裝之后的結構體不僅是我們自己使用還有可能提供給外界使用,與此同時要保證外界不能隨意修改我們的封裝邏輯,這一部分就涉及到訪問的控制權限了.
Go 語言的訪問級別有兩種,一種是公開的另一種就是私有的,由于沒有繼承特性,也不涉及子類和父類之間訪問權限繼承問題,頓時覺得沒有繼承也不是什么壞事嘛,少了很多易錯的概念!
雖然現在理解起來很簡單,具體實際使用上是否便利還不好判斷.
關于可見性的命名規范如下:
- 名稱一般使用大駝峰命名法即 CamelCase
- 首字母大寫表示公開的 public ,小寫表示私有的 private .
- 上述規則不僅適用于方法,包括結構體,變量和常量等幾乎是 Go 語言的全部.
那么問題了,這里的 public 和 private 是針對誰來說?
Go 語言中的基本結構是包 package,這里的包和目錄有區別,并不像 JAVA 語言那樣包和目錄嚴格相關聯的,這一點對于 Java 小伙伴來說需要特別注意.
包是相關代碼的集合,這些代碼可能存放于不同的目錄文件中,就是通過包 package 的聲明告訴 Go編譯器說:我們是一個家族整體.
如果不同的文件目錄可以聲明在同一個包中,這樣相當于允許家族外遷,只保留姓氏就好.
還是用代碼說話吧,散落在各地的小伙伴能不能有共同的姓氏!


pack.go 源碼文件和 pack_test 測試文件都位于相同的目錄 pack 下且包的聲明也相同都是 pack.
這種情況相當于一家氏族位于一個村落中一起生活,和其他語言到表現一致.
現在試一下這個氏族的一部分人能不能搬到其他村落居住呢?

難不成跨域地域有點大,不支持定義方法嗎?那移動一下使其離 pack 目錄近一點試試看!

還是不行,不能新建子目錄,那么和原來在一個目錄下呢?

只有這樣是可以被標識位結構體的方法的,如果不是方法,完全可以任意存放,這一點就不再演示了,小伙伴可自行測試一下喲!

"github.com/snowdreams1006/learn-go/oop/pack" 是當前文件中導入依賴包路徑,因此調用者能否正常訪問到我們封裝的結構體.
在當前結構體中的屬性被我們設置成了小寫字母開頭,所以不在同一包是無法訪問該屬性的.

封裝后如何擴展
設計者封裝好對象供其他人使用,難免會有疏忽不足之處,此時使用者就需要擴展已存在的結構體了.
如果是面向對象的設計思路,最簡單的實現方式可能就是繼承了,重寫擴展什么的都不在話下,可是 Go 并不這么認為,不支持繼承!
所以剩下的方法就是組合了,這也是學習面向對象時的前人總結的一種經驗: 多用組合少用繼承!
現在想一想,Go 語言不但貫徹了這一思想,更是嚴格執行了,因為 Go 直接取消了繼承特性.


通過自定義結構體內部屬性是 Lang 類型,進而擴展原來 Lang 不具備的方法或者重寫原來的方法.
如果我們的自定義結構體剛好只有這么一個屬性,完全可以使用簡化形式,說到這里其實有必要特別說明一下,專業叫法稱之為別名.

作為設計者和使用者都已經考慮到了,封裝的基本知識也要告一段落了,由于 Go 不支持繼承,也沒必要演示相關代碼,唯一剩下的只有接口了.
雖然 Go 同樣是不支持多態,但是 Go 提供的接口確實與眾不同,別有一番滋味在心頭,下一節將開始探索接口.
關于封裝的復盤
- 定義結構體字段

結構體有多個字段時彼此直接換行,不用逗號也不用分號之類的,不要多此一舉.
- 定義結構體方法

原本是普通的函數,函數名前面加入指向當前結構體的參數時,函數不再是函數而是方法,同時當前結構體參數叫做接收者,類似于其他面向對象語言中的 this 或 self 關鍵字實現的效果.
- 字面量聲明結構體

字面量聲明結構體除了這種類似于有參構造函數使用方式,還有無參和全參構造函數使用方式,這里說的構造函數只是看起來像并不真的是構造函數.
- new 聲明結構體

new 函數和其他主流的編程語言 new 關鍵字類似,用于聲明結構體,不同于字面量聲明方式,new 函數的輸出對象是指針類型.
- 首字母大小寫控制訪問權限
不論是變量名還是方法名,名稱首字母大寫表示公開的,小寫表示私有的.
- 代碼的基本組織單元是包
訪問控制權限也是針對代碼包而言,一個目錄下只有一個代碼包,包名和目錄名沒有必然聯系.
- 復合擴展已有類型

自定義結構體內嵌其他結構體,通過復合而不是繼承的方式實現對已有類型的增強控制,也是一種推薦的編程規范.
- 別名擴展已有類型

別名可以看成單字段結構體的簡化形式,可以用來擴展已存在的結構體類型,也支持方法等特性.
最后,非常感謝你的閱讀,鄙人知識淺薄,如有描述不當的地方,還請各位看官指出,你的每一次留言我都會認真回復,你的轉發就是對我最大的鼓勵!
如果需要查看相關源碼,可以直接訪問 https://github.com/snowdreams1006/learn-go,同時也推薦關注公眾號與我交流.