結構良好的邏輯:Golang OOP 教程

已發表: 2022-03-11

Go 是面向對象的嗎? 是真的嗎? Go(或“Golang”)是一種後 OOP 編程語言,它藉鑑了 Algol/Pascal/Modula 語言家族的結構(包、類型、函數)。 儘管如此,在 Go 中,面向對象的模式對於以清晰易懂的方式構建程序仍然很有用。 本 Golang 教程將採用一個簡單的示例,並演示如何將綁定函數的概念應用於類型(也稱為類)、構造函數、子類型、多態性、依賴注入和使用 mock 進行測試。

Golang OOP 中的案例研究:從車輛識別號 (VIN) 中讀取製造商代碼

每輛汽車的唯一車輛識別號除了“運行”(即序列)號之外,還包括有關汽車的信息,例如製造商、生產工廠、汽車型號,以及它是從左側行駛還是從左側行駛?右手邊。

確定製造商代碼的函數可能如下所示:

 package vin func Manufacturer(vin string) string { manufacturer := vin[: 3] // if the last digit of the manufacturer ID is a 9 // the digits 12 to 14 are the second part of the ID if manufacturer[2] == '9' { manufacturer += vin[11: 14] } return manufacturer }

這是一個證明示例 VIN 有效的測試:

 package vin_test import ( "vin-stages/1" "testing" ) const testVIN = "W09000051T2123456" func TestVIN_Manufacturer(t *testing.T) { manufacturer := vin.Manufacturer(testVIN) if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

所以這個函數在給定正確的輸入時可以正常工作,但是它有一些問題:

  • 不能保證輸入字符串是 VIN。
  • 對於短於三個字符的字符串,該函數會導致panic
  • ID 的可選第二部分僅是歐洲 VIN 的一項功能。 對於製造商代碼的第三位數為 9 的美國汽車,該函數將返回錯誤的 ID。

為了解決這些問題,我們將使用面向對象的模式對其進行重構。

Go OOP:將函數綁定到類型

第一個重構是使 VIN 成為自己的類型並將Manufacturer()函數綁定到它。 這使得函數的目的更清晰,並防止粗心的使用。

 package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }

然後我們調整測試並引入無效 VIN 的問題:

 package vin_test import( "vin-stages/2" "testing" ) const ( validVIN = vin.VIN("W0L000051T2123456") invalidVIN = vin.VIN("W0") ) func TestVIN_Manufacturer(t * testing.T) { manufacturer := validVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, validVIN) } invalidVIN.Manufacturer() // panic! }

插入最後一行是為了演示如何在使用Manufacturer()函數時觸發panic 。 在測試之外,這會使正在運行的程序崩潰。

Golang 中的 OOP:使用構造函數

為了避免在處理無效 VIN 時出現panic ,可以向Manufacturer()函數本身添加有效性檢查。 缺點是每次調用Manufacturer()函數時都會進行檢查,並且必須引入錯誤返回值,這使得在沒有中間變量的情況下無法直接使用返回值(例如,地圖鍵)。

更優雅的方法是將有效性檢查放在VIN類型的構造函數中,以便僅針對有效 VIN 調用Manufacturer()函數,而不需要檢查和錯誤處理:

 package vin import "fmt" type VIN string // it is debatable if this func should be named New or NewVIN // but NewVIN is better for greping and leaves room for other // NewXY funcs in the same package func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }

