C++ CH3-CH4


Chapitre 3. Le C/C++ un peu plus loin


Dans cette partie nous allons aborder d'autres aspects du langage indispensable à la programmation. Il s'agit des structures de contrôle (if, while, goto, etc…), et des types de donnés complexes (array, struct, union). Des précisions seront également données sur les différentes classes de variables utilisables en C/C++.
3.1. Les structures de contrôle

Le C/C++ dispose de toutes les structures de contrôle nécessaire à la programmation. Leur syntaxe est donné ci-dessous.
3.1.1. La structure conditionnelle if
Syntaxe :
if (test) opération;
test est une expression dont la valeur est booléenne ou entière. Toute valeur non nulle est considérée comme vraie. Si le test est vrai, opération est exécuté. Ce peut être une instruction ou un bloc d'instructions. Une variante permet de spécifier l'action à exécuter en cas de test faux :
if (test) opération1;
else opération2;
Note: Attention ! Les parenthèses autour de test sont nécessaires !
Les opérateurs de comparaison sont les suivants :
Tableau 3-1. Opérateurs de comparaison
== égalité
!= inégalité
< infériorité
> supériorité
<= infériorité ou égalité
>= supériorité ou égalité

Les opérateurs logiques applicables aux expressions booléennes sont les suivants :

Tableau 3-2. Opérateurs logiques
&& et logique
|| ou logique
! négation logique

Il n'y a pas d'opérateur ou exclusif logique.
Exemple 3-1. Test conditionnel if

if (a < c && a!=0)
{
min=a;
nouveau_min=1;
}

3.1.2. La boucle for
Syntaxe :
for (initialisation ; test ; itération) opération;
initialisation est une instruction (ou un bloc d'instructions) exécutée avant le premier parcours de la boucle du for. test est une expression dont la valeur déterminera la fin de la boucle. itération est l'opération à effectuer en fin de boucle, et opération constitue le traitement de la boucle. Chacune de ces parties est facultative.
La séquence d'exécution est la suivante :
initialisation
test : saut en fin du for ou suite
opération
itération
retour au test
fin du for.
Exemple 3-2. Boucle for
somme = 0;
for (i=0; i<=10; i=i+1) somme = somme + i;
Note: En C++, il est possible que la partie initialisation déclare une variable. Dans ce cas, la variable déclarée n'est définie qu'à l'intérieur de l'instruction for. Par exemple,
for (int i=0; i<10; i++);
est strictement équivalent à :

{
int i;
for (i=0; i<10; i++);
}

Ceci signifie que l'on ne peut pas utiliser la variable i après l'instruction for, puisqu'elle n'est définie que dans cette instruction. Ceci permet de réaliser des variables muettes, qui ne servent qu'à l'instruction for dans laquelle elles sont définies.
Note: Cette règle n'est pas celle utilisée par la plupart des compilateurs C++. La règle qu'ils utilisent spécifie que la variable déclarée dans la partie initialisation de l'instruction for reste déclarée après cette instruction. La différence est subtile, mais importante. Ceci pose assurément des problèmes de compatibilité avec les programmes C++ écrits pour ces compilateurs, puisque dans un cas la variable doit être redéclarée et dans l'autre cas elle ne le doit pas. Il est donc recommandé de ne pas déclarer de variables dans la partie initialisation des instructions for pour assurer une portabilité maximale.

3.1.3. Le while Syntaxe :
while (test) opération;
opération est effectuée tant que test est vérifié. Comme pour le if, les parenthèses autour du test sont nécessaires. L'ordre d'exécution est :
test
opération
Exemple 3-3. Boucle while

somme = i = 0;
while (somme<1000)
{
somme = somme + 2*i/(5+i);
i = i+1;
}

3.1.4. Le do Syntaxe :
do opération;
while (test);
opération est effectuée jusqu'à ce que test ne soit plus vérifié. L'ordre d'exécution est :
opération
test

Exemple 3-4. Boucle do
p = i = 1;
do
{
p = p * i;
i = i +1;
} while (i!=10);
3.1.5. Le branchement conditionnel
Syntaxe :
switch (valeur)
{
case cas1:
[opération;
[break;]
]
case cas2:
[opération;
[break;]
]

case casN:
[opération;
[break;]
]
[default:
[opération;
[break;]
]
]
}
valeur est évalué en premier. Son type doit être entier. Selon le résultat de l'évaluation, l'exécution du programme se poursuit au cas de même valeur. Si aucun des cas ne correspond et si default est présent, l'exécution se poursuit après default. Si en revanche default n'est pas présent, on sort du switch.
L'opération qui suit le case approprié ou default est exécutée. Puis, l'opération du cas suivant est exécutée (on ne sort donc pas du switch). Pour forcer la sortie du switch, on doit utiliser le mot-clé break.
Exemple 3-5. Branchement conditionnel switch
i= 2;
switch (i)
{
case 1 :
case 2 : // Si i=1 ou 2, la ligne suivante sera exécutée.
i=2-i;
break;
case 3 :
i=0; // Cette ligne ne sera jamais exécutée.
default :
break;
}
Note: Il est interdit d'effectuer une déclaration de variable dans un des case d'un switch.
3.1.6. Le saut
Syntaxe :
goto étiquette;
et
étiquette:
étiquette est le point d'arrivée du saut goto. Elle peut avoir n'importe quel nom d'identificateur, et est toujours suivi de deux points (:).
Il n'est pas possible d'effectuer des sauts en dehors d'une fonction. En revanche, il est possible d'effectuer des sauts en dehors et à l'intérieur des blocs d'instructions sous certaines conditions. Si la destination du saut se trouve après une déclaration, cette déclaration ne doit pas comporter d'initialisations. De plus, ce doit être la déclaration d'un type simple (c'est à dire une déclaration qui ne demande pas l'exécution de code) comme les variables, les structures ou les tableaux. Enfin, si, au cours d'un saut, le contrôle d'exécution sort de la portée d'une variable, celle-ci est détruite.
Note: Ces dernières règles sont particulièrement importantes en C++ si la variable est un objet dont la classe a un constructeur ou un destructeur non trivial. Voir le chapitre 7 pour plus de détails à ce sujet.
Autre règle spécifique au C++ : il est impossible d'effectuer un saut à l'intérieur d'un bloc de code en exécution protégée try {}. Voir aussi le Chapitre 9 concernant les exceptions.
3.1.7. Les commandes de rupture de séquence
Syntaxe :
continue;
ou
break;
ou
return [valeur];
ou
goto étiquette;
A part le goto, qui a déjà été vu, il y a d'autres commandes de rupture de séquence (c'est à dire de changement de la suite des instructions à exécuter).
return permet de quitter immédiatement la fonction en cours. break permet de passer à l'instruction suivant l'instruction while, do, for ou switch la plus imbriquée (celle dans laquelle on se trouve).
continue saute directement à la dernière ligne de l'instruction while, do ou for la plus imbriquée. Cette ligne est l'accolade fermante. C'est à ce niveau que les tests de continuation sont faits pour for et do, ou que le saut au début du while est effectué (suivi immédiatement du test). On reste donc dans la structure dans laquelle on se trouvait au moment de l'exécution de continue, contrairement à ce qui se passe avec le break.
Exemple 3-6. Rupture de séquence par continue
// Calcule la somme des 1000 premiers entiers pairs :
somme_pairs=0;
for (i=0; i<1000; i=i+1)
{
if (i%2==1) continue;
somme_pairs=somme_pairs + i;
}

