Logica bine structurată: un tutorial Golang OOP
Publicat: 2022-03-11Go este orientat pe obiecte? Poate fi? Go (sau „Golang”) este un limbaj de programare post-OOP care își împrumută structura (pachete, tipuri, funcții) din familia de limbaje Algol/Pascal/Modula. Cu toate acestea, în Go, modelele orientate pe obiecte sunt încă utile pentru structurarea unui program într-un mod clar și ușor de înțeles. Acest tutorial Golang va lua un exemplu simplu și va demonstra cum să aplicați conceptele de funcții de legare la tipuri (aka clase), constructori, subtipări, polimorfism, injecție de dependență și testare cu simulari.
Studiu de caz în Golang OOP: citirea codului producătorului dintr-un număr de identificare a vehiculului (VIN)
Numărul unic de identificare al fiecărei mașini include, pe lângă un număr „de rulare” (adică, de serie) – informații despre mașină, cum ar fi producătorul, fabrica producătoare, modelul de mașină și dacă este condusă din stânga sau partea dreaptă.
O funcție pentru a determina codul producătorului ar putea arăta astfel:
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 }
Și iată un test care demonstrează că un exemplu de VIN funcționează:
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) } }
Deci, această funcție funcționează corect atunci când i se oferă intrarea corectă, dar are câteva probleme:
- Nu există nicio garanție că șirul de intrare este un VIN.
- Pentru șirurile mai scurte de trei caractere, funcția provoacă o
panic
. - A doua parte opțională a ID-ului este o caracteristică numai a VIN-urilor europene. Funcția ar returna ID-uri greșite pentru mașinile din SUA având un 9 ca a treia cifră a codului producătorului.
Pentru a rezolva aceste probleme, îl vom refactoriza folosind modele orientate pe obiecte.
Go OOP: Legarea funcțiilor la un tip
Prima refactorizare este de a face VIN-urile propriul lor tip și de a lega funcția Manufacturer()
la acesta. Acest lucru face scopul funcției mai clar și previne utilizarea necugetă.
package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }
Apoi adaptăm testul și introducem problema VIN-urilor invalide:
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! }
Ultima linie a fost inserată pentru a demonstra cum să declanșați o panic
în timp ce utilizați funcția Manufacturer()
. În afara unui test, acest lucru ar bloca programul care rulează.
OOP în Golang: Utilizarea constructorilor
Pentru a evita panic
la manipularea unui VIN invalid, este posibil să adăugați verificări de valabilitate la funcția Manufacturer()
în sine. Dezavantajele sunt că verificările ar fi făcute la fiecare apel la funcția Manufacturer()
și că ar trebui introdusă o valoare returnată de eroare, ceea ce ar face imposibilă utilizarea directă a valorii returnate fără o variabilă intermediară (de exemplu, ca o cheie de hartă).
O modalitate mai elegantă este să puneți verificările de validitate într-un constructor pentru tipul VIN
, astfel încât funcția Manufacturer()
să fie apelată numai pentru VIN-uri valide și să nu aibă nevoie de verificări și de tratare a erorilor:
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) }
Desigur, adăugăm un test pentru funcția NewVIN
. VIN-urile nevalide sunt acum respinse de 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) } }
Testul pentru funcția Manufacturer()
poate acum omite testarea unui VIN invalid, deoarece acesta ar fi fost deja respins de constructorul NewVIN
.
Go OOP Capcană: Polimorfismul în mod greșit
În continuare, dorim să facem diferența între VIN-urile europene și cele non-europene. O abordare ar fi extinderea type
VIN la o struct
și stocarea indiferent dacă VIN-ul este european sau nu, îmbunătățind constructorul în consecință:
type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }
Soluția mai elegantă este crearea unui subtip de VIN
pentru VIN-urile europene. Aici, steag-ul este stocat implicit în informațiile de tip, iar funcția Manufacturer()
pentru VIN-uri non-europene devine drăguță și concisă:
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 }
În limbajele OOP precum Java, ne-am aștepta ca subtipul EUVIN
să fie utilizabil în orice loc unde este specificat tipul VIN
. Din păcate, acest lucru nu funcționează în 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) } } }
Acest comportament poate fi explicat prin alegerea deliberată a echipei de dezvoltare Go de a nu accepta legarea dinamică pentru tipurile care nu sunt interfețe. Acesta permite compilatorului să știe ce funcție va fi apelată în timpul compilării și evită suprasarcina de expediere a metodei dinamice. Această alegere descurajează, de asemenea, utilizarea moștenirii ca model general de compoziție. În schimb, interfețele sunt calea de urmat (iertați jocul de cuvinte).

