Хорошо структурированная логика: учебник Golang OOP
Опубликовано: 2022-03-11Является ли Go объектно-ориентированным? Может ли так быть? Go (или «Голанг») — это пост-ООП язык программирования, который заимствует свою структуру (пакеты, типы, функции) из языковой семьи Алгол/Паскаль/Модула. Тем не менее, в Go объектно-ориентированные шаблоны по-прежнему полезны для ясного и понятного структурирования программы. В этом руководстве по Golang будет рассмотрен простой пример и показано, как применять концепции функций привязки к типам (также называемым классами), конструкторам, подтипам, полиморфизму, внедрению зависимостей и тестированию с помощью макетов.
Практический пример в 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
. - Необязательная вторая часть идентификатора является функцией только европейских VIN. Функция будет возвращать неправильные идентификаторы для автомобилей США, имеющих 9 в качестве третьей цифры кода производителя.
Чтобы решить эти проблемы, мы проведем рефакторинг с использованием объектно-ориентированных шаблонов.
Перейти ООП: привязка функций к типу
Первый рефакторинг заключается в том, чтобы сделать 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! }
Последняя строка была вставлена, чтобы продемонстрировать, как вызвать panic
при использовании функции Manufacturer()
. Вне теста это приведет к сбою работающей программы.
ООП в Golang: использование конструкторов
Чтобы избежать panic
при обработке недопустимого VIN, можно добавить проверки достоверности в саму функцию Manufacturer()
. Недостатки заключаются в том, что проверки будут выполняться при каждом вызове функции Manufacturer()
, и что придется вводить возвращаемое значение ошибки, что сделает невозможным использование возвращаемого значения напрямую без промежуточной переменной (например, как ключ карты).
Более элегантный способ — поместить проверки правильности в конструктор для типа VIN
, чтобы функция Manufacturer()
вызывалась только для действительных VIN и не нуждалась в проверках и обработке ошибок:
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
.
Перейти на ООП-ловушку: неправильный полиморфизм
Далее мы хотим различать европейские и неевропейские VIN. Одним из подходов может быть расширение type
VIN до 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. Здесь флаг неявно сохраняется в информации о типе, и функция Manufacturer()
для неевропейских VIN становится красивой и лаконичной:
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, мы ожидаем, что подтип 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: правильный полиморфизм
Компилятор Go рассматривает тип как реализацию интерфейса, когда он реализует объявленные функции (утиная типизация). Поэтому, чтобы использовать полиморфизм, тип 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 заключается в том, что ни один конструктор не должен вызывать другой конструктор . Если это будет тщательно применено, каждый синглтон, используемый в приложении, будет создан на самом верхнем уровне. Как правило, это будет функция начальной загрузки, которая создает все необходимые объекты, вызывая их конструкторы в соответствующем порядке, выбирая подходящую реализацию для предполагаемой функциональности программы.
Первый шаг — сделать 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: выигрышная комбинация (если все сделано правильно)
Критики могут сказать: «Почему бы не использовать Java, если вы все равно занимаетесь ООП?» Ну, потому что вы получаете все другие изящные преимущества Go, избегая ресурсоемких VM/JIT, проклятых фреймворков с колдовством аннотаций, обработкой исключений и перерывами на кофе во время выполнения тестов (последнее может быть проблемой для некоторых).
Из приведенного выше примера становится ясно, как объектно-ориентированное программирование на Go может создавать более понятный и более быстрый код по сравнению с простой императивной реализацией. Хотя Go не задумывался как ООП-язык, он предоставляет инструменты, необходимые для объектно-ориентированного структурирования приложения. Вместе с группировкой функций в пакеты ООП в Golang можно использовать для предоставления повторно используемых модулей в качестве строительных блоков для больших приложений.
Как партнер Google Cloud, специалисты Toptal, сертифицированные Google, доступны для компаний по запросу для их наиболее важных проектов.