Lógica bien estructurada: un tutorial de Golang OOP

Publicado: 2022-03-11

¿Go está orientado a objetos? ¿Puede ser? Go (o “Golang”) es un lenguaje de programación post-OOP que toma prestada su estructura (paquetes, tipos, funciones) de la familia de lenguajes Algol/Pascal/Modula. Sin embargo, en Go, los patrones orientados a objetos siguen siendo útiles para estructurar un programa de forma clara y comprensible. Este tutorial de Golang tomará un ejemplo simple y demostrará cómo aplicar los conceptos de funciones de enlace a tipos (también conocidos como clases), constructores, subtipado, polimorfismo, inyección de dependencia y pruebas con simulacros.

Estudio de caso en Golang OOP: lectura del código del fabricante a partir de un número de identificación del vehículo (VIN)

El número único de identificación del vehículo de cada automóvil incluye, además de un número "en ejecución" (es decir, de serie), información sobre el automóvil, como el fabricante, la fábrica productora, el modelo del automóvil y si se conduce desde la izquierda o hacia la izquierda. lado derecho.

Una función para determinar el código del fabricante podría verse así:

 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 }

Y aquí hay una prueba que demuestra que un VIN de ejemplo 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) } }

Entonces, esta función funciona correctamente cuando se le da la entrada correcta, pero tiene algunos problemas:

  • No hay garantía de que la cadena de entrada sea un VIN.
  • Para cadenas de menos de tres caracteres, la función genera panic .
  • La segunda parte opcional de la identificación es una característica de los VIN europeos únicamente. La función devolvería identificaciones incorrectas para automóviles estadounidenses que tengan un 9 como tercer dígito del código del fabricante.

Para resolver estos problemas, lo refactorizaremos utilizando patrones orientados a objetos.

Go OOP: vinculación de funciones a un tipo

La primera refactorización es hacer que los VIN sean de su propio tipo y vincular la función Manufacturer() a ellos. Esto hace que el propósito de la función sea más claro y evita el uso irreflexivo.

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

Luego adaptamos la prueba e introducimos el problema de los VIN no vá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! }

La última línea se insertó para demostrar cómo desencadenar un panic al usar la función Manufacturer() . Fuera de una prueba, esto bloquearía el programa en ejecución.

OOP en Golang: uso de constructores

Para evitar el panic al manejar un VIN no válido, es posible agregar controles de validez a la función Manufacturer() . Las desventajas son que las comprobaciones se realizarían en cada llamada a la función Manufacturer() y que se tendría que introducir un valor de retorno de error, lo que haría imposible usar el valor de retorno directamente sin una variable intermedia (por ejemplo, como una clave de mapa).

Una forma más elegante es colocar las verificaciones de validez en un constructor para el tipo de VIN , de modo que la función Manufacturer() se llame solo para VIN válidos y no necesite verificaciones ni manejo de errores:

 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) }

Por supuesto, agregamos una prueba para la función NewVIN . Los VIN no válidos ahora son rechazados por el constructor:

 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) } }

La prueba de la función Manufacturer() ahora puede omitir la prueba de un VIN no válido, ya que el constructor de NewVIN ya lo habría rechazado.

Go OOP Pitfall: polimorfismo de la manera incorrecta

A continuación, queremos diferenciar entre VIN europeos y no europeos. Un enfoque sería extender el type de VIN a una struct y almacenar si el VIN es europeo o no, mejorando el constructor en consecuencia:

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

La solución más elegante es crear un subtipo de VIN para los VIN europeos. Aquí, la bandera se almacena implícitamente en la información de tipo, y la función Manufacturer() para los VIN no europeos se vuelve agradable y 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 }

En lenguajes OOP como Java, esperaríamos que el subtipo EUVIN se pueda usar en todos los lugares donde se especifica el tipo de VIN . Desafortunadamente, esto no funciona en 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) } } }

