Une expression est composée d'opérateurs et d'opérandes (variables ou constantes). L'expression la plus simple ne comporte qu'une variable. Par exemple:
a
Une expression renvoie toujours quelque chose. Par exemple, 3 + 4
renvoie 7. Pas étonnant.
Mais l'expression b = 3 + 4
met 7 dans la variable b, mais en plus elle renvoie 7.
De sorte que l'expression a = (b = 3 + 4)
mettra 7 dans b, mais aussi dans a.
Une instruction est :
{
"}
"if
if ( expression ) instruction
L'expression est évaluée : si le résultat est différent
de 0 (en C) ou de false
(en C++), l'instruction est exécutée, sinon rien n'est fait. Exemple:
if (i==0) nb = nb + 1;
L'instruction if
peut aussi rendre la forme if... else
:
if ( expression ) instruction1; else instruction2;
ou encore if...else if...else
:
if ( expression ) instruction1; else if ( expression2) instruction2; else instruction3;
switch
Dans ce dernier cas, on risque d'aboutir à un code pas très lisible; l'instruction switch
offre une meilleure structuration du code:
switch(expression) { case valeur constante: instructions1; case autre valeur constante: instructions2; default: instructions; }
Le switch
est une instruction de prise de décision à choix multiples qui, en fonction de la valeur de l'expression,
effectue les instructions associées à la valeur correspondante.
switch(a) { case '-' : exp = a - b; break; case '+' : exp = a + b: break; default : cout << "Operateur inconnu\n"; }
switch
ou if
.while (expression) instruction;
L'expression est évaluée ; si elle est non nulle (en C) ou différente de false (en C++) le bloc instruction est exécuté et l'expression est à nouveau évaluée ceci jusqu'à ce que l'expression
prenne la valeur nulle. Exemple:
while (c!=' ') { c = getchar(); chaine[i++] = c; }
for
for (expression1; expression2; expression3) instruction;
: expression1 est évaluée
(initialisation de la boucle); puis expression 2 est évaluée : si sa valeur est nulle (false en C++) la boucle s'arrête,
sinon la boucle continue avec l'exécution de instruction, puis évaluation de l'expression3; (qui contient généralement l'incrémentation d'une variable entière ou d'un itérateur), puis expression2 est à nouveau évaluée. La boucle for est équivalente à:
expression1; while(expression2) {instruction; expression3; }
. Exemple d'utilisation de la boucle for; calcul de 2 n:
x=1; for (int i=0; i<n; i++) x = 2*x;
for
basée sur des intervallesNous verrons dans le chapitre sur la stdlib qu'il existe une autre intruction for, basée sur la notion d'intervalles,
et qui peut être très utile lorsque l'on travaille avec des conteneurs (équivalente au foreach
de java ou de perl).
do... while
do instruction; while (expression);
Ressemble à l'instruction while
précédemment rencontrée, sauf que l'expression étant évaluée à la fin de la boucle et non pas au début, le bloc instruction sera toujours exécuté au moins une fois.
Il s'agit d'un if..then..else très compact, et qui peut être utilisé dans les expressions elles-mêmes. Elle permet d'écrire un code très compact, mais qui risque d'être fort peu lisible. Aussi: ne pas en abuser !
Les deux codes suivants sont équivalents:
int A; if ( b == 1 ) { A = 3000; } else { A = -10; }
int A = b==1?3000:-10;
return
ou return expression
L'instruction return
sert à sortir d'une fonction et
à retourner au programme appelant ; si elle est suivie d'une expression, la valeur de l'expression est retournée
au programme appelant.
break
L'instruction break
ne peut apparaître que dans un switch
ou une boucle while
, for
ou do...while
: : elle termine l'exécution du plus petit bloc (switch
ou boucle) qui l'entoure.
continue
L'instruction continue
est utilisée uniquement dans une boucle ; elle sert à se positionner en fin de boucle, c'est à dire, à aller évaluer l'expression de fin de boucle pour exécuter, éventuellement, l'occurrence suivante de la boucle.
goto identificateur
Dans l'instruction goto
l'identificateur doit être une étiquette située dans la fonction courante. L'exécution continue à la première instruction qui suit l'étiquette. (Une étiquette est un identificateur suivi de ; elle est visible dans toute la fonction où elle est déclarée.)
Il n'est pas possible de déclarer en langage C des constantes autres que littérales (pi = 3.14). Le préprocesseur va nous permettre de définir des constantes symboliques, qui sont en fait vues comme des macros:
#define PI 3.14 #define PI2 3.14 * 3.14 ... float x = PI;
Nous verrons que la situation est très différente en C++
Les constantes entières peuvent être écrites en décimal (base 10), en octal (base 8) ou en hexadécimal (base 16). La détermination de la base se fait comme suit :
On peut ajouter à une constante entière le suffixe u ou U pour indiquer qu'elle est non signée (unsigned). On peut lui ajouter le suffixe l ou L pour indiquer qu'elle est de type long.
165 | constante entière en base 10 |
---|---|
0245 | constante entière en base 8 |
0xA5 | constante entière en base 16 |
0xffff | constante entière en base 16 |
0x165u | constante entière non signée en base 16 |
Une constante flottante sert à représenter un nombre réel; elle se compose d'une partie entière, d'un point décimal, d'une partie fractionnaire, d'un e ou E, d'un exposant entier éventuellement signé, d'un suffixe de type f ou F (pour float), l ou L (pour long double). On peut omettre la partie entière ou la partie fractionnaire, mais pas les deux; on peut aussi omettre le point décimal ou le e suivi de l'exposant, mais pas les deux.
notation C | notation mathématique |
---|---|
3.15 | 3,15 |
-45.20 | -45,2 |
-3.e10 | -3 x 1010 |
8.5e-4 | 8,5 x 10 -4 |
35E-3 | 35 x 10 -3 |
-3E-5 | -3 x 10 -5 |
Une constante de type caractère est une séquence de un ou plusieurs caractères placés entre apostrophes, par exemple 'a'.
La valeur d'une constante caractère ne contenant qu'un seul caractère est la valeur décimale du caractère sur la machine d'exécution (en général, le code ASCII).
Pour affecter à une constante de type caractère certains caractères spéciaux, on utilise les séquences d'échappement suivantes :
Nom français | Nom anglais | notation abrégée | notation C |
---|---|---|---|
fin de ligne | newline (linefeed) | LF | \n |
tabulation horizontale | horizontal tab | HT | \t |
tabulation verticale | vertical tab | VT | \v |
retour en arrière | backspace | BS | \b |
retour chariot | carriage return | CR | \r |
saut de page | formfeed | FF | \f |
signal sonore | audible alert | BEL | \ |
antislash | backslash | \ | \\ |
point d'interrogation | question mark | ? | \? |
apostrophe | single quote | ' | \' |
guillemet | double quote | \ | |
nombre octal | octal number | ooo | \ooo (o : chiffre octal) |
nombre hexadécimal | hexadecimal number | hh | \xhh (h : chiffre hexadécimal) |
Une constante de type chaîne de caractères se compose d'une suite de caractères placés entre guillemets : "ceci est une chaine".
De façon interne, une constante chaîne de caractères est un tableau de caractères ; elle se termine par un caractère nul `\0'. Pour stocker une chaîne de n caractères, il faut donc n+1 octets.
Attention à bien faire la différence entre une constante
de type caractère et une chaîne de caractères qui ne contient qu'un caractère : la première est un entier, la seconde
est un tableau qui contient un caractère et `\0'.
La directive constexpr
remplace les #define
du langage C. Elle est plus riche ( et doit être utilisée en C++ moderne à la place de #define
.
constexpr int a=56; constexpr float pi=3.14;
Les types de base du C++
sont les
mêmes que les types du C
, avec les extensions
suivantes:
bool
class
(fondamental, car c'est lui qui
permet de définir les objets)Un nom [de type, de variable, de fonction, ... ]
n'est utilisable qu'entre telle et telle ligne du code. Cet espace est
appelé la portée du nom. La portée du nom A
est définie
de la manière suivante:
A
A
a été définie. La fin de bloc est marquée par une
accolade fermante }
.Une variable peut être déclarée à n'importe quel
endroit du code, alors que le C impose une déclaration de variable en
début de bloc uniquement. Exemple:
... { ... int A=5; // DEBUT DE LA PORTEE DE A ... }; // FIN DE LA PORTEE DE A ...
Un nom global est un nom défini à l'extérieur de
toute fonction, de toute classe, de tout espace de noms ().
Un nom global sera donc accessible dans tout le
programme.
Ne pas confondre variable globale et variable locale à la fonction
main:
... int A; // variable globale int main() { int B; // variable locale a main int C=f1(); }; int f1() { ... int C=A; // pas de pb, A est accessible C += B; // ERREUR B n'est pas connu ici return C; };
La portée d'une variable est bien entendu la portée du nom de cette variable. Concrètement, la mémoire est allouée dès le début de la portée, et la mémoire sera rendue au système dès la fin de la portée.
Une exception à la règle de
la portée: dans l'exemple ci-dessous, la portée de la variable
i
est le bloc situé en-dessous de l'instruction
for
. C'est parfaitement logique, car cela permet de
définir des variables muettes dans les boucles for
, mais
ce comportement est différent de ce qu'on connaît en C
(sauf depuis la norme de 99).
... for (int i=0; i<10;i++) // DEBUT DE PORTEE DE i {... ... }; // FIN DE PORTEE DE i
Une déclaration de variables comprend:
const
, extern
,
virtual
, ...)int
etc., ou type
défini par le programmeur)Exemple:
int* A [];
La déclaration de variables ci-dessus est constituée de la manière suivante:
int
A
*
et
[]
. Les opérateurs postfixés
([]
) ayant une priorité supérieure aux
opérateur préfixés (*
), on a déclaré
un tableau de pointeurs, non pas un pointeur vers un tableau.Quelques déclarations légales:
char* ctbl[] = {"bleu","blanc","rouge"}; // 3 parties sont spécifiées const int A = 2; // 4 parties int B; // 2 parties seulement
quelques descripteurs sur lesquels nous reviendrons ultérieurement:
const
mutable
const
static
virtual
Il est le support des caractères le plus souvent codés en ASCII parfois en EBCDIC. Il représente un entier sur 8 bits; sa valeur peut aller de:
signed char
(positif ou
négatif)unsigned char
(uniquement positif).La norme ANSI introduit un type permettant d'utiliser des
alphabets de plus de 255 caractères : wchar_t; il est défini dans le fichier
<stddef.h>
(en C), ou <cstddef.h>
(en C++)
Ce type peut être utilisé avec des qualificatifs pour indiquer sa taille (long
ou short
), et le fait qu'il soit signé (signed
) ou non (unsigned
).
Pour les entiers et les caractères, le qualificateur signed
est appliqué par défaut.
Un booléen peut prendre les valeurs true
ou false
. Il est possible de convertir un booléen en
entier et vice-versa; dans ce cas, true
se convertit en
1
, false
se convertit en 0
.
Dans l'autre sens, 0
est converti en false
et tout entier non nul est converti en true
.
C'est un type particulier à valeurs entières; à chaque énumération est associée un ensemble de constantes nommées.:
enum Jour {lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche}; Jour jour; ... jour = lundi;
Par défaut, la valeur associée au premier nom est 0, au second 1, etc. Dans l'exemple précédent, la valeur associée à lundi sera 0, à mardi 1, etc. Il est possible de préciser la valeur associée à un ou plusieurs noms. Dans ce cas, les constantes qui suivent les constantes affectées sont incrémentées de 1:
enum Mois {janvier=1, fevrier, mars, avril, mai, juin, juillet, aout, septembre, octobre, novembre, decembre};
janvier vaut 1, fevrier 2, mars 3, etc.
L'inconvénient du type enum issu du C est qu'il s'agit simplement d'un sous-ensemble d'entiers, du coup il est possible de comparer des jour_t avec des mois_t. Le C++ moderne apporte un "vrai" type énumération, qui permet d'exprimer que les jours et les mois sont bien deux objets différents:
enum class Jour {lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche}; enum class Mois {janvier, fevrier, mars, avril, mai, juin, juillet, aout, septembre, octobre, novembre, decembre};
Un tout petit code complet en C:
#include <stdio.h> enum Jour {lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche}; enum Mois {janvier, fevrier, mars, avril, mai, juin, juillet, aout, septembre, octobre, novembre, decembre}; int main() { enum Jour j = lundi; enum Mois m = mardi; if (j==m) { /* Hé oui ça compile.... */ printf ( "hoho\n"); } }
Le même code (mais qui ne compile pas !) en C++:
#include <stdio.h> enum Jour {lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche}; enum Mois {janvier=1, fevrier, mars, avril, mai, juin, juillet, aout, septembre, octobre, novembre, decembre}; int main() { Jour j = Jour::lundi; Mois m = Mois::janvier; if (j==m) { // En C++ ça ne compile pas ! printf ( "hoho\n"); } }
Ils sont codés de façon interne sous forme mantisse + exposant.
Il s'agit d'un "pseudo-type", qui veut dire "rien". On peut l'employer comme type de pointeur;
void*
signifie "pointeur vers n'importe quoi". void
peut aussi être employé comme
type de retour de fonction:
void f()
signifie "fonction qui ne renvoie aucune valeur" (procédure en pascal).
La déclaration suivante:
fonction();
est illégale en C++, mais correspond en C à une fonction qui renvoie une valeur entière (la valeur par défaut). Pour ne rien renvoyer il faut spécifier:
void fonction();
L'opérateur sizeof
renvoie le nombre d'octets pris par une variable d'un type donné. Le C ne normalisant pas la taille de ses types de base (en particulier les entiers
et les pointeurs), celle-ci dépend du compilateur et de la machine sur laquelle le code est exécuté. Il est donc important d'utiliser cet opérateur à chaque fois que l'on a besoin
de connaître la taille d'une variable.
cout << "Taille d'un entier = " << sizeof( int );
Il est possible, en utilisant l'en-tête standard stdint.h, d'avoir des types entiers de taille prédictible, quelque
soit le système sur lequel on se trouve: en effet, cet en-tête propose entre autres les types int8_t, int16_t, int32_t, int64_t
, ainsi que
leurs versions non signées: uint8_t, uint16_t, uint32_t, uint64_t
.
#include <stdint.h> ... int8_t a; int16_t b; uint32_t c
En plus des types de base, il existe un nombre théoriquement infini de types pouvant être construits à partir des types de base :
Ces constructions de types dérivés peuvent se faire en général de manière récursive.
Le type auto est utilisé avec un initialiseur, il permet de dire au compilateur que le type de la variable que l'on est en train d'initialiser est le même que le type de la variable située à droite du signe = :
int A; ... int B = A; auto C = A;
Dans l'exemple ci-dessus, A,B et C sont tous les trois des entiers. Ce type est surtout intéressant lorsqu'on utilise des templates, a fortiori lorsqu'on utilise la bibliothèque standard: en effet, les déclarations de types sont dans ce cas assez fastidieuses.
Ce chapitre décrit les tableaux, tels qu'ils sont utilisés en langage C. Nous verrons plus loin que le C++, s'il permet la définition de tableaux "à la C",
offre plusieurs autres possibilités pour définir des tableaux (par exemple ici: ).
Du coup, ce chapitre est fait pour les ptérodactyles, en C++ moderne pas de tableaux !
Un tableau est un ensemble d'éléments de même type, chaque élément du tableau étant repéré par son ou ses indices. Un tableau peut être à une dimension (1 indice, identique à un vecteur), à 2 dimensions (2 indices, identique à une matrice) ou à n dimensions (n indices).
La déclaration d'un tableau à une dimension se fait en donnant le type de base des éléments du tableau suivi du nom du tableau puis entre crochets le nombre de ses éléments, qui doit être une constante. Si n
est le nombre d'éléments du tableau, les indices de ses éléments varient de 0
à n-1
.
int tab[10]; /* Tableau de 10 entiers, les indices vont de 0 à 9 */ tab[0] = 5; tab[5] = 2;
Pour des raisons d'extensibilité et de lisibilité, il est recommandé de définir une constante pour indiquer la dimension d'un tableau en utilisant la directive
#define
du préprocesseur:
#define TAILLE 10 /* Attention à la syntaxe: pas de ; */ int tab[TAILLE];
Comme pour une variable simple, un tableau peut être initialisé lors de sa déclaration par des valeurs constantes énumérées entre accolades et séparées par des virgules:
int tab[5] = {0,1,2,3,4}; float x[5] = {1.3,2.4,9.3}; /* Les constantes sont affectées aux 3 premiers éléments, les 2 derniers sont initialisés à 0 */ int num[] = {0,1,2}; /* On peut ne pas spécifier la dimension, qui est alors égale au nombre de constantes */
Pour passer un tableau en paramètre d'une fonction, on doit passer obligatoirement deux paramètres:
On peut procéder de deux manières équivalentes pour déclarer l'adresse de base du tableau:
int tab[]
: il n'y a pas d'allocation mémoire à réaliser, donc la dimension du tableau n'est pas
spécifiée dans la déclaration.int *tab
// Déclaration de la fonction: 1ère manière void fonction_1(int tab[],int taille) { ... } // Déclaration de la fonction: 2nde manière void fonction_2(int* tab,int taille) { ... } // Utilisation de ces fonctions: pas de différence ! int main() { int t[TAILLE]; fonction_1(t,TAILLE); fonction_2(t,TAILLE); }
Lorsque l'on manipule des tableaux, on utilise fréquemment les boucles itératives ( for ou while) et les opérateurs d'incrémentation:
Exemple:Initialisation d'un tableau avec une boucle for
#define TAILLE 100 float tableau[TAILLE]; int i; for (i=0;i<TAILLE;++i) tableau[i]=i*i;
Le C++ 11 apporte une nouvelle boucle for, bien plus simple d'utilisation et plus lisible lorsqu'il s'agit d'itérer à travers un tableau ou une autre structure de données équivalente. L'exemple précédent peut aussi s'écrire:
#define TAILLE 100 float tableau[TAILLE]; int somme = 0; for (auto x : tableau) { somme += x; }
Exemple: Recherche du premier élément nul d'un tableau
int tab[TAILLE] ) {18, 15, 13, 10, 0, 67}; int i = 0; while (i<TAILLE && tab[i]!=0) // Attention à ne pas déborder ! ++i; cout << i << '\n';
En langage C, une chaîne de caractères est stockée en mémoire sous forme d'un tableau de caractères (voir constante chaîne de caractères).
Par exemple, la chaîne littérale "bonjour" sera stockée ainsi:
b | o | n | j | o | u | r | \0 |
char ligne[80]; /* Déclaration d'une chaine de 80 caractères */ char titre[] = "Introduction"; /* Initialisation d'une chaine de 13 caractères */ char salut[] = {'b','o','n','j','o','u','r','\0'}; /* Initialisation lourde mais correcte */ char salut[] = "bonjour"; /* Pareil, mais plus élégant */ char pasplein[15]="pas plein"; /* La chaine est complétée par des \0 */
Stockage mémoire de la variable pasplein
:
p | a | s | p | l | e | i | n | \0 | \0 | \0 | \0 |
En C, un tableau à 2 dimensions est en fait un tableau à une dimension dont chaque élément est un tableau. L'exemple suivant déclare un tableau de 10 tableaux de 20 entiers:
int tab[10][20];
Pour accéder à un élément du tableau :
tab[i][j] = 12;
int tab[4][3] = {{1,2,3}, {4,5,6}, {7,8,9}, {10,11,12}};
Un exemple d'utilisation d'un tableau à deux dimensions:
#define LIGNES 5 #define COLONNES 10 int mat[LIGNES][COLONNES]; int i,j; for (i=0;i<LIGNES;i++) { for (j=0;j<COLONNES;j++) tab[i][j] = 0; }
La notion de structure permet de manipuler sous forme d'une entité unique un objet composé d'éléments, appelés membres ou champs, de types pouvant être différents. Elle est très proche de la notion de classe, que nous étudierons longuement ci-dessous.
Voici un exemple de déclaration d'une structure:
struct personne { char nom[20]; char prenom[20]; int no_ss; }
Voici un exemple d'initialisation d'une structure:
struct complexe { double re; double im; }; struct complexe z = {1.,1.};
L'accès aux champs d'une structure se fait avec l'opérateur .
. Par exemple, si l'on reprend la structure complexe z, on désignera le champ re par z.re. Les champs ainsi désignés peuvent être utilisés comme toute autre variable.
Une union est un objet qui contient, selon les moments, l'un de ses membres qui sont de types divers ; une union permet de stocker à une même adresse mémoire des objets de types différents.
union etiq { int x; float y; char c; }; etiq u;
u aura une taille suffisante pour contenir un float
(le plus grand des 3 types utilisés dans l'union). A un instant
donné u contiendra soit un entier, soit un réel, soit un caractère.
Les opérateurs vont nous permettre d'écrire des expressions, logiques ou arithmétiques, soit pour faire des calculs, soit
pour prendre des décisions. Tous les opérateurs renvoient une valeur, cela va nous permettre d'imbriquer les formules. Par exemple
A = 3
renvoie A
, donc on pourra écrire B = A = 3;
=
Copie d'un objet sur un autre (voir plus loin)
A = 3
+ - * / %
A = B + C
+= -= *= /= %=
A += 4 // est un raccourci de A = A + 4
++ --
Ils sont définis sur le type pointeur (voir ci-dessous) ou entier, ils servent à itérer dans une boucle. En C++, ils sont aussi définis sur les itérateurs
La valeur retournée dépend de l'opérateur utilisé: si on utilise la post-itération, on renvoie la valeur de la variable, puis on itère. Par contre si on utilise la pré-itération, on itère puis on envoie la valeur de la variable:
int i = 0; int A = i++; // A contient 0, i contient 1 int i = 0; int A = ++i; // A et i contiennent 1
== != < <= > >=
Ils renvoient 0 ou 1 (en C), false ou true (en C++). Ils sont utilisés dans les boucles while, if, etc.
if ( A == 3 ) ...
! && ||
NON, ET, OU
if ( A==3 && (B==4 || C==5) ) ...
& | ^ << >>
ET OU NON bits à bits, décalage à gauche, décalage à droite. Le C++, par l'intermédiaire de la stdlib, redéfinit les opérateurs << et >> appliqués sur des flots de sortie ou d'entrée, afin de réaliser effectivement les opérations d'entrée-sortie.
int A=1; int B= A<<2; // A et B valent 2 B = B<<2; // B vaut 4
,
La virgule permet de séparer plusieurs instructions, la valeur retournée est la valeur retournée par la dernière instruction
A = 4, B = 5; // La valeur retournée par cette expression est 5
Une fonction comprend une ou deux parties distinctes:
Une déclaration de fonction comprend trois parties:
void
si elle ne
renvoie rien)Il est possible, mais pas indispensable de spécifier le nom des arguments: ceux-ci sont considérés par le compilateur comme des variables muettes, et sont ignorés. Leur type, par contre, est une information importante et ne doit pas être omis... sauf exception signalée ci-dessous. Voici un exemple de déclaration:
int f1 (int,int,int);
Une définition de fonction comprend deux parties:
La déclaration
de fonction est optionnelle, seule est indispensable sa
définition.
Si une
fonction est déclarée avant d'être définie, déclaration et
définition doivent être identiques (mêmes nom, types de
paramètres, type de valeur de retour): l'ensemble de ces trois
caractéristiques constituant la signature de la fonction.
Les fonctions du C comme du C++ sont récursives, c'est-à-dire qu'elles peuvent s'appeler elles-mêmes. Cette propriété permet d'écrire des algorithmes très concis et clairs à lire, mais attention toutefois: la consommation de mémoire peut être conséquente... et il ne faut bien sûr pas oublier la condition de sortie !
int factorielle(int n) {return n==1?1:n*factorielle(n-1);}
Une lvalue est une expression qu'on peut mettre à gauche du signe =. Par exemple, un identifiant de variable est une lvalue. Une fonction qui renvoie une référence (voir plus loin) est une lvalue. Une lvalue est aussi une lvalue.
A = 2; // A est une lvalue A[2] = 4; // La notation A[], ou dans le cas d'un objet l'operator[], sont des lvalues
Une rvalue est une expression qu'on ne peut mettre qu'à droite du signe =. Une opération arithmétique, une constante littérale, sont des exemples de rvalues:
A + B 3 A + 1
Une rvalue est toujours un objet temporaire: les résultats des expressions ci-dessus
sont "jetés" dès que générés. Pour les conserver, il faut les mettre dans une variable, c'est-à-dire dans une lvalue ! Autrement
dit, soit je n'ai pas besoin du résultat de mon expression et je laisse la rvalue à son triste sort, soit j'ai besoin de ce résultat
et je dois copier la rvalue dans une lvalue.
Note typographique. On le verra dans la suite, il est aisé de confondre:
int * x
et y = *x
int &
et x = & y
int* x
ou int& y
pour les déclarations *x
ou &y
pour les opérateurs.Notons que la norme du C++ permet d'insérer un espace entre le caractère et le nom de la variable ou du
type, mais cette présentation est plus claire pour le lecteur, et correspond bien à
la réalité du compilateur: en effet, dans une déclaration int * x
ou
int & y
, il s'agit bel et bien d'utiliser les types int*
ou int&
.
Soit un objet de type... homme
. Comment cet objet peut-il se manipuler, et quelle analogie peut-on faire avec la vie "réelle" ?
Contrairement à ce qu'on pourrait penser, ces opérations, qui paraissent les plus simples (par analogie avec les maths), sont en fait très lourdes pour l'ordinateur...
homme jacques; homme paul = jacques;
paul
est obtenu par "clônage" à
partir de jacques
. Les deux objets sont
parfaitement identiques lorsque le code ci-dessus est exécuté,
mais ensuite ils vivent chacun leur vie, et leur destin peut
être différent dans la suite du programme.
L'initialisation comme l'affectation ne sont pas des
opérations simples a priori: elles se traduisent au minimum par une
copie bit à bit, qui peut être longue si les objets sont gros, et
éventuellement par des opérations plus complexes comme l'allocation de
ressources. On essaiera donc de les éviter dans la mesure du possible,
tout au moins lorsqu'on a affaire à des objets élaborés.
homme pierre; ... pierre = paul;
Ici, pierre
a une vie avant sa rencontre avec
paul
. L'opérateur= va "jeter" toutes les données qui
concernent pierre et les remplacer par celles de paul
. Ensuite, chaque
objet vit sa vie, comme précédemment...
Ces opérations permettent d'obtenir
deux objets égaux, on l'a vu, mais pas identiques.
homme pierre; homme& pierrot = pierre; homme& tonton = pierre;
La situation ci-contre est bien plus courante: tout simplement, pierre
porte
plusieurs surnoms. Les uns l'appelleront pierrot
, les autres tonton
.
Dans tous les cas,il s'agit de la même personne (du même objet). Tout ce qui arrivera à
pierre
arrivera aussi à pierrot
, puisqu'il s'agit du même individu.
De même qu'une personne peut avoir autant de surnoms qu'on le souhaite, de même un
objet peut avoir un nombre illimité de références. Mais il n'y a jamais qu'un seul objet.
Cette fois, on a obtenu deux objets identiques (donc aussi égaux).
homme pierre; homme* ce_mec = &pierre; homme* le_type_la_bas = &pierre; homme* encore_un_bebe= new(homme);
pierre
est montré du doigt une fois, deux fois, ... autant de fois que vous le
désirez: donc homme
désigne un objet a priori compliqué, mais
homme*
désigne tout simplement le doigt qui pointe sur un homme. (en C++,
comme en C,on a autant de types de doigts différents que d'objets pointés. Cela permet
d'éviter de nombreuses erreurs de programme).
Bien entendu, on peut avoir autant de pointeurs que
l'on veut. Mais chaque pointeur est un nouvel objet. Les pointeurs sont délicats à
manier, simplement parce qu'il est possible de "casser" le lien entre pointeur et objet pointé.
Cela peut amener deux situations ennuyeuses:
Soit la fonction coupe
qui a
deux paramètres: le coiffeur et le client, le coiffeur coupant les
cheveux au client.
void coupe(homme coiffeur, homme client); ... homme pierre; homme jacques; coupe(jacques, pierre);
pierre
ainsi que jacques
sont ici passés par valeur. Autrement
dit, arrivés au salon de coiffure, la machine clône pierre
d'une part,
jacques
d'autre part, et c'est le clône du coiffeur qui va couper les cheveux
au clône de pierre. Après quoi, les deux clônes sont détruits,
et pierre
repart avec les cheveux longs. L'histoire est stupide, certes, mais ce genre d'erreurs
arrive fréquemment (en C++, en tous cas).
void coupe(homme coiffeur, homme& client); ... homme pierre; homme jacques; coupe(jacques, pierre);
pierre
est passé par référence à la fonction
coupe
: client
est tout simplement un surnom qu'on lui donne dans ce contexte.
jacques
est toujours passé par valeur, de sorte que dans cette histoire, c'est le clône
de jacques
qui coupera les cheveux à son client
, qui se trouve être
pierre
. Pas de problème, le clône de jacques
est par définition aussi bon
coiffeur que jacques
lui-même. Mais le clônage n'est-il pas une opération un peu
compliquée, simplement pour une histoire de coupe de cheveux ? Un avantage à signaler: si
pierre
est mécontent du travail du coiffeur, il pourra toujours casser la figure au clône de
jacques
, jacques
lui-même ne sera pas touché... en termes plus techniques,
si la variable locale coiffeur
est modifiée par le programme, cela n'aura pas d'impact sur jacques
(pas d'effets de bords).
void coupe(const homme& coiffeur, homme& client); ... homme pierre; homme jacques; coupe(jacques, pierre);
dans le contexte de la fonction coupe
, pierre
s'appelle maintenant
client
, alors que jacques
s'appelle coiffeur
.
Plus besoin d'opérations compliquées comme le clônage, alors qu'un surnom fait si bien
l'affaire. De plus, le descripteur const
protège le coiffeur contre les clients mécontents:
même si pierre
est mécontent de sa coupe, il ne pourra pas casser la figure à son coiffeur
(car l'état de celui-ci ne peut changer, à cause de const
). D'un point-de-vue technique,
la variable locale coiffeur
ne peut être modifiée, il ne peut donc là non plus y avoir
d'effets de bords. Ainsi, la sémantique (signification) de cet appel et celle de l'appel précédent
sont les mêmes, simplement le code est ici plus optimisé.
Voici l'histoire d'un accouchement à haut risque, suite à l'exécution de la fonction
coït
...
humain& coit(homme& h, femme& f) { ... humain enfant = h + f; ... return enfant; }; homme pierre; femme marie; humain& loulou = coit(pierre,marie);
L'enfant, à la naissance, est retourné sous le nom loulou
... mais tout-de-suite après il est détruit, puisqu'il s'agit d'une variable
locale à la fonction coit
, qui n'existe donc que le temps que la fonction est exécutée.
Attention, cela ne veut pas dire qu'on ne doit pas
renvoyer de références en sortie d'une fonction. On ne doit pas renvoyer de référence sur un
objet interne à la fonction, car cet objet cesse d'exister lorsque la fonction a fini son exécution.
humain* coit(homme& h, femme& f) { ... humain* enfant = new humain(h,f); ... return enfant; }; homme pierre; femme marie; humain* nouveau_ne = coit(pierre,marie); ... delete(nouveau_ne);
Cette fois, ça va mieux: l'enfant est créé par l'opérateur new
(),
mais il est quelque part ailleurs (il y a eu une allocation dynamique). On ne sait que l'appeler
*enfant
.
Seul le pointeur est interne à la fonction. On renvoie (par une recopie) le pointeur à l'extérieur,
et on détruit le pointeur d'origine (mais cela n'a aucune importance, c'est juste un pointeur: l'enfant est préservé).
La fonction a créé un objet, mais c'est le code appelant qui devra rendre la mémoire. Cette situation n'est pas très propre, et surtout
elle risque d'amener à des fuites de mémoire (si on oublie d'appeler le delete). Pour éviter cela, il convient de remplacer
le pointeur
nouveau_ne
par un smart pointer, de type unique_ptr. Ainsi l'appel à la fonction devient:
unique_ptr<humain> nouveau_ne = coit(pierre,marie);
humain coit(homme& h, femme& f) { ... humain enfant = h + f; ... return enfant; }; homme pierre; femme marie; humain loulou = coit(pierre,marie);
Il y a encore quelques années, il était déconseillé de travailler de cette manière: l'enfant nait dans le contexte de la fonction coit
,
mais à la fin de la fonction, on en fait un clône, on sort le clône et on détruit l'enfant. C'est long,et peu performant,
mais ça marche.
Que s'est-il passé au juste ? Tout simplement la fonction a renvoyé une valeur, qui est une rvalue (la valeur
renvoyée par cette fonction ne peut se trouver à gauche du signe =). Comme on l'a déjà dit
, si on désire pérenniser cette valeur, la seule solution est de
la copier dans une lvalue.
Aujourd'hui, deux mécanismes permettent aux compilateurs de gérer correctement cette situation:
Soit le programme suivant:
int A=3; int& a=A; A++; cout << "valeur de A = " << A << "valeur de a = " << a << "\n";
Le programme renvoie 4 pour A comme pour a. Que
s'est-il passé ? La ligne int &a=A
qui signifie
"référence", revient à déclarer un synonyme à A (même adresse
mémoire, mais nom différent). L'adresse en mémoire sera donc la
même pour A et pour a.
La déclaration suivante dans un programme ou une fonction n'a pas de sens:
int & a; // ERREUR DE COMPILATION !!!
En effet, un synonyme est un synonyme, encore
faut-il préciser de quoi on est synonyme. Par contre, cette
déclaration en tant que membre d'une classe a un sens: on
précisera de quoi on est synonyme lors de l'initialisation de la
classe.).
De même, une telle déclaration dans une liste de
paramètres d'une fonction a une signification
On ne peut pas
changer de "cible": une fois qu'on a dit que
a
est
synonyme de A
, a
reste synonyme de
A
durant toute sa portée.
Dans le code suivant:
int A=3; int& a=A; int B=5; a=B;
L'expression: a=B
changera la valeur de
a
, donc aussi la valeur de A
.
Ce type est essentiellement utilisé en paramètre de fonctions, il permet de poser une référence sur une rvalue, c'est-à-dire d'utiliser un objet qui n'a pas de nom, et qui normalement n'a qu'une existence temporaire
De même que ci-dessus, Le programme suivant imprimera deux fois le chiffre 4:
int A=3; int* a; a = &A; A++; cout << "valeur de A = " << A << "valeur pointee par a = " << *a << "\n";
a
est un
pointeur sur un entier; A l'inverse des références, il est possible (quoique dangereux) de ne
pas l'initialiser; d'autre part, a
peut
pointer sur n'importe quelle variable de type int, ainsi que le montre le code suivant:
int A=3; int B=6; int* a; a= &A; cout << "valeur de A = " << A << "valeur pointee par a = " << *a << "\n"; a= &B; cout << "valeur de B = " << B << "valeur pointee par a = " << *a << "\n";
Dans
l'expression
a= &B
le signe
&
est un opérateur. Il ne
s'agit pas d'une déclaration de type comme dans le
paragraphe précédent: le même symbole a donc deux significations
différentes.
Il est très dangereux de laisser un pointeur non initialisé: cela signifie que le pointeur contient n'importe quoi, donc qu'il pointe
sur une zône mémoire arbitraire. Cela peut se traduire ultérieurement par des plantages difficiles à tracer. On doit donc toujours
initialiser son pointeur, quitte à l'initialiser à la valeur
NULL
(nullptr
en C++11): il sera aisé de tester la valeur du pointeur pour savoir s'il est initialisé ou non:
int * p = nullptr; ... if ( p == nullptr ) { ... }
Lorsqu'on déclare un pointeur, on doit déclarer le type de la variable pointée: int *
et float *
sont deux
types de variables différents. Cependant, il est possible de déclarer une variable de type pointeur sans préciser le type de la variable pointée: il suffit de déclarer
une variable de type void *
.
void *
permettent d'échanger des adresses mémoire avec des fonctions système (voir plus loin les fonctions de type malloc
),
cependant pour travailler avec, il faudra les convertir en de "vrais" pointeurs:
void * p = ...; int * q = (int *) p;
Lorsque l'on déclare un tableau, le nom du tableau est en fait un pointeur sur le premier élément du tableau (élément d'indice 0).
On peut incrémenter un pointeur, ce qui revient à le faire pointer sur l'élément suivant. Ainsi, tab+1
pointe sur l'élément d'indice 1 de tab
.
De façon plus générale, on peut ajouter un entier i à un pointeur: tab+i
pointe sur l'élément d'indice i de tab.
Les écritures tab[i]
et *(tab+i)
sont équivalentes, elles renvoient le contenu de la cellule i du tableau.
De même, &tab[i]
et tab+i
sont deux notations équivalentes, elles renvoient l'adresse mémoire de l'élément i.
Si l'adresse mémoire que contient tab (pointeur sur un tableau
d'entiers) est 2886, l'adresse contenue dans tab+1 ne sera pas 2887 : ce sera 2886+sizeof(int). Cette remarque concerne tous les pointeurs : pour pouvoir faire des
opérations arithmétiques sur des pointeurs,
il faut que les pointeurs soient de même type, c'est à dire qu'ils pointent sur des objets de même taille.
Quelques exemples:
#define TAILLE 100 int tab[TAILLE]; int *p, *q, *r; p = &tab[0]; /* p pointe sur le premier élément */ q = p + (TAILLE-1); /* q pointe sur le dernier élément */ r = q - (TAILLE-1); /* r pointe sur le premier élément */ // Initialiser le tableau en utilisant les pointeurs for (int i=0, int* p=tab; i < TAILLE; ++i) { *p++ = 0; } // Copier le tableau tab dans tab1 for (int i=0, int* p=tab, int* q=tab1; i < TAILLE; ++i) { *q++ = *p++; }
Le type
void *
ne peut pas être utilisé pour définir un tableau. De manière générale, il n'est pas possible de faire
des calculs d'adresse avec un void *
: en effet, comme on ne sait pas sur quel type de donnée on pointe, on ne sait pas a fortiori la taille prise par chaque donnée
individuelle. Donc tout calcul d'adresse est impossible.
L'accès aux champs d'une structure par l'intermédiaire
d'un pointeur se fait avec l'opérateur ->
:
struct personne { string nom; string prenom; int age; }; personne *p; p -> nom = "Dupont"; p -> prenom = "Claude"; p -> age = 20;
Dans l'exemple ci-dessus, on aurait aussi pu accéder au champ age par: *(p.age)
La taille d'une structure (donnée par l'opérateur sizeof) n'est pas forcément égale à la somme de la taille de ses champs.
Les principales utilisations des références sont les suivantes:
Les deux premières utilisations sont utiles pour:
Le programme ci-dessous imprime 5:
void f(int X) { X=0; }; main() { int A=5; f(A); cout << A << "\n"; };
En effet, lorsque la variable X
est
passée à la fonction f
, sa valeur est recopiée
dans la variable locale X
. C'est la copie locale de
X
qui est mise à zéro, elle sera détruite dès le retour de
la fonction. Par contre, le programme ci-dessous imprime 0:
void f(int& X) { X=0; }; main() { int A=5; f(A); cout << A << "\n"; };
En effet, la déclaration
int& X
dans le prototype de la fonction
f
indique un passage des paramètres par
référence. X
est donc un synonyme de la variable
passée, et non plus une recopie. En conséquence, la ligne
X=0
dans f
remet à 0 la
variable A
. Passer un paramètre par référence revient
donc à passer un paramètre à la fonction, tout en laissant à celle-ci
la possibilité de modifier la valeur de la variable ainsi passée, donc
d'en faire aussi une valeur de retour
Renvoyer une référence permet de renvoyer une "lvalue
",
c'est-à-dire quelque chose qui peut se mettre à gauche d'un signe =
.
Regardons en effet le programme suivant:
int A,B; int& renvAouB(bool s) { return (s==true ?) A : B; }; main() { A = 10; B = 20; cout << A << B <<"\n"; // ecrit 10 20 renvAouB(true) = 5; cout << A << B <<"\n"; // ecrit 5 20 };
La fonction renv renvoie une référence
vers la variable A
. Il est donc légal d'écrire
renv(true)=5
même s'il peut paraître
surprenant de mettre à gauche du signe égal un appel de fonction.
Ce mécanisme est utilisé par les objets définis par la bibliothèque
standard (),
en particulier
map
,
vector
etc. Il est également courant,
dans beaucoup de fonctions-membres ou d'opérateurs surchargés, de
renvoyer une référence, par exemple une référence à l'objet courant
*this
La fonction suivante a de fortes chances de planter à l'exécution: en effet, elle renvoie une
référence vers une variable locale, et lorsque l'instruction
return
est exécutée, cette variable est détruite... le
résultat est non prédictible, et gcc envoie un warning à la compilation...
dont je vous conseille de tenir compte.
int A; int& renv() { int A=99; return A; // boum !!! plantage probable. };
Pourquoi passer les paramètres par référence ? Pour deux raisons:
struct
ou
class
avec un grand nombre de champs.Dans le second cas, il y a danger: en effet, si
l'un ou l'autre champ de l'objet passé en paramètre est modifié, on se
retrouve avec un "effet de bord" non désiré, erreur pas simple à
détecter... dans ce cas, le C++
offre un moyen bien
pratique d'éviter cela: le descripteur
const
, placé devant la déclaration du
paramètre, assure que celui-ci ne pourra pas être
modifié par la fonction. Ainsi, le programme suivant ne pourra pas être compilé:
void f( const int& X) { X=0; // Erreur, car X est constant };
Les possibilités offertes par le passage de paramètres par référence rendent obsolète l'équivalent en C: le passage des paramètres par pointeurs. Voici deux programmes équivalents, à vous de décider lequel est le plus lisible:
En C:
void f(int* X) { *X=0; }; main() { int A=5; f(&A); };
En C++:
void f(int& X) { X=0; }; main() { int A=5; f(A); };
Le programme C++ est bien plus lisible, ne
serait-ce que parce que c'est lui qui minimise l'utilisation des
signes barbares tels que &
ou *
.
Il n'est utile de passer les paramètres par
pointeur que dans deux cas:
void f( int* X) { if (X == NULL) { ...faire quelque chose } else { ...faire autre chose }; };Il n'est pas possible de gérer ce cas avec des références, puisqu'une référence n'est par définition "synonyme" de quelque chose.
Une fonction prenant des paramètres
par
const &
ne doit jamais renvoyer ce
paramètre... il y a risque important de crash. Exemple:
const int& f(const int& x) { return x; }; main() { int A = 10; int B = f(A); int C = f(4); };
La ligne int B=f(A)
ne pose pas de
problème, par contre que se passe-t-il avec la ligne int C=f(4)
?
Le compilateur crée une variable temporaire de type
entier, l'initialise à 4, appelle la fonction f
qui
renverra une référence à cette variable temporaire... et supprime
juste après la variable temporaire. Résultat, on se retrouve
avec une référence qui pointe sur... rien du tout, risque important
de plantages. Voir ci-dessous (chapitre gestion de la mémoire) d'autres exemples de gags
du même genre
Le descripteur const
peut s'employer
également avec des pointeurs, de sorte que les différentes
déclarations ci-dessous sont légales, et empêchent d'écrire certaines
instructions... donc empêchent de faire certaines erreurs:
const int* a = new(int); *a = 10; // Erreur car *a est constant int* const b = new(int); b = new(int); // Erreur car b est constant const int* const c = new(int); *c = 10; // Erreur car *c est constant c = new(int); // Erreur car c est constant
L'expression
const int* a
ne garantit
pas que *a
ne changera jamais
de valeur. Il garantit uniquement qu'il sera impossible de
taper quelque chose dans le style
*a=10
. Mais le code suivant montre qu'il
est parfaitement possible que *a
change
de valeur. Il suffit pour cela qu'un autre pointeur, non constant,
soit défini avec la même adresse:
int A=10; const int* a = &A; cout << "*a = " << *a << "\n"; A=100; cout << "*a = " << *a << "\n";
Un programme C ou C++ dispose en général de 4 types de mémoire :
L'allocation et la désallocation de la mémoire dans l'entrepôt à octets se font à l'aide de fonctions de la bibliothèque standard définies dans <stdlib.h>.
Lorsque l'on déclare un pointeur sur une variable, le compilateur alloue la mémoire pour stocker le pointeur mais n'alloue pas de mémoire pour la variable pointée. Cette allocation est à la charge du programmeur (on parle d'allocation programmée). L'oubli de ces allocations est à l'origine de nombreuses erreurs d'exécution qui donneront des messages d'erreur de type:
segmentation fault : core dump
Les fonctions d'allocation mémoire sont principalement malloc
(allocation simple) et realloc
(modification de la dimension d'un espace mémoire précédemment alloué).
La fonction de desallocation (libération de la mémoire) est : free
void * malloc (size_t size);
void * calloc (size_t nbelts, size_t size);
void * realloc (void * ptr, size_t size);
void free (void * ptr);
Le type size_t
est défini dans stdlib.h
; il est équivalent, suivant les systèmes, à unsigned int
ou unsigned long int
.
La fonction malloc
alloue size
octets de mémoire contiguë; elle renvoie un pointeur générique sur la zone allouée ou NULL en cas d'échec.
La fonction calloc
alloue nbelts de size taille dans une zone mémoire, mais surtout elle les initialise (à zéro),
ce qui peut être important du point-de-vue de la sécurité.
La fonction realloc
modifie la taille du bloc mémoire pointé par ptr
(ce bloc doit avoir été alloué
au préalable par malloc
ou calloc
) pour l'amener à une taille de
size
octets; elle conserve le contenu de la zone mémoire commune à l'ancien et au nouveau bloc; le contenu de la zone
nouvellement alloué n'est pas initialisé. Si ptr
est nul, l'appel à realloc
est équivalent à
un appel à malloc
. La fonction realloc
renvoie un pointeur générique sur la nouvelle zone allouée.
La fonction free
libère l'espace mémoire alloué par une des fonctions précédentes; ptr
est le pointeur sur la zone mémoire à désallouer.
Voici un exemple d'utilisation de calloc
, dans lequel on alloue dynamiquement un tableau de 1000 entiers:
size_t dimension = 1000; int* tab = (int *) calloc ( dimension, sizeof(int) ); if ( tab==NULL) { ...traitement d'erreur... }; free(tab);
Le type class va nous permettre de créer différents objets. C'est donc grâce à ce type qu'il est possible de faire de la programmation objets en C++.
Attention, une déclaration de classe est une
déclaration de type. Or, un objet est une variable.
Une classe va permettre de créer (on dit aussi instancier) des objets d'un
certain type. En d'autres termes, une classe est un moule,
elle sert à créer des objets, mais elle n'est pas un objet
elle-même.
Voici la déclaration d'une classe qui implémente des nombres complexes:
La classe Complexe est une classe à sémantique de valeur
class complexe { public: void init(float x, float y); void copie(const complexe& y); private: float r; float i; }
Il s'agit d'une déclaration très proche du type
struct
du C. Cependant, par rapport à la
struct
du C, plusieurs différences fondamentales:
On retrouve ainsi la notion de protection
(encapsulation) des variables et des fonctions propre à la
programmation structurée, mais intégrée au système de typage,
puisqu'il s'agit de déclarer un nouveau type de données. Ce qui
correspond à l'implémentation se trouve dans la section
private
, alors que ce qui correspond à l'interface se
trouve dans la section public
. En d'autres termes,
l'intérieur de l'objet (son squelette) se trouve dans la section
private
, alors que l'interface avec le monde extérieur
(les boutons, voyants, en un mot son comportement) se trouve dans la
section public
.
Tout ce qui est déclaré dans cette section sera
utilisable uniquement (ou presque, il y a aussi les amis ) à
partir d'une variable de même classe; ainsi, dans l'exemple ci-dessus,
le code:
complexe X; ... X.r=0; X.i=0;
produira une erreur à la compilation, car
r
et i
étant des membres privés, ils ne sont
pas accessibles à partir "de l'extérieur". Par contre, si
X
et Y
sont deux complexes, le code écrit
dans les fonctions-membres de la classe complexe peut
atteindre les variables privées de toutes les variables de type
complexe, ainsi qu'on le voit dans l'exemple ci-dessous
(fonctions init
et copie
):
class complexe { public: void init(float x, float y) {r=x; i=y;}; void copie(const complexe& y) {r=y.r; i=y.i;}; private: float r; float i; }
La fonction init
accède aux membres
privés de la variable elle-même. Dans ce cas, il suffit de les
appeler par leur nom de membre (il ne peut y avoir d'ambiguité) et
l'expression r=x
signifie "affecter la partie réelle de
ce complexe à la valeur passée par paramètre".
La fonction copie
accède aux membres
privés de la variable, mais aussi
aux membres privés du
complexe y. Dans ce cas, il faut spécifier le nom de variable en plus
du nom de champ, d'où l'expression y.r
Tout ce qui est déclaré dans cette section sera
utilisable depuis l'extérieur de l'objet. Ainsi, dans l'exemple précédent les
fonctions init
et copie
peuvent être
appelées depuis le programme principal:
complexe X; ... X.init(0,0);
Cette section sera décrite plus tard, lorsque nous
aborderons l'héritage .
Les membres d'une class
peuvent être
soit des types, soit des variables, soit des fonctions. Dans ce
dernier cas, on parle de fonctions membres, ou encore de méthodes.
Dans l'exemple précédent, nous avons déclaré et
défini les deux fonctions-membres à l'intérieur de l'objet lui-même
(voir plus loin la différence entre déclaration et
définition). Cela offre deux avantages:
Toutefois, cela est difficilement concevable pour des
fonctions plus longues. Dans ce cas, on ne met dans la déclaration de
classe que la déclaration de la fonction, sa définition viendra plus
tard... oui, mais alors il faudra bien spécifier l'appartenance de
cette fonction à une classe donnée. Cela se fait avec l'opérateur
de portée ::
(Voir ci-dessous les exemples).
Il est possible de donner l'accès aux membres
privés et protégés de la classe à certaines fonctions définies par
ailleurs dans le programme, ou à toutes les fonctions membres
d'une autre classe: il suffit de déclarer ces fonctions ou ces classes
dans la section public
(il s'agit d'une fonctionnalité de l'interface) en ajoutant
devant la définition de fonction le mot-clé friend
. Nous
reparlerons des fonctions amies lors de la discussion sur la surcharge
des opérateurs
Une fonction-membre d'une classe a accès aux
données privées
de tous les objets de sa classe. Cela revient à dire que
l'unité de protection
n'est pas l'objet, mais la classe. Et la notion de fonction
amie, et surtout de classe amie
permet encore d'élargir cette notion de protection au "groupe de
classes". On peut se poser la question suivante: n'y a-t-il pas
contradiction entre l'encapsulation des données d'une part et cette
notion d'amies d'autre part ?
Bien évidemment si: à manier avec précaution... toutefois, dans
certains cas, il est utile de déclarer des classes amies: certaines
"abstractions" ne sont pas nécessairement implémentées par une seule
classe, mais par deux ou plusieurs classes. Dans ce cas, les
différentes classes participant à cette abstraction devront avoir
accès aux mêmes données privées... sans quoi nous devrons enrichir
l'interface de manière exagérée, au risque justement de
casser le processus d'encapsulation.
Dans chaque section, on peut trouver des types, des
variables, ou des fonctions. Cependant, même si le langage
ne l'impose pas, il est préférable de s'en tenir aux usages
suivants:
public
que dans la section
private
.private
: en effet, les variables jouent en quelque
sorte le rôle de squelette de l'objet, elles définissent sa
structure interneprivate
que dans la section public
.
private
, on trouvera les
fonctions qui participent au fonctionnement
interne de l'objet.public
, on trouvera les
fonctions d'interface. En particulier, on
trouvera des fonctions permettant de modifier les
variables privées (mutator), ou encore des
fonctions permettant de lire la valeur de ces variables
(accessor). Le fait de passer par des fonctions
pour ces opérations, plutôt que de déclarer simplement la
variable dans la section public
, offre une
très grande souplesse, car les fonctions membres peuvent
parfaitement faire autre chose, en interne, que de
simplement écrire ou lire une variable.Cela permettra donc de contrôler très précisément
l'accès aux données. La contrepartie étant, bien sûr, une plus grande
lourdeur, puisqu'il y a plus de fonctions à écrire. Notre objet
complexe
pourrait devenir:
class complexe { public: void init(float x, float y) {r=x; i=y; _calc_module();}; copie(const complexe& y) {r=y.r; i=y.i; m=y.m;}; float get_r() { return r;}; float get_i() { return i;}; void set_r(float x) { r=x; _calc_module();}; void set_i(float x) { i=x; _calc_module();}; float get_m() {return m;}; private: float r; float i; float m; void _calc_module(); } void complexe::_calc_module() { m = sqrt(r*r + i*i); }
Nous venons d'introduire un nouveau champ:
m
, qui représente le module. La fonction
_calc_module
est une fonction privée, appelée
automatiquement dès que la partie réelle ou la partie imaginaire du
complexe est modifiée. Ainsi, les fonctions set_r
et
set_i
modifient les champs r
et
i
de notre objet, mais elles font aussi autre
chose: elles lancent le calcul du module. Il ne serait pas possible
d'implémenter ce type de fonctionnement en utilisant pour
r
et i
des champs publics. Le prix à payer
est toutefois l'existence des fonctions get_r
,
get_i
et get_m
, qui sont triviales. Etant
déclarées inline
dans le corps de l'objet, elles
ne causeront
cependant pas de perte de performance.
Par ailleurs, il est
évident que le champ m
ne doit pas être public:
en effet, si tel était le cas, le code suivant:
complexe X; X.init(5,5); X.m=2;
serait autorisé par le compilateur, avec un résultat désastreux
(aucune cohérence dans les champs de l'objet). On peut bien sûr se demander s'il est utile de
programmer un objet complexe de cette manière. Après tout, il serait
aussi simple de lancer le calcul du module directement dans la
fonction get_m
... bien sûr, mais cette manière de faire
présente certains avantages:
complexe
ainsi défini est
cohérent, puisqu'on est assuré que le module, maintenu par
l'objet lui-même, sera toujours correct. Et la variable
m
peut être utilisée par d'autres
fonctions membres, puisque l'on est sûr qu'elle est en
permanence à jour.Mais peut-être qu'au cours du développement, nous allons justement nous apercevoir que le programme passe son temps à initialiser des complexes, et n'utilise le calcul du module qu'une fois de temps en temps. Dans ce cas, l'argument ci-dessus se renverse, et cette implémentation conduit à un objet peu performant. Qu'à cela ne tienne, nous allons réécrire l'objet complexe:
class complexe { public: void init(float x, float y) {r=x; i=y;}; copie(const complexe& y) {r=y.r; i=y.i;}; float get_r() { return r;}; float get_i() { return i;}; void set_r(float x) { r=x;}; void set_i(float x) { i=x;}; float get_m() {return sqrt(r*r+i*i);}; private: float r; float i; }
Le nouveau complexe
est plus simple que
le précédent, il calcule le module uniquement lorsque l'on en a
besoin: il n'est donc plus nécessaire de maintenir le champ
m
.
Par contre, il a un autre défaut: à chaque appel
de get_m()
, le module est recalculé, ce qui peut s'avérer coûteux si les appels à cette fonction sont nombreux. La version suivante de complexe
résoudra ce problème. Le module est calculé uniquement en cas de besoin, c'est-à-dire non pas lors de chaque appel à get_m()
, uniquement lors du premier appel à get_m()
suivant une modification du module. Voici le code, qui se complique un peu:
class complexe { public: void init(float x, float y) {r=x; i=y; m=0; m_flg=false;}; void copie(const complexe& y ) {r=y.r; i=y.i; m=y.m;}; float get_r() { return r;}; float get_i() { return i;}; void set_r(float x) { r=x; m_flg=false;}; void set_i(float x) { i=x; m_flg=false;}; float get_m(); private: float r; float i; bool m_flg; float m; void _calc_module() {m=sqrt(r*r+i*i);}; }; float complexe::get_m() { if (!m_flg) { _calc_module(); m_flg=true; }; return m; };
Ce qui est remarquable, c'est que dans ces trois
versions, seule l'implémentation a changé. Autrement dit,
tout le code qui utilise cet objet restera identique. C'est
très important, car ce code est peut-être gros, peut-être écrit par
d'autres personnes, etc. D'où l'importance de bien spécifier
l'interface, et de ne mettre dans l'interface que des
fonctions: une fonction triviale un jour peut se révéler
compliquée le lendemain, si son interface est la même le passage de
l'une à l'autre sera indolore. Passer d'une opération d'affectation
de membre à un appel de fonction (ou réciproquement) est une autre
histoire... Cet argument de maintenabilité du code vaut largement que
l'on écrive des fonctions triviales comme get_r
ou
get_i
...
Il ne faut pas abuser des fonctions
get_xxx
et set_xxx
: en effet, attention à ne
donner ainsi l'accès qu'à certains membres privés. Sans cela,
donner un accès, même réduit, à tous les membres privés, risque de
vous conduire à nier la notion d'encapsulation des données, et de
rendre problématique l'évolution de l'objet.
Nous avons dit précédemment que les types définis par
l'utilisateur devaient se comporter "presque" comme les types de base
du langage. Cela est loin d'être vrai pour ce qui est de notre objet
complexe
: par exemple, pour déclarer une variable réelle,
nous pouvons écrire float X=2;
Comment faire pour
déclarer un objet complexe, tout en l'initialisant à la valeur (2,0),
par exemple ? Actuellement, nous devons écrire:
complexe X; X.init(2,0);
Ce n'est pas génial... d'une part le code est assez
différent de ce qu'il est pour initialiser des réels ou des entiers,
mais surtout que se passe-t-il si nous oublions d'appeler la fonction
init ? Cet oubli est possible, justement parce que
l'initialisation du complexe
se fait de manière
différente des autres types.
C'est pour résoudre ce problème que
le C++ propose une fonction membre spéciale, appelée
constructeur. Le constructeur possède deux spécificités:
class complexe { public: complexe(float x, float y):r(x),i(y),m_flg(true) {_calc_module();}; void copie(const complexe& y ) {r=y.r; i=y.i; m=y.m;}; float get_r() { return r;}; float get_i() { return i;}; void set_r(float x) { r=x; m_flg=false;}; void set_i(float x) { i=x; m_flg=false;}; float get_m() const; private: float r; float i; bool m_flg; float m; void _calc_module() {m=sqrt(r*r+i*i);}; };
Rien n'a changé, à part la fonction
init
, remplacée par le constructeur
(complexe
). Mais cela change tout: en effet, on peut maintenant
écrire dans le programme utilisateur de la classe:
float A = 5; ... complexe X(2,0);
On voit qu'on a une déclaration "presque" équivalente à ce qu'on a avec un type prédéfini. La différence provient uniquement de ce que nous avons besoin de deux paramètres pour initialiser un complexe, et non pas un seul comme pour un entier ou un réel. Mais nous verrons au paragraphe suivant qu'il y a moyen de faire encore mieux.
En fait, il n'est pas indispensable de définir un
constructeur: si l'on supprime le constructeur de la définition de
classe précédente, le programme compilera toujours. Simplement, il ne
sera pas possible d'initialiser explicitement l'objet. En d'autres
termes, l'expression complexe X;
sera valide, mais
l'expression complexe X(0,0)
sera refusée par le
compilateur. Le compilateur appellera simplement le constructeur par
défaut de l'objet... Attention toutefois, celui-ci n'initialisera pas les membres de l'objet.
Le (ou les, cf. plus loin)
constructeurs définis pas l'utilisateur ne s'ajoutent pas au
constructeur par défaut, ils le remplacent. Autrement dit,
nous avons le choix entre:
complexe
sans constructeur. Dans ce cas,
l'expression complexe C;
sera acceptée, mais
l'expression complexe C(0,0);
sera refusée.complexe
avec constructeur, telle que
définie ci-dessus. Dans ce cas, l'expression complexe C;
sera refusée, mais l'expression complexe C(0,0)
sera acceptée.Bien sûr, il y a moyen de dépasser ces limitations, nous verrons comment un peu plus tard .
Si vous définissez votre constructeur par défaut, attention à bien initialiser tous les membres de votre objet: le constructeur par défaut du système est complètement désactivé, vous devez tout initialiser explicitement.
Le constructeur est le lieu idéal pour faire deux choses:
En fait, ces deux actions sont différentes. Il existe une syntaxe particulière, permettant de mettre en valeur ces différences: l'initialisation des membres peut se faire avant le bloc de définition de la fonction constructeur, mais après le nom de la fonction, comme on le voit dans le code suivant:
class complexe { private: float r; float i; ... public: complexe(float x, float y) : r(x), i(y), m(0), m_flg(false) { }; ... }
Cette manière de procéder est intéressante, car elle
sépare proprement les deux fonctions du constructeur: initalisation
des membres d'une part, exécution de code (allocation
de mémoire ou autre ressource) d'autre part. S'il n'y a rien d'autre à faire
que des initialistations, le corps de la fonction peut être vide: dans ce cas, on doit écrire
des accolades vides {}
à la suite de la liste d'initialisation.
Lorsqu'un membre est déclaré en tant
que référence, la seule manière de l'initialiser est de passer par la liste d'initialisation:
class objet { private: complexe& X; ... public: object (const complexe& C) : X(C) {}; };
Nous avons vu qu'une fonction, le constructeur, est appelée lors de la création de la variable. De même, une autre fonction, le destructeur, est appelée lors de sa destruction. Le destructeur possède les spécificités suivantes:
Un des rôles du constructeur est de demander au système certaines ressources: fichier, mémoire, etc. Il faut bien un jour rendre la ressource au système, c'est précisément le rôle du destructeur: fermeture de fichier, libération de mémoire,...
A titre d'exemple pour l'utilisation du constructeur
et du destructeur, nous allons adjoindre un système de débogage à
notre objet complexe
: le constructeur écrira un message
sur l'erreur standard, tandis que le destructeur écrira un autre
message. Ainsi, même dans le code le plus compliqué, nous aurons
toujours une trace de la création ou de la destruction de la
variable. Cela pourrait s'écrire de la manière suivante:
class complexe { public: complexe(float x, float y); ~complexe(); ... private: ... } complexe::complexe(float x, float y):r(x),i(y),m_flg(false) { cerr << "Creation d'un objet de type complexe\n"; } complexe::~complexe() { cerr << "Destruction d'un objet de type complexe\n"; } main() { ... if (...) { complexe A(0,0); // Appel du constructeur ... }; // Appel du destructeur
A l'exécution, ce programme enverra un message sur
l'erreur standard dès que l'instruction complex A(0,0);
sera exécutée (c'est-à-dire à l'entrée du if
), et à
nouveau lors de la destruction de la même variable, c'est-à-dire lors
du passage sur l'accolade fermante (}
) (fin de la
portée de la variable).
static
Le code ci-dessus envoie un message lors de chaque appel du constructeur et du destructeur. Cela peut être une aide précieuse lors de la mise au point du programme, mais il serait souhaitable de pouvoir inhiber ce fonctionnement: lorsque le programme sera mis en exploitation, le mode debug n'aura plus aucune raison d'être. Même en période de déboguage, nous voulons avoir la possibilité de passer ponctuellement en mode débug, ou de le désactiver. Voici un premier essai:
class complexe { public: complexe(float x, float y): r(x),i(y),m_flg(false),debflg(false) {}; ~complexe(); void set_debug() { debflg=true;}; void clr_debug() { debflg=false;}; ... private: ... bool debflg; } complexe::complexe(float x, float y) { ... if (debflg) {cerr << "Creation d'un objet de type complexe\n";}; } ~complexe::complexe() { if (debflg) {cerr << "Destruction d'un objet de type complexe\n";}; }
Ce code nous pose deux problèmes:
debflg
est false par défaut, et l'objet aura déjà été
créé, donc le constructeur aura déjà été appelé lorsque nous
serons en mesure d'appeler la fonction set_debug
.set_debug
ou
clr_debug
pour chaque objet, de manière
individuelle. Nous avons besoin au contraire d'un membre et
d'une fonction-membre qui puisse contrôler le mode debug
simultanément pour tous les objets complexe
.La déclaration suivante résout une partie de notre problème:
class complexe { public: complexe(float x, float y): r(x),i(y),m_flg(false) {}; private: ... static bool debflg; }
Le descripteur static
signifie que
debflg
est un membre commun à tous les objets de type
complexe
: alors qu'un membre "ordinaire" est
spécifique à chaque objet, un membre statique sera spécifique
à chaque classe d'objet. Du point-de-vue de l'allocation
mémoire, on peut considérer qu'il s'agit d'une référence à une zône de
mémoire allouée ailleurs. Du coup:
static bool debflg
ne provoquera pas
de nouvelle allocation mémoireCette seconde restriction est compréhensible; en effet, un initialisateur posé au même endroit que la déclaration aurait pour conséquence la réinitialisation du membre statique à chaque création de variable de type complexe. Ce qui rendrait ledit membre complètement inutile. Il faudra donc avoir quelque part dans le code une déclaration et initalisation de variable:
bool complexe::debflg=false;
La ligne de code ci-dessus correspond à une allocation
de mémoire, elle n'est pas concernée par les restrictions d'accès (section private de l'objet). Il s'agit d'une directive donnée au
compilateur pour allouer de la mémoire, pas d'une ligne de code exécutable.
Les variables statiques ressemblent en effet à des
variables globales, en ce sens que la mémoire est allouée dans la partie statique des données, c'est-à-dire dès que
le programme démarre: leur durée de vie est égale à celle du programme. Par contre, elles sont protégées par
les mêmes mécanismes qu'un membre ordinaire d'objet.
Le code ci-dessus présente encore un gros
inconvénient: il est impossible de jouer avec debflg
avant d'avoir créé au moins une variable de type
complexe
. La solution est d'utiliser, en plus du membre statique
debflg
, deux fonctions-membres statiques;
set_debug
et clr_debug
. De même que les
membres statiques sont liés à une classe d'objets, les
fonctions-membres statiques sont liées à une classe d'objet,
pas à un objet.
Le code devient alors:
class complexe { public: ... static void set_debug() { debflg=1;}; static void clr_debug() { debflg=0;}; private: ... static bool debflg; ... }; bool complexe::debflg=false; main () { complexe::set_debug(); // passe en mode debug ... complexe::clr_debug(); // sort du mode debug
Les membres statiques ou les fonctions-membres statiques sont des choses
très différentes des membres ou fonctions-membres ordinaires:
Les fonctions membres statiques ressemblent beaucoup aux fonctions amies
:
Le descripteur const
est un des plus
utilisés parce que très utile, mais il est aussi un des plus délicats
à utiliser. Toute variable peut être déclarée comme
const
, ce qui veut dire que cette variable est en
fait... une constante.
Puisqu'il sera impossible, une fois la constante
déclarée, de modifier sa valeur, il est indispensable de l'initialiser.
Donc l'expression
const int A;
produira un
message d'erreur, alors que const int A=5;
sera accepté
par le compilateur.
Il est utile, par exemple lorsqu'on passe
un objet par référence à une fonction, d'exprimer le fait que cet
objet est constant, c'est-à-dire que toute opération visant à
modifier explicitement l'objet doit être interdite.
Un objet peut avoir une donnée membre constante.
Soit une classe appelée tableau
. Dans son constructeur, cette classe alloue de la mémoire pour un tableau d'entiers. La mémoire est rendue au système dans le destructeur. La taille du
tableau est constante durant toute la durée de vie de l'objet
(une fois que le tableau existe, il n'est pas prévu qu'on puisse lui
changer sa taille). Par contre, la taille du tableau peut être choisie
lors de la construction de l'objet.
Afin de faire ressortir dans
le code cette spécificité, et afin d'être sûr qu'un bogue ne modifie
pas la taille du tableau inopinément, on utilise une donnée membre
constante.
class tableau { public: tableau(int); ~tableau() {free(buffer); buffer = NULL;}; private: const size_t taille; int* buffer; }; tableau::tableau(int s) : taille(s) { buffer = (int *) malloc ( taille * sizeof(int) ); }; void main() { tableau t1(1000); tableau t2(10000); };
Il est bien sûr possible d'utiliser le descripteur
const
avec des objets, pas seulement avec des variables
de types prédéfinis.
Par exemple, si nous retournons à notre objet
complexe, on pourrait définir le complexe constant i par: const
complexe i(0,i);
Oui, mais nous avons un problème: le code
suivant ne compilera jamais.
class complexe { public: complexe(float, float); float get_r() { return r;}; float get_i() { return i;}; ... private: float r; float i; ... }; main() { const complexe i(0,1); float X = i.get_i(); }
En effet, personne ne peut garantir au
compilateur que la fonction get_i()
ne va pas elle-même
modifier l'objet i. Il est clair que certaines fonctions-membres doivent
être utilisables sur des objets constants (get_r, get_i
par exemple), parce qu'elles ne vont pas modifier cet objet
(ce sont des accessor), alors que d'autres fonctions ne
peuvent pas être utilisées dans ce contexte (set_r,
set_i
), car elles vont modifier l'objet (ce sont des
mutator). Il suffit d'ajouter le mot-clé const
après la définition de la fonction pour définir un accessor.
Dans ce cas, toute tentative de modification de l'objet (qui serait une incohérence dans le code) sera détectée par le compilateur. Notre
objet complexe s'écrit donc:
class complexe { private: float r; float i; ... public: complexe(float, float); float get_r() const { return r;}; float get_i() const { return i;}; ... }; main() { const complexe i(0,1); float X = i.get_i(); }
Essayons d'utiliser le descripteur const
avec le complexe troisième version écrit plus haut. Il y a un problème
avec la fonction get_m()
. En effet, pour pouvoir utiliser
cette fonction avec un objet constant, il faut lui attribuer le
descripteur const
... Or, le compilateur refusera, car
get_r()
ne fait pas que de renvoyer la valeur du module,
il lui arrive également de le calculer. Donc, les membres
m
et flg_m
seront modifiés. Que se
passe-t-il ? Cela veut-il dire que cette implémentation est
incompatible avec le fait de déclarer des complexes constants ? Ce
serait une sévère limitation: c'est l'implémentation la plus efficace ! Pour s'en sortir, il faut tout d'abord remarquer que
get_m
ne va pas réellement modifier l'objet.
Cette fonction modifie deux membres privés, mais uniquement
pour des raisons d'implémentation. En fait, vis-à-vis de l'extérieur,
rien n'a changé: on parle de constante logique, par
opposition aux constantes physiques. Les champs qui ont le
droit de varier tout en laissant l'objet constant du point-de-vue
logique sont affublés du descripteur mutate
. Dans notre
cas, il s'agit des champs m
et
m_flg
. L'objet devient alors:
class complexe { public: ... float get_r() const { return r;}; float get_i() const { return i;}; float get_m() const; private: ... mutable bool m_flg; mutable float m; void _calc_module() const {m=sqrt(r*r+i*i);}; }; float complexe::get_m() const { if (!m_flg) { _calc_module(); m_flg=true; }; return m; };
Supposons que l'on veuille modifier à la fois la valeur de la partie réelle et la valeur de la partie imaginaire de notre nombre complexe. Nous pouvons écrire le code suivant:
complexe C(0,0); C.set_r(2); C.set_i(3);
Or, les fonctions set_r
et set_i
agissent sur le complexe C. Il est utile de se
débrouiller pour qu'elles renvoient le complexe qu'elles viennent de modifier, plutôt que rien du tout. Cela permet par exemple d'écrire le code suivant:
C.set_r(2).set_i(3);
Cette expression ne peut avoir un sens que si la valeur
renvoyée par set_r(2)
est une référence vers le
même objet que C
: dans ce cas C.set_r(2)
exécute la fonction set_r(2)
, renvoie C
, de sorte que C.set_r(2).set_r(3)
est équivalent à C.set_r(3)
Le C++ offre un outil pour arriver à ce
résultat: il s'agit du pointeur *this
. Ce pointeur est une variable privée prédéfinie qui pointe
toujours sur l'objet dans lequel on se trouve. Pour arriver
au résultat ci-dessus, il suffira donc de renvoyer *this
comme valeur de retour. D'où la définition suivante des fonctions
set_xxx
:
class complexe { public: complexe& set_r(float x) { r=x; return *this;}; complexe& set_i(float y) { r=y; return *this;}; private: ...
Le pointeur *this
est très utilisé pour les opérateurs, et prendra tout son sens avec eux . Voilà au passage une nouvelle utilisation de la référence en tant que
valeur de retour d'une fonction
.
Dans les exemples précédents, on s'est toujours arrangé pour donner un nom différent à la varaible membre d'un objet et au paramêtre du constructeur. En effet, il faut éviter d'utiliser des constructions dans le genre x(x)
ou x=x
. this
permet d'éviter de se creuser trop la tête:
class complexe { public: complexe(float r, float i): this->r(r),this->i(i),...{}; private: float x,y; }