3.2. Retour sur les types

En dehors des types de variables simples, le C/C++ permet de créer des types plus complexes.
3.2.1. Les structures
Les types complexes peuvent se construire à l'aide de structures. Pour cela, on utilise le mot-clé struct. Sa syntaxe est la suivante :
struct [nom_structure]
{
type champ;
[type champ;
[…]]
};
Il n'est pas nécessaire de donner un nom à la structure. La structure contient plusieurs autres variables, appelées champs. Leur type est donné dans la déclaration de la structure. Ce type peut être n'importe quel autre type, même une structure.
La structure ainsi définie peut alors être utilisée pour définir une variable dont le type est cette structure.
Pour cela, deux possibilités :
• faire suivre la définition de la structure par l'identificateur de la variable ; Exemple 3-7. Déclaration de variable de type structure
struct Client
{
unsigned char Age;
unsigned char Taille;
} Jean;
ou, plus simplement :
struct
{
unsigned char Age;
unsigned char Taille;
} Jean;
Dans le deuxième exemple, le nom de la structure n'est pas mis.
• déclarer la structure en lui donnant un nom, puis déclarer les variables avec la syntaxe suivante :
[struct] nom_structure identificateur;
Exemple 3-8. Déclaration de structure
struct Client
{
unsigned char Age;
unsigned char Taille;
};
struct Client Jean, Philippe;
Client Christophe; // Valide en C++ mais invalide en C
Dans cet exemple, le nom de la structure doit être mis, car on utilise cette structure à la ligne suivante. Pour la déclaration des variables Jean et Philippe de type structure client, le mot-clé struct a été mis. Ceci n'est pas nécessaire en C++, mais l'est en C. Le C++ permet donc de déclarer des variables de type structure exactement comme si le type structure était un type prédéfini du langage. La déclaration de la variable Christophe ci-dessus est invalide en C.
Les éléments d'une structure sont accédés par un point, suivi du nom du champ de la structure à accéder. Par exemple, l'âge de Jean est désigné par Jean.Age.
Note: Le typage du C++ est plus fort que celui du C, parce qu'il considère que deux types ne sont identiques que s'ils ont le même nom. Alors que le C considère que deux types qui ont la même structure sont des types identiques, le C++ les distingue. Ceci peut être un inconvénient, car des programmes qui pouvaient être compilés en C ne le seront pas forcément par un compilateur C++. Considérons l'exemple suivant :
int main(void)
{
struct st1
{
int a;
} variable1 = {2};
struct
{
int a;
} variable2; // variable2 a exactement la même structure
// que variable1,
variable2 = variable1; // mais ceci est ILLÉGAL en C++ !
return 0;
}
Bien que les deux variables aient exactement la même structure, elles sont de type différents ! En effet, variable1 est de type « st1 », et variable2 de type « » (la structure qui a permis de la construire n'a pas de nom). On ne peut donc pas faire l'affectation. Pourtant, ce programme était compilable en C pur…
Note: Il est possible de ne pas donner de nom à une structure lors de sa définition sans pour autant déclarer une variable. De telles structures anonymes ne sont utilisables que dans le cadre d'une structure incluse dans une autre structure :
struct struct_principale
{
struct
{
int champ1;
};
int champ2;
};
Dans ce cas, les champs des structures imbriquées seront accédés comme s'il s'agissait de champs de la structure principale. La seule limitation est que, bien entendu, il n'y ait pas de conflit entre les noms des champs des structures imbriquées et ceux des champs de la structure principale. S'il y a conflit, il faut donner un nom à la structure imbriquée qui pose problème, en en faisant un vrai champ de la structure principale.
3.2.2. Les unions
Les unions constituent un autre type de structure. Elles sont déclarées avec le mot-clé union, qui a la même syntaxe que struct. La différence entre les structures et les unions est que les différents champs d'une union occupent le même espace mémoire. On ne peut donc, à tout instant, n'utiliser qu'un des champs de l'union.
Exemple 3-9. Déclaration d'une union
union entier_ou_reel
{
int entier;
float reel;
};
union entier_ou_reel x;
x peut prendre l'aspect soit d'un entier, soit d'un réel. Par exemple :
x.entier=2;
affecte la valeur 2 à x.entier, ce qui détruit x.reel.
Si, à présent, on fait :
x.reel=6.546;
la valeur de x.entier est perdue, car le réel 6.546 a été stocké au même emplacement mémoire que l'entier x.entier.
Les unions, contrairement aux structures, sont assez peu utilisées, sauf en programmation système où l'on doit pouvoir interpréter des données de différentes manières selon le contexte. Dans ce cas, on aura avantage à utiliser des unions de structures anonymes et à accéder aux champs des structures, chaque structure permettant de manipuler les données selon une de leur interprétation possible.
Exemple 3-10. Union avec discriminant
struct SystemEvent
{
int iEventType; // Discriminant de l'événement.
// Permet de choisir comment l'interpréter.
union
{
struct
{ // Structure permettant d'interpréter
int iMouseX; // les événements souris.
int iMouseY;
};
struct
{ // Structure permettant d'interpréter
char cCharacter; // les événements clavier.
int iShiftState;
};
// etc…
};
};
3.2.3. Les énumérations
Les énumérations sont des types intégraux (c'est à dire qu'ils sont basés sur les entiers), pour lesquels chaque valeur dispose d'un nom unique. Leur utilisation permet de définir les constantes entières dans un programme et de les nommer. La syntaxe des énumérations est la suivante :
enum enumeration
{
nom1 [=valeur1]
[, nom2 [=valeur2]
[…]]
};
Dans cette syntaxe, enumeration représente le nom de l'énumération et nom1, nom2, etc… représentent les noms des énumérés. Par défaut, les énumérés reçoivent les valeurs entières 0, 1, etc… sauf si une valeur explicite leur est donnée dans la déclaration de l'énumération. Dès qu'une valeur est donnée, le compteur de valeurs se synchronise avec cette valeur, si bien que l'énuméré suivant prendra la valeur augmentée de 1.
Exemple 3-11. Déclaration d'une énumération
enum Nombre
{
un=1, deux, trois, cinq=5, six, sept
};
Dans cet exemple, les énumérés prennent respectivement leur valeurs. Comme quatre n'est pas défini, une resynchronisation a lieu lors de la définition de cinq.
Les énumérations suivent les mêmes règles que les structures et les unions en ce qui concerne la déclaration des variables : on doit répéter le mot-clé enum en C, ce n'est pas nécessaire en C++.
3.2.4. Les champs de bits
Il est possible de définir des champs de bits et de donner des noms aux bits de ces champs. Pour cela, on utilisera le mot-clé struct et on donnera le type des groupes de bits, leur nom, et enfin leur étendue :
Exemple 3-12. Déclaration d'un champs de bits
struct champ_de_bits
{
int var1; // Définit une variable classique.
int bits1a4 : 4; // Premier champ : 4 bits.
int bits5a10 : 6; // Deuxième champ : 6 bits.
unsigned int bits11a16 : 6; // Dernier champ : 6 bits.
};
La taille d'un champ de bits ne doit pas excéder celle d'un entier. Pour aller au-delà, on créera un deuxième champ de bits. La manière dont les différents groupes de bits sont placés en mémoire dépend du compilateur et n'est pas normalisée.
Les différents bits ou groupes de bits seront tous accessibles comme des variables classiques d'une structure ou d'une union :
struct champ_de_bits essai;
int main(void)
{
essai.bits1a4 = 3;
/* suite du programme */
return 0;
}
3.2.5. Initialisation des structures et des tableaux
Les tableaux et les structures peuvent être initialisées, tout comme les types classiques peuvent l'être. La valeur servant à l'initialisation est décrite en mettant les valeurs des membres de la structure ou du tableau entre accolades, en les séparant par des virgules :
Exemple 3-13. Initialisation d'une structure
// Définit le type Client :
struct Client
{
unsigned char Age;
unsigned char Taille;
unsigned int Comptes[10];
};
// Déclare et initialise la variable John :
Client John={35, 190, {13594, 45796, 0, 0, 0, 0, 0, 0, 0, 0}};
La variable John est ici déclarée comme étant de type Client et initialisée comme suit : son âge est de 35, sa taille de 190 et ses deux premiers comptes de 13594 et 45796. Les autres comptes sont nuls.
Il n'est pas nécessaire de respecter l'imbrication du type complexe au niveau des accolades, ni de fournir des valeurs d'initialisations pour les derniers membres d'un type complexe. Les valeurs par défaut qui sont utilisées dans ce cas sont les valeurs nulles du type du champ non initialisé. Ainsi, la déclaration de John aurait pu se faire ainsi :
Client John={35, 190, 13594, 45796};
3.2.6. Les alias de types
Le C/C++ dispose d'un mécanisme de création d'alias, ou de synonymes des types complexes. Le mot-clé à utiliser est typedef. Sa syntaxe est la suivante :
typedef définition alias;
alias est le nom que doit avoir le synonyme du type. définition est sa définition. Pour les tableaux, la syntaxe est particulière :
typedef type_tableau type[(taille)]([taille](…));
type_tableau est le type des éléments du tableau.
Exemple 3-14. Définition de type simple
typedef unsigned int mot;
mot est strictement équivalent à unsigned int.
Exemple 3-15. Définition de type tableau
typedef int tab[10];
tab est le synonyme de « tableau de 10 entiers ».
Exemple 3-16. Définition de type structure
typedef struct client
{
unsigned int Age;
unsigned int Taille;
} Client;
Client représente la structure client. Attention à ne pas confondre le nom de la structure (« struct client ») avec le nom de l'alias (« Client »).
Note: Pour comprendre la syntaxe de typedef, il suffit de raisonner de la manière suivante. Si on dispose d'une expression qui permet de déclarer une variable d'un type donné, alors il suffit de placer le mot-clé typedef devant cette expression pour faire en sorte que l'identificateur de la variable devienne un identificateur de type. Par exemple, si on supprime le mot-clé typedef dans la déclaration du type Client ci-dessus, alors Client devient une variable dont le type est struct client.
Une fois ces définitions d'alias effectuées, on peut les utiliser comme n'importe quel type, puisqu'ils représentent des types :
unsigned int i = 2, j; // Déclare deux unsigned int
tab Tableau; // Déclare un tableau de 10 entiers
Client John; // Déclare une structure client
John.Age = 35; // Initialise la variable John
John.Taille = 175;
for (j=0; j<10; j = j+1) Tableau[j]=j; // Initialise Tableau
3.2.7. Transtypages
Il est parfois utile de changer le type d'une valeur. Considérons l'exemple suivant : la division de 5 par 2 renvoie 2. En effet, 5/2 fait appel à la division euclidienne. Comment faire pour obtenir le résultat avec un nombre réel ? Il faut faire 5./2, car alors 5. est un nombre flottant. Mais que faire quand on se trouve avec des variables entières (i et j par exemple) ? Le compilateur signale une erreur après i dans l'expression i./j ! Il faut changer le type de l'une des deux variables. Cette opération s'appelle le transtypage. On la réalise simplement en faisant précéder l'expression à transtyper du type désiré entouré de parenthèses :
(type) expression
Exemple 3-17. Transtypage en C
int i=5, j=2;
((float) i)/j
Dans cet exemple, i est transtypé en flottant avant la division. On obtient donc 2.5.

3.3. Les classes de stockage

Les variables en C/C++ peuvent être créées de différentes manières. Elles sont classées en différents types de variables, appelés classes de stockage.
La classification la plus simple que l'on puisse faire des variables est la classification locale - globale. Les variables globales sont déclarées en dehors de tout bloc d'instructions, dans la zone de déclaration globale du programme. Les variables locales en revanche sont créées à l'intérieur d'un bloc d'instructions. Les variables locales et globales ont des durées de vie, des portées et des emplacements en mémoire différents.
La portée d'une variable est la zone du programme dans laquelle elle est accessible. La portée des variables globales est tout le programme, alors que la portée des variables locales est le bloc d'instructions dans lequel elles ont été créées.
La durée de vie d'une variable est le temps pendant lequel elle existe. Les variables globales sont créées au début du programme et détruites à la fin, leur durée de vie est donc celle du programme. En général, les variables locales ont une durée de vie qui va du moment où elles sont déclarées jusqu'à la sortie du bloc d'instructions dans lequel elles ont été déclarées. Cependant, il est possible de faire en sorte que les variables locales survivent à la sortie de ce bloc d'instructions. D'autre part, la portée d'une variable peut commencer avant sa durée de vie si cette variable est déclarée après le début du bloc d'instructions dans lequel elle est déclarée. La durée de vie n'est donc pas égale à la portée d'une variable.
La classe de stockage d'une variable permet de spécifier sa durée de vie et sa place en mémoire (sa portée est toujours le bloc dans lequel la variable est déclarée). Le C/C++ dispose d'un éventail de classes de stockage assez large et permet de spécifier le type de variables que l'on désire utiliser :
  • auto : la classe de stockage par défaut. Les variables ont pour portée le bloc d'instructions dans lequel elles ont été crées. Elles ne sont accessibles que dans ce bloc. Leur durée de vie est restreinte à ce bloc. Ce mot-clé est facultatif, la classe de stockage auto étant la classe par défaut ;
  • static : cette classe de stockage permet de créer des variables dont la portée est le bloc d'instructions en cours, mais qui, contrairement aux variables auto, ne sont pas détruites lors de la sortie de ce bloc. À chaque fois que l'on rentre dans ce bloc d'instructions, les variables statiques existeront et auront pour valeurs celles qu'elles avaient avant que l'on quitte ce bloc. Leur durée de vie est donc celle du programme, et elles conservent leur valeurs. Un fichier peut être considéré comme un bloc. Ainsi, une variable statique d'un fichier ne peut pas être accédée à partir d'un autre fichier. Ceci est utile en compilation séparée (voir plus loin) ;
  • register : cette classe de stockage permet de créer une variable dont l'emplacement se trouve dans un registre du microprocesseur. Il faut bien connaître le langage machine pour correctement utiliser cette classe de variable. En pratique, cette classe est très peu utilisée ;
  • volatile : cette classe de variable sert lors de la programmation système. Elle indique qu'une variable peut être modifiée en arrière plan par un autre programme (par exemple par une interruption, par un thread, par un autre processus, par le système d'exploitation ou par un autre processeur dans une machine parallèle). Cela nécessite donc de recharger cette variable à chaque fois qu'on y fait référence dans un registre du processeur, et ce même si elle se trouve déjà dans un de ces registres (ce qui peut arriver si on a demandé au compilateur d'optimiser le programme) ;
  • const : cette classe est utilisée pour rendre le contenu d'une variable non modifiable. En quelque sorte, la variable devient ainsi une variable en lecture seule. Attention, une telle variable n'est pas forcément une constante : elle peut être modifiée soit par l'intermédiaire d'un pointeur transtypé, soit par une entité extérieure au programme (comme pour les variables volatile). Quand ce mot-clé est appliqué à une structure, aucun des champs de la structure n'est accessible en écriture. Bien qu'il puisse paraître étrange de vouloir rendre « constante » une « variable », ce mot-clé a une utilité. En particulier, il permet de faire du code plus sûr ;
  • mutable : cette classe de stockage, disponible uniquement en C++, ne sert que pour les membres des structures. Elle permet de passer outre la constance éventuelle d'une structure pour ce membre. Ainsi, un champ de structure déclaré mutable peut être modifié même si la structure est déclarée const ;
  • extern : cette classe est utilisée pour signaler que la variable peut être définie dans un autre fichier. Elle est utilisée dans le cadre de la compilation séparée (voir plus loin).
