Gut strukturierte Logik: Ein Golang-OOP-Tutorial

Veröffentlicht: 2022-03-11

Ist Go objektorientiert? Kann es sein? Go (oder „Golang“) ist eine Post-OOP-Programmiersprache, die ihre Struktur (Pakete, Typen, Funktionen) aus der Algol/Pascal/Modula-Sprachfamilie entlehnt. Trotzdem sind objektorientierte Muster in Go immer noch nützlich, um ein Programm klar und verständlich zu strukturieren. Dieses Golang-Tutorial nimmt ein einfaches Beispiel und zeigt, wie die Konzepte von Bindungsfunktionen auf Typen (auch bekannt als Klassen), Konstruktoren, Subtyping, Polymorphie, Abhängigkeitsinjektion und Tests mit Mocks angewendet werden.

Fallstudie in Golang OOP: Auslesen des Herstellercodes aus einer Fahrzeugidentifikationsnummer (VIN)

Die eindeutige Fahrzeugidentifikationsnummer jedes Autos enthält neben einer "laufenden" (dh Seriennummer) Informationen über das Auto, wie den Hersteller, die produzierende Fabrik, das Automodell und ob es von links gefahren wird rechte Seite.

Eine Funktion zur Ermittlung des Herstellercodes könnte so aussehen:

 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 }

Und hier ist ein Test, der beweist, dass eine Beispiel-VIN funktioniert:

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

Diese Funktion funktioniert also korrekt, wenn die richtige Eingabe gegeben wird, aber sie hat einige Probleme:

  • Es gibt keine Garantie dafür, dass die Eingabezeichenfolge eine VIN ist.
  • Bei Zeichenfolgen, die kürzer als drei Zeichen sind, löst die Funktion eine panic aus.
  • Der optionale zweite Teil der ID ist nur ein Merkmal europäischer FINs. Die Funktion würde falsche IDs für US-Autos mit einer 9 als dritter Stelle des Herstellercodes zurückgeben.

Um diese Probleme zu lösen, werden wir es mit objektorientierten Mustern umgestalten.

Go OOP: Funktionen an einen Typ binden

Das erste Refactoring besteht darin, VINs ihren eigenen Typ zu geben und die Manufacturer() Funktion daran zu binden. Dies macht den Zweck der Funktion klarer und verhindert eine unbedachte Verwendung.

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

Wir passen den Test dann an und führen das Problem ungültiger VINs ein:

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

Die letzte Zeile wurde eingefügt, um zu demonstrieren, wie man eine panic auslöst, während man die Funktion Manufacturer() verwendet. Außerhalb eines Tests würde dies das laufende Programm zum Absturz bringen.

OOP in Golang: Konstruktoren verwenden

Um panic beim Umgang mit einer ungültigen VIN zu vermeiden, ist es möglich, der Funktion Manufacturer() selbst Gültigkeitsprüfungen hinzuzufügen. Die Nachteile sind, dass die Überprüfungen bei jedem Aufruf der Manufacturer() -Funktion erfolgen würden und dass ein Fehlerrückgabewert eingeführt werden müsste, der es unmöglich machen würde, den Rückgabewert direkt ohne eine Zwischenvariable (z. B. as ein Kartenschlüssel).

Eine elegantere Möglichkeit besteht darin, die Gültigkeitsprüfungen in einen Konstruktor für den VIN -Typ zu packen, sodass die Funktion Manufacturer() nur für gültige VINs aufgerufen wird und keine Prüfungen und Fehlerbehandlung benötigt:

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

Natürlich fügen wir einen Test für die NewVIN Funktion hinzu. Ungültige VINs werden nun vom Konstrukteur abgelehnt:

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

Der Test für die Manufacturer() Funktion kann jetzt auf das Testen einer ungültigen VIN verzichten, da diese bereits vom NewVIN Konstruktor abgelehnt worden wäre.

Gehen Sie OOP Pitfall: Polymorphism the Wrong Way

Als nächstes wollen wir zwischen europäischen und außereuropäischen Fahrgestellnummern unterscheiden. Ein Ansatz wäre, den VIN type auf eine struct zu erweitern und zu speichern, ob die VIN europäisch ist oder nicht, und den Konstruktor entsprechend zu erweitern:

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

Die elegantere Lösung besteht darin, einen VIN -Subtyp für europäische VINs zu erstellen. Hier wird das Flag implizit in der Typangabe hinterlegt und die Manufacturer() Funktion für außereuropäische VINs wird schön übersichtlich:

 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 OOP-Sprachen wie Java würden wir erwarten, dass der Untertyp EUVIN überall dort verwendbar ist, wo der VIN -Typ spezifiziert ist. Leider funktioniert dies in Golang OOP nicht.

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

