Wertempfänger vs. Zeigerempfänger

107

Es ist für mich sehr unklar, in welchem ​​Fall ich einen Wertempfänger verwenden möchte, anstatt immer einen Zeigerempfänger zu verwenden.
So rekapitulieren Sie aus den Dokumenten:

type T struct {
    a int
}
func (tv  T) Mv(a int) int         { return 0 }  // value receiver
func (tp *T) Mp(f float32) float32 { return 1 }  // pointer receiver

In den Dokumenten heißt es auch: "Für Typen wie Basistypen, Slices und kleine Strukturen ist ein Wertempfänger sehr billig. Wenn die Semantik der Methode keinen Zeiger erfordert, ist ein Wertempfänger effizient und klar."

Der erste Punkt besagt, dass es "sehr billig" ist, aber die Frage ist mehr, ob es billiger ist als der Zeigerempfänger. Also habe ich einen kleinen Benchmark (Code on Gist) erstellt, der mir zeigte, dass der Zeigerempfänger selbst für eine Struktur mit nur einem Zeichenfolgenfeld schneller ist. Dies sind die Ergebnisse:

// Struct one empty string property
BenchmarkChangePointerReceiver  2000000000               0.36 ns/op
BenchmarkChangeItValueReceiver  500000000                3.62 ns/op


// Struct one zero int property
BenchmarkChangePointerReceiver  2000000000               0.36 ns/op
BenchmarkChangeItValueReceiver  2000000000               0.36 ns/op

(Bearbeiten: Bitte beachten Sie, dass der zweite Punkt in neueren Go-Versionen ungültig wurde, siehe Kommentare) .
Der zweite Punkt besagt, dass es "effizient und klar" ist, was eher Geschmackssache ist, nicht wahr? Persönlich bevorzuge ich Konsistenz, indem ich sie überall gleich verwende. Effizienz in welchem ​​Sinne? In Bezug auf die Leistung scheint es, dass Zeiger fast immer effizienter sind. Nur wenige Testläufe mit einer int-Eigenschaft zeigten einen minimalen Vorteil des Value-Empfängers (Bereich von 0,01 bis 0,1 ns / op).

Kann mir jemand einen Fall sagen, in dem ein Wertempfänger eindeutig sinnvoller ist als ein Zeigerempfänger? Oder mache ich im Benchmark etwas falsch, habe ich andere Faktoren übersehen?

Chrisport
quelle
3
Ich habe ähnliche Benchmarks mit einem einzelnen Zeichenfolgenfeld und auch mit zwei Feldern ausgeführt: Zeichenfolgen- und Int-Felder. Ich habe schnellere Ergebnisse vom Wertempfänger erhalten. BenchmarkChangePointerReceiver-4 10000000000 0,99 ns / op BenchmarkChangeItValueReceiver-4 10000000000 0,33 ns / op Dies verwendet Go 1.8. Ich frage mich, ob seit dem letzten Ausführen der Benchmarks Compiler-Optimierungen vorgenommen wurden. Siehe Kern für weitere Details.
pbitty
2
Du hast recht. Wenn ich meinen ursprünglichen Benchmark mit Go1.9 ausführe, erhalte ich jetzt auch andere Ergebnisse. Zeigerempfänger 0,60 ns / op,
Wertempfänger

Antworten:

117

Beachten Sie, dass in den häufig gestellten Fragen die Konsistenz erwähnt wird

Als nächstes kommt die Konsistenz. Wenn einige der Methoden des Typs Zeigerempfänger haben müssen, sollte auch der Rest vorhanden sein, damit der Methodensatz unabhängig von der Verwendung des Typs konsistent ist. Weitere Informationen finden Sie im Abschnitt über Methodensätze .

Wie in diesem Thread erwähnt :

Die Regel für Zeiger gegenüber Werten für Empfänger lautet, dass Wertmethoden für Zeiger und Werte aufgerufen werden können, Zeigermethoden jedoch nur für Zeiger

Jetzt:

Kann mir jemand einen Fall sagen, in dem ein Wertempfänger eindeutig sinnvoller ist als ein Zeigerempfänger?

