结构良好的逻辑:Golang OOP 教程
已发表: 2022-03-11Go 是面向对象的吗? 是真的吗? 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 合作伙伴,Toptal 的 Google 认证专家可根据公司最重要项目的需求提供给他们。