Dieses Verhalten kann durch die bewusste Entscheidung des Go-Entwicklungsteams erklärt werden, dynamisches Binden für Nicht-Schnittstellentypen nicht zu unterstützen. Es ermöglicht dem Compiler zu wissen, welche Funktion zur Kompilierzeit aufgerufen wird, und vermeidet den Overhead des dynamischen Methodenversands. Diese Wahl rät auch von der Verwendung der Vererbung als allgemeines Kompositionsmuster ab. Stattdessen sind Schnittstellen der richtige Weg (verzeihen Sie das Wortspiel).

Golang OOP Success: Polymorphismus auf die richtige Art und Weise

Der Go-Compiler behandelt einen Typ als Implementierung einer Schnittstelle, wenn er die deklarierten Funktionen implementiert (Ententypisierung). Daher wird zur Nutzung des Polymorphismus der VIN -Typ in eine Schnittstelle umgewandelt, die durch einen allgemeinen und einen europäischen VIN-Typ implementiert wird. Beachten Sie, dass der europäische VIN-Typ kein Untertyp des allgemeinen sein muss.

 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 }

Der Polymorphismus-Test wird nun mit einer leichten Modifikation bestanden:

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

Tatsächlich können jetzt beide VIN-Typen an jeder Stelle verwendet werden, die die VIN -Schnittstelle spezifiziert, da beide Typen der VIN -Schnittstellendefinition entsprechen.

Objektorientiertes Golang: Verwendung der Abhängigkeitsinjektion

Zu guter Letzt müssen wir entscheiden, ob eine FIN europäisch ist oder nicht. Nehmen wir an, wir haben eine externe API gefunden, die uns diese Informationen liefert, und wir haben einen Client dafür erstellt:

 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 }

Wir haben auch einen Dienst entwickelt, der VINs handhabt und insbesondere erstellen kann:

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

Dies funktioniert gut, wie der modifizierte Test zeigt:

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

Das einzige Problem hier ist, dass der Test eine Live-Verbindung zur externen API erfordert. Dies ist bedauerlich, da die API möglicherweise offline oder einfach nicht erreichbar ist. Auch das Aufrufen einer externen API ist zeitaufwändig und kann Geld kosten.

Da das Ergebnis des API-Aufrufs bekannt ist, sollte es möglich sein, es durch ein Mock zu ersetzen. Leider erstellt der VINService im obigen Code selbst den API-Client, sodass es keine einfache Möglichkeit gibt, ihn zu ersetzen. Um dies zu ermöglichen, sollte die API-Client-Abhängigkeit in den VINService . Das heißt, es sollte erstellt werden, bevor der VINService Konstruktor aufgerufen wird.

Die OOP-Richtlinie von Golang lautet hier, dass kein Konstruktor einen anderen Konstruktor aufrufen sollte . Wenn dies gründlich angewendet wird, wird jedes in einer Anwendung verwendete Singleton auf der obersten Ebene erstellt. Typischerweise ist dies eine Bootstrapping-Funktion, die alle benötigten Objekte erstellt, indem sie ihre Konstruktoren in der richtigen Reihenfolge aufruft und eine geeignete Implementierung für die beabsichtigte Funktionalität des Programms auswählt.

Der erste Schritt besteht darin, den VINAPIClient zu einer Schnittstelle zu machen:

 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 }

Dann kann der neue Client in den VINService injiziert werden:

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

Damit ist es nun möglich, einen API-Client-Mock für den Test zu verwenden. Neben der Vermeidung von Aufrufen einer externen API während Tests kann der Mock auch als Sonde zum Sammeln von Daten über die API-Nutzung dienen. Im folgenden Beispiel prüfen wir nur, ob die IsEuropean Funktion tatsächlich aufgerufen wird.

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

Dieser Test wird bestanden, da unser IsEuropean -Probe einmal während des Aufrufs von CreateFromCode .

Objektorientierte Programmierung in Go: Eine erfolgreiche Kombination (wenn es richtig gemacht wird)

Kritiker könnten sagen: „Warum nicht Java verwenden, wenn Sie sowieso OOP machen?“ Nun, weil Sie all die anderen raffinierten Vorteile von Go erhalten und gleichzeitig eine ressourcenhungrige VM/JIT, verflixte Frameworks mit Annotations-Voodoo, Ausnahmebehandlung und Kaffeepausen beim Ausführen von Tests vermeiden (letzteres könnte für einige ein Problem sein).

Anhand des obigen Beispiels wird deutlich, wie die objektorientierte Programmierung in Go im Vergleich zu einer einfachen, zwingenden Implementierung zu besser verständlichem und schneller laufendem Code führen kann. Obwohl Go keine OOP-Sprache sein soll, bietet es die notwendigen Werkzeuge, um eine Anwendung objektorientiert zu strukturieren. Zusammen mit Gruppierungsfunktionen in Paketen kann OOP in Golang genutzt werden, um wiederverwendbare Module als Bausteine ​​für große Anwendungen bereitzustellen.


Google Cloud-Partner-Logo.

Als Google Cloud Partner stehen die Google-zertifizierten Experten von Toptal Unternehmen on demand für ihre wichtigsten Projekte zur Verfügung.