Chapitre 2 Les structures de base
2.1 Types de base et constantes
En C++, les types de base sont :
-
bool : booléen1,
peut valoir true ou false,
- char : caractère (en général 8 bits), qui peuvent aussi être
déclarés explicitement signés (signed char) ou non signés
(unsigned char),
- int : entier (16 ou 32 bits, suivant les machines), qui
possède les variantes short [int] et long [int],
tous trois pouvant également être declarés non signés
(unsigned),
- float : réel (1 mot machine),
- double : réel en double précision (2 mots machines), et sa
variante long double (3 ou 4 mots machine),
- void qui spécifie un ensemble vide de valeurs.
Les constantes caractères s'écrivent entre ``quotes''
simples :
'a' 'G' '3' '*' '['
Certains caractères de contrôle s'écrivent par des séquences prédéfinies ou
par leur code octal ou hexadécimal, comme par exemple :
\n \t \r \135 \' \x0FF
Les constantes entières peuvent s'écrire en notations décimale,
hexadécimale (précédées de 0x2) ou octale
(précédées de 03).
Pour forcer la constante à être de type entier long, il faut ajouter
un L à la fin, de même le suffixe u indique une constante
non signée :
12 -43 85 18642 54L 255u 38ul
0xabfb 0x25D3a 0x3a
0321 07215 01526
Les constantes réelles s'écrivent avec point décimal et éventuellement
en notation exponentielle :
532.652 -286.34 12.73
52e+4 42.63E-12 -28.15e4
Les constantes de type chaînes de caractères (voir plus loin) s'écrivent
entre guillemets :
"Home sweet home"
"Français, je vous ai compris."
2.2 Opérateurs et expressions
C++ offre un jeu très étendu d'opérateurs, ce qui permet
l'écriture d'une grande variété d'expressions.
Un principe général est que toute expression retourne une valeur.
On peut donc utiliser le résultat de l'évaluation d'une expression comme partie
d'une autre expression.
De plus, le parenthésage permet de forcer l'ordre d'évaluation.
Les opérateurs disponibles sont les suivants :
2.2.1 Opérateurs arithmétiques
-
+
- addition
- -
- soustraction
- *
- multiplication
- /
- division (entière ou réelle)
- %
- modulo (sur les entiers)
2.2.2 Opérateurs relationnels
-
> >= <= <
- comparaisons
- == !=
- égalité et inégalité
- !
- négation (opérateur unaire)
- &&
- et relationnel
- ||
- ou relationnel
2.2.3 L'affectation
-
=
- affectation
Il faut bien noter que, comme en Java, le signe = est l'opérateur
d'affectation et
non de comparaison ; cela prête parfois à confusion, et entraîne des
erreurs difficiles à discerner.
À noter aussi que l'affectation est
une expression comme une autre, c'est-à-dire qu'elle
retourne une valeur. Il est donc possible d'écrire :
a = b = c+2;
ceci revenant à affecter à b le résultat de l'évaluation
de c+2, puis à a le résultat de l'affectation b = c+2,
c'est-à-dire la valeur qu'on a donnée à b.
Remarquez l'ordre d'évaluation de la droite vers la gauche.
2.2.4 Opérateurs d'incrémentation et de décrémentation
-
++
- incrémentation
- --
- décrémentation
Ces opérateurs, qui ne peuvent être appliqués que sur les types
scalaires, peuvent s'employer de deux manières : en principe, s'ils
préfixent une variable, celle-ci sera incrémentée (ou
décrémentée) avant utilisation dans le reste de l'expression ;
s'ils la postfixent, elle ne sera modifiée qu'après utilisation.
Ainsi :
a = 5; b = 6;
c = ++a - b;
donnera à c la valeur 0, alors que
a = 5; b = 6;
c = a++ - b;
lui donnera la valeur -1.
Faites cependant attention dans les expressions un peu complexes où l'on
réutilise la même variable plusieurs fois : l'ordre d'évaluation n'est
pas garanti, et l'expression peut donc avoir des résultats différents
suivant la machine utilisée. Par exemple, le résultat de l'expression
suivante est indéfini :
t[++a] = a;
2.2.5 Opérateurs logiques
Ce sont les opérateurs permettant d'effectuer des
opérations au niveau des bits (masquages).
-
&
- and.
Exemple : a & 0x000F extrait les 4 bits de poids faible de a.
- |
- or.
Ainsi, b = b | 0x100 force à 1 le 9ème bit de b.
-
^
- xor.
- <<
- shift à gauche.
a = b << 2 met dans a la valeur de b où
tous les bits ont été décalés de 2 positions vers la gauche.
- >>
- shift à droite.
~
- complément à 1 (opérateur unaire).
2.2.6 Modifier la valeur d'une variable
Nous avons déjà vu l'affectation, l'incrémentation et la
décrémentation.
Il arrive très souvent qu'on calcule la nouvelle valeur
d'une variable en fonction de son ancienne valeur.
C++ fournit pour cela un
jeu d'opérateurs combinés, de la forme
<variable> <op>= <expr>
où
<op> est un opérateur.
Une telle expression est équivalente à l'expression :
<variable> = <variable> <op> <expr>
-
+=
- a += b équivaut à a = a + b; ---
À noter : a++ a += 1
a = a + 1
- -=
- idem, de même que *=, /=, %=,
<<=, >>=, &=, |= et ^=.
2.2.7 Expressions conditionnelles
expr1 ? expr2 : expr3
est évaluée de la manière suivante :
si expr1 |
alors expr2 |
|
sinon expr3 |
fsi
|
Cela est pratique par exemple pour calculer le maximum de 2 nombres sans
passer par une fonction :
z = (a > b) ? a : b;
Cette construction pourrait bien sûr s'exprimer avec une structure
conditionnelle de la forme si--alors--sinon, mais l'écriture sous
forme d'expression conditionnelle est plus compacte ; les ``vrais''
programmeurs C++ sont même convaincus qu'elle est plus lisible !
2.2.8 Conversions de types
On désire souvent changer le type du résultat retourné par une
expression. Pour cela existe le mécanisme de cast.
Celui-ci a été profondément modifié par la norme définitive de C++ ;
comme beaucoup de casts ``ancien régime'' existent encore,
nous expliquons ici les deux, en insistant sur la norme officielle.
Nous vous recommandons d'ailleurs d'utiliser les constructions de cette
dernière.
Certaines explications font référence à des notions qui ne sont
expliquées que dans les chapitres suivants...
L'ancien système
(<nom de type>) expression
retourne une valeur dont le type est celui qui est indiqué dans la
première parenthèse, et qui est obtenue en convertissant
le résultat de l'expression dans le type spécifié.
La norme officielle
Les opérateurs de conversion de types proposés par la norme
sont particulièrement utiles dans des contextes tels que le
polymorphisme, afin de convertir un objet d'une classe de base vers
une classe dérivée.
En effet, jusqu'à l'introduction de ces opérateurs, ce type de
conversion délicate était entièrement à la charge du
programmeur, qui devait vérifier la validité de la
conversion avant de la réaliser.
Du coup, cela pouvait engendrer quelques problèmes de sécurités, que
ces nouveaux opérateurs sont censés résoudre.
Certains nouveaux casts se basent sur une fonctionnalité
ajoutée il y a quelques années au langage C++ : la Run-Time Type
Identification (RTTI)4.
L'objectif de ces nouveaux opérateurs est de disposer d'une syntaxe
améliorée, plus claire, d'une sémantique moins ambiguë et de réaliser
des conversions de types en toute sécurité.
Les opérateurs de cast sont au nombre de 4 :
-
L'opérateur static_cast<T> (expr) : cet opérateur est
utilisé pour effectuer des conversions qui sont résolues à la
compilation.
Il peut être utilisé pour convertir un pointeur (ou une référence)
sur une classe de base vers un pointeur (ou une référence) sur une
classe dérivée.
L'opérateur n'effectue aucune vérification au cours de l'exécution
(comme son nom l'indique) et doit donc être utilisé pour des
conversions non-ambiguës.
Mal utilisé, il renvoie un résultat indéfini.
Il doit surtout être utilisé pour effectuer des conversions
arithmétiques.
Il est assez proche de la conversion de l'ancien système, mais permet de
supprimer des trous de sécurité qui existaient.
-
L'opérateur const_cast<T> (expr) : cet opérateur permet de
supprimer la constance d'un objet.
Ce n'est pas très naturel, mais utile dans certaines situations ; il
doit être ainsi utilisé avec parcimonie.
Un exemple d'utilisation se trouve ci-dessous :
void f(Article &i)
{
}
void g(const Article &j)
{
f(j); // Erreur : j est constant et f n'attend pas un const
f(const_cast<Article&> (j)); // Ok
}
De même, il peut être utilisé à l'intérieur d'une méthode de classe
constante : en l'appliquant sur le pointeur this, on peut
modifier par la suite l'objet courant (!)
-
L'opérateur dynamic_cast<T> (expr) : c'est certainement
l'un des nouveaux opérateurs les plus intéressants.
Il peut uniquement être utilisé sur des pointeurs ou des références
pour naviguer dans une hiérarchie de classes.
Il peut être utilisé pour convertir un objet d'une classe dérivée
vers un objet d'une classe de base ou inversement.
Dans le premier cas, c'est une classique conversion statique qui est
effectuée tandis que dans le second cas c'est une conversion
dynamique qui est réalisée, en se basant sur le système RTTI.
Dans ce cas, si la conversion est possible, l'opérateur de
conversion renvoie un pointeur valide, ou un pointeur nul
sinon.
Cette fonctionnalité est très puissante comme le montre
l'exemple suivant :
#define MAXELTS 1000
int main()
{
Article* lesArticle[MAXELTS];
// Initialisation du tableau avec des articles hétérogènes
// Deux cas différenciés : Alcools ou autres articles
for (int i = 0; i < MAXELTS; i++) {
BoissonAlcoolisee* ba;
ba = dynamic_cast<BoissonAlcoolisee*> (lesArticle[i]);
// Si l'article est un alcool, affichage du nom et du
// degré d'alcool. Affiche du nom uniquement sinon.
if (ba != NULL)
cout << ba->nom() << " (" << ba->degre() << ")" << endl;
else
cout << lesArticle[i]->nom() << endl;
}
}
-
L'opérateur reinterpret_cast<T> (expr) : cet opérateur
peut être utilisé pour convertir des objets dont les types ne sont
pas en relation.
Le résultat de la conversion est dépendante de l'implantation, et
n'est ainsi pas portable.
Il peut être utilisé dans certains contextes particuliers de
conversion entre types de pointeurs de fonctions.
Conclusion : parmi les nouveaux opérateurs introduits,
static_cast et le dynamic_cast sont à utiliser en priorité.
2.2.9 Récapitulatif
Pour finir ce long paragraphe, notons aussi que l'appel à une fonction est une
expression comme une autre. Enfin, une expression peut dans certains cas
être une suite de plusieurs expressions indépendantes séparées par des
virgules ; voir à cet égard ce qui sera dit par la suite sur la structure
itérative par exemple (cf. § 2.3.3).
Nous donnons ci-dessous un tableau récapitulatif des opérateurs de C++,
classés dans l'ordre décroissant des priorités.
Certains de ces opérateurs n'ont pas été mentionnés ci-dessus, mais sont
décrits dans la suite du polycopié.
1 |
Fonction/Sélection/Portée |
() [] . -> :: |
2 |
Unaire |
* & - ! ~ ++ -- typeid sizeof |
|
|
casts new delete |
3 |
Multiplicatif |
* / % |
4 |
Additif |
+ - |
5 |
Décalages |
<< >> |
6 |
Relationnels |
< > <= >= |
7 |
Inégalité/Egalité |
== != |
8 |
et logique |
& |
9 |
xor logique |
^ |
10 |
ou logique |
| |
11 |
et relationnel |
&& |
12 |
ou relationnel |
|| |
13 |
Affectation |
= <op>= |
14 |
Conditionnel |
? : |
15 |
Exceptions |
throw |
16 |
Virgule |
, |
2.3 Structuration d'un programme C++
Contrairement à Java, toutes les fonctions ne sont pas incluses dans une
classe en C++.
En ce sens, C++ hérite de son prédécesseur C une structure modulaire, et on
peut très bien concevoir un programme C++ composé d'un grand nombre de
modules compilés séparément.
Chaque module est alors composé de fonctions,
et éventuellement de déclarations de variables globales.
Dans l'ensemble des modules, une fonction particulière, ayant pour
nom main(), doit obligatoirement exister, et de manière unique.
On l'appelle souvent le programme principal, par abus de langage.
Il serait sûrement plus correct de dire que c'est le point d'entrée à
l'exécution du programme.
C++ doit donc être considéré plutôt comme un langage
``multi-paradigmes'', permettant d'allier programmation object,
programmation procédurale et programmation générique, et non comme un
langage à objets ``pur et dur''.
Ceci étant dit, il est fortement conseillé de ne pas multiplier les
fonctions hors classe ; dans bien des cas, seule la fonction main,
et éventuellement quelques fonctions annexes à des fins utilitaires, ont
vocation à être définies hors d'une structuration en classes.
De même, nous déconseillons fortement l'emploi de variables
globales ; comme en Java, il est beaucoup plus judicieux, lorsque cela est
nécessaire, d'utiliser des variables de classe regroupées dans une classe
ad hoc.
Chaque fonction a la syntaxe suivante :
typeRetour nomDeLaFonction(spécification des paramètres formels)
{
suite de déclarations de variables locales et d'instructions
}
Les paramètres formels doivent être séparés par des virgules, et sont typés.
Précisons ces notions en voyant une petite fonction :
int moyenne(int a, int b)
{
int c = (a+b)/2;
return c;
}
Remarque : comme en Java, on peut passer à la fonction main des
paramètres correspondant aux paramètres d'appel du programme.
2.3.1 Instructions et blocs
Chaque instruction est terminée par un point-virgule.
Un bloc est une suite d'instructions délimitées par une accolade
ouvrante { et une accolade fermante }.
À l'intérieur de tout bloc, on peut aussi définir des
variables locales à ce bloc :
if (n > 0) {
int cumul = 0;
for (int i=0 ; i < n ; i++) ....
....
}
Il est conseillé de déclarer les variables locales le plus tard possible,
seulement au moment où on en a effectivement besoin.
Attention à l'instruction vide ---; --- qui est source
potentielle d'erreurs difficiles à détecter, comme dans :
/* Exemple d'une instruction vide involontaire */
for ( ... ) ; // Ici le point-virgule indique une instruction vide
// à exécuter à chaque itération ; ce n'était pas
// forcément le souhait du programmeur
Vous avez peut-être remarqué que j'ai lâchement profité de l'occasion
pour introduire les deux types de commentaires valides en C++.
Les portions de code comprises entre /* et */ sont des
commentaires, de même que celles comprises entre // et la fin de la
ligne.
Ceci étant dit, nous vous conseillons fortement de vous en tenir aux
commentaires compris entre // et la fin de la ligne.
2.3.2 Structures conditionnelles
La condition s'exprime de la manière suivante :
|
if (<expression>) |
|
<instruction-1> |
[ |
else |
|
<instruction-2> ]
|
où l'exécution de l'une ou de l'autres des branches alors ou sinon
va dépendre de l'évaluation de <expression> : si le résultat est
vrai, on exécutera <instruction-1>, sinon on
effectuera <instruction-2>.
De manière tout à fait classique, s'il y a plusieurs instructions dans
la partie alors ou la partie sinon, on mettra un bloc.
Quand il y a plusieurs conditions imbriquées et qu'il y a ambiguïté sur
un else, on le rattache au if le plus proche.
Une autre instruction conditionnelle se comporte comme un branchement calculé.
Par conséquent, il ne faut
surtout pas oublier de mettre les break aux endroits
nécessaires :
switch |
(<expression>) { |
|
case <constante-1> : <suite d'instructions> break; |
|
case <constante-2> : <suite d'instructions> break; |
|
... |
|
case <constante-n> : <suite d'instructions> break; |
|
default : <suite d'instructions> |
|
}
|
Si on ne met pas de break, l'exécution va continuer à la suite au
lieu de sortir du switch, puisque les différentes constantes
correspondent seulement à des étiquettes de branchement.
Il y a parfois des cas où c'est l'effet souhaité ; mais il faut être très
prudent et le documenter explicitement, le cas échéant !
2.3.3 Structures itératives
Plusieurs structures itératives existent en C++.
Voici la première :
while |
(<expression>) |
|
<instruction>
|
la partie <instruction> pouvant bien sûr être un bloc.
C'est la structure tant-que classique.
Une autre structure itérative est la suivante :
for |
(<expr1> ; <expr2> ; <expr3>) |
|
<instruction>
|
où <expr1>, <expr2> et <expr3>
sont des expressions.
Souvenez-vous qu'une expression peut aussi être une suite d'expressions
séparées par des virgules.
C'est dans cette structure que cela est le plus utilisé.
Cette construction est équivalente à :
<expr1>; |
while |
(<expr2>) { |
|
<instruction>; |
|
<expr3>; |
}
|
Résumons en disant que <expr1> indique l'initialisation
avant entrée dans la boucle, <expr2> est la condition de
poursuite de l'itération, et <expr3> est la partie
exécutée à la fin de chaque itération.
Une ou plusieurs de ces expressions peuvent être vides ; en particulier :
for ( ; ; )
est une boucle infinie !
Une dernière variante de la structure itérative est :
do |
|
|
<instruction> |
while (<expression>);
|
qui permet d'effectuer l'instruction (ou le bloc) une première fois avant
le premier test sur la condition d'arrêt.
Nous avons déjà vu l'emploi de break dans les structures
conditionnelles.
En fait, break permet plus généralement de sortir
prématurément et proprement d'une structure de contrôle.
Ainsi, on peut l'utiliser dans une itération pour sortir sans
passer par la condition d'arrêt.
Donnons en exemple une boucle qui lit un
caractère en entrée (par une fonction getchar())
et qui s'arrête sur la lecture du caractère '&' :
for ( ; ; ) if ((c = getchar()) == '&') break;
Cette fonction peut bien sûr s'écrire plus simplement :
while ((c = getchar()) != '&') ; // le point-virgule ici est
// l'instruction vide !
Une autre instruction particulière qui peut être utile dans les
itérations est continue, qui permet de se rebrancher prématurément
en début d'itération.
Enfin, signalons que C++ permet aussi de faire goto ; mais comme nous
sommes des informaticiens bien élevés qui ne disent jamais de gros mots,
nous n'en parlerons pas...
2.4 Fonctions
Théoriquement, toute fonction retourne une valeur, qui peut être
utilisée ou non.
Toutefois, un mot clé particulier, void, permet
d'indiquer qu'une fonction ne retourne pas de valeur (ce qui en fait
stricto sensu une procédure et non une fonction !).
Le passage de paramètres peut se faire par valeur ou par référence.
Le passage d'une référence se note par le caractère &.
En voici un exemple avec une procédure qui échange les valeurs de deux
variables :
void swap(int& a, int& b)
{
int tmp = a; a = b; b = tmp;
}
...
int x, y;
...
swap(x, y);
Il est conseillé de passer le plus systématiquement possible
les objets (par opposition aux variables de type simple) par référence, et
non par valeur.
Une référence peut également être déclarée
constante, par exemple pour passer la référence d'un objet de grande
taille, tout en interdisant l'accès en écriture dans la fonction ou la procédure.
Avec un passage par valeur, l'objet serait dupliqué dans la pile
d'exécution.
Cette fonctionnalité très intéressante apporte un degré de contrôle
supérieur à ce que permet Java sur les opérations permises sur l'objet qui
est passé à une fonction.
Nous vous conseillons de l'utiliser le plus possible.
En supposant l'existence d'un type Matrice décrivant une matrice,
on peut par exemple écrire :
void print(const Matrice& m)
{
// le compilateur interdit toute tentative
// de modification de la variable m dans
// le corps de la procédure print
}
Une fonction peut être déclarée inline, comme dans l'exemple
suivant :
inline int max(int x, int y) { return (x > y ? x : y); }
La qualification inline indique au compilateur qu'il est
préférable de remplacer chaque appel à la fonction par le
code correspondant.
Cette qualification n'est qu'indicative, et n'est en particulier pas
prise en compte si elle est irréalisable, en particulier parce que le
compilateur aurait besoin de connaître l'adresse de la fonction.
Comme en Java, une fonction peut être surchargée ;
la discrimination est alors faite sur le nombre et le
type des paramètres effectifs.
Notons aussi qu'il est possible de définir des valeurs par
défaut pour certains paramètres de fonctions.
Certaines fonctions sont appelées avec des paramètres qui changent
rarement.
Considérons par exemple une fonction ecranInit qui est
chargée d'initialiser un écran d'ordinateur (en mode caractères).
Dans 90% des cas, l'écran a les dimensions 24 lignes × 80
caractères et doit être initialisé dans 99% des cas avec le
caractère ' ', qui provoque l'effacement de l'écran.
Plutôt que de contraindre le programmeur à énumérer des paramètres
qui sont généralement invariants, C++ offre la possibilité de
donner des valeurs par défaut à certains paramètres lors de la
déclaration de la fonction, comme ci-dessous :
void ecranInit(Ecran ecran, int lig = 24, int col = 80, char fond = ' ');
void ecranInit(Ecran ecran, int lig, int col, char fond)
{
...
}
int main()
{
Ecran ec;
ecranInit(ec); // Éq. à : ecranInit(ec, 24, 80, ' ');
ecranInit(ec, 26); // Éq. à : ecranInit(ec, 26, 80, ' ');
ecranInit(ec, 26, 92); // Éq. à : ecranInit(ec, 26, 92, ' ');
ecranInit(ec, 26, 92, '+');
}
Quelques remarques sur cette fonctionnalité :
-
Une fonction peut définir des valeurs par défaut pour tous ses
paramètres ou seulement pour une partie.
Les paramètres acceptant des valeurs par défaut doivent se trouver
après les paramètres sans valeur par défaut dans la liste
des paramètres acceptés par une fonction.
-
Les valeurs par défaut de chaque paramètre ne peuvent être
mentionnées qu'une seule fois.
Ainsi, par convention, ces valeurs sont généralement mentionnées
dans la déclaration de la fonction et pas dans sa
définition (donc dans le header et pas dans le
fichier de suffixe .cpp ou .C).
-
L'ordre de déclaration des paramètres est important : dans
l'exemple ci-dessus il est en effet impossible de donner une
valeur à col sans en donner une auparavant à
lig.
D'une façon générale, il faut donc positionner parmi les
paramètres ayant des valeurs par défaut en premier ceux qui ont le
plus de chances d'être modifiés.
2.5 Variables
Les variables d'un programme C++ peuvent avoir plusieurs
classes de stockage :
-
automatiques
- : c'est l'option par défaut pour toute variable
interne d'une fonction. L'allocation se fait dans la pile d'exécution.
- externes
- ou globles : ce sont les variables définies à l'extérieur
de toute fonction, et qui sont donc globales.
Si on fait référence dans
une fonction à une variable définie dans un autre module, on précisera
qu'elle est externe par le mot-clé extern.
NB : Nous déconseillons fortement l'utilisation de variables
externes.
- statiques
- : une variable globale statique (mot-clé static)
est une variable dont le nom n'est pas exporté à l'édition de liens, et
qui reste donc invisible hors du module où elle est définie.
Une variable interne à une fonction qui est déclarée statique est une
variable rémanente : sa portée de visibilité est réduite à la
fonction, mais elle n'est initialisée que la première fois où la fonction
qui la déclare est appelée ; ensuite, sa valeur persiste d'un appel de la
fonction à l'autre.
Le mot-clé static permet également de définir les variables et
méthodes de classe (cf. § 3.8).
- registres
- : on peut demander qu'une variable de type entier,
caractère ou pointeur soit implantée dans un registre, ce qui est souvent
utile quand on veut aller vite. Les indices dans les tableaux et les
pointeurs en mémoire sont souvent de bons candidats pour être déclarés
comme registres.
Attention : seule une variable automatique peut être de type registre.
De plus, le mot-clé register,
à employer dans ce cas, ne donne qu'une indication au compilateur ; on ne
garantit pas que la variable sera bien en registre, le compilateur
n'ayant à sa disposition qu'un nombre limité de registres. Sauf cas très
particuliers, comme en programmation système ou en micro-optimisation de
code dans des boucles particulières, nous vous déconseillons de recourir
à l'emploi de ce mot-clé.
Les déclarations de variables peuvent en plus être agrémentées de l'un des
deux mots clés suivants :
-
const
- : la variable désigne en fait une constante ; aucune
modification n'est autorisée dans le programme.
- volatile
- : un objet déclaré volatile peut être
modifié par un événement extérieur à ce qui est contrôlé par le
compilateur (exemple : variable mise à jour par l'horloge système). Cette
indication donnée au compilateur lui signale que toute optimisation sur
l'emploi de cette variable serait hasardeuse.
2.6 Pointeurs
Les pointeurs sont des variables contenant des adresses.
Ils permettent donc de faire de l'adressage indirect.
Ainsi :
int* px;
déclare une variable px qui est un pointeur sur un entier.
La variable pointée par px est notée *px.
Inversement, pour une variable
int x;
on peut accéder à l'adresse de x par la notation &x.
Ainsi, je peux écrire :
px = &x;
ou
x = *px;
Voici une autre manière d'écrire la fonction swap() qui
échange deux entiers, cette fois-ci en passant par des pointeurs :
void swap(int* px, int* py)
{
int temp; // variable temporaire
temp = *px;
*px = *py;
*py = temp;
}
et pour échanger deux paramètres on appellera :
int a,b;
swap(&a,&b);
Attention : un des pièges les plus classiques en C++ est celui du
pointeur non initialisé. Le fait d'avoir déclaré une variable de type
pointeur ne suffit pas pour pouvoir déréférencer ce pointeur. Encore
faut-il qu'il pointe sur une ``case'' mémoire valide. Pour reprendre
l'exemple précédent, si j'écris
int* px;
*px = 3;
j'ai de très fortes chances d'avoir une erreur à l'exécution, puisque
px ne désigne pas une adresse mémoire dans laquelle j'ai le droit
d'écrire. Ce n'est qu'après avoir écrit par exemple px = &x;
comme dans l'exemple ci-dessus que l'instruction *px = 3; devient
valide.
2.6.1 Les tableaux
On déclare un tableau de la manière suivante :
int a[10];
Il y a une très forte relation entre un pointeur et un tableau.
Dans l'exemple précédent, a est en fait une constante de type
adresse ; en effet, a est l'adresse du début du tableau.
Par conséquent, on peut écrire les choses suivantes :
int* pa, a[10];
pa = &a[0];
ou
pa = a;
Mais attention, il y a des différences dues au fait que a est une
adresse constante alors que pa est une variable.
Ainsi, on peut écrire
pa = a;
mais il n'est pas valide d'écrire
a = pa;
Quand on veut passer un tableau en paramètre formel d'une fonction, il est
équivalent d'écrire :
void funct(int tab[])
ou
void funct(int* tab)
car on passe dans les deux cas une adresse.
Remarque : comme en Java, les indices, qui correspondent à des
déplacements à partir du début du tableau, commencent toujours à 0.
Voyons maintenant comment on peut utiliser cette équivalence entre
pointeurs et tableaux pour parcourir un tableau sans recalculer
systématiquement l'adresse du point courant.
Le problème est de calculer la moyenne d'une matrice 200 × 200
d'entiers.
int tab[200][200];
long int moyenne=0;
int* p = tab;
for (int i=0 ; i < 200 ; i++)
for (int j=0 ; j < 200 ; j++ , p++)
moyenne += *p;
moyenne /= 40000;
Remarque : on peut écrire cela de manière encore plus efficace
en profitant du fait qu'on utilise p pour l'incrémenter en
même temps.
Par ailleurs, une seule boucle suffit, et il est inutile d'utiliser
des compteurs :
int tab[200][200];
long int moyenne=0;
int* p = tab;
int* stop = p + 200 * 200;
for ( ; p < stop ; ) // on ne fait plus p++ ici
moyenne += *p++; // on accède à la valeur pointée
// par p, puis on l'incrémente
moyenne /= 40000;
Mais attention : le programme devient ainsi à peu près illisible, et
je déconseille d'abuser de telles pratiques, qui ne sont justifiées que
dans des cas extrêmes, où l'optimisation du code est un impératif.
Notez aussi qu'il est exclu de réaliser des ``affectations globales'' sur
les tableaux, autrement que par le mécanisme des pointeurs (pas de recopie
globale).
2.6.2 Allocation dynamique de mémoire
L'allocation et la libération dynamique de mémoire sont réalisées par les
opérateurs new et delete.
Une expression comprenant l'opération new retourne un pointeur sur
l'objet alloué. On écrira donc par exemple :
int* pi = new int;
Pour allouer un tableau dynamique, on indique la taille souhaitée comme
suit :
int* tab = new int[20];
Contrairement à Java, C++ n'a pas de mécanisme de ramasse-miettes ; c'est
donc à vous de libérer la mémoire dynamique dont vous n'avez plus besoin
(voir aussi la notion de destructeur pour les classes ---
§ 3.2) :
delete pi;
delete [] tab;
L'exemple ci-dessous reprend et illustre l'utilisation de new
et de delete pour des variables et des tableaux :
// Allocation d'une variable et d'un tableau en C++
int main()
{
int* pi = new int;
int* tab = new int[10];
if ((pi != NULL) && (tab != NULL)) {
...
delete pi;
delete [] tab;
}
}
2.6.3 Arithmétique sur les pointeurs
Comme le montre l'exemple du § 2.6.1, un certain nombre d'opérations
arithmétiques sont possibles sur les pointeurs, en particulier
l'incrémentation.
Tout d'abord, on peut leur ajouter ou leur soustraire un
entier n.
Cela revient à ajouter à l'adresse courante n fois la taille
d'un objet du type pointé.
Ainsi, dans un tableau, comme nous l'avons vu, l'instruction p++
(qui est la même chose que p = p+1) fait pointer p
sur la case suivante dans le tableau, c'est-à-dire que l'adresse est
incrémentée de la taille (en octets) du type pointé.
On peut comparer deux pointeurs avec les opérateurs relationnels.
Évidemment, cela n'a de sens que s'ils pointent dans une même zone (tableau
par exemple).
Enfin, on peut soustraire deux pointeurs.
Le résultat est un entier indiquant le nombre de ``cases'' de la
taille du type pointé entre les deux pointeurs.
Là encore, cela n'a de signification que si les deux pointeurs pointent
dans la même zone contiguë.
2.6.4 Compléments sur les pointeurs
On pourrait encore dire beaucoup sur les pointeurs.
Nous nous contentons ici de signaler quelques points que le lecteur
intéressé par la poétique de C++ pourra approfondir dans la
littérature appropriée :
-
C++ propose deux manières de représenter les chaînes de
caractères : celle héritée de C et le type string de la
bibliothèque standard C++. Nous vous conseillons bien entendu
d'utiliser ce dernier (cf. § 4.5.3).
Mais comme vous risquez d'être parfois confrontés à des chaînes de
caractères ``à l'ancienne'' (c'est-à-dire à la mode C), sachez que ce
sont des tableaux de caractères terminés par le caractère nul (de code
0, et noté comme l'entier 0 ou le caractère \0
).
- On peut bien sûr utiliser des tableaux de pointeurs, des
pointeurs de pointeurs, des pointeurs de pointeurs de
pointeurs, etc.
Bref, vous voyez ce que je veux dire...
- On peut même manipuler des tableaux de fonctions, des
pointeurs de fonctions, ce qui permet d'appeler plusieurs
fonctions différentes en se servant du même pointeur.
2.7 La bibliothèque d'entrées-sorties
Nous ne prétendons pas couvrir dans ce polycopié les très nombreuses
fonctionnalités couvertes par la bibliothèque standard C++.
Cependant, il nous semble utile de donner quelques indications sur les
entrées-sorties.
Pour utiliser la bibliothèque, il faut inclure son fichier de
déclarations :
#include <iostream>
Les opérations standards d'entrée et de sortie sont fournies par trois
flots (streams), désignés par les variables suivantes :
-
cin désigne le flot d'entrée standard (typiquement, votre
clavier),
- cout désigne le flot de sortie standard (typiquement, la
fenêtre d'exécution sur votre écran),
- cerr désigne le flot standard des messages d'erreur.
Les opérateurs << et >> sont redéfinis pour
permettre des écritures et lectures aisées :
#include <iostream>
#include <string>
// ...
cout << "Bonjour, comment vous appelez-vous ? ";
string nom;
cin >> nom;
if (nom.string_empty()) {
cerr << "erreur : nom vide" << endl;
}
else {
cout << nom << ", donnez-moi maintenant votre âge : ";
int age;
cin >> age;
if (age > 35) {
cout << "Ouah, vous n'êtes plus tout jeune !" << endl;
}
else {
cout << "Blanc bec !" << endl;
}
}
On notera au passage l'emploi de string, la bibliothèque de
manipulation de chaînes de caractères C++ (cf. § 4.5.3) et de
la constante endl, qui indique le passage à la ligne.
Bien entendu, la bibliothèque iostream fournit de nombreuses
autres fonctionnalités d'entrée-sortie, et la bibliothèque fstream
fournit les fonctionnalités de manipulation de fichiers.
Nous nous sommes contentés ici de donner quelques rudiments vous permettant
d'écrire vos tout premiers programmes...
Vous trouverez en annexe A quelques indications
supplémentaires sur les entrées-sorties.
2.8 Les namespaces
Les namespaces (espaces de noms) ont été
introduits dans la norme définitive de C++.
Dans des projets conséquents, il n'est en effet pas rare d'utiliser plusieurs
bibliothèques C++ qui peuvent parfois définir les mêmes
identificateurs, ce qui génère des conflits.
Avec les espaces de noms, il ne doit plus y avoir de conflits de
noms : les déclarations restent cachées dans un namespace
jusqu'à ce qu'on fasse explicitement appel à lui.
On rejoint à plusieurs égards la notion de package en Java...
Pour définir un namespace, il faut utiliser le mot-clé
namespace comme cela est présenté :
namespace MonNameSpace
{
// Toutes les déclarations sont regroupées
// dans ce bloc
int f();
// D'autres déclarations...
// Fin du namespace
};
La déclaration d'un même namespace peut être réalisée dans
plusieurs fichiers d'interface, le namespace complet résultant
alors de l'union des déclarations.
Dans l'exemple présenté, le nom complet de la fonction f
devient MonNameSpace::f
, selon une syntaxe qui est similaire
à celle des méthodes membres de classe.
Cependant, afin de ne pas être contraint de désigner la fonction
f par rapport à son namespace lorsqu'il n'y a pas de
risque de conflit, des facilités d'utilisation sont
disponibles grâce à la définition d'alias ou à l'utilisation du
mot-clé using :
// Si on souhaite définir un alias sur le nom d'un
// namespace
namespace mon = MonNameSpace;
mon::f(); // Appelle MonNameSpace::f()
// Si la fonction f est la seule à être présente,
// on peut déclarer
using MonNameSpace::f;
f(); // Appelle MonNameSpace::f()
// Si on souhaite bénéficier de toutes les déclarations
// d'un namespace sans avoir à les préfixer du nom
// du namespace
using namespace MonNameSpace;
L'utilisation de ces directives using est cependant à bannir des fichiers
d'interface, pour éviter des conflits qui pourraient apparaître dans les
modules incluant ces interfaces.
Du coup, la plupart des déclarations de la bibliothèque standard C++
ont été regroupées dans un namespace, appelé std.
Ainsi, un programme utilisant des fonctionnalités de la bibliothèque
standard devra comporter la directive
using namespace std;
pour éviter d'avoir à écrire explicitement std::cout
, par exemple.
- 1
- La présence d'un type booléen explicite est assez récente ;
auparavant, les entiers étaient interprétés comme des booléens
suivant leur valeur nulle ou non-nulle et par compatibilité, un
certain nombre de compilateurs C++
continuent à accepter des valeurs entières à la place de valeurs
booléennes.
- 2
- zéro-X.
- 3
- zéro.
- 4
- Identification des types au cours de
l'exécution.