Logica ben strutturata: un tutorial OOP di Golang

Pubblicato: 2022-03-11

Go è orientato agli oggetti? Può essere? Go (o “Golang”) è un linguaggio di programmazione post-OOP che prende in prestito la sua struttura (pacchetti, tipi, funzioni) dalla famiglia di linguaggi Algol/Pascal/Modula. Tuttavia, in Go, i modelli orientati agli oggetti sono ancora utili per strutturare un programma in modo chiaro e comprensibile. Questo tutorial di Golang prenderà un semplice esempio e dimostrerà come applicare i concetti di associazione di funzioni a tipi (ovvero classi), costruttori, sottotipi, polimorfismo, iniezione di dipendenze e test con mock.

Caso di studio a Golang OOP: lettura del codice del produttore da un numero di identificazione del veicolo (VIN)

Il numero di identificazione univoco del veicolo di ogni auto include, oltre a un numero "di corsa" (cioè di serie), informazioni sull'auto, come il produttore, la fabbrica di produzione, il modello dell'auto e se è guidata da sinistra o lato destro.

Una funzione per determinare il codice del produttore potrebbe essere simile a questa:

 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 }

Ed ecco un test che dimostra che un esempio di VIN funziona:

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

Quindi questa funzione funziona correttamente quando viene fornito il giusto input, ma presenta alcuni problemi:

  • Non vi è alcuna garanzia che la stringa di input sia un VIN.
  • Per le stringhe più corte di tre caratteri, la funzione provoca il panic .
  • La seconda parte opzionale dell'ID è una caratteristica solo dei VIN europei. La funzione restituirebbe ID errati per le auto statunitensi che hanno un 9 come terza cifra del codice del produttore.

Per risolvere questi problemi, lo rifattorizzeremo utilizzando modelli orientati agli oggetti.

Go OOP: associazione di funzioni a un tipo

Il primo refactoring consiste nel rendere i VIN del proprio tipo e associarvi la funzione Manufacturer() . Ciò rende più chiaro lo scopo della funzione e previene un utilizzo sconsiderato.

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

Quindi adattiamo il test e introduciamo il problema dei VIN non validi:

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

L'ultima riga è stata inserita per dimostrare come innescare un panic durante l'utilizzo della funzione Manufacturer() . Al di fuori di un test, ciò provocherebbe l'arresto anomalo del programma in esecuzione.

OOP in Golang: utilizzo dei costruttori

Per evitare il panic quando si maneggia un VIN non valido, è possibile aggiungere controlli di validità alla funzione Manufacturer() stessa. Gli svantaggi sono che i controlli verrebbero eseguiti su ogni chiamata alla funzione Manufacturer() e che dovrebbe essere introdotto un valore di ritorno di errore, che renderebbe impossibile utilizzare il valore di ritorno direttamente senza una variabile intermedia (ad es. una chiave della mappa).

Un modo più elegante è inserire i controlli di validità in un costruttore per il tipo VIN , in modo che la funzione Manufacturer() venga chiamata solo per VIN validi e non necessiti di controlli e gestione degli errori:

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

Naturalmente, aggiungiamo un test per la funzione NewVIN . I VIN non validi vengono ora rifiutati dal costruttore:

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

Il test per la funzione Manufacturer() ora può omettere il test di un VIN non valido poiché sarebbe già stato rifiutato dal costruttore NewVIN .

Go OOP Trabocchetto: il polimorfismo nel modo sbagliato

Successivamente, vogliamo distinguere tra VIN europei e non europei. Un approccio sarebbe quello di estendere il type VIN a uno struct e memorizzare se il VIN è europeo o meno, migliorando di conseguenza il costruttore:

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

La soluzione più elegante è creare un sottotipo di VIN per i VIN europei. Qui, il flag è implicitamente memorizzato nelle informazioni sul tipo e la funzione Manufacturer() per i VIN non europei diventa carina 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 }

In linguaggi OOP come Java, ci aspetteremmo che il sottotipo EUVIN sia utilizzabile in ogni luogo in cui è specificato il tipo VIN . Sfortunatamente, questo non funziona in 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) } } }

