# 透過Golang實踐SOLID ## 前言 在一般`OOP(Object Oriented Programming - 物件導向)`語言中經常透過遵守SOLID原則來提升軟體的擴充性及降低維護成本。 但在非OOP體系的語言底下,由於各類語言本身的特質經常會面臨一些先天上的限制(如javascript中並不支援interface、C裡面並不提供class等)使得SOLID在實踐時往往得多動一些巧思。 本文將會分析Golang在Structural Typing方面的特性、在Golang上實踐SOLID時會遇到的問題以及相對應的解決方法。 ## Golang - Structural Typing ### 定義 在Golang中並沒有class的存在,取而代之的是 – `Struct` 一種可以自定義內容及添加方法的類別。 在一定程度上Golang中的struct和C的struct是相仿的,但Golang提供了更簡明的語法去定義他。 ```go= // struct example type foo struct { bar int } func (f foo) print() { // some code to print foo } ``` 上述的例子中struct foo定義了int member: bar以及method: Print,在沒有class的Golang中struct是非常重要的存在,舉凡對商業邏輯的抽象化、資料結構的包裝等隨處可見struct的蹤影。 但其中令struct與class截然不同的關鍵便是–struct並沒有繼承的概念及功能,這是在使用OOP為基準透過Golang開發時第一個也是最大的一個問題。 ### Type Embedding 「繼承(inheritance)」,在大多數語言中都擁有的一項功能在Golang中卻不見蹤影,在Golang中並沒有任何上對下形式的繼承,意即不會存在父struct、子struct這類東西。 而相應的在Golang中提供了「Type Embedding」將struct們組合在一起的機制。 ```go= type Sofa struct { width float32 high float32 length float32 } type (s Sofa) Ratate() { /* some code to rotate the sofa */} type House struct { mainSofa Sofa } ``` 上例中,可以看到`House`struct中存在mainSofa這個成員,而其形態是`Sofa`。 透過這種機制,Golang可以將各種不同的struct揉合起來借此達到提高重用性、降低重複片段的目的,同時又能從那惱人的繼承關係中解放出來(但依然要注意相互embedding的問題)。 又或者,也可以透過下列的例子做到近似於繼承的Type Embedding: ```go= type Animal struct { leg int } type Cat struct { Animal } ``` 上例中`Animal`具備member`leg`而Cat則將Animal直接嵌入(並且不做任何命名),此時Golang會將Animal的名稱自動設定為Cat存取Animal的key(即透過`Cat.Animal`可以存取Animal),與此同時Golang提供一種syntax sugar讓使用者可以不經由key直接在Cat上存取Animal內的所有member/method(即`Cat.leg`等價於`Cat.Animal.leg`)。這種作法讓Golang的Type Embedding和繼承極為相似,但要注意的是這僅僅只是syntax sugar,事實上`leg`還是由`Cat.Animal`所擁有。 ### 資料封裝 和其他擁有有private、public等關鍵字的語言不同,Golang中絲毫不見類似關鍵字的蹤影,而取而代之的是一種隱含又巧妙的定義方式 – __首字母大小寫__。 ```go= // Encapsulation in GO type foo struct { privateBar int PublicBar int } // ``` 上述的例子中struct foo有兩個member: `privateBar`及`PublicBar`;在Golang中無論是function, struct, member的export與否都透過首子母的大小寫來指定,如上例的`foo struct`是private struct;而foo之中的`PublicBar`是public member以此類推。 但要注意的是和其他語言不同,Golang中的private並非限定只能在struct內存取,而是限定在**相同package**下才能存取。 ### 介面 在Java, C++等經典的OOP語言中,介面(interface)往往扮演了一種描述類別(class)的角色,從規範類別應該提供的方法(method)到類別應該擁有的成員(member)等,清楚的描述了擁有怎麼樣內容的類別才符合該介面。 而Golang中依然保有介面的存在,在SOLID的實踐中介面通常都扮演著很重要的角色,這點在Golang中也相同。 ```go= // struct example type myInterface interface { Print(string) Hello() string member int // not works in golang! } ``` 如同上例中的`myInterface`要求符合的struct必須提供`Print(string)`及`Hello() string`方法。 但在Golang中並不強烈追求struct作為抽象化的結果存在,很多時候struct是作為一種單純的資料載體,在這樣的設計前提下Golang的介面只規範了struct應該提供的方法,__並不要求struct必須擁有哪些成員__,這也是Golang與OOP語言間的一大區別。 ## SOLID SOLID是軟體開發領域中用以提升擴充性及降低維護成本的五大原則的首字母縮寫。 分別是: * Single responsibility principle(單一職責原則) * Open/Closed principle(開放封閉原則) * Liskov Substitution principle(里氏替換原則) * Interface-segregation principles(介面隔離原則) * Dependency Inversion Principle(依賴反轉原則) 其中`單一職責原則`、`開放封閉原則`是不論任何類型的語言都能遵守的原則。 而`里氏替換原則`、`介面隔離原則`、`依賴反轉原則`則是基於OOP語言所提出的原則,但即使如此也能透過對語言的理解在一定程度上遵守各個原則所提倡的要點,並透過遵守SOLID來達到提升穩定性/可擴展性。 ### Single responsibility principle(單一職責原則) __單一職責原則__(以下簡稱`SRP`),如同字面意思它代表著合格的「對象」只會有一個「職責」,這裡的對象根據顆粒度大小的不同可以是`package`、`class`甚至是`function`。 而所謂的職責則定義為:「一個對象只會有一個改變的原因」,以下例來說: ```graphviz digraph G { rankdir="BT" node [ shape = "record" ] Car [ label = "{車輛|+ 載客() : void\l+ 行駛() : void \l+ 減免() : void \l}" ] 一般駕駛 -> Car [arrowhead=onormal] 營業駕駛 -> Car [arrowhead=onormal] } ``` 一般駕駛以及營業駕駛都依賴在車輛這個類別(class)上,在這種情況下可能會因為載客金額的計算公式需要調整使得依賴在車輛上的一般駕駛有意料外的狀況發生(例如內部成員的汙染等)。 像這樣高耦合的設計會使得維護時可能發生牽一髮而動全身的情況,於是我們可以將車輛做切割,明確的定義出自用車及營業車如下: ```graphviz digraph G { rankdir="BT" node [ shape = "record" ] 營業車輛 [ label = "{營業車輛|+ 載客() : void\l+ 行駛() : void\l + 減免() : void\l}" ] Car [ label = "{\<\<interface\>\>\n車輛|+ 行駛() : void }" ] 一般車輛 [ label = "{一般車輛|+ 行駛() : void\l}" ] 營業車輛 -> Car [arrowhead=onormal style=dotted] 一般車輛 -> Car [arrowhead=onormal style=dotted] 一般駕駛 -> 一般車輛 [arrowhead=open] 營業駕駛 -> 營業車輛 [arrowhead=open] } ``` 透過職責的單一化,可以降低類別的耦合度並提高可維護度,而在Golang當中`SRP`的實踐相對單純,和OOP語言相比只是拆分的對象從類別換成struct,並且不需要擔心繼承的父類別本身具有高耦合度,如下例: ```go= type Vehicle interface { drive() } type BusinessVehicle struct { basePrice float32 } func (BussinessVehicle) serve() { /* ... */} func (BussinessVehicle) drive() { /* ... */} type TaxiDriver struct { vehicle BussinessVehicle } ``` 上述的例子將關於車輛例子中營業車輛的部分以Golang的方式表現出來,`TaxiDriver`embedding了`BussinessVehicle`,BussinessVehicle是基於介面Vehicle的實作並加上了可以用於營業的方法`serve`,和class diagram相比其實並沒有太大的差異,Golang作為非OOP語言的影響在SRP方面並不會被展現。 但依然需要注意embedding對象是否遵守SRP,沒有什麼事情比embedding一個職責複雜、體積龐大的又高耦合的struct更令人害怕的了。 然而,SRP在概念上雖然簡單實際上並不是那麼容易實踐的原則,其原因在於**職責的劃分**。 將職責劃分得太細容易導致過分切割使得類別過於複雜,此外對於對象的命名也容易影響到閱讀者對職責的理解。 ### Open/Closed principle(開放封閉原則) 開放封閉原則(以下簡稱為OCP)指的是「對象」(和SRP一樣,對象可以是package、class或function)應該要對「擴充開放,對修改封閉」,這代表著我們應該要可以在不修改對象的前提下改變他的行為(例如修改部分非核心的商業邏輯、添加新的功能等),而唯一允許修改對象的時機則是對象本身具有bug時。 ```go= type Cart struct { items []Item } type Item struct { Price float32 } func (c Cart) Checkout() float32 { var sum float32 for _, item := range c.items { sum += item.Price } return sum } ``` 上面例子中,我們有一個購物車struct:`Cart`以及商品struct:`Item`。其中Cart可以容納多個item並提供方法`Checkout`以達到結帳的功能,在這樣的設計下如果我們想讓Item加入折扣、買一送一等促銷功能就必須修改Item以及Cart,這明顯違反了OCP(因為Checkout function並沒有任何bug,只是需要添加功能),於是我們可以將Item重新抽象化: ```go= type Cart struct { items []Item } type Item interface { Price() float32 } type NormalItem struct { price float32 } type DiscountItem struct { NormalItem discount float32 } func (n NormalItem) Price() float32 { return n.price } func (d DiscountItem) Price() float32 { return d.price * d.discount } func (c Cart) Checkout() float32 { var sum float32 for _, item := range c.items { sum += item.Price() } return sum } ``` 上面例子中我們重新定義了介面Item,規範了只要具備Price方法的struct都可以視為Item。 之後我們便可以透過實作各種不同的Item來讓Cart可以加總各種促銷類型商品的總和,如同例子中的`NormalItem`及`DiscountItem`。 而Golang實踐OCP和OOP語言最大的差別在於一般OOP語言實踐時往往透過繼承的方式,在最小限度下新增需要的片段或是透過override機制修改指定的method來擴展新的功能。此外使用繼承的方式還能提高程式碼的重用度。但Golang並不存在繼承的功能,此時只能透過更清楚的劃分各個struct的職責以及適當的抽象化來提高程式碼的重用度。 和SRP類似,OCP著重在架構上的設計而非類別/介面的設計,也因此會有伴隨而來的問題「過早優化」。在實踐OCP時必須注意的第一要點是「設計的對象有無擴展的需求」,如果在商業邏輯、需求都不明朗的開發初期便著手重構/優化程式碼可能會使得沒有擴展需求的區塊也受到OCP的洗禮,最終產出一個具備大量破碎型別、結構複雜的成品;過早的實踐OCP不但對品質沒有幫助,甚至會造成維護的負擔。 ### Liskov Substitution principle(里氏替換原則) 里氏替換原則(以下簡稱LSP):「任何父類別都應該可以被他的子類別替代,且功能不受影響」,在OOP語言中繼承的特性通常會伴隨著高耦合的結構,如果每次繼承時都任意override、overload父類別方法並不考慮方法的行為是否與父類別相符的話很可能產生隱藏的bug。 ```graphviz digraph G { rankdir="LR" node [ shape = "record" ] 正方形 -> 矩形 [arrowhead=onormal] } ``` 上方的class diagram中「長方形」是「正方形」的子類別,讓我們用Golang來表現這種繼承的關係。 ```go= type Rectangle struct { width int height int } func (r *Rectangle) SetWidth(input int) { r.width = input } func (r *Rectangle) SetHeight(input int) { r.height = input } type Square struct { Rectangle // !!! It ISN'T inherit !!! // It's syntax sugar of type embedding. } func (s *Square) SetWidth(input int) { s.width = input s.height = input } func (s *Square) SetHeight(input int) { s.width = input s.height = input } ``` 在例子中,`Square`與`Rectangle`都具備了`height`、`width`以及其Setter,但我們會發現在Square override Rectangle的Setter之後原本的行為發生了巨大的改變,原本互不相干的width及height被新的setter限制在了相同的值;這樣行為上的差異可能會導致引用的程式發生bug甚至是崩潰。 但有趣的是,上述例子在Golang中有一個致命的缺陷「繼承」。如同前文所述Golang中並不存在繼承,這代表著下方的code對於Golang來說是不會放行的。 ```go= type Rectangle struct {} type Square struct { Rectangle } func Area(input Rectangle) float32 { /* some code to get area */ } func main() { Area(Rectangle{}) // pass Area(Square{}) // error: can't not use type Square as type Rectangle } ``` 在Golang中的Type embedding即使有Syntax sugar輔助但他們依然不具備繼承關係,Square並不會被視為Rectangle的一種,也因此LSP在Golang中其實是沒有對象可以實踐的,但可以轉換成以下形式:「任何struct都該可以被其他符合相同介面的struct替換」。 前文中有提到,Golang的介面相較於其他語言的特殊之處在於Golang的介面**不規範成員,僅規範方法**,這代表著對Golang而言struct內部的結構應該交給struct自行管理,一但透過了介面將方法暴露出去就該讓**外部只憑靠方法也能正常運作**。 ```go= type Rectangle interface { Width() int Height() int SetHeight(int) SetWidth(int) } func Area(input Rectangle) int { return input.Width() * input.Height() } ``` 如同上方的片段所述,透過`Rectangle`規範了Height、Width的getter及setter,在這種設計下無論任何struct要遵守的只有簡單的規範,無須擔心struct內部的結構以及引用方和struct內部的相依。 LSP的實踐在Golang大概是最特別也最容易實踐的存在,語言特性的關係使得Golang不需要擔心父子類別的行為是否產生變異,只需根據介面規範開發即可。 ### Interface-segregation principles(介面隔離原則) 介面隔離原則(以下簡稱ISP)如同他的命名和介面息息相關,他表達的是「任何人都不應該依賴於他不使用的方法」,簡言之我們應該避免創造出巨大的介面怪獸而是將其拆分為具體且更加嬌小的介面,這樣可以有效的降低系統的耦合度並降低維護成本。 ```go= type InterfaceMonster interface { Fly() Swim() Run() Clim() Jump() Hibernate() } func AirRace(animal InterfaceMonster) { /* some code to race */ } ``` 上例中我們創造了一隻介面怪獸`InterfaceMonster`,他規範了合格的對象要會飛會跑會游泳等,但我們的引用對象`AirRace`只需要會飛的動物皆可參加。這時過大的介面就會帶來負擔,沒有人會也沒有人想實作一隻會飛的大象(小飛象?)或者會冬眠的獅子,這時我們應該根據需求拆分我們的介面。 ```go= type SwimmableAnimal interface { Swim() } type FlyableAnimal interface { Fly() } // ect... func AirRace(animal FlyableAnimal) { /* some code to race */ } func SwimmingRace(animal SwimmableAnimal) { /* some code to race */ } // ect... ``` 修改後一切都合理了,不會游泳的鳥與不會飛的海豚都能參加他們擅長的比賽項目了。 ISP相較其他原則而言是比較直觀且容易理解的原則,且沒有太大風險不需要考慮實踐的時機點(和SRP、OCP不同)但要注意不要將介面過度切分,適當的依照需求定義介面才不會使得ISP走味。 ### Dependency Inversion Principle(依賴反轉原則) 依賴反轉原則(以下簡稱DIP)主要闡述的有兩大要點: * 高層級的模組不應該依賴於低層級的模組,兩者都應該依賴於介面。 * 介面不應該依賴於實體(Class, Struct),而是實體應該依賴於介面。 上述兩項要件其實在闡述的是同一件事「任何依賴關係中都不該有任何人依賴於實體」,無論是「實體依賴於實體」或是「介面依賴於實體」都不該發生。 接下來,先讓我們透過下例了解在依賴關係中的層級高低該如何定義。 ```graphviz digraph G { rankdir="LR" compound=true; ranksep=1 node [ shape = "record" ] subgraph cluster_A { label="Package A" node[shape=tab] "Vehicle" } subgraph cluster_B { label="Package B" node[shape=tab] "Engine" } "Vehicle" -> "Engine" } ``` 在依賴關係中,依賴人的一方為高層級,被依賴的則為低層級。故上例中`Vehicle`為層級高的模組,而`Engine`則為層級低的模組。 理解完依賴層級後,讓我們透過上例來探討DIP。我們可以看到`Vehicle`實實在在的依賴於`Engine`,兩者皆是「實體」而非「介面」,這就是標準的「高層級模組依賴低層級模組的現象」。這種依賴關係會造成Vehicle過度依賴Engine的所有細節,從接口到成員任何關於Engine的細節變動都可能影響Vehicle,進而導致不可預知的Bug並很大幅度的加重維護的負擔。 讓我們把上例修改成符合DIP的形式: ```graphviz digraph G { rankdir="TD" compound=true; ranksep=1 node [ shape = "record" ] subgraph cluster_A { label="Package A" node[shape=tab] "Vehicle" "<<interface>>\nEngine" } subgraph cluster_B { label="Package B" node[shape=tab] "V8 Engine" "ZR Engine" } "Vehicle" -> "<<interface>>\nEngine" "V8 Engine" -> "<<interface>>\nEngine" [arrowhead=onormal style=dotted] "ZR Engine" -> "<<interface>>\nEngine" [arrowhead=onormal style=dotted] } ``` 現在我們成功讓Vehicle依賴於介面`Engine`,並有了新的Engine實作`ZR Engine`及`V8 Engine`,這代表著我們的Vehicle不用再擔心Engine的實作細節有所更動(因為它變成了介面),只需要應用Engine暴露出來的方法,透過這種方式即使今天臨時想要更換其他的Engine實作也能確保Vehicle運作順暢。 像這樣將「高層級模組依賴低層級模組」轉變為「低層級模組依賴高層級介面」的方式就是「依賴反轉」。不但成功將模組從高耦合的依賴關係解放出來,並讓未來需要進行測試時可以透過Mock的方式讓**測試目標單一化**是DIP的最大價值。 有趣的是DIP實踐時往往會伴隨著大量介面的產生,如果在設計介面的同時沒有一併實踐ISP可能會導致往後實作的困難,可以說DIP要和ISP一起實踐才能發揮最大價值。 ## 結語 透過將SOLID完整探討一次不難發現Golang沒有設計成OOP語言的一大原因:比起嘗試透過OOP描述現實事物,Golang更傾向透過合理的struct拆分資料/商業邏輯,並透過介面來描述各個`package`, `struct`之間應該暴露什麼接口、傳遞什麼資料。 這樣做最大的好處是可以避免任何package與package之間、struct與struct之間的過度耦合,保持高穩定性的同時又能具備一定彈性,讓開發者在開發時可以免除掉OOP語言帶來的負擔,並設計出一樣有效又穩定的程式。甚至可以說Golang透過捨棄OOP讓SOLID在實踐上變得更加輕鬆也不為過。
×
Sign in
Email
Password
Forgot password
or
By clicking below, you agree to our
terms of service
.
Sign in via Facebook
Sign in via Twitter
Sign in via GitHub
Sign in via Dropbox
Sign in with Wallet
Wallet (
)
Connect another wallet
New to HackMD?
Sign up