Pour déclarer une classe de stockage particulière, il suffit de faire précéder la déclaration de la variable par l'un des mots-clés auto, static, register, etc… On n'a le droit de n'utiliser que les classes de stockage non contradictoires. Par exemple, register et extern sont incompatibles, de même que register et volatile, et const et mutable. Par contre, static et const, de même que const et volatile, peuvent être utilisées simultanément.
Exemple 3-18. Déclaration d'une variable locale statique
int appels(void)
{
static int n = 0;
return n = n+1;
}
Cette fonction mémorise le nombre d'appels qui lui ont été faits dans la variable n et renvoie ce nombre.
int appels(void)
{
int n = 0;
return n =n + 1;
}
Cette fonction renverra toujours 1. La variable n est créée, initialisée, incrémentée et détruite à chaque appel. Elle ne survit pas à la fin de l'instruction return.
Exemple 3-19. Déclaration d'une variable constante
const i=3;
i prend la valeur 3 et ne peut plus être modifiée.
Les variables globales qui sont définies sans le mot-clé const sont traitées par le compilateur comme des variables de classe de stockage extern par défaut. Ces variables sont donc accessibles à partir de tous les fichiers du programme. En revanche, cette règle par défaut n'est pas valide pour les variables définies avec le mot-clé const. Ces variables sont automatiquement déclarées static par le compilateur, ce qui signifie qu'elles ne sont accessibles que dans le fichier dans lequel elles ont été déclarées. Pour les rendre accessibles aux autres fichiers, il faut impérativement les déclarer avec le mot-clé extern avant de les définir.
Exemple 3-20. Déclaration de constante externes
int i = 12; // i est accessible de tous les fichiers.
const int j = 11; // Synonyme de "static const int j = 11;".
extern const int k; // Déclare d'abord la variable k…
const int k = 12; // puis donne la définition.
Notez que toutes les variables définies avec le mot-clé const doivent être initialisées lors de leur définition. En effet, on ne peut pas modifier la valeur des variables const, elles doivent donc avoir une valeur initiale. Enfin, les variables statiques non initialisées prennent la valeur nulle.
Les mots-clés const et volatile demandent au compilateur de réaliser des vérifications additionnelles lors de l'emploi des variables qui ont ces classes de stockage. En effet, le C/C++ assure qu'il est interdit de modifier (du moins sans magouiller) une variable de classe de stockage const, et il assure également que toute les références à une variable de classe de stockage volatile se feront sans optimisations dangereuses. Ces vérifications sont basées sur le type des variables manipulées. Dans le cas des types de base, ces vérifications sont simples et de compréhension immédiate. Ainsi, les lignes de code suivantes :
const int i=3;
int j=2;
i=j; // Illégal : i est de type const int.
génèrent une erreur parce qu'on ne peut pas affecter une valeur de type int à une variable de type const int. En revanche, pour les types complexes (pointeurs et références en particulier), les mécanismes de vérifications sont plus fins. Nous verrons quels sont les problèmes soulevés par l'emploi des mots-clés const et volatile avec les pointeurs et les références dans le chapitre suivant.
Enfin, en C++ uniquement, le mot-clé mutable permet de rendre un champ de structure const accessible en écriture :
Exemple 3-21. Utilisation du mot-clé mutable
struct A
{
int i; // Non modifiable si A est const.
mutable int j; // Toujours modifiable.
};
const A a={1, 1}; // i et j valent 1.
int main(void)
{
a.i=2; // ERREUR ! a est de type const A !
a.j=2; // Correct : j est mutable.
return 0;
}

