Pointeurs vs valeurs dans les paramètres et valeurs de retour

330

Dans Go, il existe différentes façons de renvoyer un struct valeur ou une tranche de celle-ci. Pour les individus que j'ai vus:

type MyStruct struct {
    Val int
}

func myfunc() MyStruct {
    return MyStruct{Val: 1}
}

func myfunc() *MyStruct {
    return &MyStruct{}
}

func myfunc(s *MyStruct) {
    s.Val = 1
}

Je comprends les différences entre ceux-ci. Le premier retourne une copie de la structure, le second un pointeur sur la valeur de structure créée dans la fonction, le troisième s'attend à ce qu'une structure existante soit transmise et remplace la valeur.

J'ai vu tous ces modèles être utilisés dans divers contextes, je me demande quelles sont les meilleures pratiques à ce sujet. Quand utiliseriez-vous quoi? Par exemple, le premier pourrait être correct pour les petites structures (car la surcharge est minime), la seconde pour les plus grandes. Et le troisième si vous voulez être extrêmement efficace en mémoire, car vous pouvez facilement réutiliser une seule instance de structure entre les appels. Existe-t-il des meilleures pratiques pour savoir quand les utiliser?

De même, la même question concernant les tranches:

func myfunc() []MyStruct {
    return []MyStruct{ MyStruct{Val: 1} }
}

func myfunc() []*MyStruct {
    return []MyStruct{ &MyStruct{Val: 1} }
}

func myfunc(s *[]MyStruct) {
    *s = []MyStruct{ MyStruct{Val: 1} }
}

func myfunc(s *[]*MyStruct) {
    *s = []MyStruct{ &MyStruct{Val: 1} }
}

Encore une fois: quelles sont les meilleures pratiques ici. Je sais que les tranches sont toujours des pointeurs, donc retourner un pointeur sur une tranche n'est pas utile. Cependant, dois-je renvoyer une tranche de valeurs de struct, une tranche de pointeurs vers des structures, dois-je passer un pointeur vers une tranche comme argument (un modèle utilisé dans l' API Go App Engine )?

Zef Hemel
la source
1
Comme vous le dites, cela dépend vraiment du cas d'utilisation. Tous sont valables selon la situation - est-ce un objet modifiable? voulons-nous une copie ou un pointeur? etc. BTW vous n'avez pas mentionné l'utilisation new(MyStruct):) Mais il n'y a pas vraiment de différence entre les différentes méthodes d'allocation des pointeurs et de leur retour.
Not_a_Golfer
15
C'est littéralement de l'ingénierie. Les structures doivent être assez grandes pour que le retour d'un pointeur accélère votre programme. Ne vous embêtez pas, codez, profilez, corrigez si utile.
Volker
1
Il n'y a qu'une seule façon de renvoyer une valeur ou un pointeur, et c'est de renvoyer une valeur ou un pointeur. La façon dont vous les affectez est un problème distinct. Utilisez ce qui fonctionne pour votre situation et écrivez du code avant de vous en préoccuper.
JimB
3
BTW juste par curiosité, je benchamrked cela. Le retour des structures par rapport aux pointeurs semble avoir à peu près la même vitesse, mais passer des pointeurs vers des fonctions en aval est nettement plus rapide. Bien que ce ne soit pas à un niveau, cela importerait
Not_a_Golfer
1
@Not_a_Golfer: Je suppose que l'allocation bc se fait uniquement en dehors de la fonction. L'analyse comparative des valeurs par rapport aux pointeurs dépend également de la taille de la structure et des modèles d'accès à la mémoire après coup. Copier des éléments de la taille d'une ligne de cache est aussi rapide que possible, et la vitesse de déréférencement des pointeurs du cache du processeur est très différente de celle de déréférencement entre la mémoire principale.
JimB

Réponses:

392

tl; dr :

  • Les méthodes utilisant des pointeurs récepteurs sont courantes; la règle d'or pour les récepteurs est : "En cas de doute, utilisez un pointeur."
  • Les tranches, les cartes, les canaux, les chaînes, les valeurs de fonction et les valeurs d'interface sont implémentées avec des pointeurs en interne, et un pointeur vers ces derniers est souvent redondant.
  • Ailleurs, utilisez des pointeurs pour les grandes structures ou les structures que vous devrez changer, sinon transmettez des valeurs , car changer les choses par surprise via un pointeur est source de confusion.

