Fonction retournant une référence

Le problème exposé dans ce sujet a été résolu.

Bonjour,

J’ai le code suivant :

#include <iostream>

int& ref()
{
    int x{ 5 };
    return x;
}

int main()
{
    int& r{ ref() };
    std::cout << r << std::endl;

    return 0;
}

Dans le cours, encore en bêta, il est dit que ce code amènera à un comportement indéterminé. Hors chez moi il compile parfaitement et affiche en sortie 5. J’ai donc plusieurs question à son propos :

  • Pourquoi je n’ai pas d’erreur ? Ma variable locale n’existant plus, la référence ne doit plus pouvoir référer cette variable, non ?
  • Quel est la différence entre : int& r{ ref() }; et int r{ ref() }; ? Dans les deux cas le code compile parfaitement et m’affiche bien 5.

Merci pour votre aide !

Bonjour,

Un comportement indéterminé ca veut justement dire que le comportement est indéterminé, ca peut marcher, comme pas du tout. Et non que le code ne doit pas compiler: il compile mais fait ce qu’il veut.

Il n’y a pas d’erreur car le code est valide syntaxiquement. Selon les compilateurs et les options tu auras peut-être des warnings par contre.

int& r{ ref() }; créé une référence sur le retour de ta fonction, donc dans ton cas une référence sur un objet local qui n’existe plus. int r{ ref() }; créé un nouveau int en l’initialisant avec la valeur de la référence retournée (qui est toujours une référence sur une un objet local qui n’existe plus).

Un comportement indéterminé ca veut justement dire que le comportement est indéterminé, ca peut marcher, comme pas du tout. Et non que le code ne doit pas compiler: il compile mais fait ce qu’il veut.

Justement je pensais que le code allait compiler et planter. Autant pour moi. :)

int& r{ ref() }; créé une référence sur le retour de ta fonction, donc dans ton cas une référence sur un objet local qui n’existe plus.

Si mon objet local n’existe plus, comment est-ce que, lors de l’affichage, j’ai bien la valeur 5 qui est affiché ? Je me serais attendu à voir un nombre totalement différent.

int r{ ref() }; créé un nouveau int en l’initialisant avec la valeur de la référence retournée (qui est toujours une référence sur une un objet local qui n’existe plus).

Encore une fois, je ne comprend pas. Si je créé un nouveau int en l’initialisant avec la valeur de référence (qui en soit n’existe plus) pourquoi 5 ?

Et enfin, après m’être un peu amusé, voilà un comportement que je ne comprend pas :

#include <iostream>

int& ref()
{
    int x{ 5 };
    return x;
}

int main()
{
    int& r_1{ ref() };
    int& r_2{ ref() };

    std::cout << "r_1: " << r_1 << std::endl; // affiche 5
    std::cout << "r_2: " << r_2 << std::endl; // affiche n'importe quoi (1528867808)
    std::cout << "r_1: " << r_1 << std::endl; // affiche n'importe quoi (1528867808)

    return 0;
}

Pourquoi est-ce que r_1 m’affiche bien 5 et r_2 n’importe quoi ? Et pourquoi lorsque je ré-affiche r_1 je n’ai plus 5 ? Quelque chose dans la gestion de la mémoire m’échappe.

Merci pour tes réponses !

+0 -0

int& r{ ref() }; créé une référence sur le retour de ta fonction, donc dans ton cas une référence sur un objet local qui n’existe plus.

Si mon objet local n’existe plus, comment est-ce que, lors de l’affichage, j’ai bien la valeur 5 qui est affiché ? Je me serais attendu à voir un nombre totalement différent.

Ma question : pourquoi t’attends-tu à voir un nombre totalement différent ? Pour quelle raison le compilo aurait-il dû aller modifier la mémoire où tu vas lire illégalement1 ? La mémoire libérée n’est pas effacée. Imagine ton disque dur quand tu supprimes un fichier : tant que y’a pas à nouveau besoin de la place, le contenu du fichier existe toujours sur le disque, mais il n’est simplement plus listé comme un fichier.

int r{ ref() }; créé un nouveau int en l’initialisant avec la valeur de la référence retournée (qui est toujours une référence sur une un objet local qui n’existe plus).