Chapitre 4. Les pointeurs et références


Les pointeurs sont des variables très utilisées en C et en C++. Ils doivent être considérés comme des variables, il n'y a rien de sorcier derrière les pointeurs. Cependant, les pointeurs ont un domaine d'application très vaste.
Les références sont des identificateurs synonymes d'autres identificateurs, qui permettent de manipuler certaines notions introduites avec les pointeurs plus souplement. Elles n'existent qu'en C++.

4.1. Notion d'adresse

Tout objet manipulé par l'ordinateur est stocké dans sa mémoire. On peut considérer que cette mémoire est constituée d'une série de « cases », cases dans lesquelles sont stockées les valeurs des variables ou les instructions du programme. Pour pouvoir accéder à un objet (la valeur d'une variable ou les instructions à exécuter par exemple), c'est à dire au contenu de la case mémoire dans laquelle cet objet est enregistré, il faut connaître le numéro de cette case. Autrement dit, il faut connaître l'emplacement en mémoire de l'objet à manipuler. Cet emplacement est appelé l'adresse de la case mémoire, et par extension, l'adresse de la variable ou l'adresse de la fonction stockée dans cette case et celles qui la suivent.
Toute case mémoire a une adresse unique. Lorsque l'on utilise une variable ou une fonction, le compilateur manipule l'adresse de cette dernière pour y accéder. C'est lui qui connaît cette adresse, le programmeur n'a pas à s'en soucier.

4.2. Notion de pointeur

Une adresse est une valeur. Cette valeur est constante, car en général un objet ne se déplace pas en mémoire.
Un pointeur est une variable qui contient l'adresse d'un objet, par exemple l'adresse d'une autre variable. On dit que le pointeur pointe sur la variable pointée. Ici, pointer signifie « faire référence à ». La valeur d'un pointeur peut changer : cela ne signifie pas que la variable pointée est déplacée en mémoire, mais plutôt que le pointeur pointe sur autre chose.
Afin de savoir ce qui est pointé par un pointeur, les pointeurs disposent d'un type. Ce type est construit à partir du type de l'objet pointé. Ceci permet au compilateur de vérifier que les manipulations réalisées en mémoire par l'intermédiaire du pointeur son valides. Le type des pointeur se lit « pointeur de … », où les points de suspension représentent le nom du type de l'objet pointé.
Les pointeurs se déclarent en donnant le type de l'objet qu'ils devront pointer, suivi de leur identificateur précédé d'une étoile :
int *pi; // pi est un pointeur d'entier.
Note: Si plusieurs pointeurs doivent être déclarés, l'étoile doit être répétée :
int *pi1, *pi2, j, *pi3;
Ici, pi1, pi2 et pi3 sont des pointeurs d'entiers et j est un entier.
Il est possible de faire un pointeur sur une structure dans une structure en indiquant le nom de la structure comme type du pointeur :
typedef struct nom
{
nom *pointeur; // Pointeur sur une structure "nom".
...
} MaStructure;
Ce type de construction permet de créer des listes de structures, dans lesquelles chaque structure contient l'adresse de la structure suivante dans la liste.
Il est également possible de créer des pointeurs sur des fonctions, et d'utiliser ces pointeurs pour paramétrer un algorithme avec l'action de la fonction pointée. Nous détaillerons plus loin ce type d'utilisation des pointeurs.

4.3. Déréférencement, indirection

Un pointeur ne servirait strictement à rien s'il n'y avait pas de possibilité d'accéder à l'adresse d'une variable ou d'une fonction (on ne pourrait alors pas l'initialiser), ou s'il n'y avait pas moyen d'accéder à l'objet référencé par le pointeur (la variable pointée ne pourrait pas être manipulée, ou la fonction pointée ne pourrait pas être appelée).
Ces deux opérations sont respectivement appelées indirection et déréférencement. Il existe deux opérateurs permettant de récupérer l'adresse d'un objet et d'accéder à l'objet pointé. Ces opérateurs sont respectivement & et *.
Il est très important de s'assurer que les pointeurs que l'on manipule sont tous initialisés (c'est à dire contiennent l'adresse d'un objet valide, et pas n'importe quoi). En effet, accéder à un pointeur non initialisé revient à lire, ou plus grave encore, à écrire dans la mémoire à un endroit complètement aléatoire (selon la valeur initiale du pointeur lors de sa création). En général, on initialise les pointeurs dès leur création, ou, s'ils doivent être utilisés ultérieurement, on les initialise avec le pointeur nul. Ceci permettra de faire ultérieurement des tests sur la validité du pointeur, ou au moins de détecter les erreurs. En effet, l'utilisation d'un pointeur initialisé avec le pointeur nul génère souvent une faute de protection du programme, que tout bon débogueur est capable de détecter. Le pointeur nul se note NULL.
Note: NULL est une macro définie dans le fichier d'en-tête stdlib.h. En C, elle représente la valeur des pointeurs non initialisés. Malheureusement, cette valeur peut ne pas être égale à l'adresse 0 (certains compilateurs utilisent la valeur -1 pour NULL par exemple). C'est pour cela que cette macro a été définie, afin de représenter, selon le compilateur, la bonne valeur. Voir le Chapitre 6 pour plus de détails sur les macros et sur les fichiers d'en-tête.
La norme du C++ fixe la valeur nulle des pointeurs à 0. Par conséquent, les compilateurs C/C++ qui définissent NULL comme étant égal à -1 posent un problème de portabilité certain, puisque un programme C qui utilise NULL n'est plus valide en C++. Par ailleurs, un morceau de programme C++ compilable en C qui utiliserait la valeur 0 ne serait pas correct en C.
Il faut donc faire un choix : soit utiliser NULL en C et 0 en C++, soit utiliser NULL partout, quitte à redéfinir la macro NULL pour les programmes C++ (solution qui me semble plus pratique).
Exemple 4-1. Déclaration de pointeurs
int i=0; // Déclare une variable entière.
int *pi; // Déclare un pointeur sur un entier.
pi=&i; // Initialise le pointeur avec l'adresse de cette
// variable.
*pi = *pi+1; // Effectue un calcul sur la variable pointée par pi,
// c'est à dire sur i lui-même, puisque pi contient
// l'adresse de i.
/* A ce stade, i ne vaut plus 0, mais 1. */
Il est à présent facile de comprendre pourquoi il faut répéter l'étoile dans la déclaration de plusieurs pointeurs :
int *p1, *p2, *p3;
signifie syntaxiquement : p1, p2 et p3 sont des pointeurs d'entiers, mais aussi *p1, *p2 et *p3 sont des entiers.
Si l'on avait écrit :
int *p1, p2, p3;
seul p1 serait un pointeur d'entier. p2 et p3 seraient des entiers.
L'accès aux champs d'une structure par le pointeur sur cette structure se fera avec l'opérateur '->', qui remplace '(*).'.
Exemple 4-2. Utilisation de pointeurs de structures
struct Client
{
int Age;
};
Client structure1;
Client *pstr = &structure1; pstr->Age = 35; // On aurait pu écrire (*pstr).Age=35;
4.4. Notion de référence

En plus des pointeurs, le C++ permet de créer des références. Les références sont des synonymes d'identificateurs. Elles permettent de manipuler une variable sous un autre nom que celui sous laquelle cette dernière a été déclarée.
Note: Les références n'existent qu'en C++. Le C ne permet pas de créer des références.
Par exemple, si « id » est le nom d'une variable, il est possible de créer une référence « ref » de cette variable. Les deux identificateurs id et ref représentent alors la même variable, et celle-ci peut être accédée et modifiée à l'aide de ces deux identificateurs indistinctement.
Toute référence doit se référer à un identificateur : il est donc impossible de déclarer une référence sans l'initialiser. De plus, la déclaration d'une référence ne crée pas un nouvel objet comme c'est le cas pour la déclaration d'une variable par exemple. En effet, les références se rapportent à des identificateurs déjà existants. La syntaxe de la déclaration d'une référence est la suivante :
type &référence = identificateur;
Après cette déclaration, référence peut être utilisé partout où identificateur peut l'être. Ce sont des synonymes.
Exemple 4-3. Déclaration de références
int i=0;
int &ri=i; // Référence sur la variable i.
ri=ri+i; // Double la valeur de i (et de ri).
est possible de faire des références sur des valeurs numériques. Dans ce cas, les références doivent être déclarées comme étant constantes, puisqu'une valeur est une constante :
const int &ri=3; // Référence sur 3.
int &error=4; // Erreur ! La référence n'est pas constante.

4.5. Lien entre les pointeurs et les références

Les références et les pointeurs sont étroitement liés. En effet, si l'on utilise une référence pour manipuler un objet, cela revient exactement à manipuler un pointeur constant contenant l'adresse de l'objet manipulé. Les références permettent simplement d'obtenir le même résultat que les pointeurs avec un plus grande facilité d'écriture.
Par exemple, considérons le morceau de code suivant :
int i=0;
int *pi=&i;
*pi=*pi+1; // Manipulation de i via pi.
et faisons passer l'opérateur & de la deuxième ligne à gauche de l'opérateur d'affectation :
int i=0;
int &*pi=i; // Ceci génère une erreur de syntaxe mais nous
// l'ignorons pour les besoins de l'explication.
*pi=*pi+1;
Maintenant, comparons avec le morceau de code équivalent suivant :
int i=0;
int &ri=i;
ri=ri+1; // Manipulation de i via ri.
Nous constatons que la référence ri peut être identifiée avec l'expression *pi, qui représente bel et bien la variable i. Ainsi, ri représente exactement i. Ceci permet de comprendre l'origine de la syntaxe de déclaration des références.

4.6. Passage de paramètres par variable ou par valeur

Il y a deux méthodes pour passer des variables en paramètres dans une fonction : le passage par valeur ou le passage par variable. Ces méthodes sont décrites ci-dessous.
4.6.1. Passage par valeur
La valeur de l'expression passée en paramètre est copiée dans une variable locale. C'est cette variable qui est utilisée pour faire les calculs dans la fonction appelée.
Si l'expression passée en paramètre est une variable, son contenu est copié dans la variable locale. Aucune modification de la variable locale dans la fonction appelée ne modifie la variable passée en paramètre, parce que ces modifications ne s'appliquent qu'à une copie de cette dernière.
Le C ne permet de faire que des passages par valeur.
Exemple 4-4. Passage de paramètre par valeur
int i=2;
void test(int j) // j est la copie de la valeur passée en
// paramètre
{
j=3; // Modifie j, mais pas i.
return;
}
int main(void)
{
test(i); // Le contenu de i est copié dans j.
// i n'est pas modifié. Il vaut toujours 2.
test(2); // La valeur 2 est copiée dans j.
return 0;
}
4.6.2. Passage par variable
La deuxième technique consiste à passer non plus la valeur des variables comme paramètre, mais à passer les variables elles-mêmes. Il n'y a donc plus de copie, plus de variables locales. Toute modification du paramètre dans la fonction appelée entraîne la modification de la variable passée en paramètre.
Le C ne permet pas de faire ce type de passage de paramètres (le C++ le permet en revanche).
Exemple 4-5. Passage de paramètre par variable en Pascal
Var i : integer;
Procedure test(Var j : integer)
Begin
{La variable j est strictement égale
à la variable passée en paramètre.}
j:=2; {Ici, cette variable est modifiée.}
End;
Begin
i:=3; {Initialise i à 3}
test(i); {Appelle la fonction. La variable i est passée en
paramètres, pas sa valeur. Elle est modifiée par
la fonction test.}
{Ici, i vaut 2.}
End.
Puisque la fonction attend une variable en paramètre, on ne peut plus appeler test avec une valeur (test(3) est maintenant interdit, car 3 n'est pas une variable : on ne peut pas le modifier).
4.6.3. Avantages et inconvénients des deux méthodes
Les passages par valeurs permettent d'éviter de détruire par mégarde les variables passées en paramètre.
Les passages par variables sont plus rapides et plus économes en mémoire que les passages par valeur, puisque les étapes de la création de la variable locale et la copie de la valeur ne sont pas faites. Il faut donc éviter les passages par valeur dans les cas d'appels récursifs de fonction ou de fonctions travaillant avec des grandes structures de données (matrices par exemple).
4.6.4. Comment passer les paramètres par variable en C ?
Il n'y a qu'une solution : passer l'adresse de la variable. Ceci constitue donc une application des pointeurs.
Voici comment l'exemple Exemple 4-5 serait programmé en C :
Exemple 4-6. Passage de paramètre par variable en C
void test(int *pj) // test attend l'adresse d'un entier…
{
*pj=2; // pour le modifier.
return;
}
void main(void)
{
int i=3;
test(&i); // On passe l'adresse de i en paramètre.
/* Ici, i vaut 2. */
return;
}
À présent, il est facile de comprendre la signification de & dans l'appel de scanf : les variables à entrer sont passées par variable.
4.6.5. Passage de paramètres par référence
La solution du C est exactement la même que celle du Pascal du point de vue sémantique. En fait, le Pascal procède exactement de la même manière en interne, mais la manipulation des pointeurs est masquée par le langage. Cependant, plusieurs problèmes se posent au niveau syntaxique :
  • la syntaxe est lourde dans la fonction, à cause de l'emploi de l'opérateur * devant les paramètres ;
  • la syntaxe est dangereuse lors de l'appel de la fonction, puisqu'il faut systématiquement penser à utiliser l'opérateur & devant les paramètres. Un oubli devant une variable de type entier et la valeur de l'entier est utilisée à la place de son adresse dans la fonction appelée (plantage assuré, essayez avec scanf).
Le C++ permet de résoudre tous ces problème à l'aide des références. Au lieu de passer les adresses des variables, il suffit de passer les variables elles-mêmes en utilisant des paramètres sous la forme de références. La syntaxe des paramètres devient alors :
type &identificateur [, type &identificateur […]]
Exemple 4-7. Passage de paramètre par référence en C++
void test(int &i) // i est une référence du paramètre constant.
{
i = 2; // Modifie le paramètre passé en référence.
return;
}
int main(void)
{
int i=3;
test(i);
// Après l'appel de test, i vaut 2.
// L'opérateur & n'est pas nécessaire pour appeler
// test.
return 0;
}

4.7. Arithmétique des pointeurs

Il est possible d'effectuer des opérations arithmétiques sur les pointeurs.
Les seules opérations valides sont les opérations externes (addition et soustraction des entiers) et la soustraction de pointeurs. Elles sont définies comme suit (la soustraction d'un entier est considérée comme l'addition d'un entier négatif) :
p + i = adresse contenue dans p + i*taille(élément pointé par p)
et :
p1 - p2 = adresse contenue dans p1 - adresse contenue dans p2
Si p est un pointeur d'entier, p+1 est donc le pointeur sur l'entier qui suit immédiatement celui pointé par p. On retiendra surtout que l'entier qu'on additionne au pointeur est multiplié par la taille de l'élément pointé pour obtenir la nouvelle adresse.
Le type du résultat de la soustraction de deux pointeurs est très dépendant de la machine cible et du modèle mémoire du programme. En général, on ne pourra jamais supposer que la soustraction de deux pointeurs est un entier (que les chevronnés du C me pardonnent, mais c'est une erreur très grave). En effet, ce type peut être insuffisant pour stocker des adresses (une machine peut avoir des adresses sur 64 bits et des données sur 32 bits). Pour résoudre ce problème, le fichier d'en-tête stdlib.h contient la définition du type à utiliser pour la différence de deux pointeurs. Ce type est nommé ptrdiff_t.
Exemple 4-8. Arithmétique des pointeurs
int i, j;
ptrdiff_t delta = &i - &j; // Correct
int error = &i - &j; // Peut marcher, mais par chance.
Il est possible de connaître la taille d'un élément en octets en utilisant l'opérateur sizeof. Il a la syntaxe d'une fonction :
sizeof(type|expression)
Il attend soit un type, soit une expression. La valeur retournée est soit la taille en octets du type, soit celle du type de l'expression. Dans le cas des tableaux, il renvoie la taille totale du tableau. Si son argument est une expression, celle-ci n'est pas évaluée (donc si il contient un appel à une fonction, celle-ci n'est pas appelée). Par exemple :
sizeof(int)
renvoie la taille d'un entier en octet, et :
sizeof(2+3)
renvoie la même taille, car 2+3 est de type entier. 2+3 n'est pas calculé.
Note: L'opérateur sizeof renvoie la taille des types en tenant compte de leur alignement. Ceci signifie par exemple que même si un compilateur espace les éléments d'un tableau afin de les aligner sur des mots mémoire de la machine, la taille des éléments du tableau sera celle des objets de même type qui ne se trouvent pas dans ce tableau (ils devront donc être alignés eux aussi). On a donc toujours l'égalité suivante :
sizeof(tableau) = sizeof(élément) * nombre d'éléments

4.8. Utilisation des pointeurs avec les tableaux

Les tableaux sont étroitement liés aux pointeurs parce que, de manière interne, l'accès aux éléments des tableaux se fait par manipulation de leur adresse de base, de la taille des éléments et de leurs indices. En fait, l'adresse du n-ième élément d'un tableau est calculée avec la formule :
Adresse_n = Adresse_Base + n*taille(élément)
où taille(élément) représente la taille de chaque élément du tableau et Adresse_Base l'adresse de base du tableau. Cette adresse de base est l'adresse du début du tableau, c'est donc à la fois l'adresse du tableau et l'adresse de son premier élément.
Ce lien apparaît au niveau du langage dans les conversions implicites de tableaux en pointeurs, et dans le passage des tableaux en paramètre des fonctions.
4.8.1. Conversions des tableaux en pointeurs
Afin de pouvoir utiliser l'arithmétique des pointeurs pour manipuler les éléments des tableaux, le C++ effectue les conversions implicites suivantes lorsque nécessaire :
  • tableau vers pointeur d'élément ;
  • pointeur d'élément vers tableau.
Ceci permet de considérer les expressions suivantes comme équivalentes :
identificateur[n]
et :
*(identificateur + n)
si identificateur est soit un identificateur de tableau, soit celui d'un pointeur.
Exemple 4-9. Accès aux éléments d'un tableau par pointeurs
int tableau[100];
int *pi=tableau;
tableau[3]=5; // Le 4ème élément est initialisé à 5
*(tableau+2)=4; // Le 3ème élément est initialisé à 4
pi[5]=1; // Le 5ème élément est initialisé à 1
Note: On prendra garde à certaines subtilités. Les conversions implicites sont une facilité introduite par le compilateur, mais en réalité, les tableaux ne sont pas des pointeurs, ce sont des variables comme les autres, à ceci près : leur type est convertible en pointeur sur le type de leurs éléments. Il en résulte parfois quelques ambiguïtés lorsque l'on manipule les adresses des tableaux. En particulier, on a l'égalité suivante :
&tableau == tableau
en raison du fait que l'adresse du tableau est la même que celle de son premier élément. Il faut bien comprendre que dans cette expression, une conversion a lieu. Cette égalité n'est donc pas exacte en théorie. En effet, si c'était le cas, on pourrait écrire :
*&tableau == tableau
puisque les opérateurs * et & sont conjugués. D'où :
tableau == *&tableau = *(&tableau) == *(tableau) == t[0]
ce qui est faux (le type du premier élément n'est en général pas convertible en type pointeur.).
4.8.2. Paramètres de fonction de type tableau
La conséquence la plus importante de la conversion tableau vers pointeur se trouve dans le passage par variable des tableaux dans une fonction. Lors du passage d'un tableau en paramètre d'une fonction, la conversion implicite a lieu, les tableaux sont donc toujours passés par variable, jamais par valeur. Il est donc faux d'utiliser des pointeurs pour les passer en paramètre, car le paramètre aurait le type pointeur de tableau. On ne modifierait pas le tableau, mais bel et bien le pointeur du tableau. Le programme aurait donc de fortes chances de planter.
Par ailleurs, certaines caractéristiques des tableaux peuvent être utilisées pour les passer en paramètre dans les fonctions.
Il est autorisé de ne pas spécifier la taille de la dernière dimension des paramètres de type tableau dans les déclarations et les définitions de fonctions. En effet, la borne supérieure des tableaux n'a pas besoin d'être précisée pour manipuler leurs éléments (on peut malgré tout la donner si cela semble nécessaire).
Cependant, pour les dimensions deux et suivantes, les tailles des premières dimensions restent nécessaires. Si elles n'étaient pas données explicitement, le compilateur ne pourrait pas connaître le rapport des dimensions. Par exemple, la syntaxe :
int tableau[][];
utilisée pour référencer un tableau de 12 entiers ne permettrait pas de faire la différence entre les tableaux de deux lignes et de six colonnes et les tableaux de trois lignes et de quatre colonnes (et leurs transposés respectifs). Une référence telle que :
tableau[1][3] ne représenterait rien. Selon le type de tableau, l'élément référencé serait le quatrième élément de la deuxième ligne (de six éléments), soit le dixième élément, ou bien le quatrième élément de la deuxième ligne (de quatre éléments), soit le huitième élément du tableau. En précisant tous les indices sauf un, il est possible de connaître la taille du tableau pour cet indice à partir de la taille globale du tableau, en la divisant par les tailles sur les autres dimensions (2 = 12/6 ou 3 = 12/4 par exemple).
Le programme d'exemple suivant illustre le passage des tableaux en paramètre :
Exemple 4-10. Passage de tableau en paramètre
int tab[10][20];
void test(int t[][20])
{
// Utilisation de t[i][j] …
return;
}
int main(void)
{
test(tab); // Passage du tableau en paramètre.
return 0;
}

4.9. Références et pointeurs constants et volatiles

L'utilisation des mots-clés const et volatile avec les pointeurs et les références est un peu plus compliquée qu'avec les types simples. En effet, il est possible de déclarer des pointeurs sur des variables, des pointeurs constants sur des variables, des pointeurs sur des variables constantes et des pointeurs constants sur des variables constantes (bien entendu, il en est de même avec les références). La position des mots-clés const et volatile dans les déclarations des types complexes est donc extrêmement importante. En général, le mot-clé const ou volatile caractérise ce qui le suit dans la déclaration. Ainsi :
const int * pi;
permet de déclarer un pointeur d'entier constant. Mais :
int j; int * const pi=&j;
déclare pi comme étant constant, et de type pointeur d'entier.
Note: Les déclarations C++ peuvent devenir très compliquées et difficiles à lire. Il existe une astuce qui permet de les interpréter facilement. Lors de l'analyse de la déclaration d'un identificateur X, il faut toujours commencer par une phrase du type « X est un … ». Pour trouver la suite de la phrase, il suffit de lire la déclaration en partant de l'identificateur et de suivre l'ordre imposé par les priorités des opérateurs. L'ordre des priorités peut être modifié par la présence de parenthèses. L'annexe B donne les priorités de tous les opérateurs du C++.
Ainsi, dans l'exemple suivant :
const int *pi[12];
void (*pf)(int * const pi);
la première déclaration se lit de la manière suivante : « pi (pi) est un tableau ([]) de 12 (12) entiers (int) constants (const) ». La deuxième déclaration se lit : « pf (pf) est un pointeur (*) de fonction (()) de pi (pi), qui est lui-même une constante (const) de type pointeur (*) d'entier (int). Cette fonction ne renvoie rien (void) ».
Le C et le C++ n'autorisent que les écritures qui conservent ou augmentent les propriétés de constance et de volatilité. Par exemple, le code suivant est correct :
char *pc;
const char *cpc;
cpc=pc; // Le passage de pc à cpc augmente la constance.
parce qu'elle signifie que si l'on peut écrire dans une variable par l'intermédiaire du pointeur pc, on peut s'interdire de le faire en utilisant cpc à la place de pc. En revanche, si on n'a pas le droit d'écrire dans une variable, on ne peut en aucun cas se le donner.
Cependant, les règles du langage relatives à la modification des variables peuvent parfois paraître étranges. Par exemple, le langage interdit une écriture telle que celle-ci :
char *pc;
const char **ppc;
ppc = &pc; // Interdit !
Pourtant, cet exemple ressemble beaucoup à l'exemple précédent. On pourrait penser que le fait d'affecter un pointeur de pointeur de variable à un pointeur de pointeur de variable constante revient à s'interdire d'écrire dans une variable qu'on a le droit de modifier. Mais en réalité, cette écriture va contre les règles de constances, parce qu'elle permettrait de modifier une variable constante. Pour s'en convaincre, il faut regarder l'exemple suivant :
const char c='a'; // La variable constante.
char *pc; // Pointeur par l'intermédiaire duquel
// nous allons modifier c.
const char **ppc=&pc; // Interdit, mais supposons que ce ne le
// soit pas.
*ppc=&c; // Parfaitement légal.
*pc='b'; // Modifie la variable c.
Que s'est-il passé ? Nous avons, par l'intermédiaire de ppc, affecté l'adresse de la constante c au pointeur pc. Malheureusement, pc n'est pas un pointeur de constante, et ceci nous a permis de modifier la constante c.
Afin de gérer correctement cette situation (et les situations plus complexes qui utilisent des triples pointeurs ou encore plus d'indirection), le C et le C++ interdisent l'affectation de tout pointeur dont les propriétés de constance et de volatilité sont moindres de celles du pointeur cible. La règle exacte est la suivante :
  1. On note cv les différentes qualifications de constance et de volatilité possibles (à savoir : const volatile, const, volatile ou aucune classe de stockage).
  2. Si le pointeur source est un pointeur cvs,0 de pointeur cvs,1 de pointeur … de pointeur cvs,n-1 de type Ts cvs,n, et que le pointeur destination est un pointeur cvd,0 de pointeur cvd,1 de pointeur … de pointeur cvd,n-1 de type Td cvs,n, alors l'affectation de la source à la destination n'est légale que si :
  • les types source Ts et destination Td sont compatibles ;
  • il existe un nombre entier strictement positif N tel que, quel que soit j supérieur ou égal à N, on ait :
  • si const apparaît dans cvs,j, alors const apparaît dans cvd,j ;
  • si volatile apparaît dans cvs,j, alors volatile apparaît dans cvd,j ;
  • et tel que, quel que soit 0
Ces règles sont suffisamment compliquées pour ne pas être apprises. Les compilateurs se chargeront de signaler les erreurs s'il y en a en pratique. Par exemple :
const char c='a';
const char *pc;
const char **ppc=&pc; // Légal à présent.
*ppc=&c;
*pc='b'; // Illégal (pc a changé de type).
L'affectation de double pointeur est à présent légale, parce que le pointeur source a changé de type (on ne peut cependant toujours pas modifier le caractère c).
Il existe une exception notable à ces règles : l'initialisation des chaînes de caractères. Les chaînes de caractères telles que :
"Bonjour tout le monde !"
sont des chaînes de caractères constantes. Par conséquent, on ne peut théoriquement affecter leur adresse qu'à des pointeurs de caractères constants :
const char *pc="Coucou !"; // Code correct.
Cependant, il a toujours été d'usage de réaliser l'initialisation des chaînes de caractères de la même manière :
char *pc="Coucou !"; // Théoriquement illégal, mais toléré.
Par compatibilité, le langage fournit donc une conversion implicite entre « const char * » et « char * ». Cette facilité ne doit pas pour autant vous inciter à transgresser les règles de constance : utilisez les pointeurs sur les chaînes de caractères constants autant que vous le pourrez (quitte à réaliser quelques copies de chaînes lorsqu'un pointeur de caractère simple doit être utilisé).
En C++, les références constantes sont très utiles pour réaliser des passages de variables par paramètres sans pour autant donner à la fonction le droit de modifier les paramètres. Ce type de situation apparaît souvent lorsque l'on veut passer en paramètre une grosse structure ou toute autre variable dont la copie n'est pas nécessaire (et donc déconseillée si l'on tient à avoir de bonnes performances).
Exemple 4-11. Passage de paramètres constant par référence
typedef struct
{

} structure;
void ma_fonction(const structure & s)
{

return ;
}
Dans cet exemple, s est une référence sur une structure constante. Le code se trouvant à l'intérieur de la fonction ne peut donc pas utiliser la référence s pour modifier la structure (on notera cependant que c'est la fonction elle-même qui s'interdit l'écriture dans la variable s. const est donc un mot-clé « coopératif ». Il n'est pas possible à un programmeur d'empêcher ses collègues d'écrire dans ses variables avec le mot-clé const. Nous verrons dans le Chapitre 8 que le C++ permet de pallier à ce problème grâce à une technique appelée l'encapsulation.).
Un autre avantage des références constantes pour les passages par variables est que si le paramètre n'est pas une variable, ou s'il n'est pas du bon type, une variable locale du type du paramètre est créée et initialisée avec la valeur du paramètre transtypé.
Exemple 4-12. Création d'un objet temporaire lors d'un passage par référence
void test(const int &i)
{
… // Utilisation de la variable i
// dans la fonction test. La variable
// i est créée si nécessaire.
return ;
}
int main(void)
{
test(3); // Appel de test avec une constante.
return 0;
}
Au cours de cet appel, une variable locale est créée (la variable i de la fonction test), et 3 lui est affecté.

4.10. Les chaînes de caractères : pointeurs et tableaux à la fois !

On a vu dans le premier chapitre que les chaînes de caractères n'existaient pas en C++. Ce sont en réalité des tableaux de caractères dont le dernier caractère est le caractère nul.
Ceci a plusieurs conséquences. La première, c'est que les chaînes de caractères sont aussi des pointeurs sur des caractères, ce qui se traduit dans la syntaxe de la déclaration d'une chaîne de caractères constante :
const char *identificateur = "chaîne";
identificateur est déclaré ici comme étant un pointeur de caractère, puis il est initialisé avec l'adresse de la chaîne de caractère constante "chaîne".
La deuxième est le fait qu'on ne peut pas faire, comme en Pascal, des affectations de chaînes de caractères, ni des comparaisons. Par exemple, si « nom1 » et « nom2 » sont des chaînes de caractères, l'opération :
nom1=nom2;
n'est pas l'affectation du contenu de nom2 à nom1. C'est une affectation de pointeur : le pointeur nom1 est égal au pointeur nom2 et pointent sur la même chaîne ! Une modification de la chaîne pointée par nom1 entraîne donc la modification de la chaîne pointée par nom2…
De même, le test nom1==nom2 est un test entre pointeurs, pas entre chaînes de caractères. Même si deux chaînes sont égales, le test sera faux si elles ne sont pas au même emplacement mémoire.

4.11. Allocation dynamique de mémoire

Les pointeurs sont surtout utilisés pour créer un nombre quelconque de variables, ou des variables de taille quelconque, en cours d'exécution du programme. Normalement, une variable est c
Commentaires (1)

1. mkout14 07/08/2013

uggs,

Ajouter un commentaire

Vous utilisez un logiciel de type AdBlock, qui bloque le service de captchas publicitaires utilisé sur ce site. Pour pouvoir envoyer votre message, désactivez Adblock.

MOBILISONS NOUS POUR LE DEVELOPPEMENT DE SARGHINE EN AIDANT L'ASSOCIATION AMSIRAR.

Créer un site gratuit avec e-monsite - Signaler un contenu illicite sur ce site

×