Lógica bem estruturada: um tutorial de OOP de Golang
Publicados: 2022-03-11O Go é orientado a objetos? Pode ser? Go (ou “Golang”) é uma linguagem de programação pós-OOP que empresta sua estrutura (pacotes, tipos, funções) da família de linguagens Algol/Pascal/Modula. No entanto, em Go, os padrões orientados a objetos ainda são úteis para estruturar um programa de forma clara e compreensível. Este tutorial Golang terá um exemplo simples e demonstrará como aplicar os conceitos de funções de ligação a tipos (classes), construtores, subtipagem, polimorfismo, injeção de dependência e testes com mocks.
Estudo de caso em Golang OOP: lendo o código do fabricante de um número de identificação do veículo (VIN)
O número de identificação do veículo exclusivo de cada carro inclui – além de um número “em execução” (ou seja, de série) – informações sobre o carro, como o fabricante, a fábrica de produção, o modelo do carro e se ele é conduzido do lado esquerdo ou esquerdo. lado direito.
Uma função para determinar o código do fabricante pode ter esta aparência:
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 }
E aqui está um teste que prova que um exemplo de VIN funciona:
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) } }
Portanto, esta função funciona corretamente quando recebe a entrada correta, mas apresenta alguns problemas:
- Não há garantia de que a string de entrada seja um VIN.
- Para strings com menos de três caracteres, a função causa um
panic
. - A segunda parte opcional do ID é um recurso apenas dos VINs europeus. A função retornaria IDs errados para carros dos EUA com 9 como o terceiro dígito do código do fabricante.
Para resolver esses problemas, vamos refatorá-lo usando padrões orientados a objetos.
Go OOP: vinculando funções a um tipo
A primeira refatoração é tornar os VINs seu próprio tipo e vincular a função Manufacturer()
a ele. Isso torna o objetivo da função mais claro e evita o uso imprudente.
package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }
Em seguida, adaptamos o teste e introduzimos o problema de VINs inválidos:
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! }
A última linha foi inserida para demonstrar como acionar um panic
ao usar a função Manufacturer()
. Fora de um teste, isso travaria o programa em execução.
OOP em Golang: usando construtores
Para evitar o panic
ao lidar com um VIN inválido, é possível adicionar verificações de validade à própria função Manufacturer()
. As desvantagens são que as verificações seriam feitas em cada chamada para a função Manufacturer()
, e que um valor de retorno de erro teria que ser introduzido, o que tornaria impossível usar o valor de retorno diretamente sem uma variável intermediária (por exemplo, como uma chave de mapa).
Uma maneira mais elegante é colocar as verificações de validade em um construtor para o tipo VIN
, para que a função Manufacturer()
seja chamada apenas para VINs válidos e não precise de verificações e tratamento de erros:
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) }
Claro, adicionamos um teste para a função NewVIN
. VINs inválidos agora são rejeitados pelo construtor:
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) } }
O teste para a função Manufacturer()
agora pode omitir o teste de um VIN inválido, pois ele já teria sido rejeitado pelo construtor NewVIN
.
Armadilha da OOP: Polimorfismo da Maneira Errada
Em seguida, queremos diferenciar entre VINs europeus e não europeus. Uma abordagem seria estender o type
VIN para um struct
e armazenar se o VIN é europeu ou não, aprimorando o construtor de acordo:
type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }
A solução mais elegante é criar um subtipo de VIN
para VINs europeus. Aqui, o sinalizador é armazenado implicitamente nas informações de tipo, e a função Manufacturer()
para VINs não europeus se torna agradável e concisa:
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 }
Em linguagens OOP como Java, esperamos que o subtipo EUVIN
seja utilizável em todos os lugares onde o tipo VIN
é especificado. Infelizmente, isso não funciona em 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) } } }
Esse comportamento pode ser explicado pela escolha deliberada da equipe de desenvolvimento Go de não dar suporte à vinculação dinâmica para tipos que não são de interface. Ele permite que o compilador saiba qual função será chamada em tempo de compilação e evita a sobrecarga do despacho de método dinâmico. Essa escolha também desencoraja o uso de herança como um padrão de composição geral. Em vez disso, as interfaces são o caminho a percorrer (desculpe o trocadilho).