Golang OOP Succes: Polimorfism the Right Way
Compilatorul Go tratează un tip ca pe o implementare a unei interfețe atunci când implementează funcțiile declarate (duck typing). Prin urmare, pentru a folosi polimorfismul, tipul VIN
este convertit într-o interfață care este implementată de un tip VIN general și european. Rețineți că nu este necesar ca tipul VIN european să fie un subtip al celui 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 }
Testul de polimorfism trece acum cu o ușoară modificare:
// 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 fapt, ambele tipuri de VIN pot fi acum utilizate în orice loc care specifică interfața VIN
, deoarece ambele tipuri respectă definiția interfeței VIN
.
Golang orientat pe obiecte: Cum să utilizați injecția de dependență
Nu în ultimul rând, trebuie să decidem dacă un VIN este european sau nu. Să presupunem că am găsit un API extern care ne oferă aceste informații și am creat un client pentru el:
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 }
De asemenea, am construit un serviciu care gestionează VIN-urile și, în special, le putem crea:
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) }
Acest lucru funcționează bine, așa cum arată testul modificat:
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) } }
Singura problemă aici este că testul necesită o conexiune live la API-ul extern. Acest lucru este regretabil, deoarece API-ul ar putea fi offline sau pur și simplu nu este accesibil. De asemenea, apelarea unui API extern necesită timp și poate costa bani.
Deoarece rezultatul apelului API este cunoscut, ar trebui să fie posibil să fie înlocuit cu un simulacro. Din păcate, în codul de mai sus, VINService
însuși creează clientul API, așa că nu există o modalitate ușoară de a-l înlocui. Pentru a face acest lucru posibil, dependența de client API ar trebui să fie injectată în VINService
. Adică, ar trebui creat înainte de a apela constructorul VINService
.
Ghidul Golang OOP aici este că niciun constructor nu ar trebui să apeleze un alt constructor . Dacă acest lucru este aplicat temeinic, fiecare singleton folosit într-o aplicație va fi creat la cel mai înalt nivel. De obicei, aceasta va fi o funcție de bootstrapping care creează toate obiectele necesare prin apelarea constructorilor lor în ordinea corespunzătoare, alegând o implementare potrivită pentru funcționalitatea dorită a programului.
Primul pas este să faceți din VINAPIClient
o interfață:
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 }
Apoi, noul client poate fi injectat în 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) }
Cu asta, acum este posibil să utilizați o simulare de client API pentru test. Pe lângă evitarea apelurilor către un API extern în timpul testelor, simularea poate acționa și ca o sondă pentru a colecta date despre utilizarea API-ului. În exemplul de mai jos, verificăm doar dacă funcția IsEuropean
este de fapt numită.
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) } }
Acest test trece, deoarece sonda noastră IsEuropean
rulează o dată în timpul apelului către CreateFromCode
.
Programare orientată pe obiecte în Go: o combinație câștigătoare (când este făcută corect)
Criticii ar putea spune: „De ce să nu folosești Java dacă oricum faci OOP?” Ei bine, pentru că obțineți toate celelalte avantaje ingenioase ale Go, evitând în același timp un VM/JIT amanat de resurse, cadre nebunești cu adnotări voodoo, gestionarea excepțiilor și pauze de cafea în timp ce rulați teste (acestea din urmă ar putea fi o problemă pentru unii).
Cu exemplul de mai sus, este clar cum programarea orientată pe obiecte în Go poate produce un cod mai ușor de înțeles și de rulare mai rapidă, în comparație cu o implementare simplă, imperativă. Deși Go nu este menit să fie un limbaj OOP, acesta oferă instrumentele necesare pentru a structura o aplicație într-un mod orientat pe obiecte. Împreună cu funcționalitățile de grupare în pachete, OOP în Golang poate fi folosit pentru a oferi module reutilizabile ca blocuri de construcție pentru aplicații mari.
În calitate de partener Google Cloud, experții Toptal certificați de Google sunt disponibili companiilor la cerere pentru cele mai importante proiecte ale acestora.