Un cas où vous devriez souvent utiliser un pointeur:

  • Les récepteurs sont des pointeurs plus souvent que les autres arguments. Il n'est pas inhabituel que les méthodes modifient la chose sur laquelle elles sont appelées, ou que les types nommés soient de grandes structures, donc les conseils sont par défaut sur les pointeurs, sauf dans de rares cas.
    • L' outil de copie de Jeff Hodges recherche automatiquement les récepteurs non minuscules passés par valeur.

Certaines situations où vous n'avez pas besoin de pointeurs:

  • Les directives de révision de code suggèrent de passer de petites structures comme type Point struct { latitude, longitude float64 }, et peut-être même des choses un peu plus grandes, en tant que valeurs, à moins que la fonction que vous appelez ne puisse les modifier en place.

    • La sémantique des valeurs évite les situations d'alias où une affectation par ici modifie une valeur par surprise.
    • Ce n'est pas Go-y de sacrifier une sémantique propre pour un peu de vitesse, et parfois passer de petites structures par valeur est en fait plus efficace, car cela évite les échecs de cache ou les allocations de tas.
    • Ainsi, la page de commentaires de révision de code de Go Wiki suggère de passer par valeur lorsque les structures sont petites et susceptibles de rester ainsi.
    • Si la "grande" coupure semble vague, elle l'est; sans doute de nombreuses structures sont dans une plage où soit un pointeur ou une valeur est OK. En tant que limite inférieure, les commentaires de révision de code suggèrent que les tranches (trois mots machine) sont raisonnables à utiliser comme récepteurs de valeur. Comme quelque chose de plus proche d'une limite supérieure, bytes.Replaceprend 10 mots d'arguments (trois tranches et un int).
  • Pour les tranches , vous n'avez pas besoin de passer un pointeur pour changer les éléments du tableau. io.Reader.Read(p []byte)change les octets de p, par exemple. C'est sans doute un cas spécial de "traiter les petites structures comme des valeurs", car en interne, vous passez autour d'une petite structure appelée un en- tête de tranche (voir l' explication de Russ Cox (rsc) ). De même, vous n'avez pas besoin d'un pointeur pour modifier une carte ou communiquer sur un canal .

  • Pour les tranches, vous allez redimensionner (changer le début / la longueur / la capacité de), les fonctions intégrées comme appendaccepter une valeur de tranche et en retourner une nouvelle. J'imiterais ça; cela évite le crénelage, le retour d'une nouvelle tranche permet d'attirer l'attention sur le fait qu'un nouveau tableau peut être alloué, et il est familier aux appelants.

    • Ce n'est pas toujours pratique de suivre ce modèle. Certains outils tels que les interfaces de base de données ou les sérialiseurs doivent être ajoutés à une tranche dont le type n'est pas connu au moment de la compilation. Ils acceptent parfois un pointeur sur une tranche dans un interface{}paramètre.
  • Les cartes, les canaux, les chaînes et les valeurs de fonction et d'interface , comme les tranches, sont des références internes ou des structures qui contiennent déjà des références, donc si vous essayez simplement d'éviter de copier les données sous-jacentes, vous n'avez pas besoin de leur passer des pointeurs . (rsc a écrit un article séparé sur la façon dont les valeurs d'interface sont stockées ).

    • Vous devrez peut-être toujours passer des pointeurs dans le cas plus rare où vous souhaitez modifier la structure de l'appelant: flag.StringVarprend un *stringpour cette raison, par exemple.

