Mécanique du langage des piles et des pointeurs

Prélude


Il s'agit du premier des quatre articles de la série qui fournira un aperçu de la mécanique et de la conception des pointeurs, des piles, des tas, de l'analyse d'échappement et de la sémantique Go / pointeur. Ce message concerne les piles et les pointeurs.

Table des matières:

  1. Mécanique du langage sur les piles et les pointeurs
  2. Mécanique du langage sur l'analyse d'évasion ( traduction )
  3. Mécanique du langage sur le profilage de la mémoire
  4. Philosophie de conception sur les données et la sémantique

introduction


Je ne vais pas dissimuler - les pointeurs sont difficiles à comprendre. S'ils sont mal utilisés, les pointeurs peuvent provoquer des erreurs désagréables et même des problèmes de performances. Cela est particulièrement vrai lors de l'écriture de programmes compétitifs ou multithread. Sans surprise, de nombreuses langues essaient de cacher des pointeurs aux programmeurs. Cependant, si vous écrivez dans Go, vous ne pouvez pas échapper aux pointeurs. Sans une compréhension claire des pointeurs, il vous sera difficile d'écrire du code propre, simple et efficace.

Bordures de cadre


Les fonctions sont exécutées dans les limites des trames qui fournissent un espace mémoire séparé pour chaque fonction correspondante. Chaque trame permet à la fonction de fonctionner dans son propre contexte et fournit également un contrôle de flux. Une fonction a un accès direct à la mémoire à l'intérieur de son cadre via un pointeur, mais l'accès à la mémoire à l'extérieur du cadre nécessite un accès indirect. Pour qu'une fonction accède à la mémoire en dehors de sa trame, cette mémoire doit être utilisée conjointement avec cette fonction. La mécanique et les limites fixées par ces limites doivent être comprises et étudiées en premier.

Lorsqu'une fonction est appelée, une transition entre deux trames se produit. Le code passe de la trame de la fonction appelante à la trame de la fonction appelée. Si les données sont nécessaires pour appeler la fonction, ces données doivent être transférées d'une trame à l'autre. Le transfert de données entre deux trames dans Go se fait "par valeur".

L'avantage de la transmission de données «par valeur» est la lisibilité. La valeur que vous voyez dans l'appel de fonction est ce qui est copié et accepté de l'autre côté. C'est pourquoi j'associe «passer par la valeur» avec WYSIWYG, car ce que vous voyez est ce que vous obtenez. Tout cela vous permet d'écrire du code qui ne cache pas le coût de basculement entre deux fonctions. Cela aide à maintenir un bon modèle mental de la façon dont chaque appel de fonction affectera le programme pendant la transition.

Regardez ce petit programme qui appelle une fonction en passant des données entières "par valeur":

Listing 1:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11    // Pass the "value of" the count.
12    increment(count)
13
14    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20    // Increment the "value of" inc.
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }

Lorsque votre programme Go démarre, le runtime crée le programme principal pour commencer à exécuter tout le code, y compris le code à l'intérieur de la fonction principale. Gorutin est le chemin d'exécution qui s'intègre dans le thread du système d'exploitation, qui s'exécute finalement sur un noyau. À partir de la version 1.8, chaque goroutine est fourni avec un bloc initial de mémoire continue de 2048 octets, qui forme l'espace de pile. Cette taille de pile initiale a changé au fil des ans et pourrait changer à l'avenir.

La pile est importante car elle fournit un espace mémoire physique pour les limites de trame qui sont données à chaque fonction individuelle. Au moment où le goroutine principal exécute la fonction principale du listing 1, la pile de programmes (à un niveau très élevé) ressemblera à ceci:

Figure 1:



Dans la figure 1, vous pouvez voir qu'une partie de la pile a été «encadrée» pour la fonction principale. Cette section est appelée le " cadre de pile ", et c'est ce cadre qui indique la limite de la fonction principale sur la pile. Le cadre est défini comme faisant partie du code qui s'exécute lorsque la fonction est appelée. Vous pouvez également voir que la mémoire de la variable count a été allouée à 0x10429fa4 à l'intérieur du cadre pour main.

