Heim >Backend-Entwicklung >Golang >Go Generics: Ein tiefer Einblick
Vor der Einführung von Generika gab es mehrere Ansätze zur Implementierung generischer Funktionen, die verschiedene Datentypen unterstützen:
Ansatz 1: Implementieren Sie eine Funktion für jeden Datentyp
Dieser Ansatz führt zu extrem redundantem Code und hohen Wartungskosten. Für jede Änderung muss für alle Funktionen derselbe Vorgang ausgeführt werden. Da die Go-Sprache außerdem das Überladen von Funktionen mit demselben Namen nicht unterstützt, ist es auch unpraktisch, diese Funktionen für externe Modulaufrufe verfügbar zu machen.
Ansatz 2: Verwenden Sie den Datentyp mit der größten Reichweite
Um Coderedundanz 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 und vermeidet so Präzisionsverluste. Obwohl dadurch das Code-Redundanzproblem bis zu einem gewissen Grad gelöst wird, muss jeder Datentyp zunächst in den Typ float64 konvertiert werden. Wenn Sie beispielsweise int mit int vergleichen, ist immer noch eine Typumwandlung erforderlich, was nicht nur die Leistung beeinträchtigt, sondern auch unnatürlich erscheint.
Ansatz 3: Verwenden Sie den Typ interface{}
Die Verwendung des Typs interface{} löst die oben genannten Probleme effektiv. Der Typ interface{} führt jedoch zu einem gewissen Laufzeitaufwand, da er zur Laufzeit Typzusicherungen oder Typbeurteilungen erfordert, was zu einer gewissen Leistungseinbuße führen kann. Darüber hinaus kann der Compiler bei Verwendung des Typs interface{} keine statische Typprüfung durchführen, sodass einige Typfehler möglicherweise erst zur Laufzeit entdeckt werden.
Go 1.18 führte die Unterstützung für Generika ein, was eine bedeutende Änderung seit der Open-Source-Bereitstellung der Go-Sprache darstellt.
Generics sind ein Merkmal von Programmiersprachen. Es ermöglicht Programmierern, bei der Programmierung generische Typen anstelle tatsächlicher Typen zu verwenden. Anschließend werden die generischen Typen durch explizite Übergabe oder automatische Ableitung während tatsächlicher Aufrufe ersetzt, wodurch der Zweck der Code-Wiederverwendung erreicht wird. Bei der Verwendung von Generika 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 Generika sind die Verbesserung der Wiederverwendbarkeit des Codes und der Typsicherheit. Im Vergleich zu herkömmlichen formalen Parametern machen Generika 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 Generika zur Kompilierzeit bestimmt werden, kann gleichzeitig eine Typprüfung durchgeführt werden, wodurch Typkonvertierungsfehler vermieden werden.
In der Go-Sprache sind sowohl Interface{} als auch Generics Werkzeuge für den Umgang mit mehreren Datentypen. Um ihre Unterschiede zu diskutieren, werfen wir zunächst einen Blick auf die Implementierungsprinzipien von Interface{} und Generics.
interface{} ist eine leere Schnittstelle ohne Methoden im Schnittstellentyp. Da alle Typen interface{} implementieren, können damit Funktionen, Methoden oder Datenstrukturen erstellt werden, die jeden Typ akzeptieren können. Die zugrunde liegende Struktur von interface{} zur Laufzeit wird als eface dargestellt, dessen 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 Größe, Art, Hash-Funktion und Zeichenfolgendarstellung 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 Datenfeld gespeichert. andernfalls speichert das Datenfeld einen Zeiger auf die tatsächlichen Daten.
Wenn ein Objekt eines bestimmten Typs einer Variablen vom Typ interface{} zugewiesen wird, führt die Go-Sprache implizit die Boxing-Operation von eface aus und setzt das Feld _type auf den Typ des Werts und das Datenfeld auf die Daten des Werts . Wenn beispielsweise die Anweisung var i interface{} = 123 ausgeführt wird, erstellt Go eine Eface-Struktur, wobei das Feld _type den Typ int und das Feld data den Wert 123 darstellt.
Beim Abrufen des gespeicherten Werts von der Schnittstelle {} findet ein Unboxing-Prozess statt, dh eine Typzusicherung oder Typbeurteilung. Dieser Prozess erfordert die explizite Angabe des erwarteten Typs. 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 in dieser 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 die Schnittstelle{} Operationen an mehreren Datentypen durch Boxing- und Unboxing-Operationen zur Laufzeit unterstützt.
Das Go-Kernteam war bei der Bewertung der Implementierungsschemata von Go-Generika sehr vorsichtig. Insgesamt wurden drei Umsetzungspläne eingereicht:
Das Schablonenschema ist auch das Implementierungsschema, das von Sprachen wie C und Rust zur Implementierung von Generika übernommen wird. Sein Implementierungsprinzip besteht darin, dass während des Kompilierungszeitraums entsprechend den spezifischen Typparametern beim Aufruf der generischen Funktion oder den Typelementen in den Einschränkungen für jedes Typargument eine separate Implementierung der generischen Funktion 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 der Vorhersage von Befehlsverzweigungen möglicherweise nicht effizient ausgeführt werden.
Das Dictionaries-Schema generiert nur eine Funktionslogik für die generische Funktion, fügt der Funktion jedoch ein Parameter-Dikt als ersten Parameter hinzu. Der dict-Parameter speichert die typbezogenen Informationen der Typargumente beim Aufruf der generischen Funktion und übergibt die Wörterbuchinformationen mithilfe des AX-Registers (AMD) während des Funktionsaufrufs. Der Vorteil dieses Schemas besteht darin, dass es den Overhead der Kompilierungsphase reduziert und die Größe der Binärdatei nicht erhöht. Es erhöht jedoch den Laufzeitaufwand, kann in der Kompilierungsphase keine Funktionsoptimierung durchführen und weist Probleme wie die Wörterbuchrekursion auf.
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 }
Go hat schließlich die beiden oben genannten Schemata integriert und das GC Shape Schablonenschema 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-Speicherzuweiser/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 jedem instanziierten Funktionscode automatisch einen dict-Parameter hinzu, um verschiedene Typen mit derselben GC-Form zu unterscheiden.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
Anhand der 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 statische Verarbeitung verschiedener Datentypen in der Kompilierungsphase unterstützen. Im praktischen Einsatz gibt es vor allem folgende Unterschiede:
(1) Leistungsunterschied: Die Boxing- und Unboxing-Vorgänge, die ausgeführt werden, wenn unterschiedliche Datentypen der Schnittstelle{} zugewiesen oder von ihr abgerufen werden, sind kostspielig und führen zu zusätzlichem Overhead. Im Gegensatz dazu erfordern Generics keine Boxing- und Unboxing-Vorgänge, und der von Generics generierte Code ist für bestimmte Typen optimiert, wodurch ein Overhead bei der Laufzeitleistung vermieden wird.
(2) Typsicherheit: Bei Verwendung des Typs interface{} kann der Compiler keine statische Typprüfung durchführen und nur Typzusicherungen zur Laufzeit durchführen. Daher werden einige Typfehler möglicherweise erst zur Laufzeit entdeckt. Im Gegensatz dazu wird der generische Code von Go zur Kompilierungszeit generiert, sodass der generische Code zur Kompilierungszeit Typinformationen erhalten kann, wodurch die Typsicherheit gewährleistet wird.
In der Go-Sprache dürfen Typparameter nicht direkt mit Null verglichen werden, da Typparameter zur Kompilierungszeit einer Typprüfung unterzogen werden, während Null zur Laufzeit ein spezieller Wert ist. Da der zugrunde liegende Typ des Typparameters zur Kompilierungszeit unbekannt ist, kann der Compiler nicht bestimmen, ob der zugrunde liegende Typ des Typparameters den Vergleich mit Null unterstützt. Um die Typsicherheit aufrechtzuerhalten und potenzielle Laufzeitfehler zu vermeiden, erlaubt die Go-Sprache daher keinen direkten Vergleich zwischen Typparametern und Null.
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 }
Der Typ T des zugrunde liegenden Elements muss ein Basistyp sein und darf kein Schnittstellentyp sein.
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 }
Elemente vom Unionstyp können keine Typparameter sein und Nicht-Schnittstellenelemente müssen paarweise disjunkt sein. Wenn mehr als ein Element vorhanden ist, kann es weder einen Schnittstellentyp mit nicht leeren Methoden enthalten, noch kann es vergleichbar sein oder Vergleichbares einbetten.
var i interface{} = "hello" s, ok := i.(string) if ok { fmt.Println(s) // Output "hello" } else { fmt.Println("not a string") }
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{}
Um Generika sinnvoll nutzen zu können, sollten bei der Anwendung folgende Punkte beachtet werden:
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){}
Der obige Code meldet einen Fehler: Ungültige Operation: Zeiger von ptr (Variable vom Typ T, eingeschränkt durch *int | *uint) müssen identische Basistypen haben. Der Grund für diesen Fehler liegt darin, dass T ein Typparameter ist und der Typparameter kein Zeiger ist und die Dereferenzierungsoperation nicht unterstützt. Dies kann gelöst werden, indem die Definition wie folgt geändert wird:
// 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 }
Insgesamt lassen sich die Vorteile von Generika in drei Aspekten zusammenfassen:
Lassen Sie mich abschließend Leapcell vorstellen, die am besten geeignete Plattform für die Bereitstellung von Go-Diensten.
Erfahren Sie mehr in der Dokumentation!
Leapcell Twitter: https://x.com/LeapcellHQ
Das obige ist der detaillierte Inhalt vonGo Generics: Ein tiefer Einblick. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!