Fiche 2 Spécificités du C++ (hors POO), références, allocation dynamique de mémoire... Le C++ est un langage dit orienté objet (OO) conçu à partir du langage C. De ce fait la plupart des fonctionalités du C restent utilisables en C++. Nous abordons ici les spécifités du C++ par rapport au C qui ne relèvent pas de la programmation orientée objet (POO). Il s agit notamment des références des opérateurs d allocation dynamique de mémoire new et delete de la déclaration des variables des arguments de fonctions par défaut de la surdéfinition de fonction du type bool des espaces de noms Les nouveaux opérateurs de cast et les fonctions en ligne (inline), autres spécifités du C++, ne sont pas abordées ici. 2.1 Les références 2.1.1 Rappels sur les adresses et les pointeurs Une variable prend de l espace en mémoire. Par exemple, un entier i occupe 4 octets. L endroit où se trouve la variable i en mémoire est l adresse de i. Inversement, l adresse en notation héxadécimale 0x22ff74A correpond aux 4 octets soit 32 bits 01001001001001010010101010010101010 de l entier i. L adresse d une variable est donnée par &nom_de_la_variable. De même qu une variable peut contenir des valeurs, un pointeur est un conteneur d adresse. Au cours de sa vie, une variable peut valoir 12 puis 222 puis -27 alors qu un pointeur désignera l adresse en mémoire soit, successivement, 0x21fd740 puis 0x22ff74A puis 0x22aa23B. Un pointeur possède lui aussi un type, à savoir que l adresse se réfère à un type de données (par exemple un pointeur sur un entier). En outre, un pointeur occupe lui aussi de l espace
mémoire. Il prend 2 octets qui servent à conserver l adresse. On peut donc créer des pointeurs sur des pointeurs La déclaration d un pointeur se présente de la façon suivante // Déclaration type_du_pointeur *nom_pointeur; tandis que son affectation s écrira // Affectation nom_pointeur = adresse_de_la_variable; Exemple: int i; int *pt_i; pt_i = &i; 2.1.2 Définition des références Une référence permet de donner deux noms différents pour une seule et même case mémoire. Le symbole de la référence est & (le même que pour l adresse d une variable). On déclare une référence de cette façon int i = 0; // j est une référence de i int &j = i; // j et i représentent alors la même case mémoire Une référence nécessite obligatoirement une initialisation vers une variable qu elle va référencer, l instruction int &j; n est pas correcte et sera rejetée par le compilateur. En outre, une référence pointe toujours vers le même objet; on ne pourra donc pas changer sa destination. Les références servent rarement directement. Elle sont le plus souvent utilisées dans les fonctions en argument ou en valeur de retour. Le principal intérêt de la référence est qu elle permet de laisser le compilateur mettre en œuvre les instructions adéquates pour le transfert par adresse, c est à dire qu une réference est explicitée lors de la déclaration et la définition d une fonction et ensuite la variable est utilisée via son nom sans se soucier de préciser si on veut utiliser son adresse ou sa valeur comme c est le cas pour les pointeurs (via les symbole * ou &). 2.1.3 Références et fonctions rappel sur les fonctions Syntaxe type_retourné nom_fonction(..., type_argument nom_argument,...) // Opérations avec les arguments return variable_retournée; Exemple : double norme(const double a, const double b) const double resultat = std::sqrt(a*a +b*b); return resultat; // ou directement return std::sqrt (a*a + b*b); 2
Prototype de fonction type_retourné nom_fonction(..., type_argument nom_argument,...); Le prototype indique au compilateur que la fonction prototypée existe, mais que sa définition interviendra après son appel. Un prototype de fonction est une déclaration de fonction. Exemple: double norme(const double a, const double b); // NB attention au point virgule const double x = 2.5, y = 3.4; cout << norme(x,y) << endl; double norme(const double a, const double b) const double resultat = std::sqrt(a*a +b*b); return resultat; 2.1.4 Modification de variables via l utilisation d une fonction Une fonction en C/C++, dans son utilisation la plus commune i.e. argument transmis par valeur comme dans l exemple précédent, ne modifie aucune variable en argument. La fonction utilise ses arguments pour créer, par exemple, une nouvelle valeur qu elle retourne (la valeur resultat dans notre exemple). Plus précisément, lors de l appel d une fonction, par exemple norme (4, 5), Norme crée deux variables locales a et b et réalise les opérations de recopie suivantes: a = 4 et b = 5. De même, en déclarant deux variables double x = 4.1; et double y = 5.6;, l appel à la fonction norme (x, y) entrainera la création de deux nouvelles variables locales a et b en assignant leurs valeurs a = x et b = y. La fonction ne possède que les valeurs de x et y; ainsi utilisée, elle ne pourra jamais modifier x et y. 2.1.5 Argument transmis par adresse Imaginons qu au lieu de transmettre des valeurs à la fonction, on transmette des adresses ou des pointeurs qui contiennent une adresse. La fonction crée toujours deux variables a et b au moment de son appel et copie dans ces deux variables les adresses utilisées lors de l appel de la fonction. La fonction norme se réécrit ainsi double norme(const double *a, const double *b) const double resultat = std::sqrt((*a)*(*a) + (*b)*(*b)); return resultat; que l on appelera au sein du programme principal via l instruction suivante const double x = 2.5, y = 3.4; cout << norme(&x, &y) << endl; On ne travaille plus avec les valeurs de x et y qui sont ponctuellement 4.1 et 5.6 mais avec les données de x et y i.e. les 01001 qui composent x et y. On peut donc modifier ces données au sein de la fonction, car on travaille directement avec elles et non avec leurs simples valeurs recopiées. La solution proposée présente néanmoins quelques inconvénients d une part, la syntaxe est lourde en raison de l emploi de l opérateur * devant les paramètres, 3
d autre part, la syntaxe est dangereuse lors de l appel de la fonction, puisqu il faut systématiquement penser à utiliser l opérateur & devant les paramètres. Un oubli devant une variable de type entier et la valeur de l entier est utilisée à la place de son adresse dans la fonction appelée. Le C++ permet de résoudre ces problèmes à l aide des références 2.1.6 Argument transmis par référence Comme nous venons de le rappeler, en langage C, les arguments et la valeur de retour d une fonction sont transmis par valeur y compris lors de la transmission par adresse puisque les valeurs de pointeurs sont recopiées. Le C++ peut entièrement prendre en charge la transmission par adresse à travers l utilisation des références. Le principal intérêt de la notion de référence est qu elle laisse le compilateur mettre en œuvre les mécanismes adaptés au transfert par adresse. Il n est plus alors nécessaire de passer par des pointeurs. L utilisateur de la fonction (qui n est pas nécessairement celui qui l a écrite) n a, dès lors, plus à connaitre la nature des arguments à transmettre (une variable ou son adresse). L exemple précédent devient double norme(const double &a, const double &b) const double resultat = std::sqrt(a*a + b*b); return resultat; const double x = 2.5, y = 3.4; cout << norme(x,y) << endl; Dans l instruction double norme(const double &a, const double &b); la notation double &a signifie que a est une information de type double transmise par référence. On notera que, dans la fonction norme, est utilisé le symbole a pour désigner cette variable et non l opérateur d indirection *. Cette amélioration est extrêmement importante du point de vue des performances. Il est ainsi fortement recommandé de passer par référence tous les paramètres dont la copie peut prendre beaucoup de temps tels que les tableaux et plus encore les instances de classe (en pratique, seuls les types de base du langage pourront être passés par valeur). Le gain en temps du fait de ne plus créer de copie d objet, devient non négligeable a fortiori, lors d appels successifs de la fonction. 2.2 Allocation dynamique de mémoire via les opérateurs new et delete Si nous considérons le cas d un tableau statique, sa dimension doit être connue du compilateur au moment même de la compilation. Une déclaration telle que unsigned int n = 0; std::cin >> n; double tableau[n]; n est pas correcte du fait que le compilateur ne connaît pas, au préalable, l espace mémoire nécessaire à l allocation. 4
Dans l hypothèse où la taille d un objet n est connue que lors de l exécution du programme, il est alors inévitable d allouer dynamiquement de la mémoire, c est-à-dire de réserver de la mémoire alors que le programme est en cours d exécution. En langage C, la gestion dynamique de mémoire fait appel aux fonctions malloc et free. Si comme toute fonction standard leur implémentation est possible en C++, leur utilisation demeure lourde et non adaptée à la programmation orientée objet. Le C++ propose deux nouveaux opérateurs new et delete pour gérer l allocation dynamique de mémoire. 2.2.1 Utilisation de l opérateur new Comme malloc en langage C, new alloue une certaine quantité de mémoire pour le tableau et renvoie un pointeur sur le début du tableau. La syntaxe est la suivante type * pt_tableau = new type[nbr_element]; Exemple int *pt_tableau; unsigned int n_dimension_tableau = 666; pt_tableau = new int[n_dimension_tableau]; 2.2.2 Utilisation de l opérateur delete[] De même que free libère l espace alloué par malloc en langage C, delete[] libère l espace mémoire alloué par l opérateur new. Ainsi, delete[] pt_tableau; restaurera l espace mémoire précédemment alloué pour 666 valeurs entières. De manière générale, l utilisation de l opérateur new implique nécessairement l utilisation de sa contrepartie delete. Dans le cas contraire, des fuites de mémoire, i.e. de la perte de mémoire libre au profit d objet qui n existent plus, sont à craindre et peuvent, sous certaines conditions, rendre le système totalement inutilisable (cf exemples du diaporama Spécificités du C++ (hors POO), références, allocation dynamique de mémoire... ). 2.3 Utilisation de la bibliothèque standard du C++ (cas des fichiers d en-tête) La grande majorité des fichiers d en-tête utilisés dans le langage C est présente et utilisable en C++. Toutefois, leur définition a été optimisée pour les besoins du C++. Dans la pratique lors de l inclusion des fichiers d en-tête, les fichiers tels que math.h, time.h seront remplacés par les fichiers cmath, ctime... En règle générale, les fichiers d en-tête issus de la bibliothèque standard abandonnent l extension.h. 2.4 Déclaration de variables En C, il faut d abord déclarer les variables, puis ensuite les instructions. Ainsi, les déclarations sont regroupées en début de fonction ou de bloc. En C++, il est possible de déclarer les variables n importe où, tant qu elle sont déclarées avant les instructions qui les utilisent. À titre d exemple double x;... x=...; int y = sqrt(x);... 5
est possible en C++. Les déclarations lors des initilisations des instructions structurées (for, while, switch,...) sont aussi possibles, comme ici for (int i = 0; i < 10; i++)... 2.5 Argument par défaut Le C++ permet d affecter des valeurs par défauts aux arguments dans les prototypes de fonctions ( déclarations de fonctions). Le prototype s écrit alors : type nom_fonction(..., type argument = valeur_par_défaut); Soit void initialisation(double abs, double ord = 5.6) que l on appelera de la façon suivante initialisation(4); // valeur 5.6 par défaut pour ord En l absence de second argument, le compilateur assigne la valeur 5.6 à la variable ord. Lorsqu une déclaration prévoit des valeurs par défauts, les arguments concernés doivent obligatoirement être les derniers dans la liste. 2.6 Surdéfinition de fonction En C++ deux fonctions peuvent porter le même nom a à condition qu elles n aient pas les mêmes arguments. Le compilateur se charge d appeler la bonne fonction suivant le contexte c est-à-dire au vu de la liste d arguments donnée lors de l appel. Ainsi double fonction_lambda(int, double, char); et double fonction_lambda(double); seront deux fonctions différentes grâce aux différents types d arguments déclarés. Exemple, le programme suivant a de façon générale, on parle de surdéfinition ou surcharge lorsqu un même symbole peut avoir deux significations différentes en fonction du contexte. 6
#include<iostream> using namespace std; double fonction_lambda(int, double, char); double fonction_lambda(double); int i = 2; double a = 3.3; char c = 'b'; double res = fonction_lambda(i,a,c); cout << "res = " << res << endl; res = fonction_lambda(a); cout << "res = " << res << endl; return 0; double fonction_lambda(int i, double a, char c) cout << "fonction lambda numéro 1, int = " << i << " double = " << a << " char = " << c << endl; return a; double fonction_lambda(double a) cout << "fonction lambda numéro 2, double = " << a << endl; return a*2; affiche fonction lambda numéro 1, int = 2 double = 3.3 char = b res = 3.3 fonction lambda numéro 2, double = 3.3 res = 6.6 2.7 Le type bool Le type bool permet de déclarer des variables booléennes, c est à dire des variables à deux états, vrai et faux (true et false). Les conversions implicites définies sont : de bool en numérique i.e. true devenant 1 et false devenant 0, de numérique (y compris flottant) vers bool à savoir que toute valeur non nulle devient true et zéro est équivalent à false. Exemple d utilisation 7
#include<iostream> using namespace std; bool neutre = true; bool masse = false; if (neutre &&!masse) cout << "je suis un photon" << endl; else cout << "je ne peux pas être un photon" << endl; masse = true; if (neutre &&!masse) cout << "je suis un photon" << endl; else cout << "je ne peux pas être un photon" << endl; return 0; 2.8 Les espaces de noms Les espaces de noms sont des zones de déclaration qui permettent de délimiter la recherche des noms des identificateurs par le compilateur. Leur but est essentiellement de regrouper les identificateurs et d éviter les conflits de noms entre plusieurs parties d un même projet. À titre d exemple, lorsque l on doit utiliser plusieurs bibliothèque dans un programme, on peut être confronté au problème dit de pollution de l espace des noms, à savoir qu un même identificateur peut très bien avoir été utilisé par plusieurs bibliothèques. Ce type de conflit provient du fait que le C++ ne fournit qu un seul espace de noms de portée globale. Grâce aux espaces de noms non globaux, ce type de problème peut être évité. Il s agit donc de donner un nom à un espace de déclarations, en procédant ainsi namespace nom_bibli // Déclaration usuelles de fonctions, variables... Pour se référer aux identificateurs définis dans cet espace de noms, on utilise l instruction using using namespace nom_bibli; que l on place à un niveau global, i.e. entre l inclusion des fichiers d entête et le corps du programme. Les identificateurs de la librairie standard (les opérateurs d entrée/sortie de flux cout, cin notamment) sont ainsi définis dans l espace de noms std. Ainsi, chaque programme principal débutera généralement par #include <iostream> // librairie standard de gestion des flux d'entrées/sortie... using namespace std; // corps du programme principal Dans le cas où plusieurs espaces de noms sont utilisés, certains comportant des identificateurs identiques, on pourra lever l ambiguïté en utilisant l opérateur de portée :: en remplaçant, par exemple, cout par std::cout Exemple d utilisation 8
#include<iostream> //utilisation de l'espace de nom standard pour ``cout'' using namespace std; namespace utl void dump() cout << "utl::dump" << endl; namespace io void dump() cout << "io::dump" << endl; // Utilisation de l'espace de nom io using namespace io; // Par défaut, io::dump() dump(); // Précision de l'espace de nom utl::dump(); io::dump(); 9