Il y a un autre point intéressant, illustré sur la figure 1. Toute la mémoire de pile sous la trame active n'est pas valide, mais la mémoire de la trame active et au-dessus est valide. Vous devez comprendre clairement la frontière entre la partie valide et non valide de la pile.

Adresses


Les variables sont utilisées pour attribuer un nom à un emplacement de mémoire spécifique pour améliorer la lisibilité du code et vous aider à comprendre avec quelles données vous travaillez. Si vous avez une variable, alors vous avez une valeur en mémoire, et si vous avez une valeur en mémoire, alors elle doit avoir une adresse. Sur la ligne 09, la fonction principale appelle la fonction println intégrée pour afficher la "valeur" et l '"adresse" de la variable de comptage.

Listing 2:

09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

L'utilisation de l'esperluette «&» pour obtenir l'adresse de l'emplacement d'une variable n'est pas nouvelle, d'autres langues utilisent également cet opérateur. La sortie de la ligne 09 devrait ressembler à la sortie ci-dessous si vous exécutez du code sur une architecture 32 bits telle que Go Playground:

Listing 3:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Appel de fonction


Ensuite, sur la ligne 12, la fonction principale appelle la fonction d'incrémentation.

Listing 4:

12    increment(count)

Faire un appel de fonction signifie que le programme doit créer une nouvelle section de mémoire sur la pile. Cependant, tout est un peu plus compliqué. Pour réussir un appel de fonction, il est prévu que les données soient transférées à travers la limite de trame et placées dans une nouvelle trame pendant la transition. En particulier, une valeur entière devrait être copiée et transmise pendant l'appel. Vous pouvez voir cette exigence en regardant la déclaration de la fonction d'incrémentation à la ligne 18.

Listing 5:

