Go Generics: Ein tiefer Einblick
Grace Collins
Solutions Engineer · Leapcell

1. Go ohne Generics
Vor der Einführung von Generics gab es mehrere Ansätze zur Implementierung generischer Funktionen, die verschiedene Datentypen unterstützen:
Ansatz 1: Implementierung einer Funktion für jeden Datentyp Dieser Ansatz führt zu extrem redundantem Code und hohen Wartungskosten. Jede Modifikation erfordert die gleiche Operation an allen Funktionen. Da die Go-Sprache keine Funktionsüberladung mit dem gleichen Namen unterstützt, ist es auch unbequem, diese Funktionen für externe Modulaufrufe freizugeben.
Ansatz 2: Verwenden des Datentyps mit dem größten Bereich
Um Code-Redundanz zu vermeiden, besteht eine andere Methode darin, den Datentyp mit dem größten Bereich zu verwenden, d. h. Ansatz 2. Ein typisches Beispiel ist math.Max
, das die größere von zwei Zahlen zurückgibt. Um Daten verschiedener Datentypen vergleichen zu können, verwendet math.Max
float64
, den Datentyp mit dem größten Bereich unter den numerischen Typen in Go, als Eingabe- und Ausgabeparameter, wodurch Präzisionsverluste vermieden werden. Obwohl dies das Problem der Code-Redundanz bis zu einem gewissen Grad löst, muss jeder Datentyp zuerst in den Typ float64
konvertiert werden. Wenn man beispielsweise int
mit int
vergleicht, ist immer noch eine Typkonvertierung erforderlich, was nicht nur die Leistung beeinträchtigt, sondern auch unnatürlich erscheint.
Ansatz 3: Verwenden des Typs interface{}
Die Verwendung des Typs interface{}
löst die oben genannten Probleme effektiv. Der Typ interface{}
führt jedoch zu einem gewissen Laufzeit-Overhead, da er Typzusicherungen oder Typbeurteilungen zur Laufzeit erfordert, was zu einer gewissen Leistungsminderung führen kann. Wenn der Typ interface{}
verwendet wird, kann der Compiler außerdem keine statische Typprüfung durchführen, so dass einige Typfehler möglicherweise erst zur Laufzeit entdeckt werden.
2. Vorteile von Generics
Go 1.18 führte die Unterstützung für Generics ein, was eine bedeutende Änderung seit der Open-Sourcing der Go-Sprache darstellt. Generics ist ein Merkmal von Programmiersprachen. Es ermöglicht Programmierern, generische Typen anstelle von tatsächlichen Typen in der Programmierung zu verwenden. Dann werden durch explizites Übergeben oder automatischen Ableiten während der tatsächlichen Aufrufe die generischen Typen ersetzt, wodurch der Zweck der Code-Wiederverwendung erreicht wird. Bei der Verwendung von Generics wird der zu bearbeitende Datentyp als Parameter angegeben. Solche Parametertypen werden als generische Klassen, generische Schnittstellen und generische Methoden in Klassen, Schnittstellen bzw. Methoden bezeichnet. Die Hauptvorteile von Generics sind die Verbesserung der Code-Wiederverwendbarkeit und der Typsicherheit. Im Vergleich zu herkömmlichen formalen Parametern machen Generics das Schreiben von universellem Code prägnanter und flexibler, bieten die Möglichkeit, verschiedene Datentypen zu verarbeiten, und verbessern die Ausdruckskraft und Wiederverwendbarkeit der Go-Sprache weiter. Da die spezifischen Typen von Generics zur Kompilierzeit bestimmt werden, kann gleichzeitig eine Typprüfung durchgeführt werden, wodurch Typkonvertierungsfehler vermieden werden.
3. Unterschiede zwischen Generics und interface{}
In der Go-Sprache sind sowohl interface{}
als auch Generics Werkzeuge zur Verarbeitung mehrerer Datentypen. Um ihre Unterschiede zu diskutieren, betrachten wir zunächst die Implementierungsprinzipien von interface{}
und Generics.
3.1 interface{}
Implementierungsprinzip
interface{}
ist eine leere Schnittstelle ohne Methoden im Schnittstellentyp. Da alle Typen interface{}
implementieren, kann sie verwendet werden, um Funktionen, Methoden oder Datenstrukturen zu erstellen, die jeden Typ akzeptieren können. Die zugrunde liegende Struktur von interface{}
zur Laufzeit wird als eface
dargestellt, deren Struktur unten dargestellt ist und hauptsächlich zwei Felder enthält: _type
und data
.
type eface struct { _type *_type data unsafe.Pointer } type type struct { Size uintptr PtrBytes uintptr // number of (prefix) bytes in the type that can contain pointers Hash uint32 // hash of type; avoids computation in hash tables TFlag TFlag // extra type information flags Align_ uint8 // alignment of variable with this type FieldAlign_ uint8 // alignment of struct field with this type Kind_ uint8 // enumeration for C // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? Equal func(unsafe.Pointer, unsafe.Pointer) bool // GCData stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, GCData is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. GCData *byte Str NameOff // string form PtrToThis TypeOff // type for pointer to this type, may be zero }
_type
ist ein Zeiger auf die _type
-Struktur, die Informationen wie die Größe, Art, Hash-Funktion und String-Darstellung des tatsächlichen Werts enthält. data
ist ein Zeiger auf die tatsächlichen Daten. Wenn die Größe der tatsächlichen Daten kleiner oder gleich der Größe eines Zeigers ist, werden die Daten direkt im Feld data
gespeichert; andernfalls speichert das Feld data
einen Zeiger auf die tatsächlichen Daten.
Wenn ein Objekt eines bestimmten Typs einer Variablen des Typs interface{}
zugewiesen wird, führt die Go-Sprache implizit die Boxing-Operation von eface
durch, wobei das Feld _type
auf den Typ des Werts und das Feld data
auf die Daten des Werts gesetzt wird. Wenn beispielsweise die Anweisung var i interface{} = 123
ausgeführt wird, erstellt Go eine eface
-Struktur, wobei das Feld _type
den Typ int
darstellt und das Feld data
den Wert 123 darstellt.
Beim Abrufen des gespeicherten Werts aus interface{}
erfolgt ein Unboxing-Prozess, d. h. eine Typzusicherung oder Typbeurteilung. Dieser Prozess erfordert, dass der erwartete Typ explizit angegeben wird. Wenn der Typ des in interface{}
gespeicherten Werts mit dem erwarteten Typ übereinstimmt, ist die Typzusicherung erfolgreich, und der Wert kann abgerufen werden. Andernfalls schlägt die Typzusicherung fehl, und für diese Situation ist eine zusätzliche Behandlung erforderlich.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
Es ist ersichtlich, dass interface{}
Operationen an mehreren Datentypen durch Boxing- und Unboxing-Operationen zur Laufzeit unterstützt.
3.2 Generics Implementierungsprinzip
Das Go-Kernteam war sehr vorsichtig bei der Bewertung der Implementierungsschemata von Go-Generics. Es wurden insgesamt drei Implementierungsschemata eingereicht:
- Stenciling-Schema
- Dictionaries-Schema
- GC Shape Stenciling-Schema
Das Stenciling-Schema ist auch das Implementierungsschema, das von Sprachen wie C++ und Rust zur Implementierung von Generics verwendet wird. Sein Implementierungsprinzip ist, dass während der Kompilierungszeit, je nach den spezifischen Typparametern, wenn die generische Funktion aufgerufen wird, oder den Typelementen in den Einschränkungen, eine separate Implementierung der generischen Funktion für jedes Typargument generiert wird, um Typsicherheit und optimale Leistung zu gewährleisten. Diese Methode verlangsamt jedoch den Compiler. Denn wenn viele Datentypen aufgerufen werden, muss die generische Funktion für jeden Datentyp unabhängige Funktionen generieren, was zu sehr großen kompilierten Dateien führen kann. Gleichzeitig kann der generierte Code aufgrund von Problemen wie CPU-Cache-Fehlern und Befehlszweigvorhersagen möglicherweise nicht effizient ausgeführt werden.
Das Dictionaries-Schema generiert nur eine Funktionslogik für die generische Funktion, fügt aber einen Parameter dict
als ersten Parameter der Funktion hinzu. Der Parameter dict
speichert die typspezifischen Informationen der Typargumente, wenn die generische Funktion aufgerufen wird, und übergibt die Dictionary-Informationen während des Funktionsaufrufs mit dem AX-Register (AMD). Der Vorteil dieses Schemas ist, dass es den Overhead der Kompilierungsphase reduziert und die Größe der Binärdatei nicht erhöht. Es erhöht jedoch den Laufzeit-Overhead, kann keine Funktionsoptimierung in der Kompilierungsphase durchführen und hat Probleme wie die Dictionary-Rekursion.
type Op interface{ int|float } func Add[T Op](m, n T) T { return m + n } // After generation => const dict = map[type] typeInfo{ int : intInfo{ newFunc, lessFucn, //...... }, float : floatInfo } func Add(dict[T], m, n T) T{}
Go hat schließlich die obigen beiden Schemata integriert und das GC Shape Stenciling-Schema für die generische Implementierung vorgeschlagen. Es generiert Funktionscode in Einheiten der GC-Form eines Typs. Typen mit derselben GC-Form verwenden denselben Code wieder (die GC-Form eines Typs bezieht sich auf seine Darstellung im Go-Speicheralloziierer/Garbage Collector). Alle Zeigertypen verwenden den Typ *uint8
wieder. Für Typen mit derselben GC-Form wird ein gemeinsam genutzter instanziierter Funktionscode verwendet. Dieses Schema fügt außerdem automatisch jedem instanziierten Funktionscode einen dict
-Parameter hinzu, um verschiedene Typen mit derselben GC-Form zu unterscheiden.
type V interface{ int|float|*int|*float } func F[T V](m, n T) {} // 1. Generate templates for regular types int/float func F[go.shape.int_0](m, n int){} func F[go.shape.float_0](m, n int){} // 2. Pointer types reuse the same template func F[go.shape.*uint8_0](m, n int){} // 3. Add dictionary passing during the call const dict = map[type] typeInfo{ int : intInfo{}, float : floatInfo{} } func F[go.shape.int_0](dict[int],m, n int){}
3.3 Unterschiede
Aus den zugrunde liegenden Implementierungsprinzipien von interface{}
und Generics können wir feststellen, dass der Hauptunterschied zwischen ihnen darin besteht, dass interface{}
die Verarbeitung verschiedener Datentypen während der Laufzeit unterstützt, während Generics die Verarbeitung verschiedener Datentypen statisch in der Kompilierungsphase unterstützt. In der praktischen Anwendung gibt es hauptsächlich die folgenden Unterschiede:
(1) Leistungsunterschied: Die Boxing- und Unboxing-Operationen, die durchgeführt werden, wenn verschiedene Datentypen interface{}
zugewiesen oder daraus abgerufen werden, sind kostspielig und verursachen zusätzlichen Overhead. Im Gegensatz dazu erfordern Generics keine Boxing- und Unboxing-Operationen, und der von Generics generierte Code ist für bestimmte Typen optimiert, wodurch Laufzeitleistungs-Overhead vermieden wird.
(2) Typsicherheit: Bei Verwendung des Typs interface{}
kann der Compiler keine statische Typprüfung durchführen und kann Typzusicherungen nur zur Laufzeit durchführen. Daher können einige Typfehler möglicherweise erst zur Laufzeit entdeckt werden. Im Gegensatz dazu wird der generische Code von Go zur Kompilierzeit generiert, sodass der generische Code zur Kompilierzeit Typinformationen abrufen kann, wodurch die Typsicherheit gewährleistet wird.
4. Szenarien für Generics
4.1 Anwendbare Szenarien
- Bei der Implementierung allgemeiner Datenstrukturen: Durch die Verwendung von Generics können Sie Code einmal schreiben und ihn für verschiedene Datentypen wiederverwenden. Dies reduziert Codeduplikate und verbessert die Wartbarkeit und Erweiterbarkeit des Codes.
- Bei der Bearbeitung nativer Container-Typen in Go: Wenn eine Funktion Parameter von in Go integrierten Container-Typen wie Slices, Maps oder Channels verwendet und der Funktionscode keine spezifischen Annahmen über die Elementtypen in den Containern trifft, kann die Verwendung von Generics die Container-Algorithmen vollständig von den Elementtypen in den Containern entkoppeln. Bevor die Generics-Syntax verfügbar war, wurde normalerweise Reflection für die Implementierung verwendet, aber Reflection macht den Code weniger lesbar, kann keine statische Typprüfung durchführen und erhöht den Laufzeit-Overhead des Programms erheblich.
- Wenn die Logik der für verschiedene Datentypen implementierten Methoden gleich ist: Wenn Methoden verschiedener Datentypen die gleiche Funktionslogik haben und der einzige Unterschied der Datentyp der Eingabeparameter ist, können Generics verwendet werden, um Code-Redundanz zu reduzieren.
4.2 Nicht anwendbare Szenarien
- Ersetzen Sie Schnittstellentypen nicht durch Typparameter: Schnittstellen unterstützen ein gewisses Maß an generischer Programmierung. Wenn die Operationen an Variablen bestimmter Typen nur die Methoden dieses Typs aufrufen, verwenden Sie einfach den Schnittstellentyp direkt, ohne Generics zu verwenden. Beispielsweise verwendet
io.Reader
eine Schnittstelle, um verschiedene Datentypen aus Dateien und Zufallszahlengeneratoren zu lesen.io.Reader
ist aus der Codesicht leicht zu lesen, hocheffizient, und es gibt fast keinen Unterschied in der Ausführungseffizienz der Funktion, sodass keine Typparameter verwendet werden müssen. - Wenn die Implementierungsdetails der Methoden für verschiedene Datentypen unterschiedlich sind: Wenn die Methodenimplementierung für jeden Typ unterschiedlich ist, sollte der Schnittstellentyp anstelle von Generics verwendet werden.
- In Szenarien mit starker Laufzeitdynamik: In Szenarien, in denen die Typbeurteilung mit
switch
durchgeführt wird, führt die direkte Verwendung voninterface{}
zu besseren Ergebnissen.
5. Fallen in Generics
5.1 nil
-Vergleich
In der Go-Sprache dürfen Typparameter nicht direkt mit nil
verglichen werden, da Typparameter zur Kompilierzeit typprüft werden, während nil
ein Sonderwert zur Laufzeit ist. Da der zugrunde liegende Typ des Typparameters zur Kompilierzeit unbekannt ist, kann der Compiler nicht bestimmen, ob der zugrunde liegende Typ des Typparameters den Vergleich mit nil
unterstützt. Um die Typsicherheit zu gewährleisten und potenzielle Laufzeitfehler zu vermeiden, erlaubt die Go-Sprache daher keinen direkten Vergleich zwischen Typparametern und nil
.
// Wrong example func ZeroValue0[T any](v T) bool { return v == nil } // Correct example 1 func Zero1[T any]() T { return *new(T) } // Correct example 2 func Zero2[T any]() T { var t T return t } // Correct example 3 func Zero3[T any]() (t T) { return }
5.2 Ungültige zugrunde liegende Elemente
Der Typ T
des zugrunde liegenden Elements muss ein Basistyp sein und darf kein Schnittstellentyp sein.
// Wrong definition! type MyInt int type I0 interface { ~MyInt // Wrong! MyInt is not a base type, int is ~error // Wrong! error is an interface }
5.3 Ungültige Union Type-Elemente
Union Type-Elemente dürfen keine Typparameter sein, und Nicht-Interface-Elemente müssen paarweise disjunkt sein. Wenn es mehr als ein Element gibt, darf es keinen Schnittstellentyp mit nicht leeren Methoden enthalten, noch darf es comparable
sein oder comparable
einbetten.
func I1[K any, V interface{ K }]() { // Wrong, K in interface{ K } is a type parameter } type MyInt int func I5[K any, V interface{ int | MyInt }]() { // Correct } func I6[K any, V interface{ int | ~MyInt }]() { // Wrong! The intersection of int and ~MyInt is int } type MyInt2 = int func I7[K any, V interface{ int | MyInt2 }]() { // Wrong! int and MyInt2 are the same type, they intersect } // Wrong! Because there are more than one union elements and cannot be comparable func I13[K comparable | int]() { } // Wrong! Because there are more than one union elements and elements cannot embed comparable func I14[K interface{ comparable } | int]() { }
5.4 Schnittstellentypen können nicht rekursiv eingebettet werden
// Wrong! Cannot embed itself type Node interface { Node } // Wrong! Tree cannot embed itself through TreeNode type Tree interface { TreeNode } type TreeNode interface { Tree }
6. Best Practices
Um Generics gut zu nutzen, sollten während der Verwendung die folgenden Punkte beachtet werden:
- Vermeiden Sie eine Überverallgemeinerung.
Generics sind nicht für alle Szenarien geeignet, und es muss sorgfältig überlegt werden, in welchen Szenarien sie geeignet sind. Reflection kann bei Bedarf verwendet werden: Go verfügt über Laufzeitreflexion. Der Reflexionsmechanismus unterstützt ein gewisses Maß an generischer Programmierung. Wenn bestimmte Operationen die folgenden Szenarien unterstützen müssen, kann Reflection in Betracht gezogen werden:
(1) Bearbeitung von Typen ohne Methoden, wobei der Schnittstellentyp nicht anwendbar ist.
(2) Wenn die Operationslogik für jeden Typ unterschiedlich ist, sind Generics nicht anwendbar. Ein Beispiel ist die Implementierung des Pakets
encoding/json
. Da nicht gewünscht wird, dass jeder zu codierende Typ die MethodeMarshalJson
implementiert, kann der Schnittstellentyp nicht verwendet werden. Und da die Codelogik für verschiedene Typen unterschiedlich ist, sollten Generics nicht verwendet werden. - Verwenden Sie eindeutig
*T
,[]T
undmap[T1]T2
, anstattT
Zeigertypen, Slices oder Maps darstellen zu lassen. Anders als die Tatsache, dass Typparameter in C++ Platzhalter sind und durch echte Typen ersetzt werden, ist der Typ des TypparametersT
in Go der Typparameter selbst. Daher führt die Darstellung als Zeiger, Slice, Map und andere Datentypen während der Verwendung zu vielen unerwarteten Situationen, wie unten gezeigt:
func Set[T *int|*uint](ptr T) { *ptr = 1 } func main() { i := 0 Set(&i) fmt.Println(i) // Report an error: invalid operation }
Der obige Code meldet einen Fehler: invalid operation: pointers of ptr (variable of type T constrained by *int | *uint) must have identical base types
. Der Grund für diesen Fehler ist, dass T
ein Typparameter ist, und der Typparameter ist kein Zeiger und unterstützt die Dereferenzierungsoperation nicht. Dies kann gelöst werden, indem die Definition wie folgt geändert wird:
func Set[T int|uint](ptr *T) { *ptr = 1 }
Zusammenfassung
Insgesamt lassen sich die Vorteile von Generics in drei Aspekten zusammenfassen:
- Typen werden während des Kompilierungszeitraums bestimmt, wodurch die Typsicherheit gewährleistet wird. Was eingegeben wird, wird auch ausgegeben.
- Die Lesbarkeit wird verbessert. Der tatsächliche Datentyp ist bereits in der Kodierungsphase explizit bekannt.
- Generics führen den Verarbeitungscode für denselben Typ zusammen, wodurch die Code-Wiederverwendungsrate verbessert und die allgemeine Flexibilität des Programms erhöht wird. Generics sind jedoch keine Notwendigkeit für allgemeine Datentypen. Es ist immer noch erforderlich, sorgfältig zu überlegen, ob Generics entsprechend der tatsächlichen Nutzungssituation verwendet werden sollen.
Leapcell: Die erweiterte Plattform für Go-Webhosting, asynchrone Aufgaben und Redis
Abschließend möchte ich Leapcell vorstellen, die am besten geeignete Plattform für die Bereitstellung von Go-Diensten.
1. Multi-Language Support
- Entwickeln Sie mit JavaScript, Python, Go oder Rust.
2. Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung – keine Anfragen, keine Gebühren.
3. Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
4. Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
5. Mühelose Skalierbarkeit und hohe Leistung
- Auto-Skalierung zur einfachen Bewältigung hoher Parallelität.
- Null Betriebsaufwand – konzentrieren Sie sich einfach auf den Aufbau.
Erfahren Sie mehr in der Dokumentation!
Leapcell Twitter: https://x.com/LeapcellHQ