Encore une fois, je ne comprend pas. Si je créé un nouveau int en l’initialisant avec la valeur de référence (qui en soit n’existe plus) pourquoi 5 ?

Et enfin, après m’être un peu amusé, voilà un comportement que je ne comprend pas :

#include <iostream>

int& ref()
{
    int x{ 5 };
    return x;
}

int main()
{
    int& r_1{ ref() };
    int& r_2{ ref() };

    std::cout << "r_1: " << r_1 << std::endl; // affiche 5
    std::cout << "r_2: " << r_2 << std::endl; // affiche n'importe quoi (1528867808)
    std::cout << "r_1: " << r_1 << std::endl; // affiche n'importe quoi (1528867808)

    return 0;
}

Pourquoi est-ce que r_1 m’affiche bien 5 et r_2 n’importe quoi ? Et pourquoi lorsque je ré-affiche r_1 je n’ai plus 5 ? Quelque chose dans la gestion de la mémoire m’échappe.

Merci pour tes réponses !

Wizix

C’est le principe de comportement indéterminé, on ne peut pas prévoir ni toujours rationaliser pour tel résultat et pas un autre. Ca dépend des choix d’optimisation du compilateur, de l’état de la mémoire au runtime, etc.

En l’occurrence il y a plusieurs raisons possibles qui me viennent à l’esprit :

  • std::cout est une fonction qui utilise les mêmes emplacements sur la pile que la fonction ref(), elle va donc écrire autre chose à cet emplacement mémoire pendant la ligne 14.

  • le compilateur a réorganisé l’ordre d’exécution des lignes de code parce qu’il a le droit (pour optimiser) tant que le résultat observé est celui attendu (et si tu introduis un comportement indéfini, il fait ce qu’il veut parce que justement c’est indéfini et qu’il n’y a pas d’attendu.

Pour savoir exactement ce que fait le compilateur, il faut aller désassembler ton code mais le résultat changera à chaque compilation avec des paramètres différents, activés ou non, la plateforme, le compilateur, etc.

Le plus simple, ça reste encore de respecter la norme en lisant tout simplement les warnings du compilateur, qui ne sont pas là pour décorer.


  1. Je dis "illégalement" mais personne ne va venir te punir ou quoi, c’est pas une loi, c’est une norme internationale :D

+2 -0

Bonjour,

Je complète la réponse de germinolegrand qui a été plus rapide que moi pour écrire.

Le compilateur a le droit de générer un programme qui fait n’importe quoi sur les cas de comportement indéfini. Dès lors, chercher à savoir le «pourquoi» relèvera de la compréhension de la tambouille interne et ne servira qu’à satisfaire ta curiosité, la solution simple étant de ne pas écrire de programmes avec des comportements indéfinis. C’est aussi pourquoi tu ne trouveras pas d’explication dans un cours qui vise à t’apprendre à utiliser un langage.

Ceci étant dit, voici ce qu’il se passe dans la tambouille interne du compilateur qui provoque le comportement que tu observes :

  • tout au cours de l’exécution, une pile est utilisée pour stocker les valeurs qui ne sont utiles que pendant la durée d’un appel : on empile certaines valeurs avant ou pendant l’appel, qu’on dépile pendant ou après l’appel ;
  • la variable locale x de la fonction ref est allouée dans cette pile, donc les références r, r_1, r_2 pointent quelque part dans la pile ;
  • la pile n’est pas nettoyée, donc, pendant un temps, si on récupère la valeur pointée, celle-ci vaut toujours 5 ;
  • dès qu’un ou des appels de fonction auront empilé assez de valeurs sur la pile, la case vers laquelle pointe ta référence sera potentiellement réutilisée, c’est à ce moment que la valeur pointée sera modifiée ;
  • si au retour de ces appels, tu demandes à afficher la valeur, tu observeras donc une valeur différente.

Pour aller un peu plus loin :

  • les deux appels à ref pour initialiser r_1 et r_2 vont utiliser la pile de la même manière, donc, r_1 et r_2 pointent en fait vers la même case mémoire après tes deux appels ;
  • r_1 pointe donc toujours vers 5 après l’appel à ref parce que r_2 est elle aussi initialisée à 5 : si r_2 était initialisée avec une autre valeur, r_1 prendrait aussi cette valeur quand tu l’affiches ;
  • c’est l’appel std::cout << "r_1: " << r_1 << std::endl; qui vient empiler de nouvelles valeurs qui vont écraser la case mémoire vers laquelle pointent r_1 et r_2.

Si comprendre ce genre de détails bas-niveau t’intéresse, je t’invite à te renseigner sur la rétro-ingénierie en général, et plus particulièrement sur l’utilisation d’un debugger applicatif (WinDbg sous Windows, gdb sous Linux) et/ou d’un désassembleur (IDA).

Sur cet exemple, avec un peu d’expérience, le debugger te permettra d’utiliser la démarche suivante pour vérifier ce que j’ai raconté plus haut :

  • dans la fonction ref, afficher l’adresse de la variable x ;
  • dans la fonction main, constater que r_1 et r_2 référencent cette adresse, constater que le pointeur de pile au retour de ref pointe plus loin, poser un point d’arrêt en accès sur l’adresse que référencent r_1 et r_2, constater que celle-ci se fait bien réécrire pendant l’appel std::cout <<.

Voici à quoi cela ressemblerait avec la combinaison Visual Studio + WinDbg :

Dans le projet Visual Studio, pour retomber sur le comportement observé:
Options de la solution -> C/C++ -> Optimisation -> Optimisation -> Désactivé (/Od)
Génération en Release pour cible x86.

Dans WinDbg: Fichier -> Ouvrir un exécutable (Ctrl-E), choisir Refs.exe, puis cf ci-dessous.

Tout au long de la session dans WinDbg, on a un aperçu visuel de l'endroit où on se trouve
dans le code source dont je ne peux pas rendre compte ici. Désolé si la suite semble du coup
imbuvable.

0:000> lm --> on liste les modules (exe et dll) chargés par notre programme
start    end        module name
01310000 01318000   Refs       (deferred)    ---> notre exécutable, que j'ai appelé Refs.exe
0f570000 0f5e3000   MSVCP140   (deferred)             
0fe20000 0fe35000   VCRUNTIME140   (deferred)             
76560000 76640000   KERNEL32   (deferred)             
770e0000 772c4000   KERNELBASE   (deferred)             
772d0000 773ee000   ucrtbase   (deferred)             
77e50000 77fe0000   ntdll      (pdb symbols)          C:\ProgramData\dbg\sym\wntdll.pdb\D3AE91CEDD9309EF777F2FD5120010BE1\wntdll.pdb
0:000> bp Refs!ref --> on pose un point d'arrêt (breakpoint) à l'entrée de la fonction ref
*** WARNING: Unable to verify checksum for Refs.exe
0:000> g --> on continue jusqu'au prochain point d'arrêt
Breakpoint 0 hit
eax=006ff9d8 ebx=00453000 ecx=00000000 edx=00000000 esi=773df0b0 edi=00048af0
eip=01311080 esp=006ff9cc ebp=006ff9e0 iopl=0         nv up ei ng nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000286
Refs!ref:
01311080 55              push    ebp
0:000> ?? &x --> on est dans la fonction ref, on peut évaluer une expression C++ avec ??
                 pour obtenir l'adresse de la variable x
int * 0x006ff9c0 --> à noter que l'espace pour cette variable n'a pas encore été alloué,
                     donc pour l'instant le pointeur de pile (esp=006ff9cc) pointe plus
                     loin
0:000> pt --> on continue jusqu'au retour de la fonction
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=013110a7 esp=006ff9cc ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!ref+0x27:
013110a7 c3              ret
0:000> p --> on retourne vers main, la valeur de retour est eax=006ff9c0 qui est bien
             l'adresse de x
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=013110cb esp=006ff9d0 ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!main+0x1b:
013110cb 8945f4          mov     dword ptr [ebp-0Ch],eax ss:002b:006ff9d4={Refs!mainCRTStartup (0131195a)}
0:000> p --> on exécute le stockage de la valeur de retour dans r_1
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=013110ce esp=006ff9d0 ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!main+0x1e:
013110ce e8adffffff      call    Refs!ref (01311080)
0:000> ?? r_1 --> on demande l'adresse que référence r_1
int * 0x006ff9c0 --> c'est bien l'adresse de x
0:000> p --> on va vers le deuxième appel de ref
Breakpoint 0 hit
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=01311080 esp=006ff9cc ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!ref:
01311080 55              push    ebp
0:000> ?? &x --> la variable x est stockée au même endroit que pour le premier appel
int * 0x006ff9c0
0:000> pt --> on va a retour de la fonction
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=013110a7 esp=006ff9cc ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!ref+0x27:
013110a7 c3              ret
0:000> p --> retour vers main
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=013110d3 esp=006ff9d0 ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!main+0x23:
013110d3 8945f0          mov     dword ptr [ebp-10h],eax ss:002b:006ff9d0=00453000
0:000> p --> stockage de la valeur de retour dans r_2
eax=006ff9c0 ebx=00453000 ecx=9ebb2f4f edx=00000000 esi=773df0b0 edi=00048af0
eip=013110d6 esp=006ff9d0 ebp=006ff9e0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Refs!main+0x26:
013110d6 68e0163101      push    offset Refs!std::endl<char,std::char_traits<char> > (013116e0)
0:000> ?? r_2
int * 0x006ff9c0  --> r_2 pointe bien vers la même adresse que r_1
                  --> et cette adresse est située avant le pointeur de pile esp=006ff9d0
--> ici on a identifié le problème: r_2 pointe vers une adresse située avant le pointeur de pile,
    donc cette adresse sera réutilisée dès qu'on aura besoin d'allouer quelque chose sur la pile
0:000> ba w 4 0x006ff9c0 --> on peut demander à arrêter l'exécution quand une instruction modifie
                             la valeur stockée à cette adresse
0:000> g --> on reprend l'exécution
--> à partir de là on a plusieurs "Breakpoint 1 hit" à chaque fois que l'emplacement est modifié
--> on peut faire k pour voire la pile d'appel à ce moment et g pour continuer

Pour revenir sur la partie qui correspond à l'allocation de x, voici la fonction ref désassemblée
et son utilisation de la pile:
0:000> u Refs!ref L20 --> on demande à désassembler 20 instructions à partir de l'entrée de ref
Refs!ref [c:\users\y\source\repos\refs\main.cpp @ 4]:
01311080 55              push    ebp --> ici le pointeur de pile est décrémenté de 4 pour sauvegarder ebp
01311081 8bec            mov     ebp,esp --> ici le pointeur de pile est sauvegardé dans ebp
01311083 83ec08          sub     esp,8 --> ici le pointeur de pile est décrémenté de 8 pour
                                           réserver de la place pour les variables locales
                                           dont x
01311086 a100503101      mov     eax,dword ptr [Refs!__security_cookie (01315000)]
0131108b 33c5            xor     eax,ebp
0131108d 8945fc          mov     dword ptr [ebp-4],eax
01311090 c745f805000000  mov     dword ptr [ebp-8],5 --> ici ebp-8 est l'adresse de x
01311097 8d45f8          lea     eax,[ebp-8]
0131109a 8b4dfc          mov     ecx,dword ptr [ebp-4]
0131109d 33cd            xor     ecx,ebp
0131109f e86f060000      call    Refs!__security_check_cookie (01311713)
013110a4 8be5            mov     esp,ebp --> ici, on revient à la valeur de pointeur de
                                             pile sauvegardée
013110a6 5d              pop     ebp --> ici, le pointeur de pile est incrémenté de 4 pour
                                         revenir à l'ancienne valeur de ebp
013110a7 c3              ret --> ici, le pointeur de pile est incrémenté de 4 pour revenir
                                 vers la fonction appelante
...

À l'entrée dans la fonction dans notre session de debug, nous avions esp=0x006ff9cc.
Pendant la période où x est utilisée, nous avons esp=0x006ff9c0.
Au retour de la fonction, on revient avec 0x006ff9d0.
Au retour de la fonction, l'adresse de x 0x006ff9c0 pointe vers un emplacement qui est situé
avant le pointeur de pile et qui sera donc réutilisé aux prochains appels de fonctions
ou plus généralement pour les prochaines allocations sur la pile.
+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte