Au coeur des langages C/C++

Les expressions

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.

Les instructions

Une instruction est :

ATTENTIONIl y a ici une importante différence entre le C et le C++: en C, le bloc doit commencer par les déclarations de variables locales au bloc, alors qu'en C++ on peut "mélanger" intructions simples et déclarations de variables.

L'instruction conditionnelle 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;

L'instruction 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.

ATTENTIONSi l'expression est égale à la première valeur constante, les instructions1 seront exécutées, mais le programme continuera et exécutera aussi les instructions2. Si vous voulez sortir du switch, il vous faudra terminer instructions1 par break, ainsi qu'on le voit ci-dessous:
   switch(a) {
       case '-' : exp = a - b; break;
       case '+' : exp = a + b: break;
       default  : cout << "Operateur inconnu\n";
   }
ATTENTIONL'utilisation des objets et de l'héritage vont permettre de supprimer un bon nombre d'instructions switch ou if.

L'instruction while

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;
}

L'instruction de boucle 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;

L'instruction de boucle for basée sur des intervalles

Nous 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).

L'instruction 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.

L'instruction ? :

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;

Autres instructions utiles:

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.)

ATTENTIONEn C, comme en C++, comme dans tous les langages, on sait qu'on peut se passer de l'instruction goto: afin d'avoir un code bien structuré et lisible, on doit s'en passer.

Les constantes en langage C

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;
ATTENTIONDans l'exemple ci-dessus, PI sera remplacé dans l'étape précédent la compilation par tout ce qui se trouve à partir de l'espace suivant PI jusqu'à la fin de ligne. Donc ne pas mettre de ;, ni de commentaire à la fin de la ligne.

Nous verrons que la situation est très différente en C++

Constantes entières

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.

Quelques exemples :

165constante entière en base 10
0245constante entière en base 8
0xA5constante entière en base 16
0xffffconstante entière en base 16
0x165uconstante entière non signée en base 16

Constantes flottantes:

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.

Quelques exemples :

notation Cnotation mathématique
3.153,15
-45.20-45,2
-3.e10-3 x 1010
8.5e-48,5 x 10 -4
35E-335 x 10 -3
-3E-5-3 x 10 -5

Constantes caractères

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çaisNom anglaisnotation abrégéenotation C
fin de lignenewline (linefeed)LF\n
tabulation horizontalehorizontal tabHT\t
tabulation verticalevertical tabVT\v
retour en arrièrebackspaceBS\b
retour chariotcarriage returnCR\r
saut de pageformfeedFF\f
signal sonoreaudible alertBEL\
antislashbackslash\\\
point d'interrogationquestion mark?\?
apostrophesingle quote'\'
guillemetdouble quote \
nombre octaloctal numberooo\ooo (o : chiffre octal)
nombre hexadécimalhexadecimal numberhh\xhh (h : chiffre hexadécimal)

Constantes chaînes de caractères

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.

ATTENTIONAttention à 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'.

Déclarations de variables

Les types de base du C++ sont les mêmes que les types du C, avec les extensions suivantes:

La portée d'un nom

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:

NOOOOONUne 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
...

Nom global

Un nom global est un nom défini à l'extérieur de toute fonction, de toute classe, de tout espace de noms (apres). Un nom global sera donc accessible dans tout le programme.

ATTENTION 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

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.

ATTENTION 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

La structure d'une déclaration de variables

Une déclaration de variables comprend:

  1. Un descripteur optionnel (const, extern, virtual, ...)
  2. Un type de base obligatoire (int etc., ou type défini par le programmeur)
  3. Un déclarateur obligatoire. Celui-ci est constitué de:
    • un nom choisi par le programmeur
    • un ou plusieurs opérateurs de déclaration.
  4. Un initialiseur optionnel.

Exemple:

int* A [];

La déclaration de variables ci-dessus est constituée de la manière suivante:

  1. Pas de descripteur
  2. Type de base: int
  3. Déclarateur constitué de:
    • Le nom A
    • Les deux opérateurs de déclaration * 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.
  4. Pas d'initialiseur.

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
Permet de définir une variable constante. Par exemple, un objet constant. Nous verrons dans la suite que cela peut apporter quelques complications.
mutable
Utilisé dans les déclaration de classes, en lien avec const
static
Utilisé dans les déclarations de classes ou de fonctions
virtual
Utilisé dans les déclaration de classes

top


Les types de base

Le type caractère : char

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:

ATTENTIONLa 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++)

Le type entier : int

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.

Le type booléen: bool (Seulement en C++)

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.

Le type énumération : enum

C'est un type particulier à valeurs entières; à chaque énumération est associée un ensemble de constantes nommées.:

enum jour_t {lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche};
jour_t 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_t={JAN=1, FEV, MAR, AVR, MAI, JUN, JUL, AOU, SEP, OCT, NOV, DEC};

JAN vaut 1, FEV 2, MAR 3, etc.

Les types réels

Ils sont codés de façon interne sous forme mantisse + exposant.

Le type void

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).

ATTENTION 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();

Nombre d'octets pris par un type donné

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 );