Sucesso de Golang OOP: polimorfismo da maneira certa
O compilador Go trata um tipo como uma implementação de uma interface quando implementa as funções declaradas (tipagem de pato). Portanto, para fazer uso do polimorfismo, o tipo VIN
é convertido em uma interface que é implementada por um tipo VIN geral e europeu. Observe que não é necessário que o tipo VIN europeu seja um subtipo do geral.
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 }
O teste de polimorfismo agora passa com uma pequena modificação:
// 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) } } }
Na verdade, ambos os tipos de VIN agora podem ser usados em todos os locais que especificam a interface VIN
, pois ambos os tipos estão em conformidade com a definição da interface VIN
.
Golang orientado a objetos: como usar injeção de dependência
Por último, mas não menos importante, precisamos decidir se um VIN é europeu ou não. Vamos supor que encontramos uma API externa que nos fornece essas informações e criamos um cliente para ela:
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 }
Também construímos um serviço que lida com VINs e, principalmente, pode criá-los:
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) }
Isso funciona bem como mostra o teste modificado:
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) } }
O único problema aqui é que o teste requer uma conexão ativa com a API externa. Isso é lamentável, pois a API pode estar offline ou simplesmente inacessível. Além disso, chamar uma API externa leva tempo e pode custar dinheiro.
Como o resultado da chamada da API é conhecido, deve ser possível substituí-lo por um mock. Infelizmente, no código acima, o próprio VINService
cria o cliente da API, portanto, não há uma maneira fácil de substituí-lo. Para tornar isso possível, a dependência do cliente da API deve ser injetada no VINService
. Ou seja, ele deve ser criado antes de chamar o construtor VINService
.
A diretriz Golang OOP aqui é que nenhum construtor deve chamar outro construtor . Se isso for aplicado completamente, cada singleton usado em um aplicativo será criado no nível mais alto. Normalmente, esta será uma função de bootstrapping que cria todos os objetos necessários chamando seus construtores na ordem apropriada, escolhendo uma implementação adequada para a funcionalidade pretendida do programa.
O primeiro passo é fazer do VINAPIClient
uma interface:
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 }
Então, o novo cliente pode ser injetado no 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) }
Com isso, agora é possível usar uma simulação de cliente de API para o teste. Além de evitar chamadas para uma API externa durante os testes, o mock também pode atuar como uma sonda para coletar dados sobre o uso da API. No exemplo abaixo, apenas verificamos se a função IsEuropean
é realmente chamada.
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) } }
Esse teste passa, pois nosso probe IsEuropean
é executado uma vez durante a chamada para CreateFromCode
.
Programação orientada a objetos em Go: uma combinação vencedora (quando bem feita)
Os críticos podem dizer: “Por que não usar Java se você está fazendo OOP de qualquer maneira?” Bem, porque você obtém todas as outras vantagens bacanas do Go, evitando uma VM / JIT com fome de recursos, estruturas malditas com vodu de anotação, tratamento de exceções e pausas para café durante a execução de testes (o último pode ser um problema para alguns).
Com o exemplo acima, fica claro como a programação orientada a objetos em Go pode produzir um código mais compreensível e de execução mais rápida em comparação com uma implementação simples e imperativa. Embora Go não seja uma linguagem OOP, ela fornece as ferramentas necessárias para estruturar um aplicativo de maneira orientada a objetos. Juntamente com as funcionalidades de agrupamento em pacotes, a OOP em Golang pode ser aproveitada para fornecer módulos reutilizáveis como blocos de construção para grandes aplicativos.
Como Google Cloud Partner, os especialistas certificados pelo Google da Toptal estão disponíveis para empresas sob demanda para seus projetos mais importantes.