Questo comportamento può essere spiegato dalla scelta deliberata del team di sviluppo Go di non supportare l'associazione dinamica per i tipi non di interfaccia. Consente al compilatore di sapere quale funzione verrà chiamata in fase di compilazione ed evita il sovraccarico dell'invio di metodi dinamici. Questa scelta scoraggia anche l'uso dell'ereditarietà come modello di composizione generale. Invece, le interfacce sono la strada da percorrere (scusate il gioco di parole).

Golang OOP Successo: il polimorfismo nel modo giusto

Il compilatore Go considera un tipo come un'implementazione di un'interfaccia quando implementa le funzioni dichiarate (tipizzazione anatra). Pertanto, per utilizzare il polimorfismo, il tipo VIN viene convertito in un'interfaccia implementata da un tipo VIN generale e da uno europeo. Si noti che non è necessario che il tipo VIN europeo sia un sottotipo di quello generale.

 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 }

Il test di polimorfismo ora passa con una leggera modifica:

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

In effetti, entrambi i tipi VIN possono ora essere utilizzati in ogni luogo che specifica l'interfaccia VIN , poiché entrambi i tipi sono conformi alla definizione dell'interfaccia VIN .

Golang orientato agli oggetti: come utilizzare l'iniezione di dipendenza

Ultimo ma non meno importante, dobbiamo decidere se un VIN è europeo o meno. Supponiamo di aver trovato un'API esterna che ci fornisce queste informazioni e di aver creato un client per questo:

 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 }

Abbiamo anche costruito un servizio che gestisce i VIN e, in particolare, può crearli:

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

Funziona bene come mostra il test modificato:

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

L'unico problema qui è che il test richiede una connessione live all'API esterna. Questo è un peccato, poiché l'API potrebbe essere offline o semplicemente non raggiungibile. Inoltre, chiamare un'API esterna richiede tempo e può costare denaro.

Poiché il risultato della chiamata API è noto, dovrebbe essere possibile sostituirlo con un mock. Sfortunatamente, nel codice sopra, lo stesso VINService crea il client API, quindi non esiste un modo semplice per sostituirlo. Per renderlo possibile, la dipendenza del client API dovrebbe essere iniettata in VINService . Cioè, dovrebbe essere creato prima di chiamare il costruttore VINService .

La linea guida OOP di Golang qui è che nessun costruttore dovrebbe chiamare un altro costruttore . Se questo viene applicato in modo completo, ogni singleton utilizzato in un'applicazione verrà creato al livello più alto. Tipicamente, questa sarà una funzione di bootstrap che crea tutti gli oggetti necessari chiamando i loro costruttori nell'ordine appropriato, scegliendo un'implementazione adatta per la funzionalità prevista del programma.

Il primo passo è rendere VINAPIClient un'interfaccia:

 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 }

Quindi, il nuovo client può essere inserito nel 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 ciò, ora è possibile utilizzare un client API mock per il test. Oltre a evitare chiamate a un'API esterna durante i test, il mock può anche fungere da sonda per raccogliere dati sull'utilizzo dell'API. Nell'esempio seguente, controlliamo semplicemente se la funzione IsEuropean è effettivamente chiamata.

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

Questo test è stato superato, poiché il nostro probe IsEuropean viene eseguito una volta durante la chiamata a CreateFromCode .

Programmazione orientata agli oggetti in Go: una combinazione vincente (se eseguita correttamente)

I critici potrebbero dire: "Perché non usare Java se stai comunque facendo OOP?" Bene, perché ottieni tutti gli altri eleganti vantaggi di Go evitando una VM/JIT affamata di risorse, maledetti framework con annotazioni voodoo, gestione delle eccezioni e pause caffè durante l'esecuzione dei test (quest'ultimo potrebbe essere un problema per alcuni).

Con l'esempio sopra, è chiaro come la programmazione orientata agli oggetti in Go possa produrre codice più comprensibile e più veloce rispetto a un'implementazione semplice e imperativa. Sebbene Go non sia pensato per essere un linguaggio OOP, fornisce gli strumenti necessari per strutturare un'applicazione in modo orientato agli oggetti. Insieme alle funzionalità di raggruppamento in pacchetti, OOP in Golang può essere sfruttato per fornire moduli riutilizzabili come elementi costitutivi per applicazioni di grandi dimensioni.


Badge Partner Google Cloud.

In qualità di Google Cloud Partner, gli esperti certificati da Google di Toptal sono a disposizione delle aziende on demand per i loro progetti più importanti.