Où vous utilisez des pointeurs:

  • Déterminez si votre fonction doit être une méthode sur la structure vers laquelle vous avez besoin d'un pointeur. Les gens attendent beaucoup de méthodes sur xde modifier x, rendant ainsi le struct modifié le récepteur peut aider à minimiser la surprise. Il existe des directives sur le moment où les récepteurs doivent être des pointeurs.

  • Les fonctions qui ont des effets sur leurs paramètres non récepteurs devraient l'indiquer clairement dans le godoc, ou mieux encore, le godoc et le nom (comme reader.WriteTo(writer)).

  • Vous mentionnez accepter un pointeur pour éviter les allocations en permettant la réutilisation; changer les API pour la réutilisation de la mémoire est une optimisation que je retarderais jusqu'à ce qu'il soit clair que les allocations ont un coût non trivial, puis je chercherais un moyen qui ne force pas l'API la plus délicate sur tous les utilisateurs:

    1. Pour éviter les allocations, l' analyse d'échappement de Go est votre ami. Vous pouvez parfois l'aider à éviter les allocations de tas en créant des types qui peuvent être initialisés avec un constructeur trivial, un littéral simple ou une valeur zéro utile comme bytes.Buffer.
    2. Considérez une Reset()méthode pour remettre un objet dans un état vide, comme certains types stdlib le proposent. Les utilisateurs qui ne se soucient pas ou ne peuvent pas enregistrer une allocation ne doivent pas l'appeler.
    3. Envisagez d'écrire des méthodes de modification sur place et des fonctions de création à partir de zéro en tant que paires correspondantes, pour plus de commodité: existingUser.LoadFromJSON(json []byte) errorpourrait être encapsulé par NewUserFromJSON(json []byte) (*User, error). Encore une fois, cela pousse le choix entre la paresse et le pincement des allocations à l'appelant individuel.
    4. Les appelants qui cherchent à recycler la mémoire peuvent laisser sync.Poolgérer certains détails. Si une allocation particulière crée beaucoup de pression sur la mémoire, vous êtes certain de savoir quand l'allocation n'est plus utilisée et que vous ne disposez pas d'une meilleure optimisation, cela sync.Poolpeut vous aider. (CloudFlare a publié un sync.Poolarticle de blog (pré- ) utile sur le recyclage.)

Enfin, si vos tranches doivent être des pointeurs: les tranches de valeurs peuvent être utiles et vous épargner des allocations et des ratés de cache. Il peut y avoir des bloqueurs:

  • L'API pour créer vos éléments peut vous forcer à pointer du doigt, par exemple vous devez appeler NewFoo() *Fooplutôt que de laisser Go s'initialiser avec la valeur zéro .
  • Les durées de vie souhaitées des articles peuvent ne pas être toutes les mêmes. La tranche entière est libérée à la fois; si 99% des éléments ne sont plus utiles mais que vous avez des pointeurs vers les 1% restants, tout le tableau reste alloué.
  • Le déplacement d'objets peut vous poser des problèmes. Notamment, appendcopie les éléments lorsqu'il agrandit le tableau sous-jacent . Les pointeurs que vous avez reçus avant le appendpoint au mauvais endroit après, la copie peut être plus lente pour les structures énormes, et par exemple, la sync.Mutexcopie n'est pas autorisée. Insérer / supprimer au milieu et trier de la même manière déplacer les éléments.

De manière générale, les tranches de valeur peuvent avoir un sens si vous mettez tous vos éléments en place à l'avant et ne les déplacez pas (par exemple, plus de appends après la configuration initiale), ou si vous continuez à les déplacer, mais vous êtes sûr que c'est OK (pas / utilisation prudente des pointeurs vers les éléments, les éléments sont assez petits pour être copiés efficacement, etc.). Parfois, vous devez penser ou mesurer les spécificités de votre situation, mais c'est un guide approximatif.

twotwotwo
la source
12
Que signifie gros structs? Y a-t-il un exemple d'une grande structure et d'une petite structure?
L'utilisateur sans chapeau
1
Comment pouvez-vous dire bytes.Replace prend 80 octets d'arguments sur amd64?
Tim Wu
2
La signature est Replace(s, old, new []byte, n int) []byte; s, ancien et nouveau sont chacun trois mots (les en- têtes de tranche le sont(ptr, len, cap) ) et n intest un mot, donc 10 mots, ce qui à huit octets / mot équivaut à 80 octets.
twotwotwo
6
Comment définissez-vous les grandes structures? Quelle taille est grande?
Andy Aldo
3
@AndyAldo Aucune de mes sources (commentaires de révision de code, etc.) ne définit de seuil, j'ai donc décidé de dire que c'était un jugement au lieu de faire un seuil. Trois mots (comme une tranche) sont traités de manière assez cohérente comme éligibles pour être une valeur dans le stdlib. J'ai trouvé une instance d'un récepteur de valeur de cinq mots tout à l'heure (texte / scanner.Position) mais je ne lirais pas grand-chose (c'est aussi passé comme pointeur!). En l'absence de repères, etc., je ferais tout ce qui semble le plus pratique pour la lisibilité.
twotwotwo
10