18 func increment(inc int) {

Si vous regardez à nouveau l'appel à la fonction d'incrémentation sur la ligne 12, vous verrez que le code passe la «valeur» du nombre de variables. Cette valeur sera copiée, transférée et placée dans un nouveau cadre pour la fonction d'incrémentation. N'oubliez pas que la fonction d'incrémentation ne peut lire et écrire en mémoire que dans sa propre trame, elle a donc besoin de la variable inc pour obtenir, stocker et accéder à sa propre copie de la valeur de compteur transmise.

Juste avant que le code à l'intérieur de la fonction d'incrémentation ne commence à s'exécuter, la pile du programme (à un niveau très élevé) ressemblera à ceci:

Figure 2:



Vous pouvez voir qu'il y a maintenant deux images sur la pile - une pour le principal et une en dessous pour l'incrément. À l'intérieur du cadre pour l'incrémentation, vous pouvez voir la variable inc contenant la valeur 10, qui a été copiée et transmise lors de l'appel de fonction. L'adresse variable inc est 0x10429f98, et elle est moins en mémoire car les trames sont poussées sur la pile, qui ne sont que des détails d'implémentation qui ne veulent rien dire. L'important est que le programme récupère la valeur de comptage dans le cadre pour principal et place une copie de cette valeur dans le cadre pour augmenter à l'aide de la variable inc.

Le reste du code à l'intérieur incrémente incrémente et affiche la "valeur" et l '"adresse" de la variable inc.

Listing 6:

21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

La sortie de la ligne 22 dans la cour de récréation devrait ressembler à ceci:

Listing 7:

inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

Voici à quoi ressemble la pile après avoir exécuté les mêmes lignes de code:

Figure 3:



Après avoir exécuté les lignes 21 et 22, la fonction d'incrémentation se termine et renvoie le contrôle à la fonction principale. Ensuite, la fonction principale affiche à nouveau la "valeur" et l '"adresse" du nombre de variables locales sur la ligne 14.

Listing 8:

14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

La sortie complète du programme dans la cour de récréation devrait ressembler à ceci:

Listing 9:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

La valeur de comptage dans la trame pour principal est la même avant et après l'appel à l'incrémentation.

Retour des fonctions


Qu'arrive-t-il réellement à la mémoire de la pile lorsque la fonction se termine et que le contrôle revient à la fonction appelante? La réponse courte n'est rien. Voici à quoi ressemble la pile après le retour de la fonction d'incrémentation:

Figure 4:



La pile ressemble exactement à la figure 3, sauf que la trame associée à la fonction d'incrémentation est désormais considérée comme une mémoire non valide. Cela est dû au fait que le cadre principal est maintenant actif. La mémoire créée pour la fonction d'incrémentation est restée intacte.

L'effacement de la trame mémoire de la fonction de retour sera une perte de temps, car on ne sait pas si cette mémoire sera à nouveau nécessaire. Le souvenir est donc resté tel qu'il était. Lors de chaque appel de fonction, lorsqu'une trame est prise, la mémoire de pile de cette trame est effacée. Cela se fait en initialisant toutes les valeurs qui correspondent au cadre. Étant donné que toutes les valeurs sont initialisées comme leur "valeur zéro", les piles sont correctement effacées à chaque appel de fonction.

Partage de valeur


Et s'il était important que la fonction d'incrémentation fonctionne directement avec la variable de comptage qui existe à l'intérieur du cadre pour principal? C'est ici que vient le temps des pointeurs. Les pointeurs ont un objectif: partager une valeur avec une fonction afin que la fonction puisse lire et écrire cette valeur, même si la valeur n'existe pas directement dans son cadre.

Si vous ne pensez pas que vous devez «partager» la valeur, vous n'avez pas besoin d'utiliser un pointeur. Lors de l'apprentissage des pointeurs, il est important de penser que l'utilisation d'un dictionnaire propre, et non d'opérateurs ou de syntaxe. N'oubliez pas que les pointeurs sont destinés à être partagés et lorsque vous lisez le code, remplacez l'opérateur & par l'expression «partage».

Types de pointeurs


Pour chaque type que vous avez déclaré ou qui a été déclaré directement par la langue elle-même, vous obtenez un type de pointeur gratuit que vous pouvez utiliser pour le partage. Il existe déjà un type intégré appelé int, il existe donc un type de pointeur nommé * int. Si vous déclarez un type nommé User, vous obtenez gratuitement un type de pointeur nommé * User.

Tous les types de pointeurs ont deux caractéristiques identiques. Tout d'abord, ils commencent par le caractère *. Deuxièmement, ils ont tous la même taille en mémoire et une représentation occupant 4 ou 8 octets qui représentent l'adresse. Sur les architectures 32 bits (par exemple, dans la cour de récréation), les pointeurs nécessitent 4 octets de mémoire et sur les architectures 64 bits (par exemple, votre ordinateur), ils nécessitent 8 octets de mémoire.

Dans la spécification, les types de pointeurssont considérés comme des littéraux de type , ce qui signifie qu'il s'agit de types sans nom composés d'un type existant.

Accès mémoire indirect


Regardez ce petit programme qui fait un appel de fonction, en passant l'adresse "par valeur". Cela séparera la variable de comptage du cadre de pile de main avec la fonction d'incrémentation:

Listing 10:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11    // Pass the "address of" count.
12    increment(&count)
13
14    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20    // Increment the "value of" count that the "pointer points to". (dereferencing)
21    *inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }

Trois modifications intéressantes ont été apportées au programme d'origine. Le premier changement est à la ligne 12:

Listing 11:

12    increment(&count)

Cette fois, à la ligne 12, le code ne copie pas et transmet la "valeur" à la variable de comptage, mais transmet son "adresse" au lieu de la variable de comptage. Vous pouvez maintenant dire: «Je partage» le nombre de variables avec l'incrément de fonction. C'est ce que dit l'opérateur & - «partager».

Sachez qu'il s'agit toujours de «passage par valeur» et que la seule différence est que la valeur que vous passez est l'adresse, pas l'entier. Les adresses sont également des valeurs; c'est ce qui est copié et passé à travers la bordure du cadre pour appeler la fonction.

Étant donné que la valeur d'adresse est copiée et transmise, vous avez besoin d'une variable à l'intérieur du cadre d'incrémentation pour obtenir et enregistrer cette adresse entière. Une déclaration de variable de pointeur entier est à la ligne 18.

