這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
關於本系列
這個系列首先是關於Go語言實踐的。在項目中實際使用Go語言也有段時間了,一個體會就是不論是官方文檔、圖書還是網路資料,關於Go語言慣用法(idiom)的介紹都比較少,基本只能靠看標準庫原始碼自己琢磨,所以我特別想在這方面有一些收集和總結。
然後這個系列也是關於設計模式的。雖然Go語言不是一門物件導向程式設計語言,但是很多物件導向設計模式所要解決的問題是在程式設計中客觀存在的。不管用什麼語言,總是要面對和解決這些問題的,只是解決的思路和途徑會有所不同。所以我想就以經典的設計模式作為切入點來展開這個系列,畢竟大家對設計模式都很熟悉了,可以避免無中生有想出一些蹩腳的應用情境。
本系列的具體主題會比較靈活,計劃主要包括這些方面的話題:
- Go語言慣用法。
- 設計模式的實現。特別是引入了閉包,協程,DuckType等語言特性後帶來的變化。
- 設計模式思想的探討。會有一些吐槽。
GoF對組合模式的定義是,將對象組合成樹形結構以表示“部分整體”的階層,組合模式使得使用者對單個對象和組合對象的使用具有一致性。
對於這句話我是有異議的,這裡先賣個關子,我們先從實際例子說起。
組合模式的例子大家都見得很多了,比如檔案系統(檔案/檔案夾)、GUI視窗(Frame/Control)、菜單(菜單/功能表項目)等等,我這裡也舉個菜單的例子,不過不是作業系統裡的菜單,是真正的菜單,KFC的……
姑且把KFC裡的食物認為是功能表項目
,一份套餐是菜單
。菜單和功能表項目有一些公有屬性:名字、描述、價格、都能被購買等,所以正如GoF所說,我們需要一致性地使用它們。它們的階層體現在一個菜單裡會包含多個功能表項目或菜單,其價格是所有子項的和。嗯,這個例子其實不是很恰當,不能很好的體現菜單包含菜單的情況,所以我多定義了一個“超值午餐”菜單,其中包含若干個套餐。
用代碼歸納總結一下,最終我們的調用代碼是這樣的:
func main() {menu1 := NewMenu("培根雞腿燕麥堡套餐", "供應時間:09:15--22:44")menu1.Add(NewMenuItem("主食", "培根雞腿燕麥堡1個", 11.5))menu1.Add(NewMenuItem("小吃", "玉米沙拉1份", 5.0))menu1.Add(NewMenuItem("飲料", "九珍果汁飲料1杯", 6.5))menu2 := NewMenu("奧爾良烤雞腿飯套餐", "供應時間:09:15--22:44")menu2.Add(NewMenuItem("主食", "新奧爾良烤雞腿飯1份", 15.0))menu2.Add(NewMenuItem("小吃", "新奧爾良烤翅2塊", 11.0))menu2.Add(NewMenuItem("飲料", "芙蓉薈蔬湯1份", 4.5))all := NewMenu("超值午餐", "周一至周五有售")all.Add(menu1)all.Add(menu2)all.Print()}
得到的輸出如下:
超值午餐, 周一至周五有售, ¥53.50------------------------培根雞腿燕麥堡套餐, 供應時間:09:15--22:44, ¥23.00------------------------ 主食, ¥11.50 -- 培根雞腿燕麥堡1個 小吃, ¥5.00 -- 玉米沙拉1份 飲料, ¥6.50 -- 九珍果汁飲料1杯奧爾良烤雞腿飯套餐, 供應時間:09:15--22:44, ¥30.50------------------------ 主食, ¥15.00 -- 新奧爾良烤雞腿飯1份 小吃, ¥11.00 -- 新奧爾良烤翅2塊 飲料, ¥4.50 -- 芙蓉薈蔬湯1份
物件導向實現
先說明一下:Go語言不是物件導向語言,實際上只有struct而沒有類或對象。但是為了說明方便,後面我會使用類
這個術語來表示struct的定義,用對象
這個術語來表示struct執行個體。
按照慣例,先使用經典的物件導向來分析。首先我們需要定義菜單和功能表項目的抽象基類,這樣使用者就可以只依賴於介面了,於是實現使用上的一致性。
Go語言中沒有繼承,所以我們把抽象基類定義為介面,後面會由菜單和功能表項目實現具體功能:
type MenuComponent interface {Name() stringDescription() stringPrice() float32Print()Add(MenuComponent)Remove(int)Child(int) MenuComponent}
功能表項目的實現:
type MenuItem struct {name stringdescription stringprice float32}func NewMenuItem(name, description string, price float32) MenuComponent {return &MenuItem{name: name,description: description,price: price,}}func (m *MenuItem) Name() string {return m.name}func (m *MenuItem) Description() string {return m.description}func (m *MenuItem) Price() float32 {return m.price}func (m *MenuItem) Print() {fmt.Printf(" %s, ¥%.2f\n", m.name, m.price)fmt.Printf(" -- %s\n", m.description)}func (m *MenuItem) Add(MenuComponent) {panic("not implement")}func (m *MenuItem) Remove(int) {panic("not implement")}func (m *MenuItem) Child(int) MenuComponent {panic("not implement")}
有兩點請留意一下。
- NewMenuItem()建立的是MenuItem,但返回的是抽象的介面MenuComponent。(物件導向中的多態)
- 因為MenuItem是分葉節點,無法提供Add() Remove() Child()這三個方法的實現,所以若被調用會panic。
下面是菜單的實現:
type Menu struct {name stringdescription stringchildren []MenuComponent}func NewMenu(name, description string) MenuComponent {return &Menu{name: name,description: description,}}func (m *Menu) Name() string {return m.name}func (m *Menu) Description() string {return m.description}func (m *Menu) Price() (price float32) {for _, v := range m.children {price += v.Price()}return}func (m *Menu) Print() {fmt.Printf("%s, %s, ¥%.2f\n", m.name, m.description, m.Price())fmt.Println("------------------------")for _, v := range m.children {v.Print()}fmt.Println()}func (m *Menu) Add(c MenuComponent) {m.children = append(m.children, c)}func (m *Menu) Remove(idx int) {m.children = append(m.children[:idx], m.children[idx+1:]...)}func (m *Menu) Child(idx int) MenuComponent {return m.children[idx]}
其中Price()
統計所有子項的Price
後加和,Print()
輸出自身的資訊後依次輸出所有子項的資訊。另注意Remove()
的實現(從slice中刪除一項)。
好,現在針對這份實現思考下面3個問題。
MenuItem
和Menu
中都有name、description這兩個屬性和方法,重複寫兩遍明顯冗餘。如果使用其它任何物件導向語言,這兩個屬性和方法都應該移到基類中實現。可是Go沒有繼承,這可真是坑爹。
- 這裡我們真正實現了使用者一致性訪問了嗎?顯然沒有,當使用者拿到一個
MenuComponent
後,依然要知道其類型後才能正確使用,假如不加判斷在MenuItem
使用Add()
等未實現的方法就會產生panic。類似地,我們大可以把檔案夾/檔案都抽象成“檔案系統節點”,可以讀取名字,可以計算佔用空間,但是一旦我們想往“檔案系統節點”中添加子節點時,還是必須得判斷它到底是不是檔案夾。
- 接著第2條繼續思考:產生某種一致性訪問現象的本質原因是什嗎?一種觀點:
Menu
和MenuItem
某種本質上是(is-a)同一個事物(MenuComponent
),所以可以對它們一致性訪問;另一種觀點:Menu
和MenuItem
是兩個不同的事物,只是恰巧有一些相同的屬性,所以可以對它們一致性訪問。
用組合代替繼承
前面說到Go語言沒有繼承,本來屬於基類的name和description不能放到基類中實現。其實只要轉換一下思路,這個問題是很容易用組合解決的。如果我們認為Menu
和MenuItem
本質上是兩個不同的事物,只是恰巧有(has-a)一些相同的屬性,那麼將相同的屬性抽離出來,再分別組合進兩者,問題就迎刃而解了。
先看抽離出來的屬性:
type MenuDesc struct {name stringdescription string}func (m *MenuDesc) Name() string {return m.name}func (m *MenuDesc) Description() string {return m.description}
改寫MenuItem
:
type MenuItem struct {MenuDescprice float32}func NewMenuItem(name, description string, price float32) MenuComponent {return &MenuItem{MenuDesc: MenuDesc{name: name,description: description,},price: price,}}// ... 方法略 ...
改寫Menu
:
type Menu struct {MenuDescchildren []MenuComponent}func NewMenu(name, description string) MenuComponent {return &Menu{MenuDesc: MenuDesc{name: name,description: description,},}}// ... 方法略 ...
Go語言中善用組合有助於表達資料結構的意圖。特別是當一個比較複雜的對象同時處理幾方面的事情時,將對象拆成獨立的幾個部分再組合到一起,會非常清晰優雅。例如上面的MenuItem
就是描述+價格,Menu
就是描述+子功能表。
其實對於Menu
,更好的做法是把children
和Add()
Remove()
Child()
也提取封裝後再進行組合,這樣Menu
的功能一目瞭然。
type MenuGroup struct {children []MenuComponent}func (m *Menu) Add(c MenuComponent) {m.children = append(m.children, c)}func (m *Menu) Remove(idx int) {m.children = append(m.children[:idx], m.children[idx+1:]...)}func (m *Menu) Child(idx int) MenuComponent {return m.children[idx]}type Menu struct {MenuDescMenuGroup}func NewMenu(name, description string) MenuComponent {return &Menu{MenuDesc: MenuDesc{name: name,description: description,},}}
Go語言的思維方式
以下是本文的重點。使用Go語言開發項目2個多月,最大的感觸就是:學習Go語言一定要轉變思維方式,轉變成功則其樂無窮,不能及時轉變會發現自己處處碰壁。
下面讓我們用真正Go的方式來實現KFC菜單。首先請默念三遍:沒有繼承,沒有繼承,沒有繼承;沒有基類,沒有基類,沒有基類;介面只是函數簽名的集合,介面只是函數簽名的集合,介面只是函數簽名的集合;struct不依賴於介面,struct不依賴於介面,struct不依賴於介面。
好了,與之前不同,現在我們不是先定義介面再具體實現,因為struct不依賴於介面,所以我們直接實現具體功能。先是MenuDesc
和MenuItem
,注意現在NewMenuItem
的傳回值類型是*MenuItem
。
type MenuDesc struct {name stringdescription string}func (m *MenuDesc) Name() string {return m.name}func (m *MenuDesc) Description() string {return m.description}type MenuItem struct {MenuDescprice float32}func NewMenuItem(name, description string, price float32) *MenuItem {return &MenuItem{MenuDesc: MenuDesc{name: name,description: description,},price: price,}}func (m *MenuItem) Price() float32 {return m.price}func (m *MenuItem) Print() {fmt.Printf(" %s, ¥%.2f\n", m.name, m.price)fmt.Printf(" -- %s\n", m.description)}
接下來是MenuGroup
。我們知道MenuGroup
是菜單/功能表項目的集合,其children
的類型是不確定的,於是我們知道這裡需要定義一個介面。又因為MenuGroup
的邏輯是對children
進行增、刪、讀操作,對children
的屬性沒有任何約束和要求,所以我們這裡暫時把介面定義為空白介面interface{}
。
type MenuComponent interface {}type MenuGroup struct {children []MenuComponent}func (m *Menu) Add(c MenuComponent) {m.children = append(m.children, c)}func (m *Menu) Remove(idx int) {m.children = append(m.children[:idx], m.children[idx+1:]...)}func (m *Menu) Child(idx int) MenuComponent {return m.children[idx]}
最後是Menu
的實現:
type Menu struct {MenuDescMenuGroup}func NewMenu(name, description string) *Menu {return &Menu{MenuDesc: MenuDesc{name: name,description: description,},}}func (m *Menu) Price() (price float32) {for _, v := range m.children {price += v.Price()}return}func (m *Menu) Print() {fmt.Printf("%s, %s, ¥%.2f\n", m.name, m.description, m.Price())fmt.Println("------------------------")for _, v := range m.children {v.Print()}fmt.Println()}
在實現Menu
的過程中,我們發現Menu
對其children
實際上有兩個約束:需要有Price()
方法和Print()
方法。於是對MenuComponent
進行修改:
type MenuComponent interface {Price() float32Print()}
最後觀察MenuItem
和Menu
,它們都符合MenuComponent
的約束,所以二者都可以成為Menu
的children
,組合模式大功告成!
比較與思考
前後兩份代碼差異其實很小:
- 第二份實現的介面簡單一些,只有兩個函數。
- New函數傳回值的類型不一樣。
從思路上看,差異很大卻也有些微妙:
- 第一份實現中介面是模板,是struct的藍圖,其屬性來源於事先對系統組件的綜合分析歸納;第二份實現中介面是一份約束聲明,其屬性來源於使用者對被使用者的要求。
- 第一份實現認為
children
中的MenuComponent
是一種具體對象,這個對象具有一系列方法可以調用,只是其方法的功能會由於子類覆蓋而表現不同;第二份實現則認為children
中的MenuComponent
可以是任意無關的對象,唯一的要求是他們“恰巧”實現了介面所指定的約束條件。
注意第一份實現中,MenuComponent
中有Add()
、Remove()
、Child()
三個方法,但卻不一定是可用的,能不能使用由具體對象的類型決定;第二份實現中則不存在這些不安全的方法,因為New函數返回的是具體類型,所以可以調用的方法都是安全的。
另外,從Menu
中取出某個child,其可用方法只有Price()
和Print()
,一樣可以完全安全的調用。如果想在MenuComponent
是Menu
的情況下往其中添加子項呢?很簡單:
if m, ok := all.Child(1).(*Menu); ok {m.Add(NewMenuItem("玩具", "Hello Kitty", 5.0))}
清晰明了,如果某child是一個Menu
,那麼我們可以對其進行Add()
操作。
更進一步,這裡我們對類型的要求其實並沒有那麼強,並不需要它一定要是Menu
,只是需要其提供組合MenuComponent
的功能,所以可以提煉出這樣一個介面:
type Group interface {Add(c MenuComponent)Remove(idx int)Child(idx int) MenuComponent}
前面的添加子項的代碼改成這樣:
if m, ok := all.Child(1).(Group); ok {m.Add(NewMenuItem("玩具", "Hello Kitty", 5.0))}
再考慮一下“購買”這個操作,物件導向的實現中,購買的類型是MenuComponent
,所以購買操作同時可以應用於Menu
和MenuItem
。如果用Go語言的思維方式來考察,可購買對象的唯一要求是有Price()
,所以購買操作的參數是這樣的介面:
type Product interface { Price() float32}
於是購買操作不僅可應用於Menu
和MenuItem
,還可用於任何提供了價格的對象。我們可以任意添加產品,不論是玩具還是會員卡或者優惠券,只要有Price()
方法就可以被購買。
總結
最後總結一下我的思考,歡迎各位討論或抨擊:
- 在組合模式中,一致性訪問是個偽需求。一致性訪問不是我們在設計時需要去滿足的需求,而是當不同實體具有相同屬性時自然產生的效果。上面的例子中,我們建立的是menu和MenuItem兩種不同的類型,但由於它們具有相同屬性,我們能以相同的方式取價格,取描述,加入menu成為子項。
- Go語言中的多態不體現在對象建立階段,而體現在對象使用階段,合理使用“小介面”能顯著減少系統耦合度。
PS. 本文所涉及的三份完整代碼,我放在play.golang.org上了:(需FQ)
- 物件導向實現:http://play.golang.org/p/2DzGhVYseY
- 使用組合:http://play.golang.org/p/KuH2Vu7f9k
- Go語言的思維方式:http://play.golang.org/p/TGjI3CDHD4