Trois raisons principales pour lesquelles vous souhaitez utiliser des récepteurs de méthode comme pointeurs:

  1. "Premièrement, et le plus important, la méthode doit-elle modifier le récepteur? Si c'est le cas, le récepteur doit être un pointeur."

  2. "Le deuxième est la considération de l'efficacité. Si le récepteur est grand, une grande structure par exemple, il sera beaucoup moins cher d'utiliser un récepteur de pointeur."

  3. "Ensuite est la cohérence. Si certaines des méthodes du type doivent avoir des récepteurs de pointeur, le reste devrait aussi, donc l'ensemble de méthodes est cohérent quelle que soit la façon dont le type est utilisé"

Référence : https://golang.org/doc/faq#methods_on_values_or_pointers

Edit: Une autre chose importante est de connaître le "type" réel que vous envoyez pour fonctionner. Le type peut être soit un «type de valeur» soit un «type de référence».

Même si les tranches et les cartes agissent comme des références, nous pourrions vouloir les passer comme pointeurs dans des scénarios comme changer la longueur de la tranche dans la fonction.

Santosh Pillai
la source
1
Pour 2, quelle est la limite? Comment savoir si ma structure est grande ou petite? De plus, existe-t-il une structure suffisamment petite pour qu'il soit plus efficace d'utiliser une valeur plutôt qu'un pointeur (de sorte qu'elle ne doive pas être référencée à partir du tas)?
zlotnika
Je dirais que plus il y a de champs et / ou de structures imbriquées à l'intérieur, plus la structure est grande. Je ne sais pas s'il existe une coupure spécifique ou un moyen standard de savoir quand une structure peut être appelée "grande" ou "grande". Si j'utilise ou crée une structure, je saurais si elle est grande ou petite en fonction de ce que j'ai dit ci-dessus. Mais c'est juste moi !.
Santosh Pillai
2

Un cas où vous devez généralement renvoyer un pointeur est lors de la construction d'une instance d'une ressource avec état ou partageable . Cela se fait souvent par des fonctions préfixées parNew .

Parce qu'ils représentent une instance spécifique de quelque chose et qu'ils peuvent avoir besoin de coordonner une certaine activité, cela n'a pas beaucoup de sens de générer des structures dupliquées / copiées représentant la même ressource - donc le pointeur retourné agit comme le handle de la ressource elle-même .

Quelques exemples:

Dans d'autres cas, les pointeurs sont renvoyés simplement parce que la structure peut être trop grande pour être copiée par défaut:


Alternativement, le retour direct de pointeurs pourrait être évité en renvoyant à la place une copie d'une structure qui contient le pointeur en interne, mais ce n'est peut-être pas considéré comme idiomatique:

nobar
la source
Implicite dans cette analyse est que, par défaut, les structures sont copiées par valeur (mais pas nécessairement leurs membres indirects).
nobar
2

Si vous le pouvez (par exemple, une ressource non partagée qui n'a pas besoin d'être transmise comme référence), utilisez une valeur. Pour les raisons suivantes:

  1. Votre code sera plus agréable et plus lisible, évitant les opérateurs de pointeurs et les vérifications nulles.
  2. Votre code sera plus sûr contre les paniques Null Pointer.
  3. Votre code sera souvent plus rapide: oui, plus vite! Pourquoi?

Raison 1 : vous allouerez moins d'articles dans la pile. L'allocation / désallocation à partir de la pile est immédiate, mais l'allocation / désallocation sur le tas peut être très coûteuse (temps d'allocation + ramasse-miettes). Vous pouvez voir quelques chiffres de base ici: http://www.macias.info/entry/201802102230_go_values_vs_references.md

Raison 2 : surtout si vous stockez des valeurs retournées dans des tranches, vos objets mémoire seront plus compactés en mémoire: boucler une tranche où tous les éléments sont contigus est beaucoup plus rapide que d'itérer une tranche où tous les éléments sont des pointeurs vers d'autres parties de la mémoire . Pas pour l'étape d'indirection mais pour l'augmentation des échecs de cache.

Briseur de mythes : une ligne de cache x86 typique fait 64 octets. La plupart des structures sont plus petites que cela. Le temps de copie d'une ligne de cache en mémoire est similaire à la copie d'un pointeur.

Ce n'est que si une partie critique de votre code est lente que j'essaierais une micro-optimisation et vérifier si l'utilisation de pointeurs améliore quelque peu la vitesse, au prix d'une moindre lisibilité et d'une maintenabilité.

Mario
la source