Tu peux aussi essayer de compiler ton code en mode debug, et de l’exécuter avec valgrind, et tu dirais bien ce qu’il te dit !
Mais je pense que même en rust ce code reste presque valide sauf que la première occurrence de p devrait être initialisée.
Ce qu’il faut bien comprendre avec les fuites mémoires, c’est qu’elles ne sont pas graves, sauf quand elles le deviennent Ce qu’on appelle généralement fuite mémoire c’est le fait de perdre la trace d’un blob (appelons ça comme ça) de mémoire. Or, tu le fais tout le temps.
void foo()
{
int m = 0;
print("%d", m);
}
int main(void)
{
foo();
return 0;
}
Pour l’état de la pile (là où sont stockées les variables temporaires, les adresses pour appeler les fonctions, bref, tout le process normal), il ne s’est rien passé entre AVANT et APRES. Or, dans foo, tu as bel et bien déclaré m … puis en as perdu la trace une fois que la pile d’appel est revenu à l’état AVANT durant le moment APRES.
Bref, en fait, des "fuites mémoires de pile" tu en fais tout le temps. Sauf que comme le temps de vie d’une variable dépend totalement des accolades à l’intérieur desquelles elle a été définie, ce n’est pas grave. En effet, cela signifie qu’une fois ces accolades terminées, ces variables locales disparaîtront.
Donc, ces variables locales ne pourriront pas ad vitam aeternam la RAM, puisqu’elles finissent par disparaître.
Ce qui est différent quand tu alloues sur le tas.
Pour te donner une idée, si on représente la RAM comme une suite de cases mémoires de 4 octets numérotés de 0 à 65536 (par exemple hein), le tas se situe dans la partie basse (à 0 et plus) et la pile dans la partie haute (de 65536 à plus bas).
Quand tu appelles une fonction, l’état local (registres, etc.) de ton programme est pushé sur la pile avant d’entrer dans la nouvelle fonction (il y a des conventions sur comment on le fait mais ici on s’en tape). Et quand une fonction se finit, l’état local est restauré à deux différences possibles :
- Une valeur retournée par la fonction, qui est alors assignée
- Les effets de bords : via des pointeurs en argument de la fonction, qui te permettent d’accéder à la mémoire "de l’état local précédent", ou au tas. Par exemple ce code :
void foo(int &m)
{
*m = 0;
}
int main(void)
{
int k = 32;
foo(&k);
return 0;
}
Permet de modifier, dans l’appel de foo, l’état local précédant l’appel de foo. Mais est-ce que c’est grave ? Uniquement quand il y a dangling pointers : si &m
a une valeur invalide (out of scope ou même null).
Mais, le problème de la pile, c’est qu’on a besoin de savoir, pour compiler (et donc, pour pouvoir générer l’assembleur qui y est liée), combien de place chaque fonction a besoin d’occuper, en octets. En échange de cette contrainte, ça fait qu’à chaque fin de fonction, le code assembleur généré sait exactement ce qu’il doit oublier et réapprendre pour "rollback" à l’état local du moment où la fonction a été appelé (moyennant la valeur de retour). Donc, si il y a des références qui sont paumés, et que ces références pointent sur la pile ce n’est pas grave. Les données qui sont liées à ces références seront oubliées, et ne s’accumuleront pas.
Alors qu’avec le tas, on a un fabuleux espace de travail ! Sauf que là, l’assembleur généré ne sait pas (par design) combien de mémoire va être allouée ou désallouée. Donc il ne peut pas faire le travail à notre place.
Et donc, si on reprend notre exemple, un peu modifié :
void foo()
{
int k = 32;
int *m = malloc(sizeof(int));
}
int main(void)
{
foo();
return 0;
}
Au moment AVANT, il n’y a rien. Puis, l’état local est sauvegardé, et on entre dans foo. Sur la pile, un espace d’une taille donnée (sizeof(int)) est créé pour accueillir notre valeur (notre variable k).
Puis, une variable de la taille d’un pointeur (i.e. comme un pointeur c’est un pointeur sur RAM, cela signifie que la taille sur la pile d’un pointeur est la même et correspond à la taille nécessaire pour stocker, une adresse, je crois que le type utilisée en C pour une adresse est size_t, mais je soupçonne mon C d’être rouillé et pense aussi à ssize_t) est créée sur la pile. Puis vient l’appel à malloc qui fait de la magie noire, demande à l’OS un espace mémoire de la taille sizeof(int) sur le tas, qui nous la donne bien gentiment généralement.
Dans l’état ICI, nous avons alors trois cases mémoires :
- Celle pour k (taille sizeof(int)) : un entier, sur la pile
- Celle pour m (taille sizeof(ssize_t)) : un pointeur, sur la pile
- Celle pour ∗m (taille sizeof(int)) : un entier, sur le tas
Et normalement, la valeur contenue dans m correspond à l’adresse où est stockée l’entier sur notre tas.
Et là, patatras, on revient à APRES. Y a-t-il une valeur de retour ? Non. Y a-t-il eu des effets de bords (i.e. : de la mémoire créée précédemment qui a été modifiée) ? Non. Donc restauration de l’état local de pile au même état que à AVANT.
Woopsy doopsy. On oublie k, on oublie m, mais pas ∗m, tout simplement car le code assembleur ne gère pas ça. Il gère la pile, pas le tas, et notre tas s’encombre.
Maintenant, imagine avec ce genre de scénario (débile, certes, mais pas si loin de ce qu’il se passe en réalité) :
void foo()
{
int k = 32;
int *m = malloc(sizeof(int));
}
int main(void)
{
for(int k = 0; k < 10; k++)
{
foo();
}
return 0;
}
Tu imagines le nombre de leaks qu’on peut avoir ? Quand on dit "fuite mémoire", le mot est sans doute mal choisi pour le programmeur. C’est du point de vue du programme qu’il y a une fuite mémoire. En effet, pour le programme, il demande constamment de la mémoire à l’OS, qui lui donne, puis il voit la mémoire partir et il ne peut plus y accéder ! La mémoire s’enfuit !
Du point de vue du programmeur, on devrait peut-être dire : un encombrement problématique de mémoire, mais c’est moins classe
Bref, tout ça pour dire que non, ton exemple du début ne pose pas de problèmes.
Je vais même pousser le vice un peu plus loin. On va sortir valgrind pour voir ce qu’il en pense. Uniquement disponible sur Linux (et pour cause), valgrind est le meilleur outil que je connaisse pour traquer les fuites mémoires. Son usage est assez simple gcc -g fu.c -o fu && valgrind fu
(tu peux omettre l’option -g
du compilateur, elle permet juste à valgrind de t’afficher en langage humain où sont les erreurs, en sauvegardant les noms des fonctions dans l’exécutable (mode debug)). Valgrind me donne ceci sur la sortie d’erreur :
==2254== Memcheck, a memory error detector
==2254== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2254== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info
==2254== Command: ./a.out
==2254==
==2254==
==2254== HEAP SUMMARY:
==2254== in use at exit: 0 bytes in 0 blocks
==2254== total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==2254==
==2254== All heap blocks were freed -- no leaks are possible
==2254==
==2254== For lists of detected and suppressed errors, rerun with: -s
==2254== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
Du coup, ton programme ne pose pas de fuites mémoires (tu peux croire valgrind, à moins que tu saches précisément ce que tu fais).
Maintenant, regardons mon dernier exemple … en code assembleur ! (qui est rouillé, alors j’espère que vais pas dire trop de bêtises).
Tu l’obtiens en faisant gcc foo.c -c -o foo.o
suivi d’un objdump -d foo.o
. Explications : Dans la compilation, il y a la production du code assembleur correspondant au programme (où on obtient les .o), puis une phase de linkage où un programme fait touuuuuut le travail pour que TON OS puisse faire fonctionner le programme. Cette deuxième partie rend le code assez illisible, et très plateforme-dépendant, donc je demande au compilateur de s’arrêter à la production de code objet avec -c
. Puis objdump
est un outil très pratique pour avoir des informations sur un fichier que tu utilises, et avec l’option -d
il désassemble ton programme pour te permettre de lire l’assembleur de façon humaine (pas uniquement du code héxadécimal). On obtient ça.
Pour lire ce qu’il suit, commence à <main>.
foo.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <foo>:
0: 55 push %rbp // sauvegarde l'état local précédant l'appel à foo
1: 48 89 e5 mov %rsp,%rbp // idem
4: 48 83 ec 10 sub $0x10,%rsp
// ** augmente la taille de la pile (de 0x10), pour accueillir k
8: c7 45 fc 20 00 00 00 movl $0x20,-0x4(%rbp) // met 32 (=0x20) dans la place faite
f: bf 04 00 00 00 mov $0x4,%edi // lié à malloc, on passe
14: e8 00 00 00 00 callq 19 <foo+0x19> // on appelle malloc
19: 48 89 45 f0 mov %rax,-0x10(%rbp) // osef
1d: 90 nop // il ne se passe rien
1e: c9 leaveq
// on laisse place nette, et notamment, on remet la pile dans son état initial (opération
// inverse à la ligne (**)
1f: c3 retq // on rend la main
0000000000000020 <main>:
20: 55 push %rbp // sauvegarde l'état local précédant l'appel à main
21: 48 89 e5 mov %rsp,%rbp // idem
24: b8 00 00 00 00 mov $0x0,%eax // osef
29: e8 00 00 00 00 callq 2e <main+0xe> // On appelle foo
2e: b8 00 00 00 00 mov $0x0,%eax // osef
33: 5d pop %rbp
34: c3 retq
Et maintenant, demandons son avis à valgrind : gcc foo.c -g -o foo && valgrind ./foo
==2266== Memcheck, a memory error detector
==2266== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2266== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info
==2266== Command: ./foo
==2266==
==2266==
==2266== HEAP SUMMARY:
==2266== in use at exit: 4 bytes in 1 blocks
==2266== total heap usage: 1 allocs, 0 frees, 4 bytes allocated
==2266==
==2266== LEAK SUMMARY:
==2266== definitely lost: 4 bytes in 1 blocks
==2266== indirectly lost: 0 bytes in 0 blocks
==2266== possibly lost: 0 bytes in 0 blocks
==2266== still reachable: 0 bytes in 0 blocks
==2266== suppressed: 0 bytes in 0 blocks
==2266== Rerun with --leak-check=full to see details of leaked memory
==2266==
==2266== For lists of detected and suppressed errors, rerun with: -s
==2266== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
Woopsy doopsy. Il est pas d’accord. Je vais même suivre son conseil et faire valgrind --leak-check=full ./foo
:
==2267== Memcheck, a memory error detector
==2267== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2267== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info
==2267== Command: ./foo
==2267==
==2267==
==2267== HEAP SUMMARY:
==2267== in use at exit: 4 bytes in 1 blocks
==2267== total heap usage: 1 allocs, 0 frees, 4 bytes allocated
==2267==
==2267== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==2267== at 0x483877F: malloc (vg_replace_malloc.c:307)
==2267== by 0x10914D: foo (foo.c:7)
==2267== by 0x109162: main (foo.c:12)
==2267==
==2267== LEAK SUMMARY:
==2267== definitely lost: 4 bytes in 1 blocks
==2267== indirectly lost: 0 bytes in 0 blocks
==2267== possibly lost: 0 bytes in 0 blocks
==2267== still reachable: 0 bytes in 0 blocks
==2267== suppressed: 0 bytes in 0 blocks
==2267==
==2267== For lists of detected and suppressed errors, rerun with: -s
==2267== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
Il te dit que tu as perdu la trace d’un mémoire définie à at 0x483877F: malloc (vg_replace_malloc.c:307)
(c’est la lib standard, on ne veut pas y mettre les pieds, et généralement c’est pas elle le problème), qui a été appelé à by 0x10914D: foo (foo.c:7)
(dans la fonction foo
qui est dans le fichier foo.c
et plus précisément sa ligne 7), elle même appelée par by 0x109162: main (foo.c:12)
(fonction main
dans le fichier foo.c
, à la ligne 12).
Elle est pas belle la vie ?
Note de bas de pavé : Pas prévu d’écrire un truc aussi gros, j’espère que c’est lisible >.>