结构合理的逻辑:Golang OOP教程

本文概述

Go是面向对象的吗?是真的吗? Go(或” Golang”)是一种后OOP编程语言, 它借鉴了Algol / Pascal / Modula语言家族的结构(包, 类型, 函数)。尽管如此, 在Go中, 面向对象的模式对于以清晰易懂的方式构造程序仍然很有用。该Golang教程将以一个简单的示例为例, 并演示如何将绑定函数的概念应用于类型(aka类), 构造函数, 子类型, 多态性, 依赖项注入以及使用模拟进行测试。

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。
  • 对于少于三个字符的字符串, 该函数会引起恐慌。
  • 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()函数时触发紧急情况。在测试之外, 这会使正在运行的程序崩溃。

Golang中的OOP:使用构造函数

为避免在处理无效的VIN时出现恐慌, 可以对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类型扩展为结构, 并存储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调用的结果, 因此应该有可能用模拟代替它。不幸的是, 在上面的代码中, 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外, 该模拟还可以充当收集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合作伙伴, srcmini的Google认证专家可以按需为最重要的项目提供公司服务。

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?