Der Kommentar zur Codeüberprüfung kann helfen:

  • Wenn der Empfänger eine Karte, eine Funktion oder ein Kanal ist, verwenden Sie keinen Zeiger darauf.
  • Wenn der Empfänger ein Slice ist und die Methode das Slice nicht neu aufteilt oder neu zuordnet, verwenden Sie keinen Zeiger darauf.
  • Wenn die Methode den Empfänger mutieren muss, muss der Empfänger ein Zeiger sein.
  • Wenn der Empfänger eine Struktur ist, die ein sync.Mutexoder ein ähnliches Synchronisationsfeld enthält , muss der Empfänger ein Zeiger sein, um ein Kopieren zu vermeiden.
  • Wenn der Empfänger eine große Struktur oder ein großes Array ist, ist ein Zeigerempfänger effizienter. Wie groß ist groß? Angenommen, es entspricht der Übergabe aller Elemente als Argumente an die Methode. Wenn sich das zu groß anfühlt, ist es auch zu groß für den Empfänger.
  • Können Funktionen oder Methoden, entweder gleichzeitig oder wenn sie von dieser Methode aufgerufen werden, den Empfänger mutieren? Ein Werttyp erstellt beim Aufrufen der Methode eine Kopie des Empfängers, sodass externe Aktualisierungen nicht auf diesen Empfänger angewendet werden. Wenn Änderungen im Originalempfänger sichtbar sein müssen, muss der Empfänger ein Zeiger sein.
  • Wenn der Empfänger eine Struktur, ein Array oder ein Slice ist und eines seiner Elemente ein Zeiger auf etwas ist, das möglicherweise mutiert, bevorzugen Sie einen Zeigerempfänger, da dies dem Leser die Absicht klarer macht.
  • Wenn der Empfänger ein kleines Array oder eine Struktur ist, die natürlich ein Werttyp ist (zum Beispiel so etwas wie der time.TimeTyp), ohne veränderbare Felder und ohne Zeiger, oder nur ein einfacher Basistyp wie int oder string, macht ein Wertempfänger Sinn .
    Ein Wertempfänger kann die Menge an Müll reduzieren, die erzeugt werden kann. Wenn ein Wert an eine Wertemethode übergeben wird, kann eine On-Stack-Kopie verwendet werden, anstatt sie auf dem Heap zuzuweisen. (Der Compiler versucht, diese Zuordnung klug zu vermeiden, kann jedoch nicht immer erfolgreich sein.) Wählen Sie aus diesem Grund keinen Wertempfängertyp aus, ohne zuvor ein Profil zu erstellen.
  • Verwenden Sie im Zweifelsfall einen Zeigerempfänger.

Der fett gedruckte Teil befindet sich beispielsweise in net/http/server.go#Write():

// Write writes the headers described in h to w.
//
// This method has a value receiver, despite the somewhat large size
// of h, because it prevents an allocation. The escape analysis isn't
// smart enough to realize this function doesn't mutate h.
func (h extraHeader) Write(w *bufio.Writer) {
...
}
VonC
quelle
16
The rule about pointers vs. values for receivers is that value methods can be invoked on pointers and values, but pointer methods can only be invoked on pointers Eigentlich nicht wahr. Sowohl Wertempfänger- als auch Zeigerempfängermethoden können für einen korrekt eingegebenen Zeiger oder Nichtzeiger aufgerufen werden. Unabhängig davon, wie die Methode aufgerufen wird, bezieht sich die Kennung des Empfängers innerhalb des Methodenkörpers auf einen By
Hart Simha
3
Es gibt eine große Erklärung hier : "Wenn x adressierbar ist und & x Methode Set enthält m, xm () ist eine Abkürzung für (& x) .m ()."
tera
@tera Ja: das wird unter stackoverflow.com/q/43953187/6309
VonC
4
Gute Antwort, aber ich bin mit diesem Punkt überhaupt nicht einverstanden: "Da dies die Absicht klarer macht", ist NOPE, eine saubere API, X als Argument und Y als Rückgabewert eine klare Absicht. Das Übergeben einer Struktur per Zeiger und das sorgfältige Lesen des Codes, um zu überprüfen, welche Attribute geändert werden, ist alles andere als klar und wartbar.
Lukas Lukac
@ HartSimha Ich denke, der obige Beitrag weist darauf hin, dass Zeigerempfängermethoden für Werttypen nicht im "Methodensatz" enthalten sind. Wenn Sie auf Ihrem verknüpften Spielplatz die folgende Zeile hinzufügen, wird ein Kompilierungsfehler angezeigt : Int(5).increment_by_one_ptr(). Ebenso wird ein Merkmal, das die Methode definiert, increment_by_one_ptrnicht mit einem Wert vom Typ zufrieden sein Int.
Gaurav Agarwal
16

Um zusätzlich zu @VonC eine großartige, informative Antwort hinzuzufügen.

Ich bin überrascht, dass niemand die Wartungskosten wirklich erwähnt hat, sobald das Projekt größer wird, alte Entwickler gehen und neue kommen. Go ist sicher eine junge Sprache.

Im Allgemeinen versuche ich, Zeiger zu vermeiden, wenn ich kann, aber sie haben ihren Platz und ihre Schönheit.