Des types entiers dont on connait la taille

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

Types dérivés

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 (C++11)

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.

Les tableaux

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.

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).

Déclaration d'un tableau

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;

ATTENTIONPour 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];

Initialisation du tableau

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 */

Passage d'un tableau à une fonction

Pour passer un tableau en paramètre d'une fonction, on doit passer obligatoirement deux paramètres:

  1. L'adresse de base du tableau
  2. La longueur du tableau (nombre de cases)

Déclaration de paramètre de type tableau:

On peut procéder de deux manières équivalentes pour déclarer l'adresse de base du tableau:


// 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);
 }

Utilisation de tableaux

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 y[TAILLE];
int i;
for (i=0;i<TAILLE;++i)
   y[i]=i*i;

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';

Tableaux (chaîne) de caractères

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

Tableaux à 2 dimensions :

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;

Initialisation du tableau à 2 dimensions:

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;
}

Les structures

Structure

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.

Déclaration d'une structure

Voici un exemple de déclaration d'une structure:

struct personne {
   char nom[20];
   char prenom[20];
   int no_ss;
}

Initialisation d'une structure:

Voici un exemple d'initialisation d'une structure:

struct complexe {
    double re;
    double im;
};
struct complexe z = {1.,1.};

Accès aux champs d'une structure:

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.

Les unions

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.

top


Opérateurs et expressions

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;

Opérateur d'affection

=

Copie d'un objet sur un autre (voir plus loin)

A = 3

Opérateurs arithmétiques binaires

+ - * / %

A = B + C

Opérateurs arithmétiques unaires

+= -= *= /= %=

A += 4 // est un raccourci de A = A + 4

Opérateur d'incrémentation ou de décrémentation

++ --

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

Opérateurs de comparaison

== != < <= > >=

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 ) ...

Opérateurs logiques

! && ||

NON, ET, OU

if ( A==3 && (B==4 || C==5) ) ...

Opérateurs agissant sur un nombre binaire

& | ^ << >>

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

Opérateur de séparation d'instruction

,

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

top


Les fonctions

Déclaration et définition de fonctions

Une fonction comprend une ou deux parties distinctes:

Déclaration

Une déclaration de fonction comprend trois parties:

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);

Définition

Une définition de fonction comprend deux parties:

ATTENTION La déclaration de fonction est optionnelle, seule est indispensable sa définition.

attention 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.

Récursivité

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);}

Lvalues, Rvalues

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

ATTENTION 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.

Pointeurs (*), références (&), descripteur const

ATTENTIONNote typographique. On le verra dans la suite, il est aisé de confondre:

Pour faciliter les choses, on écrira:

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&.

Pour les terriens Quelques analogies avec le monde dit "réel"

Soit un objet de type... homme. Comment cet objet peut-il se manipuler, et quelle analogie peut-on faire avec la vie "réelle" ?

Initialisation, opérateur = : Un peu de Science Fiction

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...

Initialisation = Clônage

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.

ATTENTIONL'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.

Opérateur= : Je me prends pour un autre.

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...

ATTENTIONCes opérations permettent d'obtenir deux objets égaux, on l'a vu, mais pas identiques.

Références = Surnoms

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.

ATTENTIONCette fois, on a obtenu deux objets identiques (donc aussi égaux).

top


Pointeurs = Attention, on vous montre du doigt

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).

ATTENTIONBien 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:

top


Pierre va chez le coiffeur...

Soit la fonction coupe qui a deux paramètres: le coiffeur et le client, le coiffeur coupant les cheveux au client.

Passage des paramètres par valeur

void coupe(homme coiffeur, homme client);
...
homme pierre;
homme jacques;
coupe(jacques, pierre);

NOOONpierre 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).

top


Passage du client par référence

void coupe(homme coiffeur, homme& client);
...
homme pierre;
homme jacques;
coupe(jacques, pierre);

NOOONpierre 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).

top


Passage du coiffeur par const référence

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é.

top


Un accouchement difficile

Voici l'histoire d'un accouchement à haut risque, suite à l'exécution de la fonction coït...

Retour d'un paramètre par valeur

humain coit(homme& h, femme& f) {
   ...
   humain enfant = h + f;
   ...
   return enfant;
};

homme pierre;
femme marie;
humain loulou = coit(pierre,marie);

Une drôle de manière de faire un enfant: 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 massacre l'enfant. Merci de ne pas prévenir le comité d'éthique... C'est long, compliqué et immoral, mais ça marche.

Que s'est-il passé au juste ? Tout simplement que 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 avant, si on désire pérenniser cette valeur, la seule solution est de la copier dans une lvalue.

top


Retour d'un paramètre par référence

humain& coit(homme& h, femme& f) {
   ...
   humain enfant = h + f;
   ...
   return enfant;
};

homme pierre;
femme marie;
humain& loulou = coit(pierre,marie);

Voilà qui est encore pire: l'enfant, après sa 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 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. Le pire, c'est que... ça peut marcher: rien ne dit que le système aura détruit tout-de-suite l'objet. Mais gare au plantage si vous changez de conditions (de compilateur, par exemple).