Este comportamiento puede explicarse por la elección deliberada del equipo de desarrollo de Go de no admitir el enlace dinámico para tipos que no son de interfaz. Permite al compilador saber qué función se llamará en el momento de la compilación y evita la sobrecarga del envío de métodos dinámicos. Esta elección también desaconseja el uso de la herencia como patrón general de composición. En cambio, las interfaces son el camino a seguir (perdón por el juego de palabras).

Éxito de Golang OOP: Polimorfismo de la manera correcta

El compilador de Go trata un tipo como una implementación de una interfaz cuando implementa las funciones declaradas (tipado de pato). Por lo tanto, para hacer uso del polimorfismo, el tipo de VIN se convierte en una interfaz implementada por un tipo de VIN general y europeo. Tenga en cuenta que no es necesario que el tipo de VIN europeo sea un subtipo del general.

 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 }

La prueba de polimorfismo ahora pasa con una ligera modificación:

 // 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) } } }

De hecho, ambos tipos de VIN ahora se pueden usar en cualquier lugar que especifique la interfaz VIN , ya que ambos tipos cumplen con la definición de interfaz VIN .

Golang orientado a objetos: cómo usar la inyección de dependencia

Por último, pero no menos importante, debemos decidir si un VIN es europeo o no. Supongamos que hemos encontrado una API externa que nos brinda esta información y hemos creado un cliente para ella:

 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 }

También hemos construido un servicio que maneja los VIN y, en particular, puede crearlos:

 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) }

Esto funciona bien como muestra la prueba modificada:

 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) } }

El único problema aquí es que la prueba requiere una conexión en vivo a la API externa. Esto es desafortunado, ya que la API podría estar fuera de línea o simplemente no accesible. Además, llamar a una API externa lleva tiempo y puede costar dinero.

Como se conoce el resultado de la llamada API, debería ser posible reemplazarlo con un simulacro. Desafortunadamente, en el código anterior, el propio VINService crea el cliente API, por lo que no hay una manera fácil de reemplazarlo. Para que esto sea posible, la dependencia del cliente API debe inyectarse en VINService . Es decir, debe crearse antes de llamar al constructor VINService .

La pauta de Golang OOP aquí es que ningún constructor debe llamar a otro constructor . Si esto se aplica a fondo, cada singleton utilizado en una aplicación se creará en el nivel más alto. Por lo general, esta será una función de arranque que crea todos los objetos necesarios llamando a sus constructores en el orden apropiado, eligiendo una implementación adecuada para la funcionalidad prevista del programa.

El primer paso es hacer del VINAPIClient una interfaz:

 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 }

Luego, el nuevo cliente se puede inyectar en 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) }

Con eso, ahora es posible usar un simulacro de cliente API para la prueba. Además de evitar llamadas a una API externa durante las pruebas, el simulacro también puede actuar como una sonda para recopilar datos sobre el uso de la API. En el siguiente ejemplo, solo verificamos si realmente se llama a la función 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) } }

Esta prueba pasa, ya que nuestra sonda IsEuropean se ejecuta una vez durante la llamada a CreateFromCode .

Programación orientada a objetos en Go: una combinación ganadora (cuando se hace bien)

Los críticos podrían decir: "¿Por qué no usar Java si estás haciendo programación orientada a objetos de todos modos?" Bueno, porque obtienes todas las otras ingeniosas ventajas de Go mientras evitas una VM/JIT que consume muchos recursos, malditos marcos con anotación vudú, manejo de excepciones y descansos para tomar café mientras ejecutas las pruebas (esto último podría ser un problema para algunos).

Con el ejemplo anterior, está claro cómo la programación orientada a objetos en Go puede producir un código mejor comprensible y de ejecución más rápida en comparación con una implementación simple e imperativa. Aunque Go no pretende ser un lenguaje OOP, proporciona las herramientas necesarias para estructurar una aplicación de forma orientada a objetos. Junto con la agrupación de funcionalidades en paquetes, OOP en Golang se puede aprovechar para proporcionar módulos reutilizables como bloques de construcción para aplicaciones grandes.


Insignia de Google Cloud Partner.

Como Google Cloud Partner, los expertos certificados por Google de Toptal están disponibles para las empresas que lo soliciten para sus proyectos más importantes.