當然,我們為NewVIN函數添加了一個測試。 無效的 VIN 現在被構造函數拒絕:

 package vin_test import ( "vin-stages/3" "testing" ) const ( validVIN = "W0L000051T2123456" invalidVIN = "W0" ) func TestVIN_New(t *testing.T) { _, err := vin.NewVIN(validVIN) if err != nil { t.Errorf("creating valid VIN returned an error: %s", err.Error()) } _, err = vin.NewVIN(invalidVIN) if err == nil { t.Error("creating invalid VIN did not return an error") } } func TestVIN_Manufacturer(t *testing.T) { testVIN, _ := vin.NewVIN(validVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Manufacturer()函數的測試現在可以省略對無效 VIN 的測試,因為它已經被NewVIN構造函數拒絕了。

Go OOP 陷阱:錯誤的多態方式

接下來,我們要區分歐洲和非歐洲 VIN。 一種方法是將 VIN type擴展為一個struct並存儲 VIN 是否是歐洲的,從而相應地增強構造函數:

 type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }

更優雅的解決方案是為歐洲VIN創建一個 VIN 子類型。 在這裡,標誌隱式存儲在類型信息中,非歐洲 VIN 的Manufacturer()函數變得簡潔明了:

 package vin import "fmt" type VIN string func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { return string(v[: 3]) } type EUVIN VIN func NewEUVIN(code string)(EUVIN, error) { // call super constructor v, err := NewVIN(code) // and cast to subtype return EUVIN(v), err } func (v EUVIN) Manufacturer() string { // call manufacturer on supertype manufacturer := VIN(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }

在像 Java 這樣的 OOP 語言中,我們希望子類型EUVIN在指定VIN類型的每個地方都可用。 不幸的是,這在 Golang OOP 中不起作用。

 package vin_test import ( "vin-stages/4" "testing" ) const euSmallVIN = "W09000051T2123456" // this works! func TestVIN_EU_SmallManufacturer(t *testing.T) { testVIN, _ := vin.NewEUVIN(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } // this fails with an error func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // having to cast testVIN already hints something is odd testVINs = append(testVINs, vin.VIN(testVIN)) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }

這種行為可以通過 Go 開發團隊故意選擇不支持非接口類型的動態綁定來解釋。 它使編譯器能夠知道在編譯時將調用哪個函數,並避免動態方法分派的開銷。 這種選擇也阻礙了將繼承用作一般組合模式。 相反,接口是要走的路(請原諒雙關語)。

Golang OOP 成功:正確的多態方式

Go 編譯器在實現聲明的函數(鴨子類型)時將類型視為接口的實現。 因此,為了利用多態性,將VIN類型轉換為通用和歐洲VIN類型實現的接口。 請注意,歐洲 VIN 類型不必是通用 VIN 類型的子類型。

 package vin import "fmt" type VIN interface { Manufacturer() string } type vin string func NewVIN(code string)(vin, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return vin(code), nil } func (v vin) Manufacturer() string { return string(v[: 3]) } type vinEU vin func NewEUVIN(code string)(vinEU, error) { // call super constructor v, err := NewVIN(code) // and cast to own type return vinEU(v), err } func (v vinEU) Manufacturer() string { // call manufacturer on supertype manufacturer := vin(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }

多態性測試現在通過了輕微的修改:

 // this works! func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // now there is no need to cast! testVINs = append(testVINs, testVIN) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }

事實上,這兩種 VIN 類型現在都可以在指定VIN接口的每個地方使用,因為這兩種類型都符合VIN接口定義。

面向對象的 Golang:如何使用依賴注入

最後但同樣重要的是,我們需要確定 VIN 是否為歐洲車輛。 假設我們找到了一個可以為我們提供這些信息的外部 API,並且我們已經為它構建了一個客戶端:

 package vin type VINAPIClient struct { apiURL string apiKey string // ... internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &VINAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns correct value return true }

我們還構建了一個處理 VIN 的服務,尤其是可以創建它們:

 package vin type VINService struct { client *VINAPIClient } type VINServiceConfig struct { APIURL string APIKey string // more configuration values } func NewVINService(config *VINServiceConfig) *VINService { // use config to create the API client apiClient := NewVINAPIClient(config.APIURL, config.APIKey) return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }

正如修改後的測試所示,這可以正常工作:

 func TestVIN_EU_SmallManufacturer(t *testing.T) { service := vin.NewVINService( & vin.VINServiceConfig {}) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

這裡唯一的問題是測試需要與外部 API 的實時連接。 這很不幸,因為 API 可能處於脫機狀態或無法訪問。 此外,調用外部 API 需要時間並且可能會花錢。

由於 API 調用的結果是已知的,因此應該可以將其替換為 mock。 不幸的是,在上面的代碼中, VINService本身創建了 API 客戶端,因此沒有簡單的方法可以替換它。 為了實現這一點,API 客戶端依賴項應該被注入到VINService中。 也就是說,它應該在調用VINService構造函數之前創建。

這裡的 Golang OOP 指南是沒有構造函數應該調用另一個構造函數。 如果這被徹底應用,應用程序中使用的每個單例都將在最頂層創建。 通常,這將是一個引導函數,它通過以適當的順序調用它們的構造函數來創建所有需要的對象,為程序的預期功能選擇合適的實現。

第一步是讓VINAPIClient成為一個接口:

 package vin type VINAPIClient interface { IsEuropean(code string) bool } type vinAPIClient struct { apiURL string apiKey string // .. internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &vinAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns something more useful return true }

然後,可以將新客戶端注入到VINService

 package vin type VINService struct { client VINAPIClient } type VINServiceConfig struct { // more configuration values } func NewVINService(config *VINServiceConfig, apiClient VINAPIClient) *VINService { // apiClient is created elsewhere and injected here return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }

有了它,現在可以使用 API 客戶端模擬進行測試。 除了避免在測試期間調用外部 API 之外,mock 還可以充當探針來收集有關 API 使用情況的數據。 在下面的示例中,我們只檢查是否實際調用了IsEuropean函數。

 package vin_test import ( "vin-stages/5" "testing" ) const euSmallVIN = "W09000051T2123456" type mockAPIClient struct { apiCalls int } func NewMockAPIClient() *mockAPIClient { return &mockAPIClient {} } func (client *mockAPIClient) IsEuropean(code string) bool { client.apiCalls++ return true } func TestVIN_EU_SmallManufacturer(t *testing.T) { apiClient := NewMockAPIClient() service := vin.NewVINService( & vin.VINServiceConfig {}, apiClient) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } if apiClient.apiCalls != 1 { t.Errorf("unexpected number of API calls: %d", apiClient.apiCalls) } }

這個測試通過了,因為我們的IsEuropean探針在調用CreateFromCode期間運行了一次。

Go 中的面向對象編程:一個成功的組合(如果做得好)

批評者可能會說,“如果你仍然在做 OOP,為什麼不使用 Java?” 好吧,因為您獲得了 Go 的所有其他漂亮優勢,同時避免了資源匱乏的 VM/JIT、帶有註釋 voodoo 的該死框架、異常處理和運行測試時的茶歇(後者可能對某些人來說是個問題)。

通過上面的示例,與簡單的命令式實現相比,在 Go 中進行面向對象編程如何生成更易於理解和更快運行的代碼是很清楚的。 儘管 Go 並不是一種 OOP 語言,但它提供了以面向對象的方式構建應用程序所需的工具。 與包中的功能分組一起,Golang 中的 OOP 可以用來提供可重用的模塊作為大型應用程序的構建塊。


Google Cloud 合作夥伴徽章。

作為 Google Cloud 合作夥伴,Toptal 的 Google 認證專家可根據公司最重要項目的需求提供給他們。