Ich benutze Zeiger, wenn:

  • Arbeiten mit großen Datenmengen
  • einen Strukturerhaltungszustand haben, z. B. TokenCache,
    • Ich stelle sicher, dass ALLE Felder PRIVAT sind, Interaktion ist nur über definierte Methodenempfänger möglich
    • Ich übergebe diese Funktion keiner Goroutine

Z.B:

type TokenCache struct {
    cache map[string]map[string]bool
}

func (c *TokenCache) Add(contract string, token string, authorized bool) {
    tokens := c.cache[contract]
    if tokens == nil {
        tokens = make(map[string]bool)
    }

    tokens[token] = authorized
    c.cache[contract] = tokens
}

Gründe, warum ich Zeiger vermeide:

  • Zeiger sind nicht gleichzeitig sicher (der ganze Punkt von GoLang)
  • einmal Zeigerempfänger, immer Zeigerempfänger (für alle Struct-Methoden zur Konsistenz)
  • Mutexe sind sicherlich teurer, langsamer und schwerer zu pflegen als die "Wertkopiekosten"
  • Apropos "Wertkopiekosten", ist das wirklich ein Problem? Vorzeitige Optimierung ist die Wurzel allen Übels. Sie können später jederzeit Zeiger hinzufügen
  • es zwingt mich direkt und bewusst dazu, kleine Strukturen zu entwerfen
  • Zeiger können größtenteils vermieden werden, indem reine Funktionen mit klarer Absicht und offensichtlicher E / A entworfen werden
  • Garbage Collection ist schwieriger mit Zeigern, glaube ich
  • leichter über Kapselung, Verantwortlichkeiten zu streiten
  • halte es einfach, dumm (ja, Zeiger können schwierig sein, weil du den Entwickler des nächsten Projekts nie kennst)
  • Unit-Test ist wie ein Spaziergang durch einen rosa Garten (nur slowakischer Ausdruck?), bedeutet einfach
  • keine NIL wenn Bedingungen (NIL kann dort übergeben werden, wo ein Zeiger erwartet wurde)

Meine Faustregel lautet: Schreiben Sie so viele gekapselte Methoden wie möglich, z.

package rsa

// EncryptPKCS1v15 encrypts the given message with RSA and the padding scheme from PKCS#1 v1.5.
func EncryptPKCS1v15(rand io.Reader, pub *PublicKey, msg []byte) ([]byte, error) {
    return []byte("secret text"), nil
}

cipherText, err := rsa.EncryptPKCS1v15(rand, pub, keyBlock) 

AKTUALISIEREN:

Diese Frage hat mich dazu inspiriert, das Thema genauer zu untersuchen und einen Blog-Beitrag darüber zu schreiben: https://medium.com/gophersland/gopher-vs-object-oriented-golang-4fa62b88c701

Lukas Lukac
quelle
Ich mag 99% von dem, was Sie hier sagen, und stimme dem voll und ganz zu. Trotzdem frage ich mich, ob Ihr Beispiel der beste Weg ist, Ihren Standpunkt zu veranschaulichen. Ist TokenCache nicht im Wesentlichen eine Karte (von @VonC - "Wenn der Empfänger eine Karte, eine Funktion oder ein Kanal ist, verwenden Sie keinen Zeiger darauf"). Da Karten Referenztypen sind, was gewinnen Sie, wenn Sie "Add ()" zu einem Zeigerempfänger machen? Alle Kopien von TokenCache verweisen auf dieselbe Karte. Siehe diesen Go-Spielplatz - play.golang.com/p/Xda1rsGwvhq
Rich
Ich bin froh, dass wir ausgerichtet sind. Toller Punkt. Eigentlich glaube ich, dass ich in diesem Beispiel einen Zeiger verwendet habe, weil ich ihn aus einem Projekt kopiert habe, in dem der TokenCache mehr Dinge als nur diese Karte verarbeitet. Und wenn ich einen Zeiger in einer Methode verwende, verwende ich ihn in allen. Schlagen Sie vor, den Zeiger aus diesem speziellen SO-Beispiel zu entfernen?
Lukas Lukac
LOL, Kopieren / Einfügen schlägt wieder zu! 😉 IMO können Sie es so lassen, wie es ist, da es eine Falle darstellt, in die man leicht hineinfallen kann, oder Sie können die Karte durch etwas ersetzen, das den Status und / oder eine große Datenstruktur zeigt.
Rich
Nun, ich bin sicher, dass sie die Kommentare lesen werden ... PS: Rich, Ihre Argumente scheinen vernünftig, fügen Sie mich auf LinkedIn (Link in meinem Profil) hinzu und freuen Sie sich, eine Verbindung herzustellen.
Lukas Lukac