Listing 12:

18 func increment(inc *int) {

Si vous avez transmis l'adresse de la valeur de type User, alors la variable devra être déclarée comme * User. Malgré le fait que toutes les variables de pointeur stockent des valeurs d'adresse, aucune adresse ne peut leur être transmise, uniquement les adresses associées au type de pointeur. Le principe de base du partage d'une valeur est que la fonction de réception doit lire ou écrire sur cette valeur. Vous avez besoin d'informations sur le type de n'importe quelle valeur pour y lire et y écrire. Le compilateur s'assurera que seules les valeurs associées au type de pointeur correct sont utilisées avec cette fonction.

Voici à quoi ressemble la pile après avoir appelé la fonction d'incrémentation:

Figure 5:



La figure 5 montre à quoi ressemble la pile lorsque le "passage par valeur" est effectué en utilisant l'adresse comme valeur. La variable de pointeur à l'intérieur du cadre pour la fonction d'incrémentation pointe désormais vers la variable de comptage, qui est située à l'intérieur du cadre pour principal.

Maintenant, en utilisant la variable pointeur, la fonction peut effectuer une opération indirecte de lecture et de modification pour la variable count située à l'intérieur du cadre pour main.

Listing 13:

21    *inc++

Cette fois, le caractère * agit comme un opérateur et est appliqué à la variable pointeur. L'utilisation de * comme opérateur signifie "la valeur vers laquelle pointe le pointeur". Une variable pointeur fournit un accès indirect à la mémoire en dehors du cadre de la fonction qui l'utilise. Parfois, cette lecture ou écriture indirecte est appelée déréférencement de pointeur. La fonction d'incrémentation doit toujours avoir une variable pointeur dans sa trame, qu'elle peut lire directement pour effectuer un accès indirect.

La figure 6 montre à quoi ressemble la pile après la ligne 21.

Figure 6:



Voici la sortie finale de ce programme:

Listing 14:

count:  Value Of[ 10 ]              Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 0x10429fa4 ]      Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
count:  Value Of[ 11 ]              Addr Of[ 0x10429fa4 ]

Vous pouvez remarquer que la «valeur» de la variable de pointeur inc correspond à «l'adresse» de la variable de comptage. Cela établit une relation de partage qui a permis un accès indirect à la mémoire en dehors de la trame. Dès que la fonction d'incrémentation écrit à travers le pointeur, le changement est visible pour la fonction principale lorsque le contrôle lui est retourné.

Les variables de pointeur ne sont pas spéciales


Les variables de pointeur ne sont pas spéciales car ce sont les mêmes variables que toute autre variable. Ils ont une allocation de mémoire et contiennent du sens. Il se trouve que toutes les variables de pointeur, quel que soit le type de valeur vers lequel elles peuvent pointer, ont toujours la même taille et la même présentation. Ce qui peut être déroutant, c'est que le caractère * agit comme un opérateur dans le code et est utilisé pour déclarer un type de pointeur. Si vous pouvez distinguer une déclaration de type d'une opération de pointeur, cela peut aider à éliminer une certaine confusion.

Conclusion


Ce message décrit le but des pointeurs, le fonctionnement de la pile et la mécanique des pointeurs dans Go. Il s'agit de la première étape de la compréhension de la mécanique, des principes de conception et des techniques d'utilisation nécessaires pour écrire du code cohérent et lisible.

Au final, voici ce que vous avez appris:

  • Les fonctions sont exécutées dans les limites de la trame, qui fournissent un espace mémoire séparé pour chaque fonction correspondante.
  • Lorsqu'une fonction est appelée, une transition entre deux trames se produit.
  • L'avantage de la transmission de données «par valeur» est la lisibilité.
  • La pile est importante car elle fournit un espace mémoire physique pour les limites de trame qui sont données à chaque fonction individuelle.
  • Toute la mémoire de la pile sous la trame active n'est pas valide, mais la mémoire de la trame active et au-dessus est valide.
  • , .
  • , , .
  • — , , .
  • , , , , .
  • - , .
  • - - , , . , .

All Articles