Retour d'un paramètre par pointeur

humain* coit(homme& h, femme& f) {
   ...
   humain* enfant = new humain(h,f);
   ...
   return enfant;
};

homme pierre;
femme marie;
humain* nouveau_ne = coit(pierre,marie);

Cette fois, ça va mieux: l'enfant est créé par new, mais il est quelque part ailleurs, et il n'a pas été baptisé (pas de nom). 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, l'enfant est préservé).

Utilisation du constructeur de déplacement (c++11)

Finalement, la meilleure solution pour renvoyer un objet créé dans une fonction est d'utiliser la sémantique de pointeurs tel que décrite ci-dessus. Or, cela est fort ennuyeux, en effet cela aboutit à un code fort peu lisible, alors que tout l'intérêt du C++, par rapport au C, est justement d'améliorer la lisibilité du code: la dernière norme, le C++11, répod à cette préoccupation grâce aux "Rvalue-references" et aux constructeurs de déplacement. Nous n'en dirons pas plus dans ce cours, sachez cependant que ces deux concepts permettent d'améliorer les performances des bibliothèques.

top


Retour au monde virtuel...

Le type référence

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.

NOOON 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.apres). De même, une telle déclaration dans une liste de paramètres d'une fonction a une signification apres

ATTENTION 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.

Le type pointeur

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";

ATTENTIONDans 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.

ATTENTION 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: il sera aisé de tester la valeur du pointeur pour savoir s'il est initialisé ou non:

int * p = NULL;
...
if ( p == NULL )
{
   ...
}

Les pointeurs de type void *

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 *.

ATTENTION Les 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;

Pointeurs et tableaux

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.

ATTENTIONSi 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++;
}

ATTENTION 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.

Pointeurs sur une structure

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)

ATTENTIONLa 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.

Une référence, pour quoi faire ?

Les principales utilisations des références sont les suivantes:

Les deux premières utilisations sont utiles pour:

Passage des paramètres par référence

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

Pourquoi renvoyer des références ?

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 (apres), 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 *thisapres

ATTENTION 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.
};

top


Le descripteur const

Utilisation avec des références:

Pourquoi passer les paramètres par référence ? Pour deux raisons:

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
};
Passage des paramètres par pointeur

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 *.

REGLE  D'OR Il n'est utile de passer les paramètres par pointeur que dans deux cas:

ATTENTION 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 apres

Utilisation de const avec des pointeurs

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

ATTENTION 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";

top


Allocation dynamique de la mémoire en C

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>.

Allocation dynamique

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

Prototype des fonctions (tels qu'ils sont définis dans stdlib.h)

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

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 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.

Sections privées, protégées, publiques

Voici la déclaration d'une classe qui implémente des nombres complexes:

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.

Section private

Tout ce qui est déclaré dans cette section sera utilisable uniquement (ou presque, il y a aussi les amis apres) à partir d'une variable de même classe; ainsi, dans l'exemple ci-dessus, le code:

complexe X;
...
X.r=0;
X.i=0;

NOOON 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;
}   

Notations

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

Section public

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);

Section protected

Cette section sera décrite plus tard, lorsque nous aborderons l'héritage apres.

Fonctions membres

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.

Définition des fonctions-membres

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 apres 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).

Fonctions et classes amies

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 apres

ATTENTION 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.

Accès aux données

REGLE  D'OR 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:

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:

NOOON
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:

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...

ATTENTION 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.

Constructeurs

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.

Constructeur prédéfini

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.

ATTENTION 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:

Bien sûr, il y a moyen de dépasser ces limitations, nous verrons comment un peu plus tard apres.

ATTENTION 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.

Initialisation des membres

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.

ATTENTIONLorsqu'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) {};
};

Destructeur

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,...

Le type complexe en mode debug

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).

Le descripteur 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:

  1. Le constructeur n'enverra jamais de message: en effet, 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.
  2. Il serait fastidieux... et pour tout dire sujet à bien des erreurs, d'appeler 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.

Une donnée membre statique

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:

Cette 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;

ATTENTIONLa 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.

ATTENTIONLes 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.

Fonctions membres statiques

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

ATTENTION Les membres statiques ou les fonctions-membres statiques sont des choses très différentes des membres ou fonctions-membres ordinaires:

ATTENTION Les fonctions membres statiques ressemblent beaucoup aux fonctions amies avant:

Le descripteur const

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.

ATTENTION 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.

const, pourquoi faire ?

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. avant

membres constants

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);
};

Objets constants

Il est bien sûr possible d'utiliser le descripteur const avec des objets, pas seulement avec des variables de types prédéfinis.

Les fonctions membres constantes

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.NOOON

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();
}
Le descripteur mutable

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;
};

Le pointeur *this

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 apres. Voilà au passage une nouvelle utilisation de la référence en tant que valeur de retour d'une fonction avant.

Utilisation de this pour nommer les variables

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;
}

top


xhtml Licence Creative Commons Emmanuel Courcelle <emmanuel.courcelle@inp-toulouse.fr>