Wie man bessere Go Funktionen schreibt
Ethan Miller
Product Engineer · Leapcell

Lass uns zuerst Ward Cunninghams Definition von „First-Class Citizens“ ansehen:
Wenn eine Programmiersprache keine Einschränkungen bei der Erstellung und Verwendung eines bestimmten Sprachelements vorsieht und wir dieses syntaktische Element genauso behandeln können wie Werte, dann können wir dieses syntaktische Element als „First-Class Citizen“ in dieser Programmiersprache bezeichnen.
Einfach ausgedrückt: In Go können Funktionen Variablen zugewiesen werden.
Eine Funktion kann als Parameter übergeben, als Variablentyp oder als Rückgabewert verwendet werden.
Als Parameter
In kube-proxy ist der Funktionstyp makeEndpointFunc
definiert, mit entsprechenden Implementierungen in ipvs, nftables und iptables.
type makeEndpointFunc func(info *BaseEndpointInfo, svcPortName *ServicePortName) Endpoint
Obwohl die Implementierungen unterschiedlich sind, können wir durch die Vereinheitlichung des Funktionstyps dieselben Endpoint-Informationen unter verschiedenen Hardware-Unterstützungen instanziieren. Die Anwendung muss sich nicht um die spezifische Implementierung von Endpoint kümmern; solange sie die notwendigen Informationen vom Knoten korrekt abrufen kann, um die übergeordnete Logik zu vervollständigen, muss sie sich nicht um die spezifischen Implementierungsdetails von Endpoint kümmern.
func NewEndpointsChangeTracker(hostname string, makeEndpointInfo makeEndpointFunc, ipFamily v1.IPFamily, recorder events.EventRecorder, processEndpointsMapChange processEndpointsMapChangeFunc) *EndpointsChangeTracker { return &EndpointsChangeTracker{ endpointSliceCache: NewEndpointSliceCache(hostname, ipFamily, recorder, makeEndpointInfo), } }
Du kannst sehen, dass NewEndpointsChangeTracker
direkt das übergebene makeEndpointInfo
verwendet, um den Cache zu initialisieren.
Closures für zustandsbehaftete Funktionen
Normalerweise wird der entsprechende Stack-Speicher freigegeben, nachdem ein Funktionsaufruf abgeschlossen ist. Aber für Funktionen mit Closures wird der Stack-Speicher erst freigegeben, nachdem der Closure ausgeführt wurde.
Um es professioneller auszudrücken: Nach der Rückgabe eines Funktionsaufrufs gibt es einen Stack-Bereich, dessen Ressourcen noch nicht freigegeben sind.
Obwohl es einen gewissen Leistungsaufwand gibt, ermöglicht uns die Bequemlichkeit, die Closures beim Programmieren bieten, sie angemessen zu verwenden, um die Codeflexibilität zu verbessern.
Closures können verwendet werden, um Parameter zu übergeben und neue Funktionen zu erstellen, wodurch zustandsbehaftete Funktionen ermöglicht werden.
func NewDeploymentController(...) (*DeploymentController, error) { logger := klog.FromContext(ctx) dInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { dc.addDeployment(logger, obj) }, UpdateFunc: func(oldObj, newObj interface{}) { dc.updateDeployment(logger, oldObj, newObj) }, DeleteFunc: func(obj interface{}) { dc.deleteDeployment(logger, obj) }, }) }
Bei der Aufruf von ResourceEventHandlerFuncs
müssen wir den Logger-Parameter nicht jedes Mal übergeben. Wir können den Logger der Funktion direkt erstellen, wodurch die wiederholte Definition des Logger-Parameters vermieden wird.
Flexible variadische Parameter
Sehen wir uns ein Beispiel für die Konstruktionsmethode SharedInformerFactory
in k8s an.
type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory
SharedInformerOption
ist ein Funktionstyp, der eine sharedInformerFactory
entgegennimmt, ihren Wert festlegt und dann dieselbe sharedInformerFactory
zurückgibt.
func WithNamespace(namespace string) SharedInformerOption { return func(factory *sharedInformerFactory) *sharedInformerFactory { factory.namespace = namespace return factory } } type sharedInformerFactory struct { //... namespace string //... }
Wir sehen, dass WithNamespace
einen Closure verwendet, um den Factory-Parameter festzulegen, und einen Funktionstyp zurückgibt.
func NewSharedInformerFactoryWithOptions( client kubernetes.Interface, defaultResync time.Duration, options ...SharedInformerOption, ) SharedInformerFactory { factory := &sharedInformerFactory{} // Apply all variadic parameters here for _, opt := range options { factory = opt(factory) } return factory }
options
in diesem Beispiel ist ein variadischer Parameter. Durch die Verwendung von Closures ermöglicht Go eine flexible Handhabung von variadischen Parametern, selbst wenn die tatsächliche Geschäftslogik das Festlegen von Parametern unterschiedlicher Typen erfordert.
Die Anwendung von Funktoren
Funktoren eignen sich hervorragend für die Durchführung von homogenen Batch-Operationen an Container-Elementen, und der Code ist oft eleganter und prägnanter als das manuelle Durchlaufen jedes Elements. Ein Funktor ist eine Schnittstelle, die eine Funktion definiert. Die Funktion nimmt eine Transformationsfunktion für die Container-Elemente als Parameter entgegen und gibt die Funktor-Schnittstelle zurück.
Die Funktor-Implementierung ist eine Struktur mit einer Container-Eigenschaft und implementiert die Schnittstellenfunktion. Die Logik besteht darin, die Container-Elemente zu durchlaufen, die Transformationsfunktion auf jedes Element anzuwenden, die Ergebnisse an einen neuen Container anzuhängen und schließlich eine neue Struktur zurückzugeben, die aus diesem neuen Container erstellt wurde.
Dies ist geeignet für Szenarien, in denen jedes Datenelement verarbeitet werden muss und die Verarbeitungslogik wie ein Plugin ausgetauscht werden kann.
Sehen wir uns ein Beispiel an:
type User struct { ID int Name string } type Functor[T any] []T func (f Functor[T]) Map(fn func(T) T) Functor[T] { var result Functor[T] for _, v := range f { result = append(result, fn(v)) } return result }
Der generische Funktor wird durch Functor
definiert, und Map
verarbeitet jedes Element.
func main() { users := []User{ {ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}, {ID: 3, Name: "Charlie"}, } upperUserName := func(u User) User { u.Name = strings.ToUpper(u.Name) return u } us := Functor[User](users) us = us.Map(upperUserName) // Print transformed user data for _, u := range us { fmt.Printf("ID: %d, Name: %s\n", u.ID, u.Name) } }
Da Go 1.18 Generics unterstützt, ist die Anwendung von Funktoren umfangreicher geworden. In der Vergangenheit musste man Funktoren für jeden Datentyp implementieren, und der Code war nicht sehr generisch.
Funktoren wirken eher wie syntaktischer Zucker; in der tatsächlichen Logikschreibung sind die Abstraktionsvorteile, die Funktoren dem Code bringen, nicht enorm. Es geht also eher um Technik – die tatsächliche Verwendung hängt von deinen realen Bedürfnissen ab.
Wie man gute Funktionen schreibt
All diese Techniken dienen letztendlich unserem Systemdesign und der Codeimplementierung und machen den Code leichter verständlich und wartbar.
Wie können wir also in der Praxis gute Funktionen schreiben?
Reduziere die Anzahl der Funktionsparameter
Wenn eine Funktion zu viele Parameter hat, überlege, ob du sie in einer Struktur zusammenfassen kannst, um die Informationen zu aggregieren.
Sehen wir uns ein Beispiel für das Erstellen eines Benutzers an:
func createUser(name string, age int, email string, address string, phoneNumber string) error{ // ... }
Hier sind alle Parameter Benutzerinformationen, daher können wir sie mit einer User
-Struktur aggregieren:
// Encapsulate parameters in a struct type User struct { Name string Age int Email string Address string PhoneNumber string } func createUser(user User) error { //... }
Auf diese Weise erhalten die Parameter mehr semantische Bedeutung, und die Aggregation bietet mehr strukturellen Kontext beim Lesen des Codes.
Du kannst auch variadische Parameter verwenden, die eine Modifikation ermöglichen, während Standardwerte bereitgestellt werden, und die mentale Belastung des Aufrufers verringern. Wenn Konfigurationswerte miteinander verknüpft sind, musst du nicht zwei schreiben; ein Wert kann von einem anderen abgeleitet werden.
Reduziere interne Variablen
Zu viele Variablen innerhalb einer Methode bedeuten oft, dass die Methode zu viele Verantwortlichkeiten trägt und aufgeteilt werden muss.
Eine Funktion, die wie ein Schweizer Taschenmesser fungiert, ist nicht gut – sie belastet Aufrufer mit zu viel kognitiver Last, und wenn du sie änderst, musst du alle Verwendungen auf mögliche Nebenwirkungen überprüfen.
Wie beurteilen wir dies in der Praxis? Durch das Schreiben von Unit-Tests, um zu sehen, ob deine Funktion zu komplex ist.
Wenn es schwierig ist, einen Unit-Test für eine Funktion zu schreiben, solltest du überlegen, ob die Funktion zu viele Verantwortlichkeiten oder unklare Semantik hat.
func CreateOrder(order *Order) error { // Validate order if len(order.Items) == 0 { return errors.New("order must have at least one item") } // Calculate total price and update inventory totalPrice := 0.0 for _, item := range order.Items { price, err := GetProductPrice(item.ProductID) if err != nil { return err } totalPrice += price * float64(item.Quantity) // Update inventory if err := UpdateInventory(item.ProductID, item.Quantity); err != nil { return err } } return nil }
In dieser Methode bedeutet das Schreiben von Unit-Tests, dass vieles überprüft werden muss: Auftragsgültigkeit, Gesamtpreisberechnung, ob der Lagerbestand aktualisiert wurde – wenn sich ein Teil ändert, werden die Tests komplexer.
Wir können Validierung, Inventar und Preisberechnung aufteilen:
func ProcessOrder(order *Order) error { // Validate if err := ValidateOrder(order); err != nil { return err } // Calculate total price totalPrice, err := CalculateTotalPrice(order) if err != nil { return err } // Update inventory if err := ProcessInventory(order); err != nil { return err } return nil }
Jetzt müssen wir ProcessOrder
nicht direkt testen – validiere es einfach in Integrationstests, da es hauptsächlich als Klebecode fungiert. Das separate Testen von Auftragsvalidierung, Gesamtpreisberechnung und Lagerbestandsaktualisierung wird viel einfacher.
Achte auf die Funktionslänge
Wie lang sollte eine Methode sein? Es gibt keine strenge Regel, aber eine übermäßige Betonung der Kürze kann auch negative Nebenwirkungen haben.
Der Schlüssel liegt darin, die zyklomatische Komplexität der Funktion zu kontrollieren. Vermeide übermäßige Komplexität, da dies die Menge an Kontext erhöht, die sich der Leser merken muss. Versuche, frühzeitig zurückzukehren, um die Belastung beim Lesen der Funktion zu verringern.
Sehen wir uns ein Beispiel für eine Benutzerregistrierungsfunktion an:
func RegisterUser(username, password, email string) error { if username == "" { return errors.New("username cannot be empty") } else { if len(username) < 3 { return errors.New("username must be at least 3 characters long") } else { if password == "" { return errors.New("password cannot be empty") } else { if len(password) < 6 { return errors.New("password must be at least 6 characters long") } else { if email == "" { return errors.New("email cannot be empty") } else { if !isValidEmail(email) { return errors.New("invalid email format") } else { // Perform user registration logic return nil } } } } } } }
Indem wir bei einem Fehler so schnell wie möglich zurückkehren, können wir es wie folgt vereinfachen:
func RegisterUser(username, password, email string) error { if username == "" { return errors.New("username cannot be empty") } if len(username) < 3 { return errors.New("username must be at least 3 characters long") } if password == "" { return errors.New("password cannot be empty") } if len(password) < 6 { return errors.New("password must be at least 6 characters long") } if email == "" { return errors.New("email cannot be empty") } if !isValidEmail(email) { return errors.New("invalid email format") } // Perform user registration logic return nil }
Sei achtsam bei der Benennung von Funktionen
Wie solltest du eine Funktion benennen? Dies ist etwas, das langfristige Überprüfung und Aufmerksamkeit erfordert.
Zum Beispiel für eine Methode, die den Gesamtpreis des Warenkorbs eines Benutzers berechnet:
func CalculateTotalPriceOfCartForUser(userID int, cartID int) float64 {}
Dies ist nicht so klar wie der folgende Name:
func GetCartTotal(userID, cartID int) float64 {}
Wenn du deine Logik in viele kleine Methoden aufteilst, kann eine inkonsistente Benennung zu redundanten Implementierungen führen. Dies kann vermieden werden, indem man sich an Namenskonventionen hält.
Stell dir vor, wir könnten die Funktion, die wir brauchen, schnell durch Intuition finden – würden wir dann noch eine neue schreiben? Eindeutig nicht.
Funktion Abstraktionsebenen
Ob der gesamte Code innerhalb einer Funktion auf der gleichen Abstraktionsebene liegt, ist auch ein Standard zur Beurteilung der Funktionsqualität.
Wenn sich Code auf der gleichen Abstraktionsebene befindet, ist es einfach, schnell zu verstehen, was die Funktion tut.
Zum Beispiel in MVC:
- Controller behandelt die ParameterValidierung,
- Service behandelt die Logik,
- Dao behandelt Datenbankoperationen.
Wenn du in einem Controller auf die Datenbank zugreifst, brichst du Abstraktionsebenen auf, wodurch der Code schwieriger zu verstehen wird.
Funktionen dienen nicht nur der Beseitigung von Duplikaten
Obwohl Funktionen doppelten Code beseitigen können, liegt ihr eigentlicher Wert in der Erstellung von Abstraktionen – nicht nur in der Entfernung von Duplikaten.
Wenn du Funktionen nur abstrahierst, um Duplikate zu beseitigen, kann dein Code zu einem verworrenen Durcheinander werden. Wenn keine Notwendigkeit zur Wiederverwendung besteht, ist möglicherweise unklar, wozu eine Funktion wirklich dient.
Anfangs habe ich Funktionen abstrahiert, indem ich nach doppeltem Code gesucht habe. Aber als die Anforderungen stiegen, erhielt der ursprünglich gemeinsam genutzte Code immer mehr personalisierte Logik.
Schließlich wurden viele Funktionen, die „zur Beseitigung von Duplikaten“ erstellt wurden, schwer verständlich, da sich die Dinge auseinanderentwickelten.
Daher ist es wichtiger, gute Abstraktionen zu erstellen, um gute Funktionen zu schreiben. Auch wenn es im Moment keine Wiederverwendung gibt, wird deine abstrahierte Logik mit dem Wachstum des Unternehmens allmählich wiederverwendet.
Mit steigenden Abstraktionsebenen gibt es weniger Details, und es kommt den realen Problemen näher, die du lösen möchtest.
Wenn wir kommunizieren, lösen wir häufiger Geschäftsprobleme durch abstrahierte Objekte. Wie man diese Probleme in hochwartbaren Code übersetzt, ist etwas, wonach wir immer streben sollten.
Zusammenfassung
Wenn du extreme Anforderungen an die Leistung hast, musst du manchmal die Lesbarkeit des Codes opfern. Dies ist jedoch in den meisten Geschäftsentwicklungsszenarien selten, daher werden wir es hier nicht im Detail diskutieren.
Fassen wir den Artikel zusammen:
-
Go-Funktionen als First-Class Citizens: Funktionen in Go können Variablen zugewiesen, als Parameter übergeben oder als Rückgabewerte verwendet werden. Dies spiegelt sich in der Definition von
makeEndpointFunc
in kube-proxy wider, wo ein einheitlicher Funktionstyp die gleiche Schnittstelle über verschiedene Hardware-Implementierungen hinweg ermöglicht. -
Closures ermöglichen es Funktionen, den Zustand zu erhalten: Closures ermöglichen es Funktionen, externe Variablen zu erfassen, was die Codeflexibilität erhöht. In
NewDeploymentController
werden Closures verwendet, um direkt auf den Logger zu verweisen, wodurch die Notwendigkeit vermieden wird, den Logger als zusätzlichen Parameter zu übergeben. Mit Closure-Funktionen wieSharedInformerOption
werden flexiblere variadische Funktionsparameter realisiert.
Am Ende haben wir auch besprochen, wie man wirklich gute Funktionen schreibt:
- Reduziere Funktionsparameter und interne Variablen, indem du Strukturen zur Kapselung verwendest oder Funktionen aufteilst.
- Kontrolliere die Funktionslänge und die zyklomatische Komplexität; kehre frühzeitig zurück, um die kognitive Belastung beim Lesen des Codes zu verringern.
- Achte auf eine genaue und prägnante Funktionsbenennung und vermeide die Neuerfindung des Rades aufgrund inkonsistenter Benennung.
- Halte den Code innerhalb einer Funktion auf der gleichen Abstraktionsebene, wodurch es einfacher wird, den Zweck einer Funktion schnell zu verstehen.
- Der Hauptwert von Funktionen liegt in der Erstellung von Abstraktionen, nicht nur in der Beseitigung von doppeltem Code.
Wir sind Leapcell, deine erste Wahl für das Hosten von Go-Projekten.
Leapcell ist die Next-Gen Serverless Plattform für Webhosting, Async Tasks und Redis:
Multi-Sprachen Unterstützung
- Entwickle mit Node.js, Python, Go oder Rust.
Unbegrenzt Projekte kostenlos bereitstellen
- Zahle nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: $25 unterstützt 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Developer Experience
- Intuitive UI für mühelose Einrichtung.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und Protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und Hohe Leistung
- Auto-Skalierung zur einfachen Handhabung hoher Parallelität.
- Kein operativer Overhead – konzentriere dich einfach auf den Aufbau.
Erfahre mehr in der Dokumentation!
Folge uns auf X: @LeapcellHQ