Résumé
La référence des étudiants et des développeurs professionnels
Cet ouvrage de référence a été conçu pour les étudiants de niveau avancé en
programmation et pour les développeurs souhaitant approfondir leur
connaissance du C ou trouver une réponse précise aux problèmes techniques
rencontrés lors du développement d’applications professionnelles.
Exhaustif et précis, l’ouvrage explore le langage C dans ses moindres recoins. Il
clarifie les points délicats et les ambiguïtés du langage, analyse le comportement
qu’on peut attendre d’un code ne respectant pas la norme ou confronté à une
situation d’exception. Tout au long de l’ouvrage, des notes soulignent les
principales différences syntaxiques entre le C et le C++, de manière à établir des
passerelles entre les deux langages.
Une annexe présente les spécificités des deux dernières moutures de la norme
ISO du langage, connues sous les noms C99 et C11.
Au sommaire
Les bases du langage C. Historique, programmes et compilation, variables et objets. Structure d’un
programme source. Jeux de caractères, identificateurs, mots-clés, séparateurs, format libre,
commentaires, tokens. Types de base. Types entiers, types caractère, types flottants, fichiers limits.h
et float.h. Opérateurs et expressions. Opérateurs arithmétiques, relationnels, logiques, de
manipulation de bits, d’affectation et d’incrémentation, de cast ; conversions numériques ; opérateur
conditionnel, séquentiel, sizeof ; priorité et associativité ; expressions constantes. Instructions
exécutables. Expressions, blocs ; instructions if, switch, do… while, while, for, break, continue ;
schémas de boucles utiles ; goto et les étiquettes. Tableaux. Déclaration, utilisation, débordement
d’indice, tableau de tableaux, initialisation d’un tableau. Pointeurs. Variable de type pointeur et
opérateur *, déclaration, propriétés arithmétiques ; opérateurs +, -, &, * et [] ; pointeurs et tableaux,
pointeur NULL, pointeurs et affectation, pointeurs génériques, comparaisons, conversions par cast.
Fonctions. Définition, déclaration et appel d’une fonction, transmission d’arguments, tableaux
transmis en arguments, variables globales et variables locales, pointeurs sur des fonctions. Entrées-
sorties standard. printf, putchar, scanf, getchar. Chaînes de caractères. Création, utilisation et
modification ; écriture et lecture avec puts, printf, gets, gets_s, scanf ; fonctions de manipulation de
chaînes (strcpy, strcat…) et de suites d’octets (memcpy, memmove…) ; fonctions de conversion en
numérique (strtod…). Structures, unions, énumérations et champs de bits. Déclaration,
représentation en mémoire, utilisation. Instruction typedef et synonymes. Fichiers. Fichiers binaires
et formatés ; fwrite et fread ; fprintf, fscanf, fputs et fgets ; fputc et fgetc ; accès direct avec fseek,
ftell… ; fopen et les modes d’ouverture ; flux prédéfinis stdin, stdout et stderr. Gestion dynamique
de la mémoire. Principes, fonctions malloc, free, calloc, realloc ; exemples d’utilisation : tableaux
dynamiques et listes chaînées. Préprocesseur. Directives et caractère #, définition de symboles et de
macros, directives de compilation conditionnelle, d’inclusion de fichier source, etc. Déclarations.
Syntaxe générale, spécificateurs, définition de fonction, interprétation de déclarations, écriture de
déclarateurs. Fiabilisation des lectures au clavier. Utilisation de scanf, gets, gets_s, fgets.
Catégories de caractères et fonctions associées. Gestion des programmes de grande taille.
Avantages et inconvénients des variables globales, partage d’identificateurs entre plusieurs fichiers
source. Fonctions à arguments variables. Règles d’écriture, macros va_start, va_arg, va_end,
fonctions vprintf, vfprintf et vsprintf. Communication avec l’environnement. Arguments reçus par la
fonction main, terminaison d’un programme, fonctions getenv et system, signaux. Caractères
étendus. Type wchar_t, fonctions mblen, mbtowc et wctomb, chaînes de caractères étendus.
Localisation. Mécanisme, fonctions setlocale et localeconv. Récursivité. Principes et exemples,
empilement des appels. Branchements non locaux. Macros setjmp et longjmp. Incompatibilités
entre C et C++. Bibliothèque standard du C. assert.h, ctype.h, errno.h, locale.h, math.h, setjmp.h,
signal.h, stdarg.h, stddef.h, stdio.h, stdlib.h, string.h, time.h. Nouveautés des normes ISO C99 et
C11. Contraintes supplémentaires, division d’entiers, tableaux de dimension variable, nouveaux
types, caractères étendus et Unicode, pointeurs restreints, structures anonymes, expressions
génériques, fonctions de vérification du débordement mémoire, threads, etc.
Biographie auteur
C. Delannoy
Ingénieur informaticien au CNRS, Claude Delannoy possède une grande pratique de la formation
continue et de l’enseignement supérieur. Réputés pour la qualité de leur démarche pédagogique, ses
ouvrages sur les langages et la programmation totalisent plus de 300 000 exemplaires vendus.
[Link]
Le guide complet
du langage
C
Claude Delannoy
ÉDITIONS EYROLLES
61, bd Saint-Germain
75240 Paris Cedex 05
[Link]
Le présent ouvrage est une nouvelle édition du livre publié à l’origine sous le titre
« La référence du C norme ANSI/ISO », puis au format semi-poche sous le titre « Langage C ».
En application de la loi du 11 mars 1957, il est interdit de reproduire intégralement ou partiellement le
présent ouvrage, sur quelque support que ce soit, sans l’autorisation de l’Éditeur ou du Centre Français
d’exploitation du droit de copie, 20, rue des Grands Augustins, 75006 Paris.
© Groupe Eyrolles, 1999, 2008, 2014, ISBN : 978-2-212-14012-5
AUX EDITIONS EYROLLES
Du même auteur
C. DELANNOY. – Programmer en langage C. Avec exercices corrigés.
N°14010, 5e édition, 2009, 276 pages (réédition avec nouvelle présentation,
2014).
C. DELANNOY. – Exercices en langage C.
N°11105, 2002, 2010 pages.
C. DELANNOY. – S’initier à la programmation et à l’orienté objet.
Avec des exemples en C, C++, C#, Python, Java et PHP.
N°14011, 2e édition, 2014, 382 pages.
C. DELANNOY. – Programmer en langage C++.
N°14008, 8e édition, 2011, 820 pages (réédition avec nouvelle présentation,
2014).
C. DELANNOY. – Exercices en langage C++.
N°12201, 3e édition, 2007, 336 pages.
C. Delannoy. – Programmer en Java. Java 8.
N°14007, 9e édition, 2014, 940 pages.
C. DELANNOY. – Exercices en Java.
N°14009, 4e édition, 2014, 360 pages.
Autres ouvrages
B. MEYER. – Conception et programmation orientées objet.
N°12270, 2008, 1222 pages.
P. ROQUES. – UML 2 par la pratique
N°12565, 7e édition, 2009, 396 pages.
G. SWINNEN. – Apprendre à programmer avec Python 3.
N°13434, 3e édition, 2012, 435 pages.
C. BLAESS. – Développement système sous Linux.
Ordonnancement multitâches, gestion mémoire, communications,
programmation réseau.
N°12881, 3e édition, 2011, 1004 pages.
C. BLAESS. – Solutions temps réel sous Linux. Avec 50 exercices corrigés.
N°13382, 2012, 294 pages.
Table des matières
Avant-propos
À qui s’adresse ce livre ?
Structure de l’ouvrage
À propos des normes ANSI/ISO
À propos de la fonction main
Remerciements
CHAPITRE 1
Généralités
1. Historique du langage C
2. Programme source, module objet et programme exécutable
3. Compilation en C : existence d’un préprocesseur
4. Variable et objet
4.1 Définition d’une variable et d’un objet
4.2 Utilisation d’un objet
5. Lien entre objet, octets et caractères
6. Classe d’allocation des variables
CHAPITRE 2
Les éléments constitutifs d’un programme source
1. Jeu de caractères source et jeu de caractères d’exécution
1.1 Généralités
1.2 Commentaires à propos du jeu de caractères source
1.3 Commentaires à propos du jeu minimal de caractères d’exécution
2. Les identificateurs
3. Les mots-clés
4. Les séparateurs et les espaces blancs
5. Le format libre
6. Les commentaires
7. Notion de token
7.1 Les différentes catégories de tokens
7.2 Décomposition en tokens
CHAPITRE 3
Les types de base
1. Les types entiers
1.1 Les six types entiers
1.2 Représentation mémoire des entiers et limitations
1.3 Critères de choix d’un type entier
1.4 Écriture des constantes entières
1.5 Le type attribué par le compilateur aux constantes entières
1.6 Exemple d’utilisation déraisonnable de constantes hexadécimales
1.7 Pour imposer un type aux constantes entières
1.8 En cas de dépassement de capacité dans l’écriture des constantes
entières
2. Les types caractère
2.1 Les deux types caractère
2.2 Caractéristiques des types caractère
2.3 Écriture des constantes caractère
2.4 Le type des constantes caractère
3. Le fichier limits.h
3.1 Son contenu
3.2 Précautions d’utilisation
4. Les types flottants
4.1 Rappels concernant le codage des nombres en flottant
4.2 Le modèle proposé par la norme
4.3 Les caractéristiques du codage en flottant
4.4 Représentation mémoire et limitations
4.5 Écriture des constantes flottantes
4.6 Le type des constantes flottantes
4.7 En cas de dépassement de capacité dans l’écriture des constantes
5. Le fichier float.h
6. Déclarations des variables d’un type de base
6.1 Rôle d’une déclaration
6.2 Initialisation lors de la déclaration
6.3 Les qualifieurs const et volatile
CHAPITRE 4
Les opérateurs et les expressions
1. Généralités
1.1 Les particularités des opérateurs et des expressions en C
1.2 Priorité et associativité
1.3 Pluralité
1.4 Conversions implicites
1.5 Les différentes catégories d’opérateurs
2. Les opérateurs arithmétiques
2.1 Les différents opérateurs numériques
2.2 Comportement en cas d’exception
3. Les conversions numériques implicites
3.1 Introduction
3.2 Les conversions numériques d’ajustement de type
3.3 Les promotions numériques
3.4 Combinaisons de conversions
3.5 Cas particulier des arguments d’une fonction
4. Les opérateurs relationnels
4.1 Généralités
4.2 Les six opérateurs relationnels du langage C
4.3 Leur priorité et leur associativité
5. Les opérateurs logiques
5.1 Généralités
5.2 Les trois opérateurs logiques du langage C
5.3 Leur priorité et leur associativité
5.4 Les opérandes de && et de || ne sont évalués que si nécessaire
6. Les opérateurs de manipulation de bits
6.1 Présentation des opérateurs de manipulation de bits
6.2 Les opérateurs « bit à bit »
6.3 Les opérateurs de décalage
6.4 Applications usuelles des opérateurs de manipulation de bits
7. Les opérateurs d’affectation et d’incrémentation
7.1 Généralités
7.2 La lvalue
7.3 L’opérateur d’affectation simple
7.4 Tableau récapitulatif : l’opérateur d’affectation simple
7.5 Les opérateurs d’affectation élargie
8. Les opérateurs de cast
8.1 Généralités
8.2 Les opérateurs de cast
9. Le rôle des conversions numériques
9.1 Conversion d’un type flottant vers un autre type flottant
9.2 Conversion d’un type flottant vers un type entier
9.3 Conversion d’un type entier vers un type flottant
9.4 Conversion d’un type entier vers un autre type entier
9.5 Cas particuliers des conversions d’entier vers caractère
9.6 Tableau récapitulatif des conversions numériques
10. L’opérateur conditionnel
10.1 Introduction
10.2 Rôle de l’opérateur conditionnel
10.3 Contraintes et conversions
10.4 La priorité de l’opérateur conditionnel
11. L’opérateur séquentiel
12. L’opérateur sizeof
12.1 L’opérateur sizeof appliqué à un nom de type
12.2 L’opérateur sizeof appliqué à une expression
13. Tableau récapitulatif : priorités et associativité des opérateurs
14. Les expressions constantes
14.1 Introduction
14.2 Les expressions constantes d’une manière générale
CHAPITRE 5
Les instructions exécutables
1. Généralités
1.1 Rappels sur les instructions de contrôle
1.2 Classification des instructions exécutables du langage C
2. L’instruction expression
2.1 Syntaxe et rôle
2.2 Commentaires
3. L’instruction composée ou bloc
3.1 Syntaxe d’un bloc
3.2 Commentaires
3.3 Déclarations dans un bloc
3.4 Cas des branchements à l’intérieur d’un bloc
4. L’instruction if
4.1 Syntaxe et rôle de l’instruction if
4.2 Exemples d’utilisation
4.3 Cas des if imbriqués
4.4 Traduction de choix en cascade
5. L’instruction switch
5.1 Exemple introductif
5.2 Syntaxe usuelle et rôle de switch
5.3 Commentaires
5.4 Quelques curiosités de l’instruction switch
6. Choix entre if et switch
7. Les particularités des boucles en C
7.1 Rappels concernant la programmation structurée
7.2 Les boucles en C
8. L’instruction do … while
8.1 Syntaxe
8.2 Rôle
8.3 Exemples d’utilisation
9. L’instruction while
9.1 Syntaxe
9.2 Rôle
9.3 Lien entre while et do … while
9.4 Exemples d’utilisation
10. L’instruction for
10.1 Introduction
10.2 Syntaxe
10.3 Rôle
10.4 Lien entre for et while
10.5 Commentaires
10.6 Exemples d’utilisation
11. Conseils d’utilisation des différents types de boucles
11.1 Boucle définie
11.2 Boucle indéfinie
12. L’instruction break
12.1 syntaxe et rôle
12.2 Exemple d’utilisation
12.3 Commentaires
13. L’instruction continue
13.1 Syntaxe et rôle
13.2 Exemples d’utilisation
13.3 Commentaires
14. Quelques schémas de boucles utiles
14.1 Boucle à sortie intermédiaire
14.2 Boucles à sorties multiples
15. L’instruction goto et les étiquettes
15.1 Les étiquettes
15.2 Syntaxe et rôle
15.3 Exemples et commentaires
CHAPITRE 6
Les tableaux
1. Exemple introductif d’utilisation d’un tableau
2. Déclaration des tableaux
2.1 Généralités
2.2 Le type des éléments d’un tableau
2.3 Déclarateur de tableau
2.4 La dimension d’un tableau
2.5 Classe de mémorisation associée à la déclaration d’un tableau
2.6 Les qualifieurs const et volatile
2.7 Nom de type correspondant à un tableau
3. Utilisation d’un tableau
3.1 Les indices
3.2 Un identificateur de tableau n’est pas une lvalue
3.3 Utilisation d’un élément d’un tableau
3.4 L’opérateur sizeof et les tableaux
4. Arrangement d’un tableau et débordement d’indice
4.1 Les éléments d’un tableau sont alloués de manière consécutive
4.2 Aucun contrôle n’est effectué sur la valeur de l’indice
5. Cas des tableaux de tableaux
5.1 Déclaration des tableaux à deux indices
5.2 Utilisation d’un tableau à deux indices
5.3 Peut-on parler de lignes et de colonnes d’un tableau à deux indices ?
5.4 Arrangement en mémoire d’un tableau à deux indices
5.5 Cas des tableaux à plus de deux indices
6. Initialisation de tableaux
6.1 Initialisation par défaut des tableaux
6.2 Initialisation explicite des tableaux
CHAPITRE 7
Les pointeurs
1. Introduction à la notion de pointeur
1.1 Attribuer une valeur à une variable de type pointeur
1.2 L’opérateur * pour manipuler un objet pointé
2. Déclaration des variables de type pointeur
2.1 Généralités
2.2 Le type des objets désignés par un pointeur
2.3 Déclarateur de pointeur
2.4 Classe de mémorisation associée à la déclaration d’un pointeur
2.5 Les qualifieurs const et volatile
2.6 Nom de type correspondant à un pointeur
3. Les propriétés des pointeurs
3.1 Les propriétés arithmétiques des pointeurs
3.2 Lien entre pointeurs et tableaux
3.3 Ordre des pointeurs et ordre des adresses
3.4 Les restrictions imposées à l’arithmétique des pointeurs
4. Tableaux récapitulatifs : les opérateurs +, -, &, * et []
5. Le pointeur NULL
6. Pointeurs et affectation
6.1 Prise en compte des qualifieurs des objets pointés
6.2 Les autres possibilités d’affectation
6.3 Tableau récapitulatif
6.4 Les affectations élargies += et -= et les incrémentations ++ et --
7. Les pointeurs génériques
7.1 Généralités
7.2 Déclaration du type void *
7.3 Interdictions propres au type void *
7.4 Possibilités propres au type void *
8. Comparaisons de pointeurs
8.1 Comparaisons basées sur un ordre
8.2 Comparaisons d’égalité ou d’inégalité
8.3 Récapitulatif : les comparaisons dans un contexte pointeur
9. Conversions de pointeurs par cast
9.1 Conversion d’un pointeur en un pointeur d’un autre type
9.2 Conversions entre entiers et pointeurs
9.3 Récapitulatif concernant l’opérateur de cast dans un contexte pointeur
CHAPITRE 8
Les fonctions
1. Les fonctions en C
1.1 Une seule sorte de module en C : la fonction
1.2 Fonction et transmission des arguments par valeur
1.3 Les variables globales
1.4 Les possibilités de compilation séparée
2. Exemple introductif de la notion de fonction en langage C
3. Définition d’une fonction
3.1 Les deux formes de l’en-tête
3.2 Les arguments apparaissant dans l’en-tête
3.3 La valeur de retour
3.4 Classe de mémorisation d’une fonction : extern et static
3.5 L’instruction return
4. Déclaration et appel d’une fonction
4.1 Déclaration sous forme de prototype
4.2 Déclaration partielle (déconseillée)
4.3 Portée d’une déclaration de fonction
4.4 Redéclaration d’une fonction
4.5 Une définition de fonction tient lieu de déclaration
4.6 En cas d’absence de déclaration
4.7 Utilisation de la déclaration dans la traduction d’un appel
4.8 En cas de non-concordance entre arguments muets et arguments
effectifs
4.9 Les fichiers en-tête standards
4.10 Nom de type correspondant à une fonction
5. Le mécanisme de transmission d’arguments
5.1 Cas où la transmission par valeur est satisfaisante
5.2 Cas où la transmission par valeur n’est plus satisfaisante
5.3 Comment simuler une transmission par adresse avec des pointeurs
6. Cas des tableaux transmis en arguments
6.1 Règles générales
6.2 Exemples d’applications
6.3 Pour qu’une fonction dispose de la dimension d’un tableau
6.4 Quelques conseils de style à propos des tableaux en argument
7. Cas particulier des tableaux de tableaux transmis en arguments
7.1 Application des règles générales
7.2 Artifices facilitant la manipulation de tableaux de dimensions
variables
8. Les variables globales
8.1 Exemples introductifs d’utilisation de variables globales
8.2 Les déclarations des variables globales
8.3 Portée des variables globales
8.4 Variables globales et édition de liens
8.5 Les variables globales sont de classe statique
8.6 Initialisation des variables globales
9. Les variables locales
9.1 La portée des variables locales
9.2 Classe d’allocation et initialisation des variables locales
10. Tableau récapitulatif : portée, accès et classe d’allocation des
variables
11. Pointeurs sur des fonctions
11.1 Déclaration d’une variable pointeur sur une fonction
11.2 Affectation de valeurs à une variable pointeur sur une fonction
11.3 Appel d’une fonction par le biais d’un pointeur
11.4 Exemple de paramétrage d’appel de fonctions
11.5 Transmission de fonction en argument
11.6 Comparaisons de pointeurs sur des fonctions
11.7 Conversions par cast de pointeurs sur des fonctions
CHAPITRE 9
Les entrées-sorties standards
1. Caractéristiques générales des entrées-sorties standards
1.1 Mode d’interaction avec l’utilisateur
1.2 Formatage des informations échangées
1.3 Généralisation aux fichiers de type texte
2. Présentation générale de printf
2.1 Notions de format d’entrée, de code de format et de code de
conversion
2.2 L’appel de printf
2.3 Les risques d’erreurs dans la rédaction du format
3. Les principales possibilités de formatage de printf
3.1 Le gabarit d’affichage
3.2 Précision des informations flottantes
3.3 Justification des informations
3.4 Gabarit ou précision variable
3.5 Le code de format g
3.6 Le drapeau + force la présence d’un signe « plus »
3.7 Le drapeau espace force la présence d’un espace
3.8 Le drapeau 0 permet d’afficher des zéros de remplissage
3.9 Le paramètre de précision permet de limiter l’affichage des chaînes
3.10 Cas particulier du type unsigned short int : le modificateur h
4. Description des codes de format des fonctions de la famille printf
4.1 Structure générale d’un code de format
4.2 Le paramètre drapeaux
4.3 Le paramètre de gabarit
4.4 Le paramètre de précision
4.5 Le paramètre modificateur h/l/L
4.6 Les codes de conversion
4.7 Les codes utilisables avec un type donné
5. La fonction putchar
5.1 Prototype
5.2 L’argument de putchar est de type int
5.3 La valeur de retour de putchar
6. Présentation générale de scanf
6.1 Format de sortie, code de format et code de conversion
6.2 L’appel de scanf
6.3 Les risques d’erreurs dans la rédaction du format
6.4 La fonction scanf utilise un tampon
6.5 Notion de caractère invalide et d’arrêt prématuré
6.6 La valeur de retour de scanf
6.7 Exemples de rencontre de caractères invalides
7. Les principales possibilités de scanf
7.1 La présentation des informations lues en données
7.2 Limitation du gabarit
7.3 La fin de ligne joue un rôle ambigu : séparateur ou caractère
7.4 Lorsque le format impose certains caractères dans les données
7.5 Attention au faux gabarit du code C
7.6 Les codes de format de la forme %[…]
8. Description des codes de format des fonctions de la famille de scanf
8.1 Récapitulatif des règles utilisées par ces fonctions
8.2 Structure générale d’un code de format
8.3 Les paramètres * et gabarit
8.4 Le paramètre modificateur h/l/L
8.5 Les codes de conversion
8.6 Les codes utilisables avec un type donné
8.7 Les différences entre les codes de format en entrée et en sortie
9. La fonction getchar
9.1 Prototype et valeur de retour
9.2 Précautions
CHAPITRE 10
Les chaînes de caractères
1. Règles générales d’écriture des constantes chaîne
1.1 Notation des constantes chaîne
1.2 Concaténation des constantes chaîne adjacentes
2. Propriétés des constantes chaîne
2.1 Conventions de représentation
2.2 Emplacement mémoire
2.3 Cas des chaînes identiques
2.4 Les risques de modification des constantes chaîne
2.5 Simulation d’un tableau de constantes chaîne
3. Créer, utiliser ou modifier une chaîne
3.1 Comment disposer d’un emplacement pour y ranger une chaîne
3.2 Comment agir sur le contenu d’une chaîne
3.3 Comment utiliser une chaîne existante
4. Entrées-sorties standards de chaînes
4.1 Généralités
4.2 Écriture de chaînes avec puts
4.3 Écriture de chaînes avec le code de format %s de printf ou fprintf
4.4 Lecture de chaînes avec gets
4.5 Lecture de chaînes avec le code de format %s dans scanf ou fscanf
4.6 Comparaison entre gets et scanf dans les lectures de chaînes
4.7 Limitation de la longueur des chaînes lues sur l’entrée standard
5. Généralités concernant les fonctions de manipulation de chaînes
5.1 Ces fonctions travaillent toujours sur des adresses
5.2 Les adresses sont toujours de type char *
5.3 Certains arguments sont déclarés const, d’autres pas
5.4 Attention aux valeurs des arguments de limitation de longueur
5.5 La fonction strlen
6. Les fonctions de copie de chaînes
6.1 Généralités
6.2 La fonction strcpy
6.3 La fonction strncpy
7. Les fonctions de concaténation de chaînes
7.1 Généralités
7.2 La fonction strcat
7.3 La fonction strncat
8. Les fonctions de comparaison de chaînes
8.1 Généralités
8.2 La fonction strcmp
8.3 La fonction strncmp
9. Les fonctions de recherche dans une chaîne
9.1 Les fonctions de recherche d’un caractère : strchr et strrchr
9.2 La fonction de recherche d’une sous-chaîne : strstr
9.3 La fonction de recherche d’un caractère parmi plusieurs : strpbrk
9.4 Les fonctions de recherche d’un préfixe
9.5 La fonction d’éclatement d’une chaîne : strtok
10. Les fonctions de conversion d’une chaîne en un nombre
10.1 Généralités
10.2 La fonction de conversion d’une chaîne en un double : strtod
10.3 Les fonctions de conversion d’une chaîne en entier : strtol et strtoul
10.4 Cas particulier des fonctions atof, atoi et atol
11. Les fonctions de manipulation de suites d’octets
11.1 Généralités
11.2 Les fonctions de recopie de suites d’octets
11.3 La fonction memcmp de comparaison de deux suites d’octets
11.4 La fonction memset d’initialisation d’une suite d’octets
11.5 La fonction memchr de recherche d’une valeur dans une suite
d’octets
CHAPITRE 11
Les types structure, union et énumération
1. Exemples introductifs
1.1 Exemple d’utilisation d’une structure
1.2 Exemple d’utilisation d’une union
2. La déclaration des structures et des unions
2.1 Définition conseillée d’un type structure ou union
2.2 Déclaration de variables utilisant des types structure ou union
2.3 Déclaration partielle ou déclaration anticipée
2.4 Mixage entre définition et déclaration
2.5 L’espace de noms des identificateurs de champs
2.6 L’espace de noms des identificateurs de types
3. Représentation en mémoire d’une structure ou d’une union
3.1 Contraintes générales
3.2 Cas des structures
3.3 Cas des unions
3.4 L’opérateur sizeof appliqué aux structures ou aux unions
4. Utilisation d’objets de type structure ou union
4.1 Manipulation individuelle des différents champs d’une structure ou
d’une union
4.2 Affectation globale entre structures ou unions de même type
4.3 L’opérateur & appliqué aux structures ou aux unions
4.4 Comparaison entre pointeurs sur des champs
4.5 Comparaison des structures ou des unions par == ou != impossible
4.6 L’opérateur ->
4.7 Structure ou union transmise en argument ou en valeur de retour
5. Exemples d’objets utilisant des structures
5.1 Structures comportant des tableaux
5.2 Structures comportant d’autres structures
5.3 Tableaux de structures
5.4 Structure comportant des pointeurs sur des structures de son propre
type
6. Initialisation de structures ou d’unions
6.1 Initialisation par défaut des structures ou des unions
6.2 Initialisation explicite des structures
6.3 L’initialisation explicite d’une union
7. Les champs de bits
7.1 Introduction
7.2 Exemples introductifs
7.3 Les champs de bits d’une manière générale
7.4 Exemple d’utilisation d’une structure de champs de bits dans une
union
8. Les énumérations
8.1 Exemples introductifs
8.2 Déclarations associées aux énumérations
CHAPITRE 12
La définition de synonymes avec typedef
1. Exemples introductifs
1.1 Définition d’un synonyme de int
1.2 Définition d’un synonyme de int *
1.3 Définition d’un synonyme de int[3]
1.4 Définition d’un synonyme d’un type structure
2. L’instruction typedef d’une manière générale
2.1 Syntaxe
2.2 Définition de plusieurs synonymes
2.3 Imbrication des définitions de synonyme
3. Utilisation de synonymes
3.1 Un synonyme peut s’utiliser comme spécificateur de type
3.2 Un synonyme n’est pas un nouveau type
3.3 Un synonyme peut s’utiliser à la place d’un nom de type
4. Les limitations de l’instruction typedef
4.1 Limitations liées à la syntaxe de typedef
4.2 Cas des tableaux sans dimension
4.3 Cas des synonymes de type fonction
CHAPITRE 13
Les fichiers
1. Généralités concernant le traitement des fichiers
1.1 Notion d’enregistrement
1.2 Archivage de l’information sous forme binaire ou formatée
1.3 Accès séquentiel ou accès direct
1.4 Fichiers et implémentation
2. Le traitement des fichiers en C
2.1 L’absence de la notion d’enregistrement en C
2.2 Notion de flux
2.3 Distinction entre fichier binaire et fichier formaté
2.4 Opérations applicables à un fichier et choix du mode d’ouverture
2.5 Accès séquentiel et accès direct
2.6 Le tampon et sa gestion
3. Le traitement des erreurs de gestion de fichier
3.1 Introduction
3.2 La détection des erreurs en C
4. Les entrées-sorties binaires : fwrite et fread
4.1 Exemple introductif de création séquentielle d’un fichier binaire
4.2 Exemple introductif de liste séquentielle d’un fichier binaire
4.3 La fonction fwrite
4.4 La fonction fread
5. Les opérations formatées avec fprintf, fscanf, fputs et fgets
5.1 Exemple introductif de création séquentielle d’un fichier formaté
5.2 Exemple introductif de liste séquentielle d’un fichier formaté
5.3 La fonction fprintf
5.4 La fonction fscanf
5.5 La fonction fputs
5.6 La fonction fgets
6. Les opérations mixtes portant sur des caractères
6.1 La fonction fputc et la macro putc
6.2 La fonction fgetc et la macro getc
7. L’accès direct
7.1 Exemple introductif d’accès direct à un fichier binaire existant
7.2 La fonction fseek
7.3 La fonction ftell
7.4 Les possibilités de l’accès direct
7.5 Détection des erreurs supplémentaires liées à l’accès direct
7.6 Exemple d’accès indexé à un fichier formaté
7.7 Les fonctions fsetpos et fgetpos
8. La fonction fopen et les différents modes d’ouverture d’un fichier
8.1 Généralités
8.2 La fonction fopen
9. Les flux prédéfinis
CHAPITRE 14
La gestion dynamique
1. Intérêt de la gestion dynamique
2. Exemples introductifs
2.1 Allocation et utilisation d’un objet de type double
2.2 Cas particulier d’un tableau
3. Caractéristiques générales de la gestion dynamique
3.1 Absence de typage des objets
3.2 Notation des objets
3.3 Risques et limitations
3.4 Limitations
4. La fonction malloc
4.1 Prototype
4.2 La valeur de retour et la gestion des erreurs
5. La fonction free
6. La fonction calloc
6.1 Prototype
6.2 Rôle
6.3 Valeur de retour et gestion des erreurs
6.4 Précautions
7. La fonction realloc
7.1 Exemples introductifs
7.2 Prototype
7.3 Rôle
7.4 Valeur de retour
7.5 Précautions
8. Techniques utilisant la gestion dynamique
8.1 Gestion de tableaux dont la taille n’est connue qu’au moment de
l’exécution
8.2 Gestion de tableaux dont la taille varie pendant l’exécution
8.3 Gestion de listes chaînées
CHAPITRE 15
Le préprocesseur
1. Généralités
1.1 Les directives tiennent compte de la notion de ligne
1.2 Les directives et le caractère #
1.3 La notion de token pour le préprocesseur
1.4 Classification des différentes directives du préprocesseur
2. La directive de définition de symboles et de macros
2.1 Exemples introductifs
2.2 La syntaxe de la directive #define
2.3 Règles d’expansion d’un symbole ou d’une macro
2.4 L’opérateur de conversion en chaîne : #
2.5 L’opérateur de concaténation de tokens : ##
2.6 Exemple faisant intervenir les deux opérateurs # et ##
2.7 La directive #undef
2.8 Précautions à prendre
2.9 Les symboles prédéfinis
3. Les directives de compilation conditionnelle
3.1 Compilation conditionnelle fondée sur l’existence de symboles
3.2 Compilation conditionnelle fondée sur des expressions
3.3 Imbrication des directives de compilation conditionnelle
3.4 Exemples d’utilisation des directives de compilation conditionnelle
4. La directive d’inclusion de fichier source
4.1 Généralités
4.2 Syntaxe
4.3 Précautions à prendre
5. Directives diverses
5.1 La directive vide
5.2 La directive #line
5.3 La directive #error
5.4 La directive #pragma
CHAPITRE 16
Les déclarations
1. Généralités
1.1 Les principaux éléments : déclarateur et spécificateur de type
1.2 Les autres éléments
2. Syntaxe générale d’une déclaration
2.1 Forme générale d’une déclaration
2.2 Spécificateur de type structure
2.3 Spécificateur de type union
2.4 Spécificateur de type énumération
2.5 Déclarateur
3. Définition de fonction
3.1 Forme moderne de la définition d’une fonction
3.2 Forme ancienne de la définition d’une fonction
4. Interprétation de déclarations
4.1 Les règles
4.2 Exemples
5. Écriture de déclarateurs
5.1 Les règles
5.2 Exemples
CHAPITRE 17
Fiabilisation des lectures au clavier
1. Généralités
2. Utilisation de scanf
3. Utilisation de gets
4. Utilisation de fgets
4.1 Pour éviter le risque de débordement en mémoire
4.2 Pour ignorer les caractères excédentaires
4.3 Pour traiter l’éventuelle fin de fichier et paramétrer la taille des
chaînes lues
CHAPITRE 18
Les catégories de caractères et les fonctions associées
1. Généralités
1.1 Dépendance de l’implémentation et de la localisation
1.2 Les fonctions de test
2. Les catégories de caractères
3. Exemples
3.1 Pour obtenir la liste de tous les caractères imprimables et leur code
3.2 Pour connaître les catégories des caractères d’une implémentation
4. Les fonctions de transformation de caractères
CHAPITRE 19
Gestion des gros programmes
1. Utilisation de variables globales
1.1 Avantages des variables globales
1.2 Inconvénients des variables globales
1.3 Conseils en forme de compromis
2. Partage d’identificateurs entre plusieurs fichiers source
2.1 Cas des identificateurs de fonctions
2.2 Cas des identificateurs de types ou de synonymes
2.3 Cas des variables globales
CHAPITRE 20
Les arguments variables
1. Écriture de fonctions à arguments variables
1.1 Exemple introductif
1.2 Arguments variables, forme d’en-tête et déclaration
1.3 Contraintes imposées par la norme
1.4 Syntaxe et rôle des macros va_start, va_arg et va_end
2. Transmission d’une liste variable
3. Les fonctions vprintf, vfprintf et vsprintf
CHAPITRE 21
Communication avec l’environnement
1. Cas particulier des programmes autonomes
2. Les arguments reçus par la fonction main
2.1 L’en-tête de la fonction main
2.2 Récupération des arguments reçus par la fonction main
3. Terminaison d’un programme
3.1 Les fonctions exit et atexit
3.2 L’instruction return dans la fonction main
4. Communication avec l’environnement
4.1 La fonction getenv
4.2 La fonction system
5. Les signaux
5.1 Généralités
5.2 Exemple introductif
5.3 La fonction signal
5.4 La fonction raise
CHAPITRE 22
Les caractères étendus
1. Le type wchar_t et les caractères multioctets
2. Notation des constantes du type wchar_t
3. Les fonctions liées aux caractères étendus mblen, mbtowc et wctomb
3.1 Généralités
3.2 La fonction mblen
3.3 La fonction mbtowc
3.4 La fonction wctomb
4. Les chaînes de caractères étendus
5. Représentation des constantes chaînes de caractères étendus
6. Les fonctions liées aux chaînes de caractères étendus : mbstowcs et
wcstombs
6.1 La fonction mbstowcs
6.2 La fonction wcstombs
CHAPITRE 23
Les adaptations locales
1. Le mécanisme de localisation
2. La fonction setlocale
3. La fonction localeconv
CHAPITRE 24
La récursivité
1. Notion de récursivité
2. Exemple de fonction récursive
3. L’empilement des appels
4. Autre exemple de récursivité
CHAPITRE 25
Les branchements non locaux
1. Exemple introductif
2. La macro setjmp et la fonction longjmp
2.1 Prototypes et rôles
2.2 Contraintes d’utilisation
CHAPITRE 26
Les incompatibilités entre C et C++
1. Les incompatibilités raisonnables
1.1 Définition d’une fonction
1.2 Les prototypes en C++
1.3 Fonctions sans valeur de retour
1.4 Compatibilité entre le type void * et les autres pointeurs
1.5 Les déclarations multiples
1.6 L’instruction goto
1.7 Initialisation de tableaux de caractères
2. Les incompatibilités incontournables
2.1 Fonctions sans arguments
2.2 Le qualifieur const
2.3 Les constantes de type caractère
ANNEXE A
La bibliothèque standard C90
1. Généralités
1.1 Les différents fichiers en-tête
1.2 Redéfinition d’une macro standard par une fonction
2. Assert.h : macro de mise au point
3. Ctype.h : tests de caractères et conversions majuscules - minuscules
3.1 Les fonctions de test d’appartenance d’un caractère à une catégorie
3.2 Les fonctions de transformation de caractères
4. Errno.h : gestion des erreurs
4.1 Constantes prédéfinies
4.2 Macros
5. Locale.h : caractéristiques locales
5.1 Types prédéfinis
5.2 Constantes prédéfinies
5.3 Fonctions
6. Math.h : fonctions mathématiques
6.1 Constantes prédéfinies
6.2 Traitement des conditions d’erreur
6.3 Fonctions trigonométriques
6.4 Fonctions hyperboliques
6.5 Fonctions exponentielle et logarithme
6.6 Fonctions puissance
6.7 Autres fonctions
7. Setjmp.h : branchements non locaux
7.1 Types prédéfinis
7.2 Fonctions et macros
8. Signal.h : traitement de signaux
8.1 Types prédéfinis
8.2 Constantes prédéfinies
8.3 Fonctions de traitement de signaux
9. Stdarg.h : gestion d’arguments variables
9.1 Types prédéfinis
9.2 Macros
10. Stddef.h : définitions communes
10.1 Types prédéfinis
10.2 Constantes prédéfinies
10.3 Macros prédéfinies
11. Stdio.h : entrées-sorties
11.1 Types prédéfinis
11.2 Constantes prédéfinies
11.3 Fonctions d’opérations sur les fichiers
11.4 Fonctions d’accès aux fichiers
11.5 Fonctions d’écriture formatée
11.6 Fonctions de lecture formatée
11.7 Fonctions d’entrées-sorties de caractères
11.8 Fonctions d’entrées-sorties sans formatage
11.9 Fonctions agissant sur le pointeur de fichier
11.10 Fonctions de gestion des erreurs d’entrée-sortie
12. Stdlib.h : utilitaires
12.1 Types prédéfinis
12.2 Constantes prédéfinies
12.3 Fonctions de conversion de chaîne
12.4 Fonctions de génération de séquences de nombres pseudo aléatoires
12.5 Fonctions de gestion de la mémoire
12.6 Fonctions de communication avec l’environnement
12.7 Fonctions de tri et de recherche
12.8 Fonctions liées à l’arithmétique entière
12.9 Fonctions liées aux caractères étendus
12.10 Fonctions liées aux chaînes de caractères étendus
13. String.h : manipulations de suites de caractères
13.1 Types prédéfinis
13.2 Constantes prédéfinies
13.3 Fonctions de copie
13.4 Fonctions de concaténation
13.5 Fonctions de comparaison
13.6 Fonctions de recherche
13.7 Fonctions diverses
14. Time.h : gestion de l’heure et de la date
14.1 Types prédéfinis
14.2 Constantes prédéfinies
14.3 Fonctions de manipulation de temps
14.4 Fonctions de conversion
ANNEXE B
Les normes C99 et C11
1. Contraintes supplémentaires (C99)
1.1 Type de retour d’une fonction
1.2 Déclaration implicite d’une fonction
1.3 Instruction return
2. Division d’entiers (C99)
3. Emplacement des déclarations (C99)
4. Commentaires de fin de ligne (C99)
5. Tableaux de dimension variable (C99, facultatif en C11)
5.1 Dans les déclarations
5.2 Dans les en-têtes de fonctions et leurs prototypes
6. Nouveaux types (C99)
6.1 Nouveau type entier long long (C99)
6.2 Types entiers étendus (C99)
6.3 Nouveaux types flottants (C99)
6.4 Le type booléen (C99)
6.5 Les types complexes (C99, facultatif en C11)
7. Nouvelles fonctions mathématiques (C99)
7.1 Généralisation aux trois types flottants (C99)
7.2 Nouvelles fonctions (C99)
7.3 Fonctions mathématiques génériques (C99)
8. Les fonctions en ligne (C99)
9. Les caractères étendus (C99) et Unicode (C11)
10. Les pointeurs restreints (C99)
11. La directive #pragma (C99)
12. Les calculs flottants (C99)
12.1 La norme IEEE 754
12.2 Choix du mode d’arrondi
12.3 Gestion des situations d’exception
12.4 Manipulation de l’ensemble de l’environnement de calcul flottant
13. Structures incomplètes (C99)
14. Structures anonymes (C11)
15. Expressions fonctionnelles génériques (C11)
16. Gestion des contraintes d’alignement (C11)
17. Fonctions vérifiant le débordement mémoire (C11 facultatif)
18. Les threads (C11 facultatif)
19. Autres extensions de C99
20. Autres extensions de C11
Index
Avant-propos
À qui s’adresse ce livre ?
L’objectif de ce livre est d’offrir au développeur un outil de référence clair et
précis sur le langage C tel qu’il est défini par la norme ANSI/ISO. Il s’adresse à
un lecteur possédant déjà de bonnes notions de programmation, qu’elles aient été
acquises à travers la pratique du C ou de tout autre langage. La vocation
première de l’ouvrage n’est donc pas de servir de manuel d’initiation, mais
plutôt de répondre aux besoins des étudiants avancés, des enseignants et des
développeurs qui souhaitent approfondir leur maîtrise du langage ou trouver des
réponses précises aux problèmes techniques rencontrés dans le développement
d’applications professionnelles.
L’ouvrage a été conçu de façon que le lecteur puisse accéder efficacement à
l’information recherchée sans avoir à procéder à une lecture linéaire. La tâche lui
sera facilitée par un index très détaillé, mais aussi par la présence de nombreuses
références croisées et de tableaux de synthèse servant à fois de résumé du
contenu d’une section et d’aiguillage vers ses différentes parties. Il y trouvera
également de nombreux encadrés décrivant la syntaxe des différentes
instructions ou fonctions du langage, ainsi que des tableaux récapitulatifs et des
canevas types.
Comme il se doit, nous couvrons l’intégralité du langage jusque dans ses aspects
les plus marginaux ou les moins usités. Pour qu’une telle exhaustivité reste
exploitable en pratique, nous l’avons largement assortie de commentaires,
conseils ou jugements de valeur ; le lecteur pourra ainsi choisir en toute
connaissance de cause la solution la plus adaptée à son objectif. Notamment, il
sera en mesure de développer des programmes fiables et lisibles en évitant
certaines situations à risque dont la connaissance reste malgré tout indispensable
pour adapter d’anciens programmes. Il peut s’agir là de quelques rares cas où la
norme reste elle-même ambiguë, ou encore d’usages syntaxiques conformes à la
norme mais suffisamment « limites » pour être mal interprétés par certains
compilateurs. Mais, plus souvent, il s’agira de possibilités désuètes, remontant à
l’origine du langage, et dont la norme n’a pas osé se débarrasser, dans le souci de
préserver l’existant. La plupart d’entre elles seront précisément absentes du
langage C++ ; bon nombre de remarques (titrées En C++) visent d’ailleurs à
préparer le lecteur à une éventuelle migration vers ce langage.
Une norme n’a d’intérêt que par la manière dont elle est appliquée. C’est
pourquoi, au-delà de la norme elle-même, nous apportons un certain nombre
d’informations pratiques. Ainsi, nous précisons le comportement qu’on peut
attendre des différents compilateurs existants en cas de non-respect de la
syntaxe. Nous faisons de même pour les différentes situations d’exception qui
risquent d’apparaître lors de l’exécution du programme. Il va de soi que ces
connaissances se révéleront précieuses lors de la phase de mise au point d’un
programme.
Malgré le caractère de référence de l’ouvrage, nous lui avons conservé une
structure comparable à celle d’un cours. Il pourra ainsi être utilisé soit
parallèlement à la phase d’apprentissage, soit ultérieurement, le lecteur
retrouvant ses repères habituels. Accessoirement, il pourra servir de support à un
cours de langage C avancé.
Toujours dans ce même souci pédagogique, nous avons doté l’ouvrage de
nombreux exemples de programmes complets, accompagnés du résultat fourni
par leur exécution. La plupart d’entre eux viennent illustrer une notion après
qu’elle a été exposée. Mais quelques-uns jouent un rôle d’introduction pour les
points que nous avons jugés les plus délicats.
Structure de l’ouvrage
La première partie de l’ouvrage est formée de 15 chapitres qui traitent des
grandes composantes du langage suivant un déroulement classique.
Le chapitre 1 expose quelques notions de base spécifiques au C qui peuvent faire
défaut au programmeur habitué à un autre langage : historique, préprocesseur,
compilation séparée, différence entre variable et objet, classe d’allocation.
Le chapitre 2 présente les principaux constituants élémentaires d’un programme
source : jeu de caractères, identificateurs, mots clés, séparateurs, espaces blancs,
commentaires.
Le chapitre 3 est consacré aux types de base, c’est-à-dire ceux à partir desquels
peuvent être construits tous les autres : entiers, flottants, caractères. Il définit
également de façon précise l’importante notion d’expression constante.
Le chapitre 4 passe en revue les différents opérateurs, à l’exception de quelques
opérateurs dits de référence ([ ], ( ), -> et «.») qui trouvent tout naturellement
leur place dans d’autres chapitres. Il étudie la manière dont sont conçues les
expressions en C, ainsi que les différentes conversions qui peuvent y apparaître :
implicites, explicites par cast, forcées par affectation.
Le chapitre 5 étudie l’ensemble des instructions exécutables du langage, après en
avoir proposé une classification.
Le chapitre 6 traite de l’utilisation naturelle des tableaux à une ou à plusieurs
dimensions. Leur manipulation, particulière au C, par le biais de pointeurs, n’est
examinée que dans le chapitre suivant. De même, le cas des tableaux transmis en
argument d’une fonction n’est traité qu’au chapitre 8.
Le chapitre 7 porte sur les pointeurs : déclarations, propriétés arithmétiques, lien
entre tableau et pointeur, pointeur NULL, affectation, pointeurs génériques,
comparaisons, conversions. Toutefois les pointeurs sur des fonctions ne sont
abordés qu’au chapitre suivant.
Le chapitre 8 est consacré aux fonctions, tant sur le plan de leur définition que
des différentes façons de les déclarer. Il examine notamment le cas des tableaux
transmis en argument, en distinguant les tableaux à une dimension des tableaux à
plusieurs dimensions. En outre, il fait le point sur les variables globales et les
variables locales, aussi bien en ce qui concerne leur déclaration que leur portée,
leur classe d’allocation ou leur initialisation.
Le chapitre 9 étudie les entrées-sorties standard, que nous avons préféré séparer
des fichiers, pour des questions de clarté, malgré le lien étroit qui existe entre les
deux. Ce chapitre décrit en détail les fonctions printf, scanf, puts, gets, putchar et
getchar.
Le chapitre 10 montre comment le C permet de manipuler des chaînes de
caractères. Il passe en revue les différentes fonctions standard correspondantes :
recopie, concaténation, comparaison, recherche. Il traite également des fonctions
de conversion d’une chaîne en un nombre, ainsi que des fonctions de
manipulation de suites d’octets.
Le chapitre 11 examine les types définis par l’utilisateur que sont les structures,
les unions et les énumérations.
Le chapitre 12 est consacré à l’instruction typedef qui permet de définir des types
synonymes.
Le chapitre 13 fait le point sur le traitement des fichiers : aspects spécifiques au
langage C, traitement des erreurs, distinction entre opérations binaires et
formatées… Puis il passe en revue les différentes fonctions standard
correspondantes.
Le chapitre 14 montre comment mettre en œuvre ce que l’on nomme la gestion
dynamique de la mémoire et en fournit quelques exemples d’application.
Le chapitre 15 étudie les différentes directives du préprocesseur.
La seconde partie de l’ouvrage, plus originale dans sa thématique, est
composée de 11 chapitres qui traitent de sujets transversaux comme la
récursivité ou les déclarations, des modalités d’application de certains éléments
de syntaxe, ou encore de possibilités peu usitées du langage.
Le chapitre 16 propose un récapitulatif sur les déclarations aussi bien sur le plan
de leur syntaxe, que sur la manière de les écrire ou de les interpréter. Sa présence
se justifie surtout par la complexité et l’interdépendance des règles de
déclaration en C : il aurait été impossible de traiter ce thème de manière
exhaustive dans chacun des chapitres concernés.
Le chapitre 17 fait le point sur la manière de pallier les problèmes de manque de
fiabilité que posent les lectures au clavier.
Le chapitre 18 montre comment le langage C distingue différentes catégories de
caractères (caractères de contrôle, graphiques, alphanumériques, de
ponctuation…) et examine les fonctions standard correspondantes.
Le chapitre 19 fournit quelques informations indispensables dans la gestion de
gros programmes nécessitant un découpage en plusieurs fichiers source.
Le chapitre 20 explique comment, à l’image de fonctions standard telles que
printf, écrire des fonctions à arguments variables en nombre et en type.
Le chapitre 21 examine les différentes façons dont un programme peut recevoir
une information de l’environnement ou lui en transmettre, ainsi que des
possibilités dites de traitement de signaux.
Le chapitre 22 montre comment, par le biais de ce que l’on nomme les
caractères étendus, le langage C offre un cadre de gestion d’un jeu de caractères
plus riche que celui offert par le codage sur un octet.
Le chapitre 23 traite du mécanisme général de localisation, qui offre à une
implémentation la possibilité d’adapter le comportement de quelques fonctions
standard à des particularités nationales ou locales.
Le chapitre 24 illustre les possibilités de récursivité du langage.
Le chapitre 25 traite d’un mécanisme dit de branchements non locaux, qui
permet de s’affranchir de l’enchaînement classique : appel de fonction, retour.
Le chapitre 26 recense les incompatibilités qui ont subsisté entre le C ANSI et le
C++, c’est-à-dire tout ce qui fait que le C++ n’est pas tout à fait un surensemble
du C.
Une importante annexe fournit la syntaxe et le rôle de l’ensemble des fonctions
de la bibliothèque standard. La plupart du temps, il s’agit d’un résumé
d’informations figurant déjà dans le reste de l’ouvrage, à l’exception de quelques
fonctions qui, compte tenu de leur usage extrêmement restreint, se trouvent
présentées là pour la première fois.
Enfin, cette nouvelle édition tient compte des deux extensions de la norme
publiées en 1999 et 2011 et connues sous les acronymes C99 et C11 :
• une importante annexe en présente la plupart des fonctionnalités ; sa situation
tardive dans l’ouvrage se justifie par le fait que ces « nouveautés » ne sont pas
intégralement appliquées par tous les compilateurs ;
• certains ajouts sont mentionnés au fil du texte, lorsque cela nous a paru utile.
À propos des normes ANSI/ISO
On parle souvent, par habitude, du C ANSI (American National Standard
Institute) alors que la première norme américaine, publiée en 1989, a été rendue
internationale en 1990 par l’ISO (International Standardization Organisation),
avant d’être reprise par les différents comités de normalisation continentaux ou
nationaux sous la référence ISO/IEC 9899:1990. En fait, l’abus de langage se
justifie par l’identité des deux documents, même si, en toute rigueur, le texte ISO
est structuré différemment du texte ANSI d’origine.
Cette norme a continué d’évoluer. Certains « additifis » publiés séparemment ont
été intégrés dans une nouvelle norme ISO/IEC 9899:1999 (nommée brièvement
C99). Une nouvelle définition est apparue avec ISO/IEC 9899:2011 (C11). La
première norme reste désignée par C ANSI ou par C90.
À propos de la fonction main
En théorie, selon la norme (C90, C99 ou C11), la fonction main qui, contrairement
aux autres fonctions, ne dispose pas de prototype, devrait disposer de l’un des
deux en-têtes suivants :
int main (void)
int main (int arg, char *argv)
En fait, tant que l’on ne cherche pas à utiliser les « arguments de la ligne de
commande », les deux formes :
int main ()
main()
sont acceptées par toutes les implémentations, la seconde s’accompagnant
toutefois fréquemment d’un message d’avertissement.
La première est la plus répandue et c’est celle qu’imposera la norme de C++.
Nous l’utiliserons généralement.
Remerciements
Je tiens à remercier tout particulièrement Jean-Yves Brochot pour sa relecture
extrêmement minutieuse de l’ouvrage, ainsi que pour les discussions nombreuses
et enrichissantes qu’il a suscitées.
1
Généralités
A priori, cet ouvrage s’adresse à des personnes ayant déjà une expérience de la
programmation, éventuellement dans un langage autre que le C. Bien qu’il
puisse être étudié de manière séquentielle, il a été conçu pour permettre l’accès
direct à n’importe quelle partie, à condition de disposer d’un certain nombre de
notions générales, plutôt spécifiques au C, et qui sont examinées ici.
Nous commencerons par un bref historique du langage, qui permettra souvent
d’éclairer certains points particuliers ou redondants. Puis nous verrons comment
se présente la traduction d’un programme, à la fois par les possibilités de
compilation séparée et par l’existence, originale, d’un préprocesseur. Nous
expliquerons ensuite en quoi la classique notion de variable est insuffisante en C
et pourquoi il est nécessaire de la compléter par celle, plus générale, d’objet.
Enfin, nous verrons qu’il existe plusieurs façons de gérer l’emplacement
mémoire alloué à une variable, ce qui se traduira par la notion de classe
d’allocation.
1. Historique du langage C
Le langage C a été créé en 1972 par Denis Ritchie avec un objectif relativement
limité : écrire un système d’exploitation (Unix). Mais ses qualités
opérationnelles ont fait qu’il a très vite été adopté par une large communauté de
programmeurs.
Une première définition rigoureuse du langage a été réalisée en 1978 par
Kernighan et Ritchie avec la publication de l’ouvrage The C Programming
Language. De nombreux compilateurs ont alors vu le jour en se fondant sur cette
définition, quitte à l’assortir parfois de quelques extensions. Ce succès
international du langage a conduit à sa normalisation, d’abord par l’ANSI
(American National Standard Institute), puis par l’ISO (International
Standardization Organisation), en 1993 par le CEN (Comité européen de
normalisation) et enfin, en 1994, par l’AFNOR. En fait, et fort heureusement,
toutes ces normes sont identiques, et l’usage veut qu’on parle de C90 (autrefois
de « C ANSI » ou de « C norme ANSI »).
La norme ANSI élargit, sans la contredire, la première définition de Kernighan
et Ritchie. Pour la comprendre et pour l’accepter, il faut savoir qu’elle a cherché
à concilier deux intérêts divergents :
• d’une part, améliorer et sécuriser le langage ;
• d’autre part, préserver l’existant, c’est-à-dire faire en sorte que les programmes
créés avant la norme soient acceptés par la norme.
Dans ces conditions, certaines formes désuètes ou redondantes ont dû être
conservées. L’exemple le plus typique réside dans les déclarations de fonctions :
la première définition prévoyait de déclarer une fonction en ne fournissant que le
type de son résultat ; la norme ANSI a prévu d’y ajouter le type des arguments,
mais sans interdire l’usage de l’ancienne forme. Il en résulte que la maîtrise des
différentes situations possibles nécessite des connaissances qui seraient devenues
inutiles si la norme avait osé interdire l’ancienne possibilité. On notera, à ce
propos, que la norme du C++, langage basé très fortement sur le C, supprime
bon nombre de redondances pour lesquelles la norme du C n’a pas osé trancher ;
c’est notamment le cas des déclarations de fonctions qui doivent obligatoirement
utiliser la seconde forme.
Après cette première normalisation, des extensions ont été apportées, tout
d’abord sous forme de simples additifs en 1994 (ISO/IEC 9899/COR1:1994) et
en 1995 (ISO/IEC 9899/COR2 :1995), lesquels se sont trouvés intégrés dans la
nouvelle norme ISO/IEC 9899:1999, désignée sous l’acronyme C99. Enfin, une
dernière norme ISO/IEC 9899:2011 est apparue, plus connue sous l’acronyme
C11.
Compte tenu du fait que tous les compilateurs ne respectent pas intégralement
les dernières normes C99 et C11 (cette dernière comportant d’ailleurs des
fonctionnalités « facultatives »), notre discours se fonde plutôt sur la norme C90.
Mais, dans la suite de l’ouvrage, il nous arrivera souvent de préciser :
• ce qui constitue un apport de la norme C90 par rapport à la première définition
du C ;
• ce qui dans la première définition est devenu désuet ou déconseillé, bien
qu’accepté par la norme ; en particulier, nous ferons souvent référence à ce qui
disparaîtra ou qui changera en C++ ;
• les points concernés par les apports des normes C99 et C11.
L’annexe en fin d’ouvrage récapitule les principaux apports de C99 et C11.
2. Programme source, module objet et programme
exécutable
Tout programme écrit en langage évolué forme un texte qu’on nomme un
« programme source ». En langage C, ce programme source peut être découpé en
un ou plusieurs fichiers source. Notez qu’on parle de fichier même si,
exceptionnellement, le texte correspondant, saisi en mémoire, n’a pas été
véritablement recopié dans un fichier permanent.
Chaque fichier source est traduit en langage machine, indépendamment des
autres, par une opération dite de « compilation », réalisée par un logiciel ou une
partie de logiciel nommée « compilateur ». Le résultat de cette opération porte le
nom de « module objet ». Bien que formé d’instructions machine, un tel module
objet n’est pas exécutable tel quel car :
• il peut lui manquer d’autres modules objet ;
• il lui manque, de toute façon, les instructions exécutables des fonctions
standards appelées dans le fichier source (par exemple printf, scanf, strcat…).
Le rôle de l’éditeur de liens est précisément de réunir les différents modules
objet et les fonctions de la bibliothèque standard afin de constituer un
programme exécutable. Ce n’est d’ailleurs que lors de cette édition de liens
qu’on pourra s’apercevoir de l’absence d’une fonction utilisée par le programme.
3. Compilation en C : existence d’un préprocesseur
En C, la traduction d’un fichier source se déroule en deux étapes totalement
indépendantes :
• un prétraitement ;
• une compilation proprement dite.
La plupart du temps, ces deux étapes sont enchaînées automatiquement, de sorte
qu’on a l’impression d’avoir affaire à un seul traitement. Généralement, on parle
du préprocesseur pour désigner le programme réalisant le prétraitement. En
revanche, les termes de « compilateur » ou de « compilation » restent ambigus
puisqu’ils désignent tantôt l’ensemble des deux étapes, tantôt la seconde.
L’étape de prétraitement correspond à une modification du texte d’un fichier
source, basée essentiellement sur l’interprétation d’instructions très particulières
dites « directives à destination du préprocesseur » ; ces dernières sont
reconnaissables par le fait qu’elles commencent par le signe #.
Les deux directives les plus importantes sont :
• la directive d’inclusion d’autres fichiers source : #include ;
• la directive de définition de macros ou de symboles : #define.
La première est surtout utilisée pour incorporer le contenu de fichiers prédéfinis,
dits « fichiers en-tête », indispensables à la bonne utilisation des fonctions de la
bibliothèque standard, la plus connue étant :
#include <stdio.h>
La seconde est très utilisée dans les fichiers en-tête prédéfinis. Elle est également
souvent exploitée par le programmeur dans des définitions de symboles telles
que :
#define NB_COUPS_MAX 100
#define TAILLE 25
4. Variable et objet
4.1 Définition d’une variable et d’un objet
Dans beaucoup de langages, les informations sont manipulées par le biais de
variables, c’est-à-dire d’emplacements mémoire portant un nom et dont le
contenu est susceptible d’évoluer. En C, il existe bien entendu des variables
répondant à une telle définition mais on peut également manipuler des
informations qui ne sont plus vraiment contenues dans des variables ; le cas le
plus typique est celui d’une information manipulée par l’intermédiaire d’un
pointeur :
int *adi ; /* adi est une variable destinée à contenir une adresse d'entier */
…
*adi = 5 ; /* place la valeur 5 dans l'entier pointé par adi */
L’entier pointé par adi ne porte pas vraiment de nom ; d’ailleurs, au fil de
l’exécution, adi peut pointer sur des entiers différents.
Pour tenir compte de cette particularité, il est donc nécessaire de définir un
nouveau mot. On utilise généralement celui d’objet1. On dira donc qu’un objet
est un emplacement mémoire parfaitement défini qu’on utilise pour représenter
une information à laquelle on peut accéder à volonté (autant de fois qu’on le
souhaite) au sein du programme.
Bien entendu, une variable constitue un cas particulier d’objet. Mais, dans notre
précédent exemple, l’emplacement pointé à un instant donné par adi est lui-
même un objet. En revanche, une expression telle n+5 n’est pas un objet dans la
mesure où l’emplacement mémoire correspondant n’est pas parfaitement défini
et où, de plus, il a un caractère relativement fugitif ; on y accédera véritablement
qu’une seule fois : au moment de l’utilisation de l’expression en question.
La question de savoir si des constantes telles que 34, ‘d' ou "bonjour" sont ou non
des objets est relativement ambiguë : une constante utilise un emplacement
mémoire mais peut-on dire qu’on y accède à volonté ? En effet, on n’est pas sûr
qu’une même constante se réfère toujours au même emplacement, ce point
pouvant dépendre de l’implémentation. La norme n’est d’ailleurs pas totalement
explicite sur ce sujet qui n’a guère d’importance en pratique. Dans la suite, nous
conviendrons qu’une constante n’est pas un objet.
4.2 Utilisation d’un objet
4.2.1 Accès par une expression désignant l’objet
Lorsqu’un objet est une variable, son utilisation ne pose guère de problème, qu’il
s’agisse d’en utiliser ou d’en modifier la valeur, même si, au bout du compte, le
nom même de la variable possède une signification dépendant du contexte dans
lequel il est employé. Par exemple, si p est une variable, dans l’expression :
p + 5
p désigne tout simplement la valeur de la variable p, tandis que dans :
p = 5 ;
p désigne la variable elle-même.
Dans le cas des objets pointés, en revanche, on ne pourra pas recourir à un
simple identificateur ; il faudra faire appel à des expressions plus complexes
telles que *adi ou *(adi+3). Ici encore, cette expression pourra intervenir soit pour
utiliser la valeur de l’objet, soit pour en modifier la valeur. Par exemple, dans
l’expression :
*adi + 5
*adi désigne la valeur de l’objet pointé par adi, tandis que dans :
*adi = 12 ;
*adi désigne l’objet lui-même.
4.2.2 Type d’un objet
Comme dans la plupart des langages, le type d’un objet n’est pas défini de façon
intrinsèque : en examinant une suite d’octets de la mémoire, on est incapable de
savoir comment l’information qui s’y trouve a été codée, et donc de donner une
valeur à l’objet correspondant. En fait, le type d’un objet n’est défini que par la
nature de l’expression qu’on utilise, à un instant donné, pour y accéder ou pour
le modifier. Certes, dans un langage où tout objet est contenu dans une variable,
cette distinction est peu importante puisque le type est alors défini par le nom de
la variable, lequel constitue le seul et unique moyen d’accéder à l’objet2. Dans
un langage comme le C, en revanche, on peut accéder à un objet par le biais d’un
pointeur. Son type se déduira, là encore, de la nature du pointeur mais avec cette
différence fondamentale par rapport à la variable qu’il est alors possible,
volontairement ou par erreur, d’accéder à un même objet avec des pointeurs de
types différents.
Certes, lorsque l’on est amené à utiliser plusieurs expressions pour accéder à un
même objet, elles sont généralement de même type, de sorte qu’on a tendance à
considérer que le type de l’objet fait partie de l’objet lui-même. Par souci de
simplicité, d’ailleurs, il nous arrivera souvent de parler du « type de l’objet ». Il
ne s’agira cependant que d’un abus de langage qui se réfère au type ayant servi à
créer l’objet, en faisant l’hypothèse que c’est celui qu’on utilisera toujours pour
accéder à l’objet.
Remarque
Parmi les différentes expressions permettant d’accéder à un objet, certaines ne permettent pas sa
modification. C’est par exemple le cas d’un nom d’une variable ayant reçu l’attribut const ou d’un
nom de tableau. Comme on le verra à la section 7.2 du chapitre 4, on parle généralement de lvalue
pour désigner les expressions utilisables pour modifier un objet.
5. Lien entre objet, octets et caractères
En langage C, l’octet correspond à la plus petite partie adressable de la mémoire,
mais il n’est pas nécessaire, comme pourrait le faire croire la traduction française
du terme anglais byte, qu’il soit effectivement constitué de 8 bits, même si cela
est très souvent le cas.
Tout objet est formé d’un nombre entier d’octets et par conséquent, il possède
une adresse, celle de son premier octet. La réciproque n’est théoriquement pas
vraie : toute adresse (d’octet) n’est pas nécessairement l’adresse d’un objet ;
voici deux contre-exemples :
• l’octet en question n’est pas le premier d’un objet ;
• l’octet en question est le premier octet d’un objet mais l’expression utilisée
pour y accéder correspond à un type occupant un nombre d’octets différent.
Malgré tout, il sera souvent possible de considérer une adresse quelconque
comme celle d’un objet de type quelconque. Bien entendu, cela ne préjugera
nullement des conséquences plus ou moins catastrophiques qui pourront en
découler.
Par ailleurs, la notion de caractère en C coïncide totalement avec celle d’octet.
Dans ces conditions, on pourra toujours considérer n’importe quelle adresse
comme celle d’un objet de type caractère. C’est d’ailleurs ainsi que l’on
procédera lorsqu’on voudra traiter individuellement chacun des octets
constituant un objet.
6. Classe d’allocation des variables
Comme on le verra, notamment au chapitre 8, l’emplacement mémoire attribué à
une variable peut être géré de deux façons différentes, suivant la manière dont
elle a été déclarée. On parle de « classe d’allocation statique » ou de « classe
d’allocation automatique ».
Les variables de classe statique voient leur emplacement alloué une fois pour
toutes avant le début de l’exécution du programme ; il existe jusqu’à la fin du
programme. Une variable statique est rémanente, ce qui signifie qu’elle conserve
sa dernière valeur jusqu’à une nouvelle éventuelle modification.
Les variables de classe automatique voient leur emplacement alloué au moment
de l’entrée dans un bloc ou dans une fonction ; il est supprimé lors de la sortie de
ce bloc ou de cette fonction. Une variable automatique n’est donc pas rémanente
puisqu’on n’est pas sûr d’y trouver, lors d’une nouvelle entrée dans le bloc, la
valeur qu’elle possédait à la sortie précédente de ce bloc.
On verra qu’une variable déclarée à un niveau global est toujours de classe
d’allocation statique, tandis qu’une variable déclarée à un niveau local (à un bloc
ou à une fonction) est, par défaut, seulement de classe d’allocation automatique.
On pourra agir en partie sur la classe d’allocation d’une variable en utilisant, lors
de sa déclaration, un mot-clé dit « classe de mémorisation ». Par exemple, une
variable locale déclarée avec l’attribut static sera de classe d’allocation statique.
On veillera cependant à distinguer la classe d’allocation de la classe de
mémorisation, malgré le lien étroit qui existe entre les deux ; d’une part la classe
de mémorisation est facultative et d’autre part, quand elle est présente, elle ne
correspond pas toujours à la classe d’allocation.
Par ailleurs, par le biais de pointeurs, le langage C permet d’allouer
dynamiquement des emplacements pour des objets. Il est clair qu’un tel
emplacement est géré d’une manière différente de celles évoquées
précédemment. On parle souvent de gestion dynamique (ou programmée). La
responsabilité de l’allocation et de la libération incombant, cette fois, au
programmeur. Comme on peut le constater, les notions de classe statique ou
automatique ne concernent donc que les objets contenus dans des variables.
Remarque
En toute rigueur, il existe une troisième classe d’allocation, à savoir la classe registre. Il ne s’agit
cependant que d’un cas particulier de la classe d’allocation automatique.
1. Attention, ce terme n’a ici aucun lien avec la programmation orientée objet.
2. Bien que certains langages autorisent, exceptionnellement, d’accéder à un même emplacement par le
biais de deux variables différentes, de types éventuellement différents.
2
Les éléments constitutifs
d’un programme source
Un programme peut être considéré, à son niveau le plus élémentaire, comme une
suite de caractères appartenant à un ensemble qu’on nomme le « jeu de
caractères source ». Bien entendu, pour donner une signification au texte du
programme, il est nécessaire d’effectuer des regroupements de ces caractères en
vue de constituer ce que l’on pourrait nommer les « éléments constitutifs » du
programme ; une telle opération est similaire à celle qui permet de déchiffrer un
texte usuel en le découpant en mots et en signes de ponctuation.
Après avoir décrit le jeu de caractères source, en montrant en quoi il se distingue
du jeu de caractères d’exécution, nous examinerons certains des éléments
constitutifs d’un programme, à savoir : les identificateurs, les mots-clés, les
séparateurs et les commentaires ; puis nous parlerons du format libre dans lequel
peut être rédigé le texte d’un programme.
Nous terminerons en introduisant la notion de « token ». Peu importante en
pratique, elle ne fait que formaliser la façon dont, le préprocesseur d’abord, le
compilateur ensuite, effectuent le découpage du programme en ses différents
éléments constitutifs.
Notez que certains des éléments constitutifs d’un programme source seront
simplement cités ici, dans la mesure où ils se trouvent tout naturellement étudiés
en détail dans d’autres chapitres ; cette remarque concerne les constantes
numériques, les constantes chaîne ou les opérateurs.
1. Jeu de caractères source et jeu de caractères
d’exécution
1.1 Généralités
On appelle « jeu de caractères source » l’ensemble des caractères utilisables pour
écrire un programme source. Il est imposé par la norme ANSI ; mais cette
dernière permet à des environnements possédant un jeu de caractères plus
restreint de représenter certains caractères par le biais de « séquences
trigraphes ».
Ce jeu doit être distingué du « jeu de caractères d’exécution » qui correspond
aux caractères susceptibles d’être traités par le programme lors de son exécution,
que ce soit par le biais d’informations de type char ou par le biais de chaînes de
caractères. Bien entendu, compte tenu de l’équivalence existant en C entre octet
et caractère, on voit qu’on disposera toujours de 256 caractères différents (du
moins lorsque l’octet occupe 8 bits, comme c’est généralement le cas). Mais ces
caractères, ainsi que leurs codes, pourront varier d’une implémentation à une
autre. Cependant, la norme prévoit un jeu minimal qui doit exister dans toute
implémentation. Là encore, elle ne précise que les caractères concernés, et en
aucun cas leur code.
On pourrait penser que, par sa nature même, le jeu de caractères d’exécution n’a
aucune incidence sur l’écriture des instructions d’un programme. En fait, il
intervient indirectement lorsqu’on introduit des constantes (caractère ou chaîne)
ou des commentaires à l’intérieur d’un programme. Par exemple, dans certaines
implémentations françaises, vous pourrez écrire :
char * ch = "résultats" ;
char c_cedille = ‘ç' ;
et ceci, bien que les caractères é et ç n’appartiennent pas au jeu de caractères
source.
Le tableau 2.1 fournit la liste complète du jeu de caractères source et du jeu
minimal des caractères d’exécution, tels qu’ils sont imposés par la norme. On
trouvera quelques commentaires dans les deux paragraphes suivants.
Tableau 2.1 : les jeux de caractères du langage C
Types de Jeu de caractères Jeu minimal de
caractères source caractères d’exécution
Lettres majuscules A B C D E F G H I J K A B C D E F G H I J K
L M N O P Q R S T U L M N O P Q R S T U
V W X Y Z V W X Y Z
Lettres minuscules a b c d e f g h i j k l m n a b c d e f g h i j k l m n
o p q r s t u v w x y z o p q r s t u v w x y z
Chiffres décimaux 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
Espace espace espace
Autres caractères ! « % & ‘ ( ) * + , - . / : ; ! « % & ‘ ( ) * + , - . / : ;
disposant d’un < = > ? _ < = > ? _
graphisme # [ \ ] ^ { | } ~ # [ \ ] ^ { | } ~
Caractères dits de tabulation horizontale tabulation horizontale1
« contrôle » car non tabulation verticale (\t)
imprimables saut de page tabulation verticale (\v)
« indication » de fin de saut de page (\f)
ligne alerte (\a)
retour arrière (\b)
retour chariot (\r)
nouvelle ligne (\n)
1. Entre parenthèses, on trouvera la séquence d’échappement correspondante, telle qu’elle sera présentée à
la section 2.3.2 du chapitre 3.
1.2 Commentaires à propos du jeu de caractères
source
On note la présence de 29 caractères disposant d’un graphisme imprimable. Pour
les environnements disposant d’un jeu de caractères restreint (tel que celui
correspondant à la norme ISO 646-10831), la norme ANSI permet que certains
des 9 derniers caractères (# [ \ ] ^ { | } ~) soient remplacés par ce que l’on
nomme des séquences trigraphes (suites de trois caractères commençant par ??).
Tableau 2.2 : les séquences trigraphes
Séquence trigraphe Caractère équivalent
??= #
??( [
??/ \
??) ]
??’ ^
??< {
??! |
??> }
??- ~
Assez curieusement, la norme semble rendre obligatoire la présence des trois
caractères de contrôle que sont les tabulations et le saut de page alors que, de
toute façon, ils joueront pour le compilateur le même rôle que des espaces. Le
premier présente l’intérêt de faciliter les « indentations » d’instructions. On ne
perdra cependant pas de vue qu’il peut poser des problèmes dès lors qu’on
cherche à manipuler un même texte source à l’aide d’éditeurs différents ou à
l’aide d’un logiciel de traitement de texte : remplacement de tabulations par un
certain nombre (variable !) d’espaces, pose de taquets de tabulation à des
endroits différents2, etc.
Par ailleurs, on notera que la norme prévoit qu’il doit exister une « manière »
d’indiquer les fins de ligne, sans imposer un caractère précis. Cette tolérance
vise simplement à reconnaître l’existence de certains environnements (cas des
PC notamment) qui utilisent une succession de deux caractères pour représenter
la fin de ligne. Mais, en théorie, la norme est très large puisqu’elle autorise
n’importe quelle « technique » permettant de reconnaître convenablement les
changements de ligne dans un programme source.
Remarque
Les éventuelles substitutions de séquences trigraphes ont lieu (une seule fois) avant passage au
préprocesseur, quel que soit l’environnement considéré. Par exemple, l’instruction :
printf ("bonjour??/n") ;
sera toujours équivalente à :
printf ("bonjour\n") ;
En revanche, toute suite de caractères de la forme ??x, où x n’est pas l’un des 9 caractères prévus
précédemment, ne sera pas modifiée. Par exemple :
printf ("bonjour??+") ;
affichera bien : bonjour??+
1.3 Commentaires à propos du jeu minimal de
caractères d’exécution
Le jeu de caractères d’exécution dépend de l’implémentation. Il s’agit donc ici
des caractères qui pourront être manipulés par le programme, c’est-à-dire ceux
qui pourront :
• être placés dans une variable de type caractère, par affectation ou par lecture ;
• apparaître dans une constante de type caractère (‘a', ‘+', etc.) ;
• apparaître dans une chaîne constante ("bonjour", "salut\n", etc.).
La norme impose un jeu minimal assez réduit puisqu’il comporte, outre tous les
caractères du jeu de caractères source, quelques caractères de contrôle
supplémentaires :
• alerte ;
• retour arrière ;
• retour chariot ;
• nouvelle ligne.
Cette fois, le caractère de fin de ligne est bien présent en tant que tel. On notera
cependant qu’il s’agit simplement de sa représentation en mémoire. Cela ne
préjuge nullement de la manière dont il sera représenté dans un fichier de type
texte (par un ou plusieurs caractères, voire suivant une autre convention).
Comme nous le verrons au chapitre 13, il existe effectivement un mécanisme
permettant à un programme de « voir » les fins de ligne d’un fichier texte
comme s’il s’agissait d’un caractère de fin de ligne.
Pour pouvoir faire apparaître dans une constante caractère ou une constante
chaîne l’un des caractères de contrôle imposés par la norme, une notation
particulière est prévue sous la forme de ce qu’on nomme souvent une « séquence
d’échappement », qui utilise le caractère \. La plus connue est \n pour la fin de
ligne (ou encore ??/n si l’on utilise les caractères trigraphes) ; les autres seront
présentées à la section 2.3.2 du chapitre 3, mais on notera dès maintenant que la
norme offre l’avantage de fournir des notations portables pour ces caractères.
Remarque
Si la norme impose le jeu de caractères minimal, elle n’impose aucunement le code utilisé pour les
représenter, lequel va dépendre de l’implémentation. En pratique, on ne rencontre qu’un nombre limité
de codes, parmi lesquels on peut citer l’EBCDIC et l’ASCII. On notera bien que, en toute rigueur,
l’ASCII est un code qui n’exploite que 7 des 8 bits d’un octet et dans lequel les 32 premiers codes (0 à
31) sont réservés à des caractères de contrôle. Beaucoup d’autres codes (comme Latin-1) sont des
surensembles de l’ASCII, les 128 premières valeurs ayant la même signification dans toutes les
variantes. À noter qu’il existe un abus de langage qui consiste à nommer ASCII ces variantes à 8 bits.
Parfois, on parle alors d’US-ASCII pour désigner le code initial à 7 bits.
Par ailleurs, comme nous le verrons au chapitre 22, le langage C permet de gérer des jeux de
caractères beaucoup plus riches que ceux offerts par un simple octet, par le biais des « caractères
étendus » et des caractères multi-octets.
2. Les identificateurs
Dans un programme, beaucoup d’éléments (variables, fonctions, types…) sont
désignés par un nom qu’on appelle « identificateur ». Comme dans la plupart des
langages, un tel identificateur est formé d’une suite de caractères choisis parmi
les lettres, les chiffres ou le caractère souligné (_), le premier d’entre eux étant
nécessairement différent d’un chiffre. Voici quelques identificateurs corrects :
lg_lig valeur_5 _total _89
Par ailleurs, les majuscules et les minuscules sont autorisées mais ne sont pas
équivalentes (contrairement, par exemple, à ce qui se produit en Turbo Pascal).
Ainsi, en C, les identificateurs « ligne » et « Ligne » désignent deux objets
différents.
En ce qui concerne la longueur des identificateurs, la norme ANSI prévoit (sauf
exception, mentionnée dans la remarque ci-après) que, au moins les 31 premiers
caractères (63 en C11), soient « significatifs » ; autrement dit, deux
identificateurs qui diffèrent par leurs 31 (63) premières lettres désigneront deux
choses différentes.
En langage C, il est donc assez facile de choisir des noms de variables
suffisamment évocateurs de leur rôle, voire de leur type ou de leur classe de
mémorisation. En voici quelques exemples :
Prix_TTC Taxe_a_la_valeur_ajoutee TaxeValeurAjoutee
PtrInt_Adresse
Remarque
En théorie, la norme (C90, aussi bien que C99 et même C11) autorise une implémentation à limiter à 6
la longueur des identificateurs dits « externes », c’est-à-dire qui subsistent après compilation : noms de
fonctions, variables globales. En pratique, ces restrictions tendent à disparaître.
3. Les mots-clés
Certains mots sont réservés par le langage à un usage bien défini. On les nomme
des « mots-clés ». Un mot-clé ne peut pas être employé comme identificateur.
Le tableau 2.3 liste les mots-clés par ordre alphabétique.
Tableau 2.3 : les mots-clés du langage C
Remarque
Contrairement à ce qui se passe dans un langage comme le Pascal, en C il n’y a théoriquement pas de
notion d’identificateur prédéfini dont la signification pourrait, le cas échéant, être modifiée par
l’utilisateur. Cependant, un tel résultat peut quand même être obtenu en faisant appel à la directive
#define du préprocesseur, comme on le verra au chapitre 15.
4. Les séparateurs et les espaces blancs
Dans notre langue écrite, les mots sont séparés par un espace, un signe de
ponctuation ou une fin de ligne. Il en va presque de même pour le langage C
dont les règles vont donc paraître naturelles. Ainsi, dans un programme, deux
identificateurs successifs entre lesquels la syntaxe n’impose aucun caractère ou
groupe de caractères particulier dit « séparateur », doivent impérativement être
séparés par ce que l’on nomme un « espace blanc » (le plus usité étant l’espace) :
On nomme espace blanc une suite de un ou plusieurs caractères choisis parmi : espace, tabulation
horizontale, tabulation verticale, changement de ligne ou changement de page.
En revanche, dès que la syntaxe impose un séparateur quelconque, il n’est pas
nécessaire de prévoir d’espaces blancs supplémentaires. Cependant, en pratique,
des espaces amélioreront généralement la lisibilité du programme.
Les caractères séparateurs comprennent tous les opérateurs (+, –, *, =, +=, ==…)
ainsi que les caractères dits de « ponctuation »3 :
( ) [ ] { } , ; : …
Par exemple, vous devrez impérativement écrire (avec au moins un espace blanc
entre int et x) :
int x,y ;
et non :
intx,y ;
En revanche, vous pourrez écrire indifféremment (le caractère virgule étant un
séparateur) :
int n,compte,total,p ;
ou plus lisiblement :
int n, compte, total, p ;
5. Le format libre
Comme le langage Pascal, le langage C autorise une « mise en page »
parfaitement libre. En particulier, une instruction peut s’étendre sur un nombre
quelconque de lignes et une même ligne peut comporter autant d’instructions
que voulu.
Les fins de ligne ne jouent pas de rôle particulier, si ce n’est celui de séparateur,
au même titre qu’un simple espace, ce qui signifie donc qu’un identificateur ne
peut être « coupé en deux par une fin de ligne. Par exemple :
int quant
ite ; /* incorrect */
est incorrect car équivalent à :
int quant ite ;
et non à :
int quantite ;
ce qui correspond à l’intuition.
En revanche, les fins de ligne ne sont pas autorisées dans les constantes chaîne.
Par exemple :
const char message = "salut, cher ami,
comment allez-vous?" ;
conduira à une erreur de compilation.
Il ne faut pas en conclure qu’il est nécessaire d’écrire une constante chaîne sur
une seule ligne car on pourra faire appel aux possibilités dites de « concaténation
des chaînes adjacentes », décrites à la section 1.2 du chapitre 10, en écrivant, par
exemple :
const char messsage = "salut, cher ami, "
"comment allez-vous?" ;
Remarques
1. Les directives à destination du préprocesseur (comme #include ou #define) ne bénéficient pas du
format libre dont nous venons de parler. De plus, la fin de ligne y joue un rôle important de
terminateur.
2. Le fait que le langage C autorise un format libre présente des contreparties. Notamment, le risque
existe, si l’on n’y prend pas garde, d’aboutir à des programmes peu lisibles. Voici, à titre
d’illustration, un programme accepté par le compilateur :
Exemple de programme mal présenté
int main() { int i,n;printf("bonjour\n") ; printf(
"je vais vous calculer 3 carrés\n") ; for (i=1;i<=
3; i++){printf("donnez un nombre entier : ") ; scanf("%d",
&n) ; printf ("son carré est: %d\n", n*n) ; } printf (
"au revoir"); }
6. Les commentaires
Comme tout langage évolué, le langage C autorise la présence de commentaires
dans les programmes source. Il s’agit de textes explicatifs destinés aux lecteurs
du programme et qui n’ont aucune incidence sur sa compilation.
Ces commentaires sont formés de caractères quelconques placés entre les
symboles /* et */. Ils peuvent apparaître à tout endroit du programme où un
espace est autorisé, y compris dans les directives à destination du préprocesseur.
En général, cependant, on se limitera à des emplacements propices à une bonne
lisibilité du programme.
Voici quelques exemples de commentaires :
/* programme de calcul de racines carrées */
/* commentaire fantaisiste &ç§{<>} ?%!!!!!! */
/* commentaire s'étendant
sur plusieurs lignes
de programme source */
/* ============================================
* commentaire quelque peu esthétique *
* et mis en boîte, pouvant servir, *
* par exemple, d'en-tête de programme *
============================================ */
Voici d’autres exemples de commentaires qui, situés au sein d’une instruction de
déclaration, permettent de définir le rôle des différentes variables :
int nb_points ; /* nombre de valeurs à calculer */
float debut, /* abscisse de début */
fin, /* abscisse de fin */
pas ; /* pas choisi */
La norme ANSI ne prévoit pas de possibilités dites de « commentaires
imbriqués ». Ainsi, une construction telle que :
/* début commentaire 1 /* commentaire 2 */ fin commentaire 1 */
conduit à considérer la dernière partie (fin commentaire 1) comme des instructions
C, et donc à une erreur de syntaxe4.
Voici, enfin, un exemple de commentaire déraisonnable mais accepté par le
compilateur :
int /*type entier*/ n /*une variable*/,/*---*/p/*une autre variable*/ ;
n /*on affecte a n*/= /*la valeur*/p/*de p*/ + /*augmentée de*/2 /* deux */ ;
Remarque
La norme C99 autorise une seconde forme de commentaire, dit « de fin de ligne » que l’on retrouve
également en C++. Un tel commentaire est introduit par // et tout ce qui suit ces deux caractères
jusqu’à la fin de ligne est considéré comme un commentaire. En voici un exemple :
printf("bonjour\n") ; // formule de politesse
7. Notion de token
Ce chapitre a étudié certains éléments constituant un programme. Les autres, tels
que les constantes, les chaînes constantes, les opérateurs, seront abordés dans
d’autres chapitres. D’une manière générale, on peut dire qu’un programme
source peut se décomposer en une succession d’éléments que l’on nomme
« tokens »5. Cette notion est bien entendu indispensable au compilateur chargé
de cette décomposition. En revanche, elle l’est beaucoup moins pour l’auteur ou
le lecteur d’un programme, excepté dans de très rares cas. Cette section présente
donc surtout un intérêt anecdotique, hormis pour ceux qui souhaiteraient réaliser
un compilateur ou, éventuellement, être en mesure d’interpréter des
constructions un peu sophistiquées.
7.1 Les différentes catégories de tokens
Si l’on souhaite être exhaustif, il faut distinguer les tokens reconnus par le
préprocesseur de ceux qui seront reconnus par le compilateur proprement dit,
même si, comme on s’y attend, la plupart d’entre eux sont communs. Le tableau
2.4 liste les différentes catégories de tokens existant dans les deux cas, ainsi que
le chapitre de l’ouvrage dans lequel ils sont décrits (certains l’étant dans le
présent chapitre).
Tableau 2.4 : les différents tokens du langage C
Comme on peut le constater, les différences de tokens entre le préprocesseur et le
compilateur sont minimes et logiques :
• les mots-clés ne sont pas reconnus par le préprocesseur qui n’y voit qu’un
identificateur ordinaire ;
• les symboles [ ] ( ) { } sont encore de simples ponctuations pour le
préprocesseur, tandis qu’ils posséderont une signification particulière pour le
compilateur, ce qui leur impose alors d’être appariés correctement.
Remarques
1. La norme ne considère pas un commentaire comme un token, dans la mesure où il est assimilé aux
caractères dits « espaces blancs », utilisés précisément pour séparer deux tokens. D’ailleurs, ces
commentaires ne sont vus que du préprocesseur et non du compilateur. En pratique, ce point est de
faible importance.
2. Les opérateurs forment une catégorie de tokens, les caractères de ponctuation en forment une autre.
Ce point est de moindre importance. En effet, on pourrait considérer qu’un séparateur n’est plus un
token puisqu’il sert à séparer des tokens. En fait, la norme a dû apporter cette précision, tout
simplement pour que les tokens identifiés par le préprocesseur soient effectivement retransmis au
compilateur et non supprimés comme le sont, par exemple, les espaces blancs ou les commentaires.
7.2 Décomposition en tokens
A priori, la démarche adoptée par le préprocesseur ou le compilateur est assez
intuitive. Cependant, la connaissance de l’algorithme utilisé peut s’avérer
nécessaire pour interpréter correctement certains cas limites. Ce dernier se
résume ainsi :
Le préprocesseur et le compilateur recherchent toujours la plus longue séquence possible de caractères
qui soit un token.
Voici quelques exemples :
x1 + 2 /* conduit aux tokens : x1, + et 2 */
x++2 /* conduit aux tokens : x, ++ et 2, comme si on avait écrit : x++ 2 */
/* et non, par exemple, aux tokens : x, +, + et 2 */
x+++3 /* conduit aux tokens : x, ++, + et 3, comme si on avait écrit : x ++ +3 */
x++++3 /* conduit aux tokens : x, ++, ++ et 3, comme si on avait écrit : */
/* x ++ ++ 3, ou encore x++ ++3, ce qui sera rejeté par le compilateur */
D’une manière générale, il est possible d’éviter au lecteur d’avoir à s’interroger
sur l’algorithme utilisé en adoptant un style de programmation évitant toute
ambiguïté. Le troisième exemple pourrait notamment être écrit ainsi (le
deuxième et le quatrième exemple étant, de toute façon, incorrects) :
x++ + 3 /* plus lisible que : x+++3 */
1. Il s’agit d’un sous-ensemble du code ASCII restreint dont nous parlerons un peu plus loin.
2. Certains environnements peuvent également poser des problèmes opposés en remplaçant
automatiquement des suites d’espaces par des tabulations…
3. Certains de ces caractères comme [ et ] apparaissent dans un opérateur ([]), lequel est lui-même un
séparateur. Néanmoins, chacun d’entre eux forme également un séparateur : on peut trouver « quelque
chose » entre [ et ].
4. Certains compilateurs acceptent les commentaires imbriqués.
5. Nous n’avons pas cherché à traduire le mot anglais.
3
Les types de base
La manipulation d’une information fait généralement intervenir la notion de
type, c’est-à-dire la manière dont elle a été codée en mémoire. La connaissance
de ce type est nécessaire pour la plupart des opérations qu’on souhaite lui faire
subir. Traditionnellement, on distingue les types simples dans lesquels une
information est, à un instant donné, caractérisée par une seule valeur, et les types
agrégés dans lesquels une information est caractérisée par un ensemble de
valeurs.
Ce chapitre étudie tout d’abord les caractéristiques des différents types de base
du langage, c’est-à-dire ceux à partir desquels peuvent être construits tous les
autres, qu’il s’agisse de types simples comme les pointeurs ou de types agrégés
comme les tableaux, les structures ou les unions. Nous les avons classés en trois
catégories : entiers, caractères et flottants, même si, comme on le verra, les
caractères apparaissent, dans une certaine mesure, comme des cas particuliers
d’entiers. Nous présenterons ensuite l’importante notion d’expression constante,
c’est-à-dire calculable par le compilateur. Enfin, nous terminerons sur la
déclaration et l’initialisation des variables d’un type de base.
1. Les types entiers
Pour les différents types entiers prévus par la norme, nous étudions ici les
différentes façons de les nommer et la manière dont les informations
correspondantes sont codées en mémoire. Nous fournissons quelques éléments
permettant de choisir le type entier le plus approprié à un objectif donné. Enfin,
nous indiquons les différentes façons d’écrire des constantes entières dans un
programme source.
1.1 Les six types entiers
En théorie, la norme ANSI prévoit qu’il puisse exister six types entiers différents
caractérisés par deux paramètres :
• la taille de l’emplacement mémoire utilisé pour les représenter ;
• un attribut précisant si l’on représente des nombres signés, c’est-à-dire des
entiers relatifs, ou des nombres non signés, c’est-à-dire des entiers naturels.
Le premier paramètre est assez classique et, comme dans la plupart des langages,
il se traduit par l’existence de différents noms de type : à chaque nom correspond
une taille qui pourra cependant dépendre de l’implémentation. Le second
paramètre, quant à lui, est beaucoup moins classique et on verra qu’il est
conseillé de ne recourir aux entiers non signés que dans des circonstances
particulières telles que la manipulation de motifs binaires.
Pour chacun des six types, il existe plusieurs façons de les nommer, compte tenu
de ce que :
• le paramètre de taille s’exprime par un attribut facultatif : short ou long ;
• le paramètre de signe s’exprime, lui aussi, par un attribut facultatif : signed ou
unsigned ;
• le mot-clé int, correspondant à un entier de taille intermédiaire, peut être omis,
dès lors qu’au moins un des qualificatifs précédents est présent.
Le tableau 3.1 récapitule les différentes manières de spécifier chacun des six
types (on parle de « spécificateur de type »), ainsi que la taille minimale et le
domaine minimal que leur impose la norme, quelle que soit l’implémentation
concernée.
Tableau 3.1 : les six types entiers prévus par la norme
Remarques
1. La norme impose qu’un type signé et un type non signé de même nom possèdent la même taille ; ce
sera par exemple le cas pour long int et unsigned long int.
2. La norme impose seulement à une implémentation de disposer de ces six types ; en particulier, rien
n’interdit que deux types différents possèdent la même taille. Fréquemment, d’ailleurs, soit les
entiers courts et les entiers seront de même taille, soit ce seront les entiers et les entiers longs. Les
seules choses dont on soit sûr sont que, dans une implémentation donnée, la taille des entiers courts
est inférieure ou égale à celle des entiers et que celle des entiers est inférieure ou égale à celle des
entiers longs.
3. C99 introduit les types supplémentaires : long long int et unsigned long long int.
1.2 Représentation mémoire des entiers et limitations
Comme le montre le tableau 3.2, la norme prévoit totalement la manière dont
une implémentation doit représenter les entiers non signés ainsi que les valeurs
positives des entiers signés. En revanche, elle laisse une toute petite latitude en
ce qui concerne la représentation des valeurs négatives.
Tableau 3.2 : contraintes imposées à la représentation des entiers
Contrainte imposée
Nature de l’entier Remarque
par la norme
Non signé Codage binaire pur Un entier non signé de
taille donnée est donc
totalement portable.
Signé Un entier positif doit La plupart des
avoir la même implémentations
représentation que utilisent, pour les
l’entier non signé de nombres négatifs, la
même valeur. représentation dite du
« complément à deux ».
Voyons cela plus en détail en raisonnant, pour fixer les idées, sur des nombres
entiers représentés sur 16 bits. Il va de soi qu’il serait facile de généraliser notre
propos à une taille quelconque.
1.2.1 Représentation des nombres non signés
La norme leur impose une notation binaire pure, de sorte que le codage de ces
nombres est totalement défini : il s’agit simplement de la représentation du
nombre en base 2. Voici des exemples de représentation de quelques nombres sur
16 bits : la dernière colonne reprend, sous forme hexadécimale classique, le
codage binaire exprimé dans la colonne précédente :
Codage, exprimé en
Valeur décimale Codage en binaire
hexadécimal
1 0000000000000001 0001
2 0000000000000010 0002
3 0000000000000011 0003
16 0000000000010000 0010
127 0000000001111111 007F
255 0000000011111111 00FF
1 025 0000010000000001 0401
32 767 0111111111111111 7FFF
32 768 1000000000000000 8000
32 769 1000000000000001 8001
64 512 1111110000000000 FC00
65 534 1111111111111110 FFFE
65 535 1111111111111111 FFFF
1.2.2 Représentation des nombres entiers signés
Lorsqu’il s’agit d’un nombre positif, la norme impose que sa représentation soit
identique à celle du même nombre sous forme non signée, ce qui revient à dire
qu’on y trouve le nombre exprimé en base 2. Dans ces conditions, comme
l’implémentation doit pouvoir distinguer entre nombres positifs et nombres
négatifs, la seule possibilité qui lui reste consiste à se baser sur le premier bit, en
considérant que 0 correspond à un nombre positif, tandis que 1 correspond à un
nombre négatif1.
Une latitude subsiste néanmoins dans la manière de coder la valeur absolue du
nombre. Actuellement, la représentation dite « en complément à deux » tend à
devenir universelle. Elle procède ainsi :
• on exprime la valeur en question en base 2 ;
• tous les bits sont « inversés » : 1 devient 0 et 0 devient 1 ;
• enfin, on ajoute une unité au résultat.
Voici des exemples de représentation de quelques nombres négatifs sur 16 bits,
lorsqu’on utilise cette technique : la dernière colonne reprend, sous forme
hexadécimale classique, le codage binaire exprimé dans la colonne précédente.
Codage, exprimé en
Valeur décimale Codage en binaire
hexadécimal
-1 1111111111111111 FFFF
-2 1111111111111110 FFFE
-3 1111111111111101 FFFD
-4 1111111111111100 FFFC
-16 1111111111110000 FFF0
-256 1111111100000000 FF00
-1 024 1111110000000000 FC00
-32 768 1000000000000000 8000
Remarques
1. Dans la représentation en complément à deux, le nombre 0 est codé d’une seule manière, à savoir
0000000000000000.
2. Si l’on ajoute 1 au plus grand nombre positif (ici 0111111111111111, soit 7FFF en hexadécimal ou
32 767 en décimal) et que l’on ne tient pas compte du dépassement de capacité qui se produit, on
obtient… le plus petit nombre négatif possible (ici 1000000000000000, soit 8 000 en hexadécimal
ou -32 768 en décimal). C’est ce qui explique le phénomène de « modulo » bien connu de
l’arithmétique entière dans le cas (fréquent) où les dépassements de capacité ne sont pas détectés.
3. Par sa nature, la représentation en complément à deux conduit à une différence d’une unité entre la
valeur absolue du plus petit négatif et celle du plus grand positif. Dans les autres représentations
(rares), on a souvent deux façons de coder le nombre 0 : avec bit de signe à 0 ou avec bit de signe à
1. Dans ce cas, on dispose d’autant de combinaisons possibles pour les positifs que pour les
négatifs, la valeur absolue de la plus petite valeur négative étant alors égale à celle de la plus grande
valeur positive qui se trouve être la même que dans la représentation en complément à deux.
Autrement dit, la manière exacte dont on représente les nombres négatifs a une incidence
extrêmement faible sur le domaine couvert par un nombre de bits donnés puisqu’elle ne joue que
sur une unité.
1.2.3 Limitations
Le nombre de bits utilisés pour représenter un entier et le codage employé
dépendent, bien sûr, de l’implémentation considérée. Il en va donc de même des
limitations qui en découlent. Le tableau 3.3 indique ce que sont ces limitations
relatives aux entiers dans le cas où ils sont codés sur 16 ou 32 bits, en utilisant la
représentation en complément à deux.
Tableau 3.3 : limitations relatives aux entiers codés en complément à deux
Dans les implémentations respectant la norme ANSI, sans utiliser la
représentation en complément à deux, la seule différence pouvant apparaître
dans ces limitations concerne uniquement la valeur absolue des valeurs
négatives. Ces dernières, comme expliqué dans la troisième remarque, peuvent
se trouver diminuées de 1 unité. D’ailleurs, la norme tient compte de cette
remarque pour imposer le domaine minimal des différents types entiers, comme
nous l’avons présenté dans le tableau 3.1.
D’une manière générale, les limites spécifiques à une implémentation donnée
peuvent être connues en utilisant le fichier en-tête limits.h qui sera décrit à la
section 3 de ce chapitre. Ce fichier contient également d’autres informations
concernant les caractéristiques des entiers et des caractères.
1.3 Critères de choix d’un type entier
Compte tenu du grand nombre de types entiers différents dont on dispose, voici
quelques indications permettant d’effectuer son choix.
Utiliser les entiers non signés uniquement lorsque cela est indispensable
À taille égale, un type entier non signé permet de représenter des nombres deux
fois plus grands (environ) qu’un type signé. Dans ces conditions, certains
programmeurs sont tentés de recourir aux types non signés pour profiter de ce
gain. En fait, il faut être prudent pour au moins deux raisons :
• dès qu’on est amené à effectuer des calculs, il est généralement difficile
d’affirmer qu’on ne sera pas conduit, à un moment ou à un autre, à un résultat
négatif non représentable dans un type non signé ;
• même s’il est permis, le mélange de types signés et non signés dans une même
expression est fortement déconseillé, compte tenu du peu de signification que
possèdent les conversions qui se trouvent alors mises en place (elles sont
décrites à la section 3 du chapitre 4).
En définitive, les entiers non signés ne devraient pratiquement jamais être
utilisés pour réaliser des calculs. On peut considérer que leur principale vocation
est la manipulation de motifs binaires, indépendamment des valeurs numériques
correspondantes. On les utilisera souvent comme opérandes des opérateurs de
manipulation de bits ou comme membres d’unions ou de champs de bits. Dans
certains cas, ils pourront également être utilisés pour échanger des informations
numériques entre différentes machines, compte tenu du caractère entièrement
portable de leur représentation. Toutefois, il sera alors généralement nécessaire
de transformer artificiellement des valeurs négatives en valeurs positives (par
exemple, en introduisant un décalage donné).
Remarque
Le mélange entre flottants et entiers non signés présente les mêmes risques que le mélange entre
entiers non signés et entiers signés. En revanche, le mélange entre entiers signés et flottants ne pose
pas de problèmes particuliers. Cela montre que l’arithmétique non signée constitue un cas bien à part.
Efficacité
En général, le type int correspond au type standard de la machine, de sorte que
l’on est quasiment assuré que c’est dans ce type que les opérations seront les
plus rapides. On pourra l’utiliser pour réaliser des programmes portables
efficaces, pour peu qu’on accepte les limitations correspondantes.
Signalons que l’on rencontre actuellement des machines à 64 bits, dans
lesquelles la taille du type int reste limitée à 32 bits, probablement par souci de
compatibilité avec des machines antérieures. Le type int reste cependant le plus
efficace car, généralement, des mécanismes spécifiques à la machine évitent
alors la dégradation des performances.
Occupation mémoire
Le type short est naturellement celui qui occupera le moins de place, sauf si l’on
peut se contenter du type char, qui peut jouer le rôle d’un petit entier (voir section
2 de ce chapitre). Toutefois, l’existence de contraintes d’alignement et le fait que
ce type peut être plus petit que la taille des entiers manipulés naturellement par
la machine, peuvent conduire à un résultat opposé à celui escompté. Par
exemple, sur une machine où le type short occupe deux octets et où le type int
occupe 4 octets, on peut très bien aboutir à la situation suivante :
• les informations de 2 octets sont alignées sur des adresses multiples de 4, ce qui
peut annuler le gain de place escompté ;
• l’accès à 2 octets peut impliquer l’accès à 4 octets avec sélection des 2 octets
utiles d’où une perte de temps.
Dans tous les cas, dans l’évaluation d’une expression, toute valeur de type short
est convertie systématiquement en int (voir éventuellement le chapitre 4), ce qui
peut entraîner une perte de temps.
En pratique, le type short pourra être utilisé pour des tableaux car la norme
impose la contiguïté de leurs éléments : on sera donc assuré d’un gain de place
au détriment éventuel d’une perte de temps.
Remarque
Tout ce qui vient d’être dit à propos du type short se transposera au petit type entier qu’est le type
char.
Portabilité des programmes
Ici, le problème est délicat dans la mesure où le terme même de portabilité est
quelque peu ambigu. En effet, s’il s’agit d’écrire un programme qui compile
correctement dans toute implémentation, on peut effectivement utiliser n’importe
quel type. En revanche, s’il s’agit d’écrire un programme qui fonctionne de la
même manière dans toute implémentation, il n’en va plus de même étant donné
qu’un spécificateur de type donné correspond à un domaine différent d’une
implémentation à une autre. Par exemple, int pourra correspondre à 2 octets sur
certaines machines, à 4 octets sur d’autres… Dans certains cas, on souhaitera
disposer d’un type entier ayant une taille bien déterminée ; on pourra y parvenir
en utilisant des possibilités de compilation conditionnelle (voir section 3.4.2 du
chapitre 15).
1.4 Écriture des constantes entières
Lorsque vous devez introduire une constante entière dans un programme, le
langage C vous laisse le choix entre trois formes d’écriture présentées dans le
tableau 3.4 :
Tableau 3.4 : les trois formes d’écriture des constantes entières
1.5 Le type attribué par le compilateur aux constantes
entières
Tant que l’on se limite à l’écriture de constantes sous forme décimale, au sein
d’expressions ne faisant pas intervenir d’entiers non signés, on peut
tranquillement ignorer la nature exacte du type que leur attribue le compilateur.
Mais cette connaissance s’avère indispensable dans les situations suivantes :
• utilisation de constantes écrites en notation décimale dans une expression où
apparaissent des quantités non signées ;
• utilisation de constantes écrites en notation hexadécimale ou octale.
Nous allons ici examiner les règles employées par le compilateur pour définir ce
type.
1.5.1 Cas usuel de la notation décimale
Une constante entière écrite sous forme décimale est du premier des types suivants dont la taille suffit
à la représenter correctement : int, long int, unsigned long int.
Ainsi, dans toute implémentation, la constante 12 sera de type int ; la constante 3
000 000 sera représentée en long si le type int est de capacité insuffisante et dans
le type int dans le cas contraire. Il faut cependant noter que les valeurs positives
non représentables dans le type long seront représentées dans le type unsigned long
si ce dernier a une capacité suffisante. Cela va à l’encontre du conseil prodigué à
la section 1.3 de ce chapitre, puisqu’on risque de se retrouver sans le vouloir en
présence d’une expression mixte. Toutefois, cette anomalie ne se produit qu’avec
des constantes qui, de toute façon, ne sont pas représentables dans
l’implémentation.
1.5.2 Cas de la notation octale ou hexadécimale
Une constante entière écrite sous forme octale ou hexadécimale est du premier des types suivants dont
la taille suffit à la représenter correctement : int, unsigned int, long int, unsigned long int.
Alors qu’une constante décimale n’est jamais non signée (excepté lorsqu’elle
n’est pas représentable en long), un constante octale ou hexadécimale peut l’être,
alors même qu’elle aurait été représentable en long. En outre, elle peut se voir
représenter avec un attribut de signe différent suivant l’implémentation. Par
exemple :
• OxFF sera toujours considérée comme un int (donc signée) car, dans toutes les
implémentations, la capacité du type int est supérieure à 255.
• OxFFFF sera considérée comme un unsigned int dans les implémentations où le
type int utilise 16 bits et comme un int dans celles où le type int utilise une
taille supérieure.
Cette remarque trouvera sa pleine justification avec les règles utilisées dans
l’évaluation d’expressions mixtes (mélangeant des entiers signés et non signés)
puisque, comme l’explique le chapitre 4, ces dernières ont alors tendance à
privilégier la conservation du motif binaire plutôt que la valeur. Quoi qu’il en
soit, on ne perdra pas de vue que l’utilisation de constantes hexadécimales ou
octales nécessite souvent la connaissance de la taille exacte du type concerné
dans l’implémentation employée et qu’elle est donc, par essence, peu portable.
1.6 Exemple d’utilisation déraisonnable de constantes
hexadécimales
La section 6 du chapitre 4, vous présentera des exemples d’utilisation
raisonnable de constantes hexadécimales, notamment pour réaliser des
« masques binaires ». Ici, nous nous contentons d’un programme qui illustre les
risques que présente un usage non justifié de telles constantes. Il a été exécuté
dans une implémentation où le type int est représenté sur 16 bits, les nombres
négatifs utilisant la représentation en complément à deux :
Utilisation déraisonnable de constantes hexadécimales
int main()
{ int n ;
n = 10 + 0xFF ; premiere valeur : 265
printf ("premiere valeur : %d\n", n) ; seconde valeur : 9
n = 10 + 0xFFFF ;
printf ("seconde valeur : %d\n", n) ;
}
À première vue, tout se passe comme si 0xFF était interprétée comme valant 255,
tandis que 0xFFFF serait interprétée comme valant -1, ce qui correspond
effectivement à la représentation sur 16 bits de la constante -1 dans le type int.
Or, comme indiqué précédement à la section 1.5.2, la norme prévoit que 0xFFFF
soit de type unsigned int, ce qui correspond à la valeur 65 565. En fait, comme on
le verra au chapitre suivant, le calcul de l’expression 10 + 0xFFFF se fait en
convertissant 10 en unsigned int. Dans ces conditions, le résultat (65 545) dépasse
la capacité de ce type ; mais la norme prévoit exactement le résultat (par une
formule de modulo), à savoir 65 545 modulo 65 536, c’est-à-dire 9. La
conversion en int qu’entraîne son affectation à n ne pose ensuite aucun
problème.
Ainsi, dans cette implémentation, tout se passe effectivement comme si les
constantes hexadécimales étaient signées : avec un type int représenté sur 16
bits, 0xFF apparaît comme positive, tandis que xFFFF apparaît comme négative. En
revanche, avec un type int représenté sur 32 bits, 0xFFFF apparaîtrait comme
positive, alors que 0xFFFFFFFF apparaîtrait comme négative.
1.7 Pour imposer un type aux constantes entières
Il est toujours possible, quelle que soit l’écriture employée (décimale, octale,
hexadécimale), de forcer le compilateur :
• à utiliser un type long en ajoutant la lettre L (ou l) à la suite de l’écriture de la
constante. Par exemple :
1L 045L 0x7F8L 25l 0xFFl
Bien entendu, la constante correspondante sera de type signed long ou unsigned
long suivant les règles habituelles présentées précédemment ;
• à utiliser un attribut unsigned en ajoutant la lettre U (ou u) à la suite de l’écriture
de la constante. Par exemple :
1U 035U 0xFFFFu
Là encore, la constante correspondante sera de type unsigned int ou unsigned long
suivant les règles présentées précédemment ;
• à combiner les deux possibilités précédentes. Par exemple :
1LU 045LU 0xFFFFlu
Cette fois, la constante correspondante est obligatoirement du type unsigned
long.
Remarque
À simple titre indicatif, sachez que le programme précédent (utilisation de constantes hexadécimales),
exécuté dans la même implémentation, fournit toujours les mêmes résultats, quelle que soit la façon
d’écrire la constante utilisée dans la sixième ligne, à savoir : 0xFFFF, 0xFFFFu, 0xFFFFl ou 0xFFFFlu.
1.8 En cas de dépassement de capacité dans l’écriture
des constantes entières
Comme le compilateur choisit d’office le type approprié, les seuls cas qui posent
vraiment problème sont ceux où vous écrivez une constante positive supérieure à
la capacité du type unsigned long int ou une constante négative inférieure au plus
petit nombre représentable dans le type long int.
On notera que, dans ce cas, la norme du langage C, comme celle des autres
langages, ne prévoit nullement comment doit réagir le compilateur2. Certains
compilateurs fournissent alors un diagnostic de compilation, ce qui est fort
satisfaisant. D’autres se contentent de fabriquer une constante fantaisiste
(généralement par perte des bits les plus significatifs !). Ainsi, sur une machine
où le type longint occupe 32 bits, une constante telle que 4 294 967 297 pourra
être acceptée à la compilation et interprétée comme valant 2 !
Rappelons qu’il est déconseillé d’utiliser des constantes décimales dont la valeur
est supérieure à la capacité du type long, tout en restant inférieure à la capacité du
type unsigned long car on serait alors amené à créer une valeur de type non signé,
sans nécessairement s’en apercevoir ; si cette constante apparaît dans une
expression utilisant des types signés, le résultat peut être différent de celui
escompté.
2. Les types caractère
Le langage C dispose non pas d’un seul, mais de deux types caractère, l’un
signé, l’autre non signé. Cette curiosité est essentiellement liée à la forte
connotation numérique de ces deux types. Ici, nous examinerons les différentes
façons de nommer ces types, leurs caractéristiques et la façon d’écrire des
constantes dans un programme.
2.1 Les deux types caractère
Les types caractère correspondent au mot-clé char. La norme ANSI prévoit en
fait deux types caractère différents obtenus en introduisant dans le spécificateur
de type, de façon facultative, un qualificatif de signe, à savoir signed ou unsigned.
Cet attribut intervient essentiellement lorsqu’on utilise un type caractère pour
représenter de petits entiers. C’est la raison pour laquelle la norme définit,
comme pour les types entiers, le domaine (numérique) minimal des types
caractère.
Contrairement à ce qui se passe pour les types entiers, l’absence de qualificatif
de signe pour les caractères ne correspond pas systématiquement au type signed
char mais plus précisément à l’un des deux types signed char ou unsigned char, ceci
suivant l’implémentation et même, parfois, dans une implémentation donnée,
suivant certaines options de compilation.
Tableau 3.5 : les deux types caractère du langage C
En C++
Alors que C dispose de deux types caractère, C++ en disposera de trois : char (malgré son ambiguïté,
ce sera un type à part entière), unsigned char et signed char.
2.2 Caractéristiques des types caractère
Le tableau 3.6 récapitule les caractéristiques des types caractère. Ces derniers
seront ensuite détaillés dans les sections suivantes de ce chapitre.
Tableau 3.6 : les caractéristiques des types caractère
Code associé – indépendant de l’attribut de signe ; Voir
à un section
caractère – dépend de l’implémentation. 2.2.1 de ce
chapitre
Caractères – au moins le jeu minimal d’exécution ; Voir
existants section
– ne pas oublier que certains caractères ne 2.2.2 de ce
sont pas imprimables. chapitre
Influence de – en pratique, aucune, dans les simples Voir
l’attribut de manipulations de variables (type section
signe conseillé : char ou unsigned char) ; 2.2.3 de ce
chapitre
– importante si l’on utilise ce type pour
représenter de petits entiers (type
conseillé signed char).
Manipulation Possible par le biais de ce type, compte Voir
d’octets tenu de l’équivalence entre octet et section
caractère (type conseillé unsigned char). 2.2.4 de ce
chapitre
2.2.1 Code associé à un caractère
Les valeurs de type caractère sont représentées sur un octet, au sens large de ce
terme, c’est-à-dire correspondant à la plus petite partie adressable de la mémoire.
En pratique, le code associé à un caractère est indépendant de l’attribut de signe.
Par exemple, on obtiendra exactement le même motif binaire dans c1 et c2 avec :
unsigned char c1 ;
signed char c2 ;
c1 = ‘a' ;
c2 = ‘a' ;
Cependant, on verra à la section 2.4, que les constantes caractère sont en fait de
type int. Les instructions précédentes font donc intervenir des conversions
d’entier en caractère dont les règles exactes sont étudiées à la section 9 du
chapitre 4. Leur examen attentif montrera que, en théorie, cette conservation du
motif binaire ne devrait être assurée que dans certains cas : caractères
appartenant au jeu minimal d’exécution, variables caractère non signées (cas de
c1 dans notre exemple) dans les implémentations utilisant la représentation en
complément à deux. En pratique, cette unicité se vérifie dans toutes les
implémentations que nous avons rencontrées et d’ailleurs, beaucoup de
programmes se basent sur elle.
Bien entendu, le code associé à un caractère donné dépend de l’implémentation.
Certes, le code dit ASCII tend à se répandre, mais comme indiqué à la section
1.3 du chapitre 2, seul le code ASCII restreint a un caractère universel ; et nos
caractères nationaux n’y figurent pas !
2.2.2 Caractères existants
La norme précise le jeu minimal de caractères d’exécution dont on doit
disposer ; il est présenté à la section 1.1 du chapitre 2. Mais d’autres caractères
peuvent apparaître dans une implémentation donnée. Ainsi, lorsque l’octet
occupe 8 bits (comme c’est presque toujours le cas), on est sûr de disposer d’un
jeu de 256 caractères parmi lesquels figurent ceux du jeu minimal. Les
caractères supplémentaires peuvent être imprimables ou de contrôle. À ce
propos, signalons qu’il existe des fonctions standards permettant de connaître la
nature (imprimable, alphabétique, numérique, de contrôle…) d’un caractère de
code donné ; elles sont présentées au chapitre 18.
2.2.3 Influence de l’attribut de signe
Dans les manipulations de variables de type caractère
En pratique, tant que l’on se contente de manipuler des caractères en tant que
tels, l’attribut de signe n’a pas d’importance. C’est le cas dans des situations
telles que :
signed char c1 ;
unsigned char c2 ;
…..
c2 = c1 ;
c1 = c2 ;
Le motif binaire est conservé par affectation. Cependant, là encore, si l’on
examine la norme à la lettre, on constate que ces situations font intervenir des
conversions (étudiées à la section 9 du chapitre 4) qui, en théorie, n’assurent
cette conservation que dans certains cas : caractères appartenant au jeu minimal
d’exécution, conversions de signé en non signé dans les implémentations
utilisant la représentation en complément à deux. En pratique, ces conversions
conservent le motif binaire dans toutes les implémentations que nous avons
rencontrées.
Remarque
Si l’on vise une portabilité absolue, on pourra toujours éviter les conversions en évitant les mélanges
d’attribut de signe. Cependant, dans ce cas, il faudra tenir compte du fait qu’une constante caractère
est de type int (voir section suivante « Le type des constantes caractère »), ce qui pourra influer sur
l’initialisation ou sur l’affectation d’une constante à une variable caractère. Si l’on suit la norme à la
lettre, on verra que le seul cas de conservation théorique sera celui où l’on utilise le type char. En
définitive, il faudra choisir entre :
• un type char qui assure la portabilité absolue du motif binaire mais qui présente une ambiguïté de
signe (soit quand on s’intéresse à sa valeur numérique, soit lorsqu’on compare deux caractères, voir
section 3.3.2 du chapitre 4) ;
• le type unsigned char qui, en théorie, n’assure la portabilité que dans les implémentations utilisant
la représentation en complément à deux) mais qui ne présente plus l’ambiguïté précédente.
Dans les expressions entières
Comme on le verra à la section 9 du chapitre 4, il existe une conversion implicite
de char en int qui pourra intervenir dans :
• des expressions dans lesquelles figurent des variables de type caractère ;
• des affectations du type caractère vers un type entier.
Ces conversions permettent aux types caractère d’être utilisés pour représenter
des petits entiers. Dans ce cas, comme on peut s’y attendre, l’attribut de signe
intervient pour définir le résultat de la conversion. On verra par exemple que,
avec :
signed char c1 ;
unsigned char c2 ;
l’expression c2+1 aura toujours une valeur positive, tandis que c1+1 pourra, suivant
la valeur de c1, être négative, positive ou nulle. De même, si n1 et n2 sont de type
int, avec ces affectations :
n1 = c1 ;
n2 = c2 ;
la valeur de n2 sera toujours positive ou nulle, tandis que celle de n1 pourra être
négative, positive ou nulle.
Les conseils fournis à la section 1.3 de ce chapitre, à propos des types entiers
s’appliquent encore ici : si l’objectif est effectivement de réduire la taille de
variables destinées à des calculs numériques classiques, il est conseillé d’utiliser
systématiquement le type signed char.
2.2.4 Manipulations d’octets
Un des atouts du langage C est de permettre des manipulations dites « proches
de la machine ». Parmi celles-ci, on trouve notamment les manipulations du
contenu binaire d’un objet, indépendamment de son type. A priori, tout accès à
un objet requiert un type précis défini par l’expression utilisée, de sorte que les
manipulations évoquées semblent irréalisables. En fait, un objet est toujours
formé d’une succession d’un nombre entier d’octets et un octet peut toujours être
manipulé par le biais du type char.
Dans ces conditions, il est bien possible de manipuler les différents octets d’un
objet quelconque, pour peu qu’on soit en mesure d’assurer la conservation du
motif binaire. Cet aspect a été exposé à la section précédente. On a vu que cette
conservation avait toujours lieu en pratique, même si en théorie, elle n’était
absolue qu’avec le type char. Par ailleurs, les manipulations d’octets sont souvent
associées à des manipulations au niveau du bit (masque, décalages…) pour
lesquelles le type unsigned char sera plus approprié (voir section 6 du chapitre 4).
Le type unsigned char constituera donc généralement le meilleur choix possible.
2.3 Écriture des constantes caractère
Il existe plusieurs façons d’écrire les constantes caractère dans un programme.
Elles ne sont pas totalement équivalentes.
2.3.1 Les caractères « imprimables »
Les constantes caractère correspondant à des caractères imprimables peuvent se
noter de façon classique, en écrivant entre apostrophes (ou quotes) le caractère
voulu, comme dans ces exemples :
‘a' ‘Y' ‘+' ‘$' ‘0' ‘<' /* caractères du jeu minimal d'exécution */
‘é' ‘à' ‘ç' /* n'existent que dans certaines implémentations */
On notera bien que l’utilisation de caractères n’appartenant pas au jeu minimal
conduit à des programmes qu’on pourrait qualifier de « semi-portables ». En
effet, une telle démarche présente les caractéristiques suivantes :
• elle est plus portable que celle qui consisterait à fournir directement le code du
caractère voulu car on dépendrait alors de l’implémentation elle-même ;
• elle n’est cependant portable que sur les implémentations qui possèdent le
graphisme en question (quel que soit son codage).
Par exemple, la notation ‘é' représente bien le caractère é dans toute
implémentation où il existe, quel que soit son codage ; mais cette notation n’est
pas utilisable dans les autres implémentations. À ce propos, il faut bien voir que
cette notation du caractère imprimable n’est visible qu’à celui qui saisit ou qui lit
un programme. Dès qu’on travaille sur des fichiers source, on a affaire à des
suites d’octets représentant chacun un caractère. Par exemple, il n’est pas rare de
saisir un caractère é dans une implémentation et de le voir apparaître
différemment lorsqu’on exploite le même programme source dans une autre
implémentation.
2.3.2 Les caractères disposant d’une « séquence d’échappement »
Certains caractères non imprimables possèdent une représentation
conventionnelle dite « séquence d’échappement », utilisant le caractère \
(antislash)3. Dans cette catégorie, on trouve également quelques caractères qui,
bien que disposant d’un graphisme, jouent un rôle particulier de délimiteurs, ce
qui les empêche d’être notés de manière classique entre deux apostrophes.
Tableau 3.7 : les caractères disposant d’une séquence d’échappement
Si le caractère \ apparaît suivi d’un caractère différent de ceux qui sont
mentionnés ici, le comportement du programme est indéterminé. Par ailleurs,
une implémentation peut introduire d’autres séquences d’échappement ; il lui est
cependant conseillé d’éviter les minuscules qui sont réservées pour une future
extension de la norme. Par essence, l’emploi de cette notation est totalement
portable, quel que soit le code correspondant dans l’implémentation. En
revanche, tout recours à un caractère de contrôle n’appartenant pas à cette liste
nécessite l’introduction directe de son code, ce qui n’assure la portabilité
qu’entre implémentations utilisant le même codage.
Remarque
La notation sous forme d’une séquence d’échappement ne dispense nullement de l’utilisation des
apostrophes dans l’écriture d’une constante caractère. Ainsi, il faudra bien écrire ‘\n' et non
simplement \n. Bien entendu, quand cette même séquence d’échappement apparaîtra dans une chaîne
constante, ces apostrophes n’auront plus aucune raison d’être. Par exemple, on écrira bien :
"bonjour\nmonsieur"
2.3.3 Écriture d’un caractère par son code
Il est possible d’utiliser directement le code du caractère, en l’exprimant,
toujours à la suite du caractère \ :
• soit sous forme octale ;
• soit sous forme hexadécimale précédée de x.
Voici quelques exemples de notations équivalentes (sur une même ligne), dans le
code ASCII restreint :
La notation octale doit comporter de 1 à 3 chiffres ; la notation hexadécimale
n’est pas soumise à des limites. Ainsi, ‘\4321' est incorrect, tandis que ‘x4321' est
correct. Toutefois, dans le dernier cas, le code obtenu en cas de dépassement de
la capacité d’un octet, n’est pas précisé par la norme. Il est donc recommandé de
ne pas utiliser cette tolérance4.
D’une manière générale, ces notations, manifestement non portables, doivent
être réservées à des situations particulières telles que :
• besoin d’un caractère de contrôle non prévu dans le jeu d’exécution, par
exemple ACK, NACK… Dans ce cas, on minimisera le travail de portage d’une
machine à une autre en prenant bien soin de définir une seule fois, au sein d’un
fichier en-tête, chacun de ces caractères par une instruction de la forme :
#define ACK 0x5
• besoin de décrire le motif binaire contenu dans un octet ; c’est notamment le
cas lorsqu’on doit recourir à un masque binaire.
Remarques
1. Le caractère \ suivi d’un caractère autre que ceux du tableau 3.7 ou d’un chiffre de 0 à 7, est
simplement ignoré. Ainsi, dans le cas du code ASCII, \9 correspond au caractère 9 (de code ASCII
57), tandis que \7 correspond au caractère de code ASCII 7, c’est-à-dire la « cloche ».
2. Avec la notation hexadécimale ou octale, comme avec la notation sous forme d’une séquence
d’échappement présentée à la section précédente 2.3.2, il ne faut pas oublier les apostrophes
délimitant une constante caractère. Bien entendu, cette remarque ne s’appliquera plus au cas des
constantes chaînes.
2.4 Le type des constantes caractère
Assez curieusement, la norme prévoit que :
Toute constante caractère est de type int et la valeur correspondante est obtenue comme si l’on
convertissait une valeur de type char (dont l’attribut de signe dépend de l’implémentation) en int.
L’explication réside probablement dans le lien étroit existant en C entre
caractères et entiers. Si l’on admet que les types caractère correspondent à des
types entiers de petite taille, il n’est alors pas plus choquant de dire qu’une
notation comme ‘a' est de type int que de dire qu’une « petite constante
numérique » comme +43 était de type int (et non short !).
Dans ces conditions, on peut s’interroger sur le fait que, suivant les
implémentations, le type char peut être signé ou non, de sorte que, suivant les
règles de conversion étudiées dans le chapitre suivant, le résultat peut être
parfois négatif. Nous allons examiner deux situations :
• on utilise les constantes caractère de façon naturelle, c’est-à-dire pour
représenter de vrais caractères ;
• on utilise les constantes caractère pour leur valeur numérique.
2.4.1 Utilisation naturelle des constantes caractère
En pratique, les trois instructions suivantes placeront le même motif dans c1, c2 et
c3 (la notation α désignant un caractère quelconque) :
char c1 = ‘ ' ;α
unsigned char c2 = ' ' ; α
signed char c3 = ' ' ; α
Il en ira de même si la constante caractère est exprimée sous forme octale ou
hexadécimale.
Cependant, si l’on examine la norme à la lettre, on constate que ces situations,
hormis la première, font intervenir une suite de deux conversions de char en int,
puis en char. En théorie (voir section 9 du chapitre 4), elles n’assurent la
conservation du motif binaire que dans certains cas : caractères appartenant au
jeu minimal d’exécution, conversions de signé en non signé dans les
implémentations utilisant le complément à deux. En pratique, ces conversions
conservent le motif binaire dans toutes les implémentations que nous avons
rencontrées. Si toutefois on cherche une portabilité absolue, on pourra se limiter
à l’utilisation du type char, à condition que l’ambiguïté correspondante ne s’avère
pas gênante (voir section précédente 2.2.3).
2.4.2 Utilisation des constantes caractère pour leur valeur
numérique
Il s’agit du cas où une telle constante apparaît dans une expression numérique ou
dans une affectation à une variable entière, ce qui peut se produire lorsqu’on
s’intéresse à la valeur du code du caractère correspondant. Dans ce cas,
l’implémentation intervient, non seulement sur la valeur obtenue, mais
éventuellement sur son signe. Voici un exemple dans lequel on suppose que le
code du caractère é est supérieur à 127, dans une implémentation codant les
caractères sur 8 bits et utilisant la représentation en complément à deux :
int n = ‘é' ; /* la valeur de n sera >0 si char est signé */
/* et >0 si char n'est pas signé */
On peut simplement affirmer que l’effet de cette déclaration sera équivalent à :
char c = ‘é' ; /* char est signé ou non suivant l'implémentation */
int n ;
…..
n = c ;
D’une manière comparable, dans la même implémentation (octets de 8 bits,
représentation en complément à deux) :
int n ='\xFE' ; /* -2 si le type char est signé par défaut */
/* 254 si le type char n'est pas signée par défaut */
On notera bien que, alors qu’on pouvait choisir l’attribut de signe d’une variable
de type char, il n’en va plus de même pour une constante. On peut toutefois faire
appel à l’opérateur de cast comme dans :
int n = (signed char) ‘\xfE' ; /* conv char -> signed char -> int */
/* -2 dans le cas du complément à deux */
int n = (unsigned char) ‘\xfE' ; /* conv char -> unsigned signed char -> int */
/* 254 dans le cas du complément à deux */
Remarques
Ici, il est important de ne pas confondre valeur et motif binaire. Le motif binaire associé à une
constante caractère est bien constant après conversion dans un type char de type quelconque (du
moins, en pratique) ; en revanche, la valeur numérique correspondante de type int, ne l’est pas.
La norme autorise des constantes caractère de la forme ‘xy' voire ‘xyz', contenant plusieurs
caractères imprimables. Certes, une telle particularité peut se justifier par le fait que la constante
produite est effectivement de type int et non de type char ; elle reste cependant d’un emploi malaisé
et, de toute façon, la valeur ainsi obtenue dépend de l’implémentation.
En C++
En C++, les constantes caractère seront effectivement de type char, et non plus de type int.
3. Le fichier limits.h
3.1 Son contenu
Ce fichier en-tête contient, sous forme de constantes ou macros (définies par la
directive #define), de nombreuses informations concernant le codage des entiers
et les limitations qu’elles imposent dans une implémentation donnée.
En voici la liste, accompagnée de la valeur minimale qu’on est assuré d’obtenir
pour chaque type d’entier quelle que soit l’implémentation. On notera la
présence d’une constante MB_LEN_MAX relative aux caractères dits « multi-octets »,
d’usage assez peu répandu, et dont nous parlerons au chapitre 22.
Tableau 3.8 : les valeurs définies dans le fichier limits.h
Valeur
Symbole Signification
minimale
CHAR_BIT 8 Nombre de bits dans un caractère
SCHAR_MIN -127 Plus petite valeur (négative) du type
signed char
SCHAR_MAX +127 Plus grande valeur du type signed char
UCHAR_MAX 255 Plus grande valeur du type unsigned char
CHAR_MIN Plus petite valeur du type char (signed
char ou unsigned char suivant
l’implémentation, ou même suivant les
options de compilation)
CHAR_MAX Plus grande valeur du type char (signed
char ou unsigned char suivant
l’implémentation, ou même suivant les
options de compilation)
MB_LEN_MAX 1 Nombre maximal d’octets dans un
caractère multi-octets (quel que soit le
choix éventuel de localisation)
SHRT_MIN - 32 767 Plus petite valeur du type short int
SHRT_MAX + 32 767 Plus grande valeur du type short int
USHRT_MAX 65 535 Plus grande valeur du type unsigned short
int
INT_MIN - 32 767 Plus petite valeur du type int
INT_MAX + 32 767 Plus grande valeur du type int
UINT_MAX 65 535 Plus grande valeur du type unsigned int
LONG_MIN - 2 147 483 Plus petite valeur du type long int
647
LONG_MAX + 2 147 Plus grande valeur du type long int
483 647
ULONG_MAX 4 294 967 Plus grande valeur du type unsigned long
int
295
3.2 Précautions d’utilisation
On notera bien que la norme impose peu de contraintes au type des constantes
définies dans limits.h. En général, on trouvera des définitions de ce genre :
#define INT_MAX +32767
Le symbole INT_MAX sera donc remplacé par le préprocesseur par la constante +32
767, laquelle sera de type int. Dans ces conditions, on évitera certains calculs
arithmétiques risquant de conduire à des dépassements de capacité dans le type
int. Le programme suivant montre ce qu’il ne faut pas faire, puis ce qu’il faut
faire, pour calculer la valeur de INT_MAX+5 :
Exemple de mauvais et de bon usage de la constante INT_MAX
#include <stdio.h>
#include <limits.h>
int main()
{
int n ;
long q ;
q = INT_MAX + 5 ; /* calcul incorrect */
printf ("INT_MAX+5 = %ld\n", q) ;
q = (long)INT_MAX + 5 ; /* calcul correct */
printf ("INT_MAX+5 = %ld\n", q) ;
}
INT_MAX+5 = -32764
INT_MAX+5 = 32772
4. Les types flottants
Nous commencerons par rappeler brièvement en quoi consiste le codage en
flottant d’un nombre réel et quels sont les éléments caractéristiques d’un codage
donné : précision, limitations, epsilon machine… Puis nous examinerons les
trois types de flottants prévus par la norme, leur nom et leurs caractéristiques
respectives. Nous terminerons par les différentes façons d’écrire des constantes
flottantes dans un programme source.
4.1 Rappels concernant le codage des nombres en
flottant
Les types flottants (appelés parfois, un peu à tort, réels) servent à représenter de
manière approchée une partie des nombres réels. Ils s’inspirent de la notation
scientifique des calculettes, dans laquelle on exprime un nombre sous forme
d’une mantisse et d’un exposant correspondant à une puissance de 10, comme
dans 0.453 E 15 (mantisse 0,453, exposant 15) ou dans 45.3 E 13 (mantisse 45,3,
exposant 13). Le codage en flottant se distinguera cependant de cette notation
scientifique sur deux points :
• le codage de la mantisse : il est généralement fait en binaire et non plus en base
10 ;
• la base utilisée pour l’exposant : on n’a guère de raison d’employer une base
décimale. En général, des bases de 2 ou de 16 seront utilisées car elles
facilitent grandement les calculs de la machine (attention, l’utilisation d’une
base 16 n’est pas incompatible avec le codage en binaire des valeurs de la
mantisse et de l’exposant) .
D’une manière générale, on peut dire que la représentation d’un nombre réel en
flottant revient à l’approcher par une quantité de la forme
s. m . be
dans laquelle :
• s représente un signe, ce qui revient à dire que s vaut soit -1, soit +1 ;
• m représente la mantisse ;
• e représente l’exposant, tel que : emin <= e <= emax ;
• b représente la base.
La base b (en pratique 2 ou 16) est fixée pour une implémentation donnée5. Il
n’en reste pas moins qu’un même nombre réel peut, pour une valeur b donnée,
être approché de plusieurs façons différentes par la formule précédente. Par
exemple, si un nombre est représentable par le couple de valeurs (m, e), il reste
représentable par le couple de valeurs (b/m, e+1) ou (mb, e-1)…
Pour assurer l’unicité de la représentation, on fait donc appel à une contrainte
dite de « normalisation ». En général, il s’agit de :
1/b <= m< 1
Dans le cas de la notation scientifique des calculettes, l’application de cette
contrainte conduirait à une mantisse commençant toujours par 0 et dont le
premier chiffre après le point décimal est non nul. Par exemple, 0.2345 et 0.124
seraient des mantisses normalisées, tandis que 3.45 ou 0.034 ne le seraient pas6.
4.2 Le modèle proposé par la norme
Contrairement à ce qui se passe, en partie du moins, pour les nombres entiers, la
norme ANSI n’impose pas de contraintes précises quant à la manière dont une
implémentation représente les types flottants. Elle se contente de proposer un
modèle théorique possible, dont le principal avantage est de donner une
définition formelle d’un certain nombre de paramètres caractéristiques tels que la
précision ou l’epsilon machine. Ce modèle correspond à la formule précédente
(voir section 4.1), dans laquelle on explicite la mantisse de la façon suivante, le
coefficient f1 étant non nul si le nombre est non nul :
Certes, cette formule définit la mantisse de façon unique, dès lors que π, b et les
limites emin et emax sont fixées. Comme on s’en doute, elles dépendront de
l’implémentation et du type de flottant utilisé (float, double ou long double). Mais il
ne s’agit que d’un modèle théorique de comportement et, même si la plupart des
implémentations s’en inspirent, quelques petits détails de codage peuvent
apparaître :
• élimination de certains bits superflus de la mantisse ; par exemple, lorsque la
base est égale à 2, le premier bit est toujours à 1 et certaines implémentations
ne le conservent pas, ce qui double la capacité ;
• réservation, comme le propose la norme IEEE 754, de certains motifs binaires
pour représenter des nombres infinis ou des quantités non représentables.
4.3 Les caractéristiques du codage en flottant
Le codage en entier n’a guère d’autres conséquences que de limiter le domaine
des valeurs utilisables. Dans le cas du codage en flottant, les conséquences sont
moins triviales et nous allons les examiner ici, en utilisant parfois le modèle
théorique présenté précédemment.
4.3.1 Représentation approchée
Le codage en flottant permet de représenter un nombre réel de façon approchée,
comme on le fait dans la vie courante en approchant le nombre réel pi par 3.14
ou 3.14159… La notion de représentation approchée paraît alors relativement
naturelle. En revanche, lorsqu’on a affaire à un nombre décimal tel que 0.1, qui
s’exprime de manière exacte dans notre système décimal, on peut être surpris de
ce qu’il ne s’exprime plus toujours de façon exacte une fois codé en flottant7.
Voici un petit programme illustrant ce phénomène, dans une implémentation où
la base b de l’exposant est égale à 2 :
Conséquences de la représentation approchée des nombres flottants
include <stdio.h>
int main()
{ float x = 0.1 ;
printf ("x avec 1 decimale : %.1e\n", x) ;
printf ("x avec 10 decimales : %.10e\n", x) ;
}
x avec 1 decimale : 1.0e-01
x avec 10 decimales : 1.0000000149e-01
Cependant, la norme impose aux entiers dont la valeur absolue est inférieure à
une certaine limite d’être représentés de façon exacte en flottant, de façon à ce
qu’un cycle de conversion entier → flottant → entier permette de retrouver la
valeur d’origine. Ces limites dépendent à la fois du type de flottant concerné et
de l’implémentation ; elles sont précisées dans le tableau 3.8, qui montre qu’elles
sont toujours au moins égales à 1E6.
4.3.2 Notion de précision
On peut définir la précision d’une représentation flottante :
• soit en considérant le nombre de chiffres en base b, c’est-à-dire finalement la
valeur de p dans le modèle défini par la norme et présenté à la section 4.2 ;
cette valeur est définie de façon exacte ;
• soit en cherchant à exprimer cette précision en termes de chiffres décimaux
significatifs ; en théorie, on peut montrer que p chiffres exacts en base b
conduisent toujours à au moins q chiffres décimaux exacts, avec q = (p-
1).log10 b ; autrement dit, tout nombre entier d’au plus q chiffres s’exprime
sans erreur en flottant.
Ces différentes valeurs sont fournies dans le fichier float.h décrit à la section 5.
4.3.3 Limitations des valeurs représentables
Dans le cas du type entier, les valeurs représentables appartenaient simplement à
un intervalle de l’ensemble des entiers relatifs. Dans le cas des flottants, on a
affaire à une limitation des valeurs de l’exposant emin et emax, lesquelles
conduisent en fait à une limitation de l’amplitude de la valeur absolue du
nombre. Les valeurs réelles représentables appartiennent donc à deux intervalles
disjoints, de la forme :
[-xmax, -xmin] [xmin, xmax] avec xmin = bemin et xmax = bemax
En outre, la valeur 0 (qui n’appartient à aucun de ces deux intervalles) est
toujours représentable de façon exacte ; l’unicité de sa représentation nécessite
l’introduction d’une contrainte conventionnelle, par exemple, mantisse nulle,
exposant 1.
4.3.4 Non-respect de certaines règles de l’algèbre
La représentation approchée des types flottants induit des différences de
comportement par rapport à l’algèbre traditionnelle.
Certes, la commutativité des opérations est toujours respectée ; ainsi les
opérations a+b ou b+a donneront-elles toujours le même résultat (même si
celui-ci n’est qu’une approximation de la somme).
En revanche, si a et b désignent des valeurs réelles et si x’ désigne
l’approximation en flottant de l’expression x, on n’est pas assuré que les
conditions suivantes soient vérifiées :
(a + b)’ = a’ + b’
(a’ + b’)’ = a’ + b’
Par exemple :
float x = 0.1, y = 0.1
…..
if (x + y == 0.2) /* peut être vrai ou faux */
De façon comparable, on n’est pas assuré que ces conditions soient vérifiées8 :
(3 * a)’ = 3 * a’
(3 * a’) ‘ = 3 * a’
Par exemple :
float x = 0.1 ;
if (3*x == 0.3) /* peut être vrai ou faux */
Par ailleurs, l’associativité de certaines opérations n’est plus nécessairement
respectée. On n’est plus assuré que :
a’ + (b’+c’)’ = (a’ + b’)’ + c’
Tout ceci se compliquera encore un peu plus avec l’incertitude qui règne en C
sur l’ordre d’évaluation des opérateurs commutatifs, comme nous le verrons à la
section 2.1.4 du chapitre 4.
4.3.5 Notion d’epsilon machine
Compte tenu de la représentation approchée du type flottant, on peut aisément
trouver des nombres eps tels que la représentation de la somme de eps+1 soit
identique à celle de 1, autrement dit que la condition suivante soit vraie :
1 + eps == 1
La plus grande de ces valeurs se nomme souvent « l’espilon machine ». On peut
montrer qu’elle est définie par :
eps = b1-p
où b et π sont définis par le modèle de comportement ANSI présenté à la section
4.2.
On en trouvera la valeur pour chacun des types flottants dans le fichier float.h
décrit à la section 5.
4.4 Représentation mémoire et limitations
La norme ANSI prévoit les trois types de flottants suivants :
Tableau 3.9 : les trois types flottants prévus par la norme
Bien entendu, les caractéristiques exactes de chacun de ces types dépendent à la
fois de l’implémentation et du type concerné. Un certain nombre d’éléments sont
cependant généralement communs, dans une implémentation donnée, aux trois
types de flottants :
• la technique d’approximation (arrondi par défaut, par excès, au plus
proche…) ;
• la valeur de la base de l’exposant b ;
• la manière dont la mantisse est normalisée.
D’autres éléments, en revanche, dépendent effectivement du type de flottant (et
aussi de l’implémentation) à savoir :
• le nombre de bits utilisés pour coder la mantisse m ;
• le nombre de bits utilisés pour coder l’exposant e.
D’une manière générale, le fichier float.h contient bon nombre d’informations
concernant les caractéristiques des flottants.
Remarques
1. La première définition du langage C (Kernighan et Ritchie) ne comportait pas le type long double.
En outre, long float y apparaissait comme un synonyme de double ; cette possibilité a disparu de
la norme.
2. Certaines implémentations acceptent des valeurs flottantes non normalisées, c’est-à-dire des valeurs
dans lesquelles la mantisse comporte un ou plusieurs de ses premiers chiffres (en base b) nuls. Dans
ce cas, il devient possible de manipuler des valeurs inférieures en valeur absolue au minimum
imparti au type, moyennant, alors une perte de précision…
4.5 Écriture des constantes flottantes
Comme dans la plupart des langages, les constantes réelles peuvent s’écrire
indifféremment suivant l’une des deux notations :
• décimale ;
• exponentielle.
La notation décimale doit obligatoirement comporter un point (correspondant à
notre virgule). La partie entière ou la partie décimale peuvent être omises (mais
bien sûr pas toutes les deux en même temps !). En voici quelques exemples
corrects :
12.43 -0.38 -.38 4. .27
En revanche, la constante 47 serait considérée comme entière et non comme
flottante. Dans la pratique, ce fait aura peu d’importance9, compte tenu des
conversions automatiques qui seront mises en place par le compilateur (et dont
nous parlerons au chapitre suivant).
La notation exponentielle utilise la lettre e (ou E) pour introduire un exposant
entier (puissance de 10), avec ou sans signe. La mantisse peut être n’importe
quel nombre décimal ou entier (le point peut être absent dès qu’on utilise un
exposant). Voici quelques exemples corrects (les exemples d’une même ligne
étant équivalents) :
4.25E4 4.25e+4 42.5E3
54.27E-32 542.7E-33 5427e-34
48e13 48.e13 48.0E13
4.6 Le type des constantes flottantes
Par défaut, toutes les constantes sont créées par le compilateur dans le type
double. Il est cependant possible d’imposer à une constante flottante :
• d’être du type float, en faisant suivre son écriture de la lettre F (ou f), comme
dans 1.25E+03f ; cela permet de gagner un peu de place mémoire, en
contrepartie d’une éventuelle perte de précision ;
• d’être du type long double, en faisant suivre son écriture de la lettre L (ou l),
comme dans 1.0L ; cela permet de gagner en précision, en contrepartie d’une
perte de place mémoire ; c’est aussi le seul moyen de représenter les valeurs
très grandes ou très petites (bien qu’un tel besoin soit rare en pratique).
4.7 En cas de dépassement de capacité dans l’écriture
des constantes
Contrairement à ce qui se produit pour les entiers, la manière dont est écrite une
constante flottante impose son type, de manière unique : float, double ou long
double. Dans chacun de ces trois cas, vous avez affaire à des limitations propres, à
la fois :
• vers « le bas » : une constante de valeur absolue trop petite ne peut être
représentée (avec une erreur relative d’approximation raisonnable) ; on parle
alors de sous-dépassement de capacité (en anglais underflow) ;
• vers « le haut » : une constante de valeur absolue trop grande ne peut être
représentée (avec une erreur relative d’approximation raisonnable) : on parle
alors de dépassement de capacité (en anglais overflow).
Là encore, suivant les compilateurs, on pourra obtenir : un diagnostic de
compilation, une valeur fantaisiste ou une utilisation des conventions IEEE 754
(présentées à la section 2.2.2) en cas de dépassement de capacité, une valeur
fantaisiste ou une valeur nulle en cas de sous-dépassement de capacité.
5. Le fichier float.h
Ce fichier contient, sous forme de constantes ou de macros (définies par la
directive #define), de nombreuses informations concernant :
• les caractéristiques du codage des flottants tel qu’il est défini par le modèle
théorique de comportement proposé par la norme et présenté à la section 4.2 :
base b, précision p en base b ou précision en base 10, epsilon machine…
• les limitations correspondantes : emin, emax, xmin, xmax…
En voici la liste. On notera que, à l’exception des symboles FLT_ROUNDS et FLT_RADIX
qui concernent les trois types flottants (float, double et long double), les autres
symboles sont définis pour les trois types avec le même suffixe et un préfixe
indiquant le type concerné :
• FLT : le symbole correspondant (par exemple, FLT_MIN_EXP) concerne le type float ;
• DBL : le symbole correspondant (par exemple, DBL_MIN_EXP) concerne le type
double ;
• LDBL : le symbole correspondant (par exemple, LDBL_MIN_EXP) concerne le type long
double.
Tableau 3.10 : le contenu du fichier float.h
Valeur
Symbole Signification
minimale
FLT_RADIX
2 Base b telle que définie à la section 4.2
FLT_ROUNDS
Méthode utilisée pour déterminer la
représentation d’un nombre réel donné :
-1 : indéterminée
0 : arrondi vers zéro
1 : arrondi au plus proche
2 : arrondi vers plus l’infini
3 : arrondi vers moins l’infini
autre : méthode définie par
l’implémentation
FLT_MANT_DIG
DBL_MANT_DIG Précision (p dans la formule de la section
LDBL_MANT_DIG 4.2)
FLT_DIG
DBL_DIG 6 Valeur q, telle que tout nombre décimal de
LDBL_DIG 10 q chiffres puisse être exprimé sans erreur
10 en notation flottante ; on peut montrer
que :
q = (p-1).log10b (+1 si b est puissance de
10)
FLT_MIN_EXP
DBL_MIN_EXP Plus petit nombre négatif n tel que
LDBL_MIN_EXP FLT_RADIX soit un nombre flottant
n-1
normalisé ; il s’agit de emin tel qu’il est
défini à la section 4.2
FLT_MIN_10_EXP
DBL_MIN_10_EXP -37 Plus petit nombre négatif n tel que 10n soit
LDBL_MIN_10_EXP -37 dans l’intervalle des nombres flottants
-37 normalisés ; on peut montrer que :
n = log 10 be min-1
FLT_MAX_EXP
DBL_MAX_EXP Plus grand nombre n tel que FLT_RADIX soit
n-1
LDBL_MAX_EXP un nombre flottant fini représentable ; il
s’agit de emax tel qu’il est défini à la section
4.2
FLT_MAX_10_EXP
DBL_MAX_10_EXP
+37 Plus grand entier n tel que 10n soit dans
LDBL_MAX_10_EXP +37 l’intervalle des nombres flottants finis
+37 représentables ; on peut montrer que :
n = log10((1-b-p)be ) max
FLT_MAX
DBL_MAX 1e37 Plus grande valeur finie représentable ; on
LDBL_MAX 1e37 peut montrer qu’il s’agit de :
1e37 (1-b-p)be max
FLT_EPSILON
DBL_EPSILON 1e-5 Écart entre 1 et la plus petite valeur
LDBL_EPSILON 1e-9 supérieure à 1 qui soit représentable ; il
1e-9 s’agit de ce que l’on nomme généralement
l’epsilon machine dont on montre qu’il est
égal à : b1-p
FLT_MIN
DBL_MIN
1e-37 Plus petit nombre flottant positif
LDBL_MIN 1e-37 normalisé. On montre qu’il est égal à : be min-
1
1e-37
6. Déclarations des variables d’un type de base
Le tableau 3.11 récapitule les différents éléments pouvant intervenir dans la
déclaration des variables d’un type de base. Ils seront détaillés dans les sections
indiquées.
Tableau 3.11 : déclaration de variables d’un type de base
Rôle d’une – associe un spécificateur de type voir section
déclaration (éventuellement complété de qualifieurs 6.1
et d’une classe de mémorisation), à un
déclarateur ;
– dans le cas des types de base, le
déclarateur se limite au nom de la
variable.
Initialisation – aucune initialisation par défaut ; voir section
variables 6.2
classe – initialisation explicite par expressions
automatique quelconques.
(ou registre)
Initialisation – initialisation par défaut à zéro ; voir section
variables 6.2
classe – initialisation explicite par des
statique expressions constantes.
Qualifieurs – const : la variable ne peut pas voir sa voir section
(const, valeur modifiée ; 6.3
volatile)
– volatile : la valeur de la variable peut
changer, indépendamment des
instructions du programme ;
– une variable constante doit être
initialisée (il existe deux rares
exceptions – voir remarque 3 de la
section 6.3.2).
Classe de – extern : pour les redéclarations de voir
mémorisation variables globales ; chapitre 8
– auto : pour les variables locales
(superflu) ;
– static : variable rémanente ;
– register : demande de maintien dans un
registre.
6.1 Rôle d’une déclaration
6.1.1 Quelques exemples simples
Comme on s’y attend, la déclaration d’une variable d’un type de base permet de
préciser son nom et son type, par exemple :
unsigned int n ; /* n est de type unsigned int */
On peut déclarer plusieurs variables dans une seule instruction. Par exemple :
unsigned int n, p ;
est équivalente à :
unsigned int n ;
unsigned int p ;
Un même type peut être défini par des spécificateurs de type différents. Par
exemple, ces quatre instructions sont équivalentes :
short int p
signed short p
signed short int p ;
short p ;
Les tableaux 3.1, 3.5 et 3.9 fournissent les différents spécificateurs de type qu’il
est possible d’utiliser pour un type donné.
6.1.2 Les déclarations de variables en général
Tant qu’on se limite à des variables d’un type de base, les déclarations restent
relativement simples puisque, comme dans les précédents exemples, elles
associent un simple identificateur à un spécificateur de type. On peut cependant
y trouver quelques informations supplémentaires, parmi les suivantes :
• une valeur initiale de la variable, comme dans :
int n, p=5, q ; /* n, p et q sont des int; p est initialisée à 5 */
• un ou plusieurs qualifieurs (const ou volatile) associés au spécificateur de type
et qui concernent donc l’ensemble des variables de la déclaration, par
exemple :
const float x, y ; /* x et y sont des float constants */
• une classe de mémorisation associée, elle aussi, à l’ensemble des variables de
la déclaration, par exemple :
static int n, q ; /* n et q sont déclarés avec la classe de mémorisation static */
Les deux premiers points (valeur initiale et qualifieurs) sont examinés ici, dans
le seul cas cependant des variables d’un type de base. Pour les autres types, on
trouvera des compléments d’information dans les chapitres correspondants
(tableaux, pointeurs, structures, unions, énumérations). Quant à la classe de
mémorisation, dont on a dit au chapitre 1 qu’elle pouvait influer sur la classe
d’allocation des variables, elle est étudiée en détail au chapitre 8.
Remarque
D’une manière générale, les déclarations en C sont complexes et parfois peu naturelles. Ainsi, une
même instruction peut déclarer des variables de types différents, par exemple un entier, un pointeur sur
un entier et un tableau d’entiers, comme dans :
unsigned int n, *adi, t[10] ;
Pour connaître le type correspondant à un identificateur donné, on considère qu’une telle déclaration
associe un spécificateur de type (ici unsigned int) non pas simplement à des identificateurs, mais à
des déclarateurs (ici n, *adi et t[10]). Il existe trois formes de déclarateurs (tableaux, pointeurs,
fonctions) qui peuvent se composer à volonté. Chacun de ces déclarateurs sera étudié dans le chapitre
correspondant, tandis que le chapitre 16 récapitulera tout ce qui concerne les déclarations.
6.2 Initialisation lors de la déclaration
Une variable peut être initialisée lors de sa déclaration comme dans :
int n = 5 ;
Cela signifie que la valeur 5 sera placée dans l’emplacement correspondant à n,
avant le début de l’exécution de la fonction ou du bloc contenant cette
déclaration (pour les variables de classe automatique) ou avant le début de
l’exécution du programme (pour les variables de classe statique).
On notera bien qu’une variable ainsi initialisée reste une « vraie variable », c’est-
à-dire que son contenu peut tout à fait être modifié lors de l’exécution du
programme ou de la fonction correspondante.
Dans une même déclaration, on peut initialiser certaines variables et pas
d’autres :
int n=5, p, q=3 ;
L’expression utilisée pour initialiser une variable porte le nom d’initialiseur. Le
chapitre8 fait le point sur les différents initialiseurs qu’il est possible d’utiliser
pour tous les types de variables. Pour résumer ce qui concerne les variables d’un
type de base, disons qu’un initialiseur peut être :
• une expression quelconque pour les variables de classe automatique ;
• une expression constante, c’est-à-dire calculable par le compilateur, pour les
variables de classe statique ; la notion d’expression constante est étudiée en
détail à la section 14 du chapitre 4.
Le type de l’expression servant d’initialiseur n’est pas obligatoirement du type
de la variable à initialiser ; il suffit qu’il soit d’un type autorisé par affectation
(voir section 7 du chapitre 4).
Voici quelques exemples :
float x = 5 ; /* la valeur entière 5 sera convertie en float */
/* comme elle le serait dans une affectation */
int n = 8.23 ; /* la valeur flottante (environ 8,23) sera convertie */
/* en int comme elle serait dans une affectation */
/* ici, il serait plus raisonnable d'écrire : */
/* int n = 8 ; */
float x = 40.73 ;
int n = x/2.3 ; /* l'expression x/2.3 est évaluée en flottant ; */
/* son résultat est converti en entier */
6.3 Les qualifieurs const et volatile
La norme ANSI a introduit la possibilité d’ajouter dans une déclaration des
qualifieurs choisis parmi les mots-clés const et volatile. Le premier est de loin le
plus utilisé, et son rapprochement avec le second n’est qu’une pure affaire de
syntaxe. Ici, nous étudions la signification de ces qualifieurs lorsqu’ils sont
appliqués à une variable d’un type de base.
6.3.1 Le qualifieur const
Considérons la déclaration :
const int n = 5, p = 12 ;
Elle précise que n et p sont de type int et que, de plus, leur valeur ne devra pas
varier au fil de l’exécution du programme. Cependant, la norme ne précise pas
de façon exhaustive les situations que le compilateur devrait interdire. Dans la
plupart des implémentations, il rejettera alors une instruction telle que les
suivantes, dès lors qu’elles figurent dans la portée de la déclaration de n (la
fonction ou le bloc pour une variable locale, la partie du fichier source suivant sa
déclaration pour une variable globale) :
n = 6 ; /* généralement rejeté puisque n est qualifié de constant */
n++ ; /* généralement rejeté puisque n est qualifié de constant */
Cependant, quelle que soit la bonne volonté du compilateur, des modifications
indirectes de ces variables restent possibles, notamment :
• par appel d’une fonction de lecture, par exemple :
scanf ("%d", &n) ; /* toujours accepté car le compilateur n'a aucune */
/* connaissance du rôle de scanf */
• par l’utilisation d’un pointeur sur ces variables (pour peu que le qualifieur const
n’ait pas été attribué à l’objet pointé, comme on le verra au chapitre 7…).
Il n’en reste pas moins que l’usage systématique de const améliore la lisibilité des
programmes.
Remarques
1. Comme on le verra au chapitre 4, une variable déclarée d’un type qualifié par const n’est pas une
expression constante ; il s’agit là d’une lacune importante de la norme ANSI du C, à laquelle le
langage C++ a d’ailleurs remédié.
2. La norme ANSI laisse l’implémentation libre d’allouer les emplacements destinés à des objets
constants dans une mémoire protégée contre toute modification pendant l’exécution du programme.
Dans ce cas, les tentatives de modification de tels objets, lorsqu’elles ne sont pas rejetées à la
compilation, provoqueront obligatoirement une erreur d’exécution.
6.3.2 Le qualifieur volatile
Il s’emploie de la même manière que const ; il sert à préciser au compilateur
qu’une variable (ou un objet pointé) peut voir sa valeur évoluer,
indépendamment des instructions du programme. Un tel changement peut par
exemple être provoqué :
• par une interruption ;
• par un périphérique qui agit sur des emplacements particuliers de la mémoire ;
dans ce cas, volatile s’appliquera généralement à un objet pointé plutôt qu’à
une variable sauf si l’on dispose dans l’implémentation concernée d’un moyen
permettant d’imposer une adresse à une variable.
L’intérêt de ce qualifieur volatile est d’interdire au compilateur d’effectuer
certaines optimisations. Par exemple, avec :
volatile int etat ;
int n ;
…..
while (…)
{ n = etat + 1 ;
…..
}
le compilateur ne sortira jamais l’instruction n = etat + 1 de la boucle, comme il
pourrait le faire si etat n’avait pas été déclarée avec le qualifieur volatile et
qu’elle n’était pas modifiée à l’intérieur de la boucle.
Remarques
1. Les qualifieurs const ou volatile s’appliquent à toutes les variables mentionnées dans l’instruction
de déclaration : il n’est pas possible, dans une même instruction, de déclarer une variable ayant le
qualifieur const et une autre ne l’ayant pas. Cette remarque ne s’appliquera cependant pas aux
variables de type pointeur, compte tenu de la manière dont ces qualifieurs sont alors utilisés.
2. En théorie, il est possible d’utiliser conjointement les deux qualifieurs const et volatile (l’ordre
est alors indifférent) :
const volatile int n ; /* la valeur de n ne peut pas être modifiée par le */
/* programme mais elle peut l'être "de l'extérieur" */
/* c'est pourquoi elle peut ne pas être initialisée */
volatile const int p = 5 ; /* même chose mais ici, p a été initialisée */
/* lors du déroulement du programme, sa valeur */
/* pourra devenir différente de 5 */
3. Une variable ayant reçu l’attribut const doit être initialisée lors de sa déclaration, à deux exceptions
près :
• elle possède en plus le qualifieur volatile : elle pourra donc être modifiée indépendamment du
programme ; son initialisation n’est donc pas indispensable mais elle reste possible ;
• il s’agit de la redéclaration d’une variable globale (par extern) ; l’initialisation a dû être faite par
ailleurs ; qui plus est, l’initialisation est alors interdite à ce niveau.
1. Même si la norme n’impose pas formellement l’existence d’un bit de signe.
2. La norme ne dit jamais comment doit réagir le compilateur ou le programme en cas de situation
d’exception, c’est-à-dire de non-respect de la norme.
3. Aussi appelé « barre inverse » ou « contre-slash » (back-slash en anglais).
4. D’autant plus que certaines implémentations se permettent de limiter d’office (souvent à 3) le nombre de
caractères pris effectivement en compte dans la notation hexadécimale.
5. En toute rigueur, rien n’interdirait à une implémentation d’utiliser une base (par exemple, 2) pour un type
(par exemple, float) et une autre base (par exemple, 16) pour un autre type (par exemple, long double).
Cela conduirait toutefois à complexifier inutilement l’unité centrale, de sorte qu’en pratique, cette
situation ne se rencontre pas !
6. Attention à ne pas confondre la contrainte de normalisation utilisée pour coder un nombre flottant en
mémoire avec celle qu’utilise printf pour afficher un tel nombre.
7. Pour s’en convaincre, il suffit d’exprimer la valeur 0,1 dans le modèle de comportement proposé par la
norme : on s’aperçoit que la conversion en binaire (exprimée en puissances négatives de b) conduit dans
les cas usuels (b=2 ou b=16) à une mantisse m ayant un nombre infini de décimales, et donc
obligatoirement à une approximation, quel que soit le nombre de bits réservés à m.
8. Ici, il n’est pas nécessaire de considérer 3’ car, dans toute implémentation, 3’ = 3 puisque tout nombre
entier d’au plus q chiffres s’exprime exactement en flottant (voir section 4.3.2) et que la valeur de q est
toujours supérieure ou égale à 6 (voir section 5).
9. Si ce n’est au niveau du temps d’exécution.
4
Les opérateurs
et les expressions
Dans tous les langages, les notions d’opérateur et d’expression sont étroitement
liées : une expression se forme à partir de variables, de constantes et
d’opérateurs. Il en va de même en langage C, qui se trouve d’ailleurs être l’un
des plus fournis en matière d’opérateurs. Cette richesse se manifeste par
l’existence d’opérateurs spécialisés de manipulation de bits et surtout par
l’existence d’opérateurs d’affectation et d’incrémentation qui modifient
notablement la notion habituelle d’expression. De surcroît, le langage C introduit
des opérateurs dits « de référence », là où d’autres langages se contentent d’une
notation syntaxique : accès aux éléments d’un agrégat ou appel de fonction. Ce
dernier aspect reste cependant mineur, dans la mesure où il n’intervient guère
que pour régler des problèmes de priorité ou d’associativité.
Ce chapitre étudie les différents opérateurs du langage C, les règles de priorité et
d’associativité correspondantes, ainsi que les éventuelles conversions implicites
qui interviennent dans l’évaluation de leurs opérandes. Toutefois, les opérateurs
dits « de référence » trouveront tout naturellement leur place dans les chapitres
correspondants : tableaux pour [], fonctions pour (), structures pour -> et .…
Pour préserver à l’ouvrage son caractère de référence, ces opérateurs seront
malgré tout cités dans certains tableaux récapitulatifs.
Après quelques généralités concernant les notions de priorité, associativité,
pluralité et conversion implicite, nous ferons une étude systématique de ces
opérateurs, en les regroupant par catégorie : arithmétiques, relationnels,
logiques, manipulation de bits, affectation, incrémentation, cast et divers. Au
passage, nous décrirons les différentes conversions implicites numériques. Enfin,
après un tableau récapitulatif des priorités et associativité de tous les opérateurs
du langage, nous présenterons ce que l’on nomme des « expressions
constantes », c’est-à-dire des expressions calculables par le compilateur.
1. Généralités
1.1 Les particularités des opérateurs et des
expressions en C
Dans la plupart des langages, une expression se définit comme l’indication d’une
suite de calculs à effecteur et dont le résultat constitue la valeur ; par ailleurs, il
existe des instructions (affectation, écriture…) pouvant faire intervenir des
expressions. Certes, cet aspect classique se retrouve en C. Par exemple, dans
l’affectation :
y = a * x + b ;
apparaît l’expression a * x + b ; de même, dans l’instruction d’affichage :
printf ("valeur %d", n + 2*p) ;
apparaît l’expression n + 2 * p.
Mais dans la plupart des langages autres que C, l’expression se contente de
posséder une valeur ; elle ne réalise aucune action, en particulier son calcul ne
modifie la valeur d’aucun objet ; autrement dit, on n’y confond pas affectation et
expression. D’autre part, dans ces langages, l’affectation est une instruction dont
le rôle est d’affecter la valeur d’une expression à un objet ; parler de la valeur
d’une affectation n’aurait pas de sens !
En C, il en va tout autrement. D’une part, certains opérateurs peuvent non
seulement intervenir au sein d’une expression, mais également agir sur le
contenu d’objets. Par exemple, l’expression ++i réalisera une action : augmenter
la valeur de i de 1 ; en même temps, elle aura une valeur, à savoir celle de i
après incrémentation. D’autre part, l’affectation y est réalisée par des opérateurs.
La notation :
i = 5
n’est pas une instruction, mais une expression formée d’un opérateur (=) recevant
deux opérandes (i et 5).
Certes, il n’y a qu’un pas entre cette dernière expression et une instruction telle
que :
i = 5 ;
Mais cette expression peut apparaître à son tour au sein d’expressions plus
complexes comme :
k = i = 5
a = b * (i = 5)
Ces dernières formes sont généralement absentes de la plupart des autres
langages.
En outre, une expression peut ne posséder aucune valeur ; ce sera le cas de
l’appel d’une fonction ne renvoyant aucune valeur.
Remarque
La principale instruction du langage C est ce qu’on nomme « l’instruction expression », c’est-à-dire
une expression terminée par un point virgule. Toute expression peut devenir une instruction en la
faisant suivre d’un point virgule : sa valeur, si elle existe, est simplement inutilisée à ce moment-là.
Par exemple, dans :
k = i = 5 ;
on a bien deux affectations : la valeur de la première affectation (ici, 5) est utilisée pour être affectée à
son tour à k ; la valeur de la seconde (en l’occurrence, ici, toujours 5) est inutilisée.
En revanche, une expression peut être utilisée ailleurs que dans une instruction expression ; dans ce
cas sa valeur est bien utilisée.
1.2 Priorité et associativité
1.2.1 Priorités et parenthèses
Lorsque plusieurs opérateurs apparaissent dans une même expression, il est
nécessaire de savoir dans quel ordre ils sont mis en jeu. En C comme dans les
autres langages, on utilise des règles de priorité qui permettent de définir
exactement l’ordre dans lequel doivent être évalués les opérateurs. Par exemple :
a + b * c /* * est prioritaire sur + : on fera donc le produit de b par c */
/* avant d'ajouter a au résultat */
En outre, des parenthèses permettent d’outrepasser ces règles de priorité, en
forçant le calcul préalable de l’expression qu’elles contiennent. Notez que ces
parenthèses peuvent également être employées pour assurer une meilleure
lisibilité d’une expression.
Ces règles de priorité sont totalement naturelles dans le cas des opérateurs
arithmétiques ; elles rejoignent alors les règles de l’algèbre traditionnelle. Quant
aux priorités des autres opérateurs, elles ont été manifestement définies de
manière à éviter au maximum le recours aux parenthèses : ces dernières pourront
toutefois toujours être utilisées pour éviter toute ambiguïté au lecteur du
programme.
On notera que, comme en algèbre, les priorités relatives ne sont pas définies
opérateur par opérateur, mais par groupe d’opérateurs. Par exemple, * et / ont
même priorité. Le choix entre deux opérateurs de même priorité se fera suivant
la règle d’associativité évoquée ci-après.
1.2.2 Associativité
En cas de priorité identique entre deux opérateurs (ou, donc, en cas d’opérateurs
identiques), les calculs s’effectuent très souvent de gauche à droite, comme
dans :
a + b - c /* + et - ont la même priorité : + est évalué avant - */
a * b / c /* * et / ont la même priorité : * est évalué avant / */
On dit qu’on a affaire à une « associativité de gauche à droite ». Quelques rares
opérateurs possèdent une « associativité de droite à gauche » ; c’est notamment
le cas de l’opérateur d’affectation :
a = b = c /* la seconde affectation est réalisée avant la première */
1.3 Pluralité
Comme dans tous les langages, on distingue :
• les opérateurs unaires, c’est-à-dire ne portant que sur un seul opérande ;
• les opérateurs binaires, c’est-à-dire portant sur deux opérandes.
En outre, de façon assez singulière, C dispose d’un opérateur ternaire, c’est-à-
dire à trois opérandes. Il s’agit de l’opérateur conditionnel qui s’exprime avec
deux symboles disjoints servant en quelque sorte à délimiter les différents
opérandes.
On notera que, comme en algèbre, certains symboles d’opérateur sont à la fois
unaires et binaires, par exemple :
a - b /* - binaire */
- c /* - unaire */
a * b /* * binaire : produit de deux nombres */
*adr /* * unaire : déréférenciation de pointeur */
Dans un tel cas, la priorité de l’opérateur unaire est différente de celle de
l’opérateur binaire correspondant. Notez qu’il en va exactement ainsi en algèbre
dans -a - b où le moins unaire est bien évalué avant le moins binaire.
En revanche, lorsqu’un même symbole opérateur binaire dispose de plusieurs
significations, en fonction du contexte, sa priorité reste exactement la même
dans les deux cas. C’est le cas de l’opérateur somme (+), qu’il soit appliqué à
deux nombres ou à un nombre et un pointeur. Cela ne concerne en fait qu’un tout
petit nombre d’opérateurs et, au demeurant, reste conforme au bon sens.
1.4 Conversions implicites
La plupart des opérateurs imposent des contraintes sur le type de leurs
opérandes. L’exemple le plus flagrant est celui des opérateurs arithmétiques
binaires qui ne sont définis que pour deux opérandes d’un même type de base
autre que char ou short. En fait, beaucoup d’opérateurs prévoient des possibilités
de conversions implicites de leurs opérandes, ce qui permet d’en élargir les
possibilités. Ainsi, les opérandes des opérateurs arithmétiques binaires seront
soumis à la fois à ce que l’on nomme des « promotions numériques » et à des
« conversions d’ajustement de type ». Les premières leur donneront un sens avec
des opérandes d’un type char ou short ; les secondes leur donneront un sens
lorsque leurs opérandes seront de type différent. De même, les opérateurs unaires
ne sont pas définis pour les types char et short. Là encore, le compilateur saura
leur donner un sens dans ce cas en soumettant leur unique opérande à des
promotions numériques.
D’une manière générale, la plupart des opérateurs, et pas seulement les
opérateurs arithmétiques, soumettent leurs opérandes à de telles conversions,
certains se limitant à des promotions numériques (les opérateurs unaires sont
obligatoirement dans ce cas). C’est pourquoi l’étude des ces conversions
implicites fait l’objet d’un paragraphe séparé. Leur existence sera cependant
toujours mentionnée pour chacun des opérateurs concernés.
1.5 Les différentes catégories d’opérateurs
Le tableau 4.1 décrit les différentes catégories d’opérateurs qui sont étudiées ici.
Tableau 4.1 : les différentes catégories d’opérateurs étudiées dans ce
chapitre
Catégorie Description Voir
Opérateurs Ils effectuent les calculs arithmétiques Section
arithmétiques classiques. 2
Opérateurs Ils permettent de comparer des valeurs Section
relationnels numériques et fournissent un résultat de 4
type « logique », c’est-à-dire ne possédant
que deux valeurs « vrai » ou « faux »,
valeurs qui seront représentées en C par
des valeurs entières particulières 0 et 1.
Opérateurs Ils relient plusieurs expressions logiques Section
logiques par et, ou ou non. En fait, là encore, ils 5
s’appliquent non seulement à une valeur
logique (c’est-à-dire en C, entier 0 ou 1),
mais également à n’importe quelle valeur
numérique, toute valeur non nulle étant
interprétée comme « vrai ».
Opérateurs de Ils travaillent directement sur des motifs Section
manipulation de binaires et permettent d’effectuer des 6
bits combinaisons logiques bit à bit et des
décalages.
Opérateurs Ils servent à affecter à un objet, la valeur Section
d’affectation d’une expression. Ils font intervenir 7
l’importante notion de lvalue, c’est-à-dire
de référence à un objet dont on peut
modifier la valeur. S’il existe plusieurs
opérateurs d’affectation, c’est parce que le
C permet de condenser certaines écritures
faisant intervenir une affectation et une
expression arithmétique simple en ce que
nous nommons un opérateur d’affectation
élargie.
Opérateurs Ce sont des cas particuliers d’affectation Section
d’incrémentation élargie ; ils permettent d’en condenser 7
encore un peu plus l’écriture.
Opérateurs cast Ils effectuent, de façon explicite, des Section
conversions de type. 8
Opérateurs Il s’agit de l’opérateur conditionnel, de Sections
divers l’opérateur séquentiel et de l’opérateur 10, 11
sizeof. et 12
2. Les opérateurs arithmétiques
On nomme « opérateurs arithmétiques », les opérateurs permettant d’effectuer
les opérations arithmétiques classiques. A priori, ils portent sur des opérandes de
type numérique, mais l’addition et la soustraction pourront posséder un ou deux
opérandes de type pointeur (ce point sera étudié au chapitre 7).
Nous présenterons d’abord les différents opérateurs existants, leur rôle et leur
priorité en citant les éventuelles conversions implicites qu’ils peuvent mettre en
œuvre. Puis nous examinerons les situations dites « d’exception », c’est-à-dire
celles dans lesquelles le résultat d’une opération n’est plus défini.
2.1 Les différents opérateurs numériques
2.1.1 Présentation générale
Le tableau 4.2 présente, classés en trois catégories, les différents opérateurs
numériques, en précisant les contraintes portant sur les opérandes et les
conversions implicites qui peuvent leur être appliquées (elles seront étudiées à la
section 3). Les opérateurs d’une même catégorie ont même priorité et les
différentes catégories sont classées par priorité décroissante. Ils ont tous une
associativité naturelle de gauche à droite.
Tableau 4.2 : les opérateurs numériques dans un contexte numérique
Remarque
Il n’existe pas d’opérateur d’élévation à la puissance. Il est nécessaire de faire appel :
• soit à des produits successifs pour des puissances entières pas trop grandes (par exemple, on
calculera x3 comme x*x*x) ;
• soit à la fonction pow de la bibliothèque standard.
2.1.2 Cas particulier de l’opérateur /
Comme pour tous les autre opérateurs, le type du résultat fourni par l’opérateur /
est défini par le type commun (après éventuelles conversions implicites) à ses
deux opérandes. Cela paraît naturel dans le cas du quotient de deux flottants,
lequel correspond tout naturellement à une valeur approchée du quotient exact.
En revanche, dans le cas du quotient de deux entiers, on obtient l’entier
correspondant à ce qu’on nomme souvent le « quotient entier », c’est-à-dire une
valeur entière approchée du quotient exact.
Par exemple, 9%5 vaut 1 (valeur approchée du quotient exact de 9 par 5, c’est-à-
dire 1,8) ; de même, 11/4 vaut 2 (valeur approchée du quotient exact de 11 par 4,
c’est-à-dire 2,75).
Le résultat de cet opérateur n’est entièrement défini que lorsque les deux valeurs
de ces deux opérandes sont non négatives : la norme prévoit alors que le résultat
soit la valeur entière approchée par défaut du quotient exact, comme dans nos
précédents exemples. En revanche, dans les autres cas, c’est-à-dire si l’un au
moins des deux opérandes est négatif, la norme laisse une latitude à
l’implémentation, celle de choisir entre valeur entière approchée par défaut ou
valeur entière approchée par excès. Par exemple :
• -9/5 vaut, suivant l’implémentation, -1 ou -2 (valeurs approchées par excès ou
par défaut du quotient exact -1,8) ;
• -11/-5 vaut, suivant l’implémentation, 2 ou 3 (valeurs approchées par défaut ou
par excès du quotient exact 2,2).
Cependant, le choix effectué par une implémentation pour l’opérateur / doit être
compatible avec un choix analogue effectué pour l’opérateur %, comme nous
allons le voir.
2.1.3 L’opérateur %
Il ne peut porter que sur des opérandes entiers et il fournit le reste de la division
entière des deux opérandes. Là encore, quand les deux opérandes sont non
négatifs, le résultat est parfaitement défini par la norme. Par exemple, 9%5 vaut
4 (reste de la division entière de 9 par 5) tandis que 11%4 vaut 3 (reste de la
division entière de 11 par 4).
En revanche, si l’un au moins des opérandes est négatif, le résultat va, comme
celui de l’opérateur /, dépendre de l’implémentation. Cependant, il faut savoir
que, dans une implémentation donnée, les résultats des opérateurs / et % sont
cohérents, ce qui signifie que si a et b désignent deux valeur entières, on a
toujours :
a = b * (a/b) + a%b
Par exemple, dans une implémentation donnée :
• si -9/5 vaut -1, alors -9%5 vaut -4 ;
• si -9/5 vaut -2, alors -9%5 vaut 1 ;
• si -11/-5 vaut 2, alors -11%-5 vaut -1 ;
• si -11/-5 vaut 3, alors -11%-5 vaut 4.
2.1.4 Priorités relatives et associativité ; cas particulier des
opérateurs commutatifs
Comme indiqué à la section 1.2, en ce qui concerne les opérateurs arithmétiques,
les choses sont naturelles et rejoignent les habitudes de l’algèbre traditionnelle.
Voici quelques exemples dans lesquels l’expression de droite, où ont été
introduites des parenthèses superflues, montre dans quel ordre s’effectuent les
calculs (les deux expressions proposées conduisent donc aux mêmes résultats) :
a + b * c a + ( b * c )
a * b + c % d ( a * b ) + ( c % d )
- c % d ( - c ) % d
- a + c % d ( - a ) + ( c % d )
- a / - b + c ( ( - a ) / ( - b ) ) + c
- a / - ( b + c ) ( - a ) / ( - ( b + c ) )
Cependant, le C fait preuve d’une légère originalité ; en effet, lorsque deux
opérateurs de même priorité sont commutatifs, la norme n’impose pas un ordre
précis des calculs, et ce malgré la règle d’associativité. Ainsi, par exemple,
l’expression :
a + b + c
pourra, suivant les cas, être évaluée comme :
a + ( b + c )
ou comme :
( a + b ) + c
Cela reste vrai même si vous placez des parenthèses dans votre expression.
Si, mathématiquement, les expressions mentionnées sont identiques, il n’en reste
pas moins que cet ordre d’évaluation a une influence sur le résultat numérique
pour au moins deux raisons :
• la précision limitée des calculs ;
• les risques de dépassement de capacité qui, suivant les valeurs concernées,
peuvent apparaître dans une des évaluations et pas nécessairement dans l’autre.
Si vous souhaitez absolument maîtriser l’ordre des calculs, il vous est possible
d’utiliser à cet effet l’opérateur unaire +. Grâce à sa priorité élevée, il supprime
certains risques de réorganisation des calculs. Ainsi, une expression telle que1 :
a + + ( b + c )
conduira au calcul de la somme b + c, avant d’ajouter la valeur de a au résultat.
Notez que :
a + ( b + c )
n’aurait pas conduit à cette certitude concernant l’ordre des calculs.
2.2 Comportement en cas d’exception
Lors de la détermination du résultat d’un opérateur arithmétique, on peut se
trouver devant ce que l’on nomme une situation d’exception, c’est-à-dire l’une
des trois possibilités suivantes :
• dépassement de capacité : résultat de calcul trop grand (en valeur absolue) pour
la capacité du type utilisé ou encore résultat négatif dans le cas d’entiers non
signés ;
• sous-dépassement de capacité : résultat de calcul trop petit (en valeur absolue)
pour la capacité du type utilisé ; cette situation ne peut se produire que pour les
types flottants ;
• tentative de division par zéro.
Comme mentionné à la section 2.1, les opérateurs arithmétiques binaires ne sont
définis que pour deux opérandes de même type, quitte à aboutir à une telle
situation par la mise en place de conversions implicites. Dans ces conditions, un
dépassement de capacité se produit dès qu’une opération possède un résultat
théorique dépassant la capacité du type commun aux deux opérandes, même si,
finalement, il existe un type plus large pouvant l’accueillir. Par exemple, la
somme de deux int pourra conduire à un dépassement de capacité, même si le
résultat peut être contenu dans un long. La même remarque s’applique au cas des
sous-dépassements de capacité. Par exemple, la somme de deux float pourra
conduire à un sous-dépassement de capacité, même si le résultat est
représentable en double ou en long double.
La norme ANSI se contente de dire que, dans ces situations, le comportement du
programme est indéterminé, excepté, comme nous le verrons, pour certaines
opérations sur des entiers non signés. En théorie, on peut donc aboutir :
• à un résultat faux ;
• à une valeur particulière servant conventionnellement à indiquer qu’un résultat
n’est plus un nombre, ou encore qu’il est infini ; c’est ce qui se produit pour
les flottants dans les implémentations qui respectent les conventions dites
« IEEE »2 ;
• à un arrêt du programme, accompagné (peut-être) d’un diagnostic d’erreur ;
• à l’exécution d’un traitement particulier fourni par le programme.
En pratique cependant, pour un type d’exception donné, bon nombre
d’environnements ont tendance à adopter des comportements semblables. Nous
examinons ici les comportements les plus répandus, en distinguant les types
entiers des types flottants ; rappelons que le cas des caractères n’a pas à être
considéré compte tenu des conversions implicites en entier qui seront mises en
place (voir section 3.3).
2.2.1 Calculs sur des entiers
Dépassement de capacité sur des entiers signés
Ce dépassement ne peut apparaître qu’en cas d’addition, de soustraction ou de
multiplication3. Le comportement du programme n’est pas imposé par la norme.
Dans la plupart des implémentations, aucun test de dépassement n’est effectué4
et, généralement, on perd les bits les plus significatifs du résultat, ce qui conduit
à un phénomène de « modulo » assez connu. D’ailleurs, bien que non portable,
cette démarche paraît naturelle à bon nombre de programmeurs et elle est même
exploitée par certains algorithmes de génération de nombres aléatoires.
Exemple
Voici un exemple obtenu dans la plupart des implémentations qui codent les
entiers sur 16 bits, suivant la représentation en complément à deux et où le
dépassement de capacité n’est pas détecté :
Exemple (théoriquement non portable) de dépassement de capacité sur des
entiers signés
#include <stdio.h>
int main()
{
int n = 32000, p =32000, q ; -1536
q = n + p;
printf ("%d", q) ;
}
Remarque
Ne confondez pas cette situation de dépassement de capacité apparaissant pendant le calcul d’une
expression, avec la tentative d’affecter à une variable une valeur non représentable dans son type (voir
section 9), même si, souvent, le comportement de l’implémentation est le même.
Dépassement de capacité sur des entiers non signés
Ce dépassement peut naturellement apparaître en cas d’addition ou de
multiplication conduisant à un résultat trop grand pour le type imparti. Mais un
problème semblable se pose dès que le premier opérande de l’opérateur - est
inférieur au second. Par simplicité, nous parlerons encore de dépassement de
capacité dans ce cas5. Contrairement à ce qui se passe pour des entiers signés, la
norme prévoit toujours le résultat d’une telle opération : il s’agit de la valeur
congrue modulo N+1 (N étant le plus grand nombre représentable) au résultat
théorique (négatif) de l’opération6.
Exemple
Voici un exemple obtenu dans une implémentation où le type unsigned int est
représenté sur 16 bits. Il est cette fois portable (en théorie, comme en pratique)
dans toutes les implémentations où le type unsigned int possède cette taille.
Exemple (portable) de dépassement de capacité sur des entiers non signés (16
bits)
#include <stdio.h>
int main()
{
unsigned int n = 64000, p = 64000, q ; 62464
q = n + p ;
printf (""%u, q) ;
}
Remarque
Même si cela n’est pas raisonnable, la norme n’interdit pas d’appliquer l’opérateur unaire - à un
nombre non signé. Dans ce cas, sauf lorsque le nombre est nul, on aboutit aussi à un dépassement de
capacité et le résultat reste défini par le même algorithme que précédemment.
Division par zéro
Le comportement du programme n’est pas imposé par la norme. En pratique, il
est rare qu’une implémentation se contente de fournir un résultat faux. En
général, on aboutit à un arrêt du programme, avec un diagnostic d’erreur. On
notera que, dans le cas de la division entière par 0, il n’existe pas de conventions
IEEE analogues à celles qui sont prévues pour les flottants.
2.2.2 Calculs sur des flottants
Dépassement de capacité
Une telle situation peut apparaître avec n’importe laquelle des opérations
arithmétiques. Là encore, le comportement du programme n’est pas imposé
par la norme. En pratique, il est rare qu’une implémentation fournisse un
résultat faux ; on rencontre l’une des deux situations suivantes :
• arrêt de l’exécution du programme, accompagné d’un message d’erreur ;
• utilisation des conventions dites IEEE consistant, ici, à utiliser des valeurs
conventionnelles pour représenter un résultat infini. Cette démarche présente
plusieurs avantages : le programme ne s’interrompt pas ; le résultat peut être
affiché par printf (on obtient généralement quelque chose ressemblant au
libellé INF ou -INF) ; le résultat peut, à son tour, intervenir dans des expressions
arithmétiques suivant des règles mathématiquement logiques. Cependant, ces
conventions ont des limites, dans la mesure où, par exemple, il est impossible
de donner une signification à +INF/+INF ; il existe d’ailleurs une notation
appropriée à ce cas : NaN (Not A Number).
Exemple
Voici un exemple exécuté dans deux environnements différents, disposant tous
les deux d’un type float ayant une capacité de l’ordre de 1038 :
Exemple de dépassement de capacité dans une multiplication flottante
#include <stdio.h>
int main() /* exécution premier environnement */
{ inf
float x ;
float y ;
x = 1e30 ; /* exécution second environnement */
y = x * x ; Floating point error : Overflow
printf ("%e", ; Abnormal program termination
}
Notez bien que c’est l’évaluation de l’expression x*x (dont le résultat est de type
float) qui a provoqué le dépassement de capacité. Si y était de type double, ce
dépassement de capacité aurait bien lieu (avec toutefois quelques exceptions,
comme indiqué dans la remarque 2 suivante).
Remarques
1. Ne confondez pas les dépassements de capacité qui apparaissent pendant le calcul d’une expression
avec la tentative d’affecter à une variable une valeur non représentable dans son type, même si,
dans certaines implémentations, les messages correspondants sont les mêmes (voir section 9).
2. En toute rigueur, la norme n’interdit pas à une implémentation d’effectuer certains calculs avec plus
de précision que nécessaire. Ainsi, dans certains cas, l’évaluation de l’expression x*x du précédent
exemple fournira un résultat de type double (et non de type float) ; c’est alors l’affectation de ce
résultat à y qui provoquera une situation d’exception. Parfois, le message sera le même qu’en cas de
dépassement de capacité, parfois il sera différent (on peut obtenir quelque chose comme « erreur de
domaine »). Qui plus est, si y est de type double, l’affectation pourra fonctionner dans certaines
implémentations et fournir un résultat correct (1.e60), contre toute attente. Cependant, on ne peut
cependant pas dire que le programme ne respecte alors pas la norme, sous prétexte qu’il fournit un
résultat juste, là où la norme dit simplement que son comportement est indéterminé !
Sous-dépassement de capacité
Une telle situation peut apparaître avec n’importe laquelle des opérations
arithmétiques. Là encore, le comportement du programme n’est pas imposé par
la norme. En pratique, on rencontre l’une des situations suivantes :
• résultat égal à 0 (n’oubliez pas que la valeur 0 est toujours représentable, de
façon exacte, en flottant) ;
• arrêt de l’exécution, accompagné d’un message d’erreur.
Comme on l’a dit dans le paragraphe précédent à propos du dépassement de
capacité, ce sous-dépassement peut se produire dans une situation telle que :
float x=1E-30 ; /* on suppose le type float limité à 1E-38 */
double y ;
y = x * x ; /* x * x est trop petit pour le type float */
Remarque
La remarque 2 précédente s’applique encore ici. Dans certaines implémentations, l’évaluation de
l’expression x*x fournira un résultat de type double (et non de type float) et l’affectation à y, de type
double, pourra fonctionner et fournir un résultat correct (1.e-60), contre toute attente.
Division par zéro
Le comportement du programme n’est pas imposé par la norme. En pratique, il
est rare qu’une implémentation se contente de fournir un résultat faux. En
général, on aboutit à l’une des situations suivantes :
• arrêt de l’exécution du programme, accompagné d’un message d’erreur ;
• utilisation des conventions IEEE consistant, ici, à utiliser des valeurs
conventionnelles pour représenter un résultat infini (dans le cas où le
numérateur n’est pas nul) ou un résultat non défini (dans le cas où le
numérateur est nul). Comme signalé précédemment à propos du dépassement
de capacité, cette démarche possède plusieurs avantages : le programme ne
s’interrompt pas ; le résultat peut être affiché par printf (ce qui conduit
généralement au libellé INF ou -INF dans le premier cas, au libellé NaN dans le
second). Le résultat peut, à son tour, intervenir dans des expressions
arithmétiques suivant des règles mathématiquement logiques.
2.2.3 Tableau récapitulatif
Le tableau 4.3 récapitule les comportements des différents opérateurs
arithmétiques, en cas d’exception. Il précise, lorsqu’il existe, le comportement
prévu par la norme et, dans le cas contraire, celui ou ceux que l’on rencontre en
pratique. Notez qu’il se limite au contexte numérique : le cas des opérateurs + et
- utilisés dans un contexte pointeur est étudié dans le chapitre relatif aux
pointeurs.
Tableau 4.3 : comportement des opérateurs arithmétiques, en cas
d’exception
3. Les conversions numériques implicites
3.1 Introduction
Aucun opérateur n’accepte d’opérandes de type char ou short. En outre, la plupart
des opérateurs binaires, en particulier les opérateurs arithmétiques étudiés dans
la section 2, ne sont définis que pour des opérandes de même type (autre que char
ou short). Cependant, le langage C leur donne une signification dans tous les cas,
en prévoyant la mise en place par le compilateur de conversions implicites.
Celles-ci sont généralement « intègres », c’est-à-dire qu’elles modifient peu la
valeur d’origine (avec, cependant, une exception dans le cas déconseillé de
mélange d’attribut de signes).
Ces conversions se classent en deux catégories :
• les conversions numériques d’ajustement de type ;
• les promotions numériques.
Les conversions numériques d’ajustement de type sont destinées à donner une
signification à un opérateur binaire lorsque ses deux opérandes sont de types
différents. Par exemple, avec :
int n ;
long q ;
l’expression n + p sera évaluée en convertissant n en long et le résultat sera de type
long.
Les promotions numériques (en anglais integral promotions7) sont destinées à
donner un sens à un opérateur (cette fois, unaire ou binaire) lorsqu’il porte sur un
ou des opérandes de type char ou short. Par exemple, avec :
short p1, p2 ;
une expression telle que p1+ p2 sera évaluée en convertissant les valeurs de p1 et
de p2 dans le type int et l’on aboutira à un résultat de type int.
Ces deux sortes de conversions pourront, naturellement, être mêlées : un même
opérande pourra être soumis à la fois à une promotion numérique et à une
conversion numérique d’ajustement de type.
D’autre part, ces conversions pourront intervenir à différentes reprises dans des
expressions comportant plusieurs opérateurs.
Ce paragraphe traite des conversions implicites les plus importantes que sont les
conversions numériques implicites. Il existe quelques autres conversions
implicites non numériques concernant les pointeurs (voir chapitre 7), les appels
de fonctions (voir chapitre 8) et la conversion d’un nom de tableau en un
pointeur (voir section 3.2 du chapitre 7)8. Par ailleurs, toutes ces conversions
implicites s’opposent aux conversions explicites, que ce soit par l’opérateur de
cast (voir section 8) ou par affectation (voir section 7). Comme nous le verrons,
contrairement aux conversions implicites, ces autres sortes de conversions ne
seront plus nécessairement intègres, c’est-à-dire qu’elles pourront modifier
notablement la valeur d’origine.
Enfin, signalons que, dans ce paragraphe, nous utilisons des exemples ne faisant
intervenir que des opérateurs arithmétiques. Il n’en reste pas moins que ces
conversions numériques implicites pourront également intervenir pour d’autres
opérateurs disposant d’un ou plusieurs opérandes numériques ; dans certains cas,
d’ailleurs, seule interviendra la promotion numérique. D’une manière générale,
lors de la présentation de chacun des opérateurs, nous préciserons, le cas
échéant, à quelles conversions peuvent être soumis ses opérandes (comme nous
l’avons déjà fait dans le tableau récapitulatif concernant les opérateurs
arithmétiques).
Remarque
On commet généralement un abus de langage en se contentant de dire, par exemple, que le
compilateur met en place telle ou telle conversion. En effet, pour être tout à fait précis, il faudrait dire
que le compilateur met en place des instructions appropriées qui, lors de leur exécution, provoqueront
telle ou telle conversion. Le compilateur ne peut procéder directement à la conversion d’une valeur
située dans une variable, valeur qui, par essence, est susceptible de varier lors de l’exécution.
On peut dire que le compilateur décide des conversions à mettre en œuvre, mais que leur déroulement
se fait pendant l’exécution du programme. Cela montre, au passage, que les conversions occupent de
la place dans le code et qu’elles prennent du temps de calcul ; éviter une conversion inutile permet de
gagner sur les deux tableaux…
3.2 Les conversions numériques d’ajustement de type
Ce sont donc des conversions que le compilateur met en place pour permettre à
un opérateur binaire de fonctionner lorsque ses deux opérandes sont de types
différents. D’une manière générale, l’idée consiste à effectuer des conversions
suivant une certaine hiérarchie préservant la valeur d’origine. C’est bien ce qui
se passe lorsque aucun opérande entier n’est non signé. En revanche, si ce n’est
pas le cas, les choses sont un peu moins satisfaisantes ; plus précisément, le cas
où aucun entier n’est signé a été traité de façon à préserver un motif binaire,
plutôt qu’une valeur. Quant au cas de mélange d’attributs de signe, on verra que
les conversions mises en œuvre sont relativement peu naturelles. Nous
étudierons donc séparément ces trois situations, résumées dans le tableau 4.4 :
• les opérandes entiers sont tous signés (il peut y avoir un opérande flottant) ; il
s’agit de la situation la plus usuelle ;
• les opérandes entiers sont tous non signés (il peut y avoir un opérande flottant,
mais en pratique ce sera rarement le cas) ;
• il y a un opérande entier signé et un opérande entier non signé (il n’y a donc
plus d’opérande flottant).
Tableau 4.4 : conversions d’ajustement de type
3.2.1 Cas d’opérandes entiers tous signés
Lorsqu’aucun opérande n’est de type entier non signé, les conversions
d’ajustement de type se font suivant la hiérarchie :
int → long → float → double → long double
Plus précisément, on peut convertir un type de cette liste en n’importe quel autre
type situé plus à droite dans la liste. Les conversions correspondantes sont
intègres ou non dégradantes, c’est-à-dire qu’elles sont censées ne pas dénaturer
la valeur d’origine. Cependant, il n’est pas certain qu’une conversion d’entier
(int ou long) vers flottant conserve exactement la valeur d’origine. Par exemple,
dans une implémentation où le type long est codé sur 32 bits et où le type float est
lui aussi codé sur 32 bits, il est certain que les grandes valeurs entières seront,
après conversion en float, légèrement erronées. On peut cependant considérer
que l’intégrité des données est vérifiée, dans la mesure où la valeur convertie est
bien celle d’origine, à la précision près inhérente au type float…
Dans le cas où les deux opérandes sont entiers, on peut montrer que, dans des
implémentations utilisant la représentation en complément à deux, ces
conversions conservent le motif binaire, à l’exception, bien entendu, d’éventuels
bits supplémentaires (à 0 ou à 1) apparaissant du côté des bits de poids forts.
Exemple
Avec :
int n = 12 ;
long p = 50000 ;
l’expression n+p sera évaluée selon ce schéma :
n + p
| |
long | conversion de n en long
| |
|__ + __| addition a p
|
long le resultat est de type long ; il vaut 50012
De même, avec :
int n = 5 ;
double x = 3.25 ;
l’expression n + x sera évaluée selon ce schéma :
n + x
| |
double | conversion de n en double
| |
|__ + __| addition a x
|
double le resultat est de type double : il vaut environ 8,25
3.2.2 Cas d’opérandes entiers tous non signés
Là encore, même si les choses sont moins usuelles, elles restent relativement
naturelles ; les conversions sont cette fois mises en place suivant la hiérarchie :
unsigned int → unsigned long → float → double → long double
Les conversions d’un entier non signé en un flottant (par nature toujours signé)
ne posent aucun problème particulier, hormis celui de la précision du résultat de
la conversion (déjà évoqué à la section 3.2.1 à propos des entiers signés).
Dans le cas où les deux opérandes sont des entiers non signés, on peut montrer
que, quelle que soit l’implémentation9, ces conversions présentent la particularité
de conserver le motif binaire, à l’exception d’éventuels bits à zéro
supplémentaires apparaissant du côté des poids forts. Ce sera d’ailleurs souvent
cette propriété qui justifiera le recours à l’utilisation d’entiers non signés. Elle
s’avérera particulièrement intéressante avec les manipulations de champs de bits.
Exemple
Avec :
unsigned int n = 12 ;
unsigned long p = 50000 ;
l’expression n+p sera évaluée selon ce shéma :
n + p
| |
unsigned long | conversion de n en unsigned long
| |
|____ + ____| addition a p
|
unsigned long le resultat est de type unsigned long ; il vaut 50012
De même, avec :
unsigned int n = 5 ;
double x = 3.25 ;
l’expression n + x sera évaluée selon ce schéma :
n + x
| |
double | conversion de n en double
| |
|__ + __| addition a x
|
double le resultat est de type double ; il vaut environ 8,25
3.2.3 Cas d’un opérande entier signé et d’un opérande entier non
signé
Rappelons que ces situations mixtes sont fortement déconseillées. En effet,
contre toute attente, la plupart des conversions prévues alors par la norme se font
de signé vers non signé. Cela signifie qu’une valeur entière signée telle que -7
devra, au bout du compte, être transformée en une valeur sans signe !
En fait, l’examen attentif des règles de conversion (au demeurant, non triviales !)
vous montrera que ce choix privilégie la conservation d’un motif binaire plutôt
que l’intégrité de la valeur ; cette dernière sera cependant respectée pour toutes
les valeurs positives ou nulles. Dans un seul but d’exhaustivité, nous allons
examiner en détail ces diverses conversions. Nous procéderons en deux étapes :
présentation des types concernés par ces conversions, algorithmes utilisés pour
définir la valeur résultante.
Les types concernés par les conversions « mixtes »
Il existe quatre situations faisant intervenir un entier signé et un entier non
signé :
Tableau 4.5 : les conversions « mixtes »
Types
Conversions prévues
opérandes
int et unsigned Conversion de l’opérande de type int dans le type
int unsigned int
long et unsigned Conversion de l’opérande de type long dans le type
long unsigned long
int et unsigned Conversion de l’opérande de type int dans le type
long unsigned long
unsigned int et Les conversions vont dépendre de l’implémentation : si
long
le type long est de taille supérieure au type int, on est sûr
qu’une valeur de type unsigned int est toujours
représentable dans le type long et l’opérande de type
unsigned int est converti en long ; le résultat est de type
long. Si, en revanche, le type long et le type int sont de
même taille, cela signifie qu’un entier non signé n’est
pas toujours représentable dans le type long ; les deux
opérandes sont convertis en unsigned long ; le résultat est
de type unsigned long. On notera bien
qu’exceptionnellement, dans ce deuxième cas, le type
du résultat ne correspond à aucun des types des deux
opérandes.
L’algorithme de conversion d’un type signé vers un type non signé
Les quatre situations citées ci-dessus correspondent en fait à cinq conversions
possibles, dont quatre se font d’un type signé vers un type non signé. La
dernière, de unsigned int en unsigned long, fait partie des conversions étudiées à la
section précédente 3.2.2, lesquelles préservent à la fois la valeur et le motif
binaire (aux bits excédentaires près).
En ce qui concerne les conversions d’un type signé en un type non signé, la
norme considère que toutes les conversions sont légales10. Dans le cas des
conversions implicites étudiées ici, il s’agit obligatoirement de conversions vers
un type d’une taille au moins égale à celle du type d’origine : si la valeur initiale
est positive ou nulle, elle est donc conservée par la conversion, ainsi d’ailleurs
que le motif binaire. En revanche, si la valeur initiale est négative, donc
manifestement non représentable dans le type d’arrivée, la conversion se fait en
ajoutant à la valeur initiale la valeur N+1, N correspondant à plus grande valeur
représentable dans le type d’arrivée.
Par exemple, dans une implémentation représentant le type int sur 16 bits, la
conversion d’un int de valeur -3 en unsigned int conduira à la valeur 65 533 (N =
65 535).
D’une manière générale, dans les implémentations, fort répandues, utilisant la
représentation en complément à deux, cette démarche revient à conserver le
motif binaire, aux bits excédentaires près (qui peuvent apparaître du côté des
poids forts lorsqu’il s’agit d’une conversion vers un type de plus grande taille).
À titre indicatif, voici un exemple de programme complet, exécuté dans une telle
implémentation (type int sur 16 bits), qui montre le caractère relativement fictif
de cette conversion signed → unsigned :
Le caractère fictif de la conversion signed → unsigned (ici, type int sur 16 bits,
en complément à deux)
#include <stdio.h>
int main()
{ int n = - 3000 ;
unsigned int p = 50000, q ; 47000
q = p + n ;
printf ("%u", q) ;
}
Remarques
1. L’algorithme de conversion signed → unsigned a été exposé ici dans le cas des conversions
implicites qui se font sans diminution de la taille. En fait, il s’agit d’un cas particulier de toutes les
conversions possibles, y compris les conversions explicites dans lesquelles il peut y avoir alors
diminution de la taille.
2. La conversion d’un type non signé vers un type signé de même taille n’est jamais mise en place de
façon implicite. En revanche, elle pourra l’être par affectation ou par l’opérateur de cast.
3.3 Les promotions numériques
Ce sont donc des conversions que le compilateur met en place de manière à
permettre à certains opérateurs de s’appliquer à des opérandes de type char ou
short. L’idée générale consiste à chercher à effectuer une conversion dans un type
de plus grande taille, tout en préservant la valeur. Là encore, les choses seront
naturelles et transparentes pour les types signés (signed char et signed short)
puisqu’on se contentera tout simplement d’une conversion dans le type int. Elles
le resteront encore pour le type unsigned char qui sera, lui aussi, converti en int. En
revanche, le cas du type unsigned short sera plus délicat car la norme a prévu deux
possibilités suivant qu’il possède ou non la même taille que le type int.
Nous examinerons donc séparément les trois situations suivantes, résumées dans
le tableau 4.6 :
• type short (équivalent de signed short) ;
• type caractère (char, signed char ou unsigned char) ;
• type unsigned short.
Tableau 4.6 : les promotions numériques
3.3.1 Cas du type short
La promotion numérique d’un opérande de type short consiste à le convertir dans
le type int. Une telle conversion est réalisable dans tous les cas en conservant la
valeur d’origine. Par exemple, avec ces déclarations :
short p = -4 ;
int n = 15 ;
l’expression p + n sera évaluée suivant ce schéma :
p + n
| |
int | promotion numerique short -> int de p ; on obtient -4
|_______+________| addition de -4 et 15 en int
|
int le resultat, de type int, vaut 11
3.3.2 Cas du type char (signé ou non)
La promotion numérique d’un opérande de type signed char, unsigned char ou char
(qui, selon l’implémentation, correspond à signed char ou unsigned char) consiste à
le convertir dans le type int ; on notera bien qu’on aboutit toujours au type int et
jamais au type unsigned int.
Une telle conversion de char en int possède des propriétés plus ou moins
satisfaisantes suivant qu’on utilise le type char pour représenter des entiers de
petite taille ou de véritables caractères.
Utilisation du type char pour représenter des entiers de petite taille
La conversion évoquée reste alors relativement naturelle :
• dans le cas de caractères signés, on obtient un nombre qui peut être positif,
négatif ou nul ; dans le cas presque universel de caractères codés sur 8 bits et
d’utilisation de la représentation en complément à deux, ce résultat est compris
entre -127 et 128 ;
• dans le cas de caractères non signés, on obtient un nombre toujours positif ou
nul ; dans le cas de caractères codés sur 8 bits, il est compris entre 0 et 255.
Malgré tout, on aura quand même intérêt à utiliser le type signed char, pour les
mêmes raisons qu’il est conseillé d’utiliser des entiers signés ; on évitera ainsi
les problèmes inhérents aux mélanges d’attribut de signe d’une même
expression.
Utilisation naturelle du type char
Si l’on utilise les types char pour représenter des caractères, une telle conversion
en int peut surprendre, mais il faut cependant remarquer que :
• la conversion n’apparaîtra que lorsqu’on cherchera à effectuer des calculs
comme dans l’expression c1 + 1 (si c1 est de type caractère) ; elle n’apparaîtra
pas dans une affectation telle que c1 = c2 (c2 étant également de type
caractère)11 ;
• le résultat d’une telle conversion est toujours positif pour les caractères
appartenant au jeu minimal d’exécution (présenté à la section 1 du chapitre 2) ;
rien d’autre ne peut être assuré pour les caractères dépendant de
l’implémentation. Cela a manifestement une incidence sur les comparaisons de
caractères ; en effet, dans le cas fréquent de la représentation en complément à
deux, tout caractère codé avec un premier bit à un apparaîtra comme négatif,
alors que les autres apparaîtront comme positifs ;
• quel que soit le code utilisé pour représenter les caractères du jeu d’exécution,
la norme impose que les codes des caractères représentant les chiffres 0 à 9
possèdent des codes consécutifs. Ainsi, avec :
char c1 = ‘2', c2 = ‘5' ;
• l’expression c2 - c1 vaudra toujours 3. En revanche, aucune autre contrainte de
ce type n’est imposée, en particulier pour les lettres de l’alphabet ! En pratique
cependant, tous les codes gardent l’ordre alphabétique pour les majuscules
d’une part, pour les minuscules (sans accent) d’autre part, même si l’on n’est
pas toujours assuré que l’écart entre les codes de deux lettres consécutives soit
égal à 1.
Exemple 1
L’expression c + 1 (c1 étant d’un type char) est évaluée ainsi :
c + 1
| |
int | promotion numerique char -> int
|___ + ___|
|
int le resultat est de type int
Voici deux exemples de programmes utilisant une telle expression, l’un dans
lequel c est signé, l’autre dans lequel il ne l’est pas ; l’implémentation code les
caractères sur 8 bits et utilise la représentation en complément à deux.
Exemple de promotion numérique signed char → int
#include <stdio.h>
int main()
{
int n ;
signed char c ;
c = ‘\xfe' ; /* ici, l'attribut de signe par défaut du type char */
/* n'intervient pas en pratique - voir section 2.4 du chapitre
3 */
n = c + 1 ;
printf ("%d", n) ;
}
-1
Exemple de promotion numérique unsigned char → int
#include <stdio.h>
int main()
{
int n;
unsigned char c ;
c = ‘\xfe' ; /* ici, l'attribut de signe par défaut du type char */
/* n'intervient pas en pratique */
n = c + 1 ;
printf ("%d", n) ;
}
255
Remarques
1. L’attribut de signe par défaut du type char peut, en théorie, influer sur la valeur de la constante
entière ‘\xfe' et donc sur le motif binaire obtenu dans c ; mais, en pratique, ce n’est pas le cas
(voir section 2.4 du chapitre 3). En revanche, si l’on écrivait directement :
n = ‘\xfe' + 1
la valeur affectée à n dépendrait de cet attribut. Par exemple, dans une implémentation utilisant la
représentation du complément à deux, on obtiendra la valeur -1 si char est signé par défaut et la
valeur 255 si char ne l’est pas.
2. Ici, nous avons affecté l’expression c+1, de type int, à une variable de type int. Mais il sera
fréquent de rencontrer des affectations à une variable de type caractère, par exemple :
c1 = c + 1 ;
c = c + 1 ; /* ou c++ */
Elles feront intervenir l’affectation d’un int à une variable de type char et, par suite, une
conversion de int en char. On peut alors montrer (voir section 9.5) que, si c est signé, tant que la
valeur de c+1 ne dépasse pas la capacité du type char, tout se passe comme si l’on avait incrémenté
de 1 le petit entier contenu dans c1.
Exemple 2
Voici un exemple exécuté dans une implémentation fort classique utilisant un
code ASCII francisé, dans lequel le caractère é possède un code supérieur à 127
et exécuté, dans une implémentation où le type char est, par défaut, signé :
Quand certains caractères (nationaux) paraissent mal ordonnés
#include <stdio.h>
int main()
{ char c1 = ‘a', c2 = ‘é' ;
if (c1 < c2) printf ("%c arrive avant %c", c1, c2) ;
else printf ("%c arrive apres %c", c1, c2) ;
}
a arrive apres é
Dans une implémentation utilisant le même codage des caractères, mais dans
laquelle le type char serait, par défaut, non signé, on aboutirait au résultat
inverse : a arrive avant é. Cette dépendance de l’attribut de signe du type char
disparaîtrait si l’on imposait le même attribut de signe (en général, non signé)
aux variables c1 et c2. Certes, une ambiguïté subsisterait encore dans une
comparaison telle que c1<'é' ; on pourrait cependant la lever en la remplaçant par
c1 < (unsigned char) ‘é'.
3.3.3 Cas du type unsigned short
La promotion numérique du type short en int ne pose pas de problème particulier
puisque toute valeur de type short peut s’exprimer dans le type int. En revanche,
le cas du type unsigned short est moins simple ; en effet, dans les implémentations
où les types short et int ont la même taille, les grandes valeurs du type unsigned
13
short ne peuvent pas s’exprimer dans le type int .
Dans ces conditions, la norme prévoit que la promotion numérique du type
unsigned short se fasse :
• dans le type int si le type int peut recevoir toutes les valeurs du type unsigned
short ;
• dans le type unsigned int dans le cas contraire.
Par exemple, avec ces déclarations :
int n ;
unsigned short q ;
l’expression n + q sera, suivant l’implémentation concernée, évaluée d’après l’un
de ces deux schémas :
n + q
| |
| int promotion numerique de unsigned short en int
|________+________|
|
int le resultat est de type int
n + q
| |
| unsigned int promotion num de unsigned short en unsigned int
unsigned int | puis conv ajust de type de int en unsigned int
|________+________|
|
unsigned int le resultat est de type unsigned int
Dans le premier cas, seule une promotion numérique intervient, alors que dans le
second cas, on trouve en plus une conversion d’ajustement de type.
Si les choses sont satisfaisantes sur le plan de la préservation de la valeur
d’origine, on constate qu’elles introduisent manifestement des aspects non
portables. En effet, le type du résultat peut être, suivant l’implémentation, signé
ou non, ce qui signifie qu’on ne maîtrise plus nécessairement le mélange
d’attributs de signe au sein d’une même expression.
Ce point peut avoir des conséquences dans une simple instruction telle que :
printf ("…", n+q) ;
puisque l’expression n+q (entière) peut être signée ou non, de sorte que le code de
format à utiliser est, selon l’implémentation, tantôt %d, tantôt %u. Il en va de même
dans les opérations de manipulation de bits dont nous parlerons à la section 6.
Remarque
On peut montrer que, quelle que soit l’implémentation, la promotion numérique d’un unsigned short
(en unsigned int ou en int) préserve toujours le motif binaire, à l’exception d’éventuels bits
supplémentaires à zéro. D’une manière générale, nous conseillons fortement de réserver le type
unsigned short aux situations où il est absolument impossible de s’en passer et, dans tous les cas,
d’éviter d’effectuer des calculs arithmétiques avec des opérandes de ce type.
3.4 Combinaisons de conversions
Jusqu’ici, nous n’avons considéré qu’un seul opérateur à la fois et chacun de ses
opérandes était soumis soit aux conversions d’ajustement de type, soit aux
promotions numériques14. D’une manière générale, ces deux sortes de
conversions peuvent être mêlées et une expression peut comporter plusieurs
opérateurs.
3.4.1 Combinaisons de conversions au niveau d’un opérateur
Les promotions numériques et les conversions d’ajustement de type peuvent être
combinées au niveau d’un même opérateur binaire. Dans ce cas, cependant, la
norme prévoit le regroupement de certaines conversions. Considérons, par
exemple :
short p ;
long q ;
A priori, on pourrait penser que l’expression p + q se trouve évaluée selon le
schéma ci-après :
p + q
| |
int | promotion numerique de short en int
| |
long | puis conversion d'ajustement de type d'int en long
|_____+_____|
|
long
En fait, la norme prévoit que la conversion de short en long se fasse directement,
sans passer par l’intermédiaire de la conversion en long, suivant ce schéma :
p + q
| |
long | conversion directe de short en long
|_____+_____|
|
long
On notera que cette démarche ne change rien au résultat ; il ne s’agit en fait que
de regrouper deux conversions consécutives en une seule.
3.4.2 Conversions d’ajustement de type avec plusieurs opérateurs
Lorsque plusieurs opérateurs apparaissent dans une expression, le choix des
conversions d’ajustement de type à mettre en œuvre est effectué en considérant
un à un les opérateurs concernés et non pas l’expression de façon globale. Par
exemple, avec ces déclarations :
int n ;
long p ;
float x ;
l’expression n * p + x sera évaluée non pas en convertissant d’abord n et p en
float, mais bel et bien selon ce schéma :
n * p + x
| | |
long | | conversion de n en long
| | |
|__ * __| | multiplication par p
| |
long | le resultat de * est de type long
| |
float | il est converti en float
| |
|____ + ____| pour etre additionne a x
|
float ce qui fournit un resultat de type float
3.4.3 Combinaisons de conversions au niveau de plusieurs
opérateurs
Les promotions numériques et les conversions d’ajustement de type peuvent être
combinées au sein d’une expression numérique. Par exemple, avec ces
déclarations :
short p1=5, p2=-2 ;
float x = 5.25 :
l’expression p1 * p2 + x est évaluée comme l’indique ce schéma :
p1 * p2 + x
| | |
int int | promotions numeriques short -> int
|____ * ____| | multiplication dans type int
| |
int |
| |
float | conversion d'ajustement de type int->float
|________+________| addition dans le type float
|
float resultat de type float (environ -4,75)
On notera bien qu’ici aucun regroupement de conversions n’est possible,
contrairement à ce qui se produisait avec l’exemple de la section 3.4.1.
3.4.4 Règles générales de conversions numériques implicites
Voici, à titre indicatif, la manière dont la norme exprime la mise en place des
conversions numériques implicites, dans le cas d’un opérateur possédant au
moins deux opérandes15 et lorsque ceux-ci sont soumis aux conversions
implicites numériques : promotions numériques et conversions d’ajustement de
type16. On y retrouve les règles présentées dans les paragraphes précédents. On
constate que les promotions numériques ne sont mentionnées qu’après les
conversions d’ajustement de type relatives aux différents types flottants ; c’est ce
qui permet de remplacer deux conversions successives par une seule conversion
comme nous l’avons vu précédemment.
• si un opérande est de type long double, l’autre est converti en long double ;
• sinon, si un opérande est de type double, l’autre est converti en double ;
• sinon, si un opérande est de type float, l’autre est converti en float ;
• sinon, si un opérande est de type unsigned long int, l’autre est converti en unsigned
long int ;
• sinon, on réalise les promotions numériques puis,
• si un des opérandes est de type unsigned long int, l’autre est converti en unsigned
long int ;
• sinon, si un opérande est de type long int et l’autre de type unsigned int, le
second est converti en long int (sauf si le type long int n’est pas « assez grand »
pour accueillir le type unsigned int, auquel cas les deux opérandes sont convertis
en unsigned long int) ;
• sinon, si un opérande est de type long int, l’autre est converti en long int ;
• sinon, si un opérande est de type unsigned int, l’autre est converti en unsigned int ;
• sinon, les deux opérandes sont de type int.
3.5 Cas particulier des arguments d’une fonction
Dans un appel de fonction, les arguments qui sont fournis sous forme
d’expression sont d’abord évalués suivant les règles habituelles relatives aux
opérateurs utilisés. Les opérandes sont soumis à d’éventuelles promotions
numériques et conversions d’ajustement de type. Ensuite, il faut distinguer deux
cas selon que le compilateur connaît ou non le type des arguments muets
correspondants.
1. Le compilateur connaît le type des arguments muets. Cela revient à dire,
comme on le verra au chapitre 8, que la fonction a fait l’objet d’une déclaration
sous la forme d’un prototype. Les valeurs des arguments sont converties dans le
type voulu, comme s’il s’agissait d’une affectation, à condition que la conversion
soit légale. Ces conversions sont présentées à la section 7 et on verra notamment
que toutes les conversions numériques sont légales, quitte à être dégradantes.
2. Le compilateur ne connaît pas le type des arguments muets. Cela peut se
produire soit lorsqu’aucun prototype n’a été mentionné (ce qui constitue un style
de programmation désuet et déconseillé), soit lorsqu’on a affaire à une fonction à
nombre d’arguments variables (comme printf). Dans ce cas, le compilateur met
en place un certain nombre de conversions implicites, à savoir :
• les promotions numériques étudiées précédemment : char et short en int, unsigned
short en int ou unsigned int ;
• une éventuelle promotion numérique supplémentaire de float en double ; cette
dernière conversion n’apparaît bien sûr que pour les expressions de type float ;
sa présence dans la norme ANSI ne se justifie que pour des raisons historiques.
Exemple 1
Considérons :
float x1, x2 ;
…..
printf ("%f", x1 + x2) ;
L’expression x1+ x2 est d’abord évaluée suivant les règles usuelles, ce qui conduit
à un résultat de type float. Comme le type de l’argument correspondant attendu
par printf n’est pas connu, ce résultat est alors soumis à la promotion numérique
de float en double, avant d’être transmis à printf.
Exemple 2
Considérons maintenant :
void f(int) ;
float x1, x2 ;
…..
f(x1+x2) ;
Ici encore, l’expression x1+ x2 est d’abord évaluée suivant les règles usuelles, ce
qui conduit à un résultat de type float. Mais cette fois, compte tenu du prototype
de f, elle est convertie dans le type int, avant d’être transmise à la fonction f.
Remarque
On voit qu’il est impossible à une fonction d’arguments de type non connu de recevoir un argument de
type char, short ou float. C’est d’ailleurs ce qui se produit pour la fonction printf dans laquelle,
par exemple :
• le code de format %c correspond à un entier et non à un caractère ;
• le code de format %f correspond à un double et non à un float.
4. Les opérateurs relationnels
4.1 Généralités
Comme tout langage, C permet de comparer des expressions à l’aide
d’opérateurs relationnels (ou « de comparaison »), comme dans :
2 * a > b + 5
Toutefois, en langage C, il n’existe pas de véritable type logique (on dit aussi
« booléen »), c’est-à-dire ne prenant que les valeurs vrai ou faux ; en fait, le
résultat d’une comparaison est un entier valant :
• 0 si le résultat de la comparaison est faux ;
• 1 si le résultat de la comparaison est vrai.
Ainsi, le résultat d’une comparaison peut, assez curieusement, intervenir dans
des calculs arithmétiques comme :
3 * (2 * a > b + 5) /* vaut soit 0, soit 3 */
(n < p) + (p < q) /* vaut 2 si n<p<q, 1 ou 0 sinon */
Par ailleurs, les expressions comparées pourront être d’un type de base
quelconque et elles seront soumises aux règles de conversions présentées dans
les paragraphes précédents. Cela signifie que la comparaison des caractères
s’effectuera après promotion numérique en int, c’est-à-dire, en définitive, suivant
la valeur du code utilisé pour les représenter ; on n’oubliera pas dans ce cas que,
comme l’indique le paragraphe 3.3.2 :
• la valeur obtenue pourra dépendre de l’attribut de signe du type caractère ;
• l’ordre alphabétique ne sera que partiellement respecté.
Enfin, le langage C permettra de comparer des pointeurs. La signification exacte
de telles comparaisons est présentée au chapitre 7.
4.2 Les six opérateurs relationnels du langage C
Le tableau 4.7 liste les six opérateurs relationnels existant en C, avec leur
signification dans un contexte numérique, c’est-à-dire avec des opérandes d’un
type de base (caractère, entier ou flottant). On notera que quatre d’entre eux sont
formés de l’association de deux caractères ; ceux-ci ne doivent, en aucun cas,
être séparés par un espace.
Tableau 4.7 : les opérateurs relationnels dans un contexte numérique
Exemple
Soit ces déclarations :
int n = 5, p = 15 ;
long q = 25 ;
float x = 5.43 ;
char ca = ‘a', cf = ‘f', cA= ‘A' ;
char cff = ‘\xFF' ; /* char signé ou non suivant l'implémentation */
/* ‘xFF' est un int ; voir section 2.4 du chapitre 3 */
Voici quelques exemples d’expressions de comparaison et la valeur
correspondante (bien entendu, ces expressions n’auront d’intérêt que si elles sont
utilisées, soit dans une expression plus complète, soit dans une instruction, en
particulier une instruction conditionnelle telle que if ou while) :
n == p /* résultat : l'entier 0 (faux) */
n <= q /* conversion d'ajustement de type de n en long */
/* résultat : l'entier 1 (vrai) */
x >= n /* conversion d'ajustement de type de n en float */
/* résultat : l'entier 1 (vrai) */
ca < cf /* promotion numérique de ca et cf en int */
/* résultat : en général l'entier 1 (vrai) car, la plupart du */
/* temps, les codes des minuscules sont ordonnés */
cA < cf /* promotion numérique de cA et cf en int */
/* résultat : entier 0 ou 1, suivant l'implémentation17 car, bien que */
/* A et f appartiennent au jeu standard d'exécution */
/* l'ordre des valeurs de leur code n'est pas imposé */
cff > 1 /* promotion num de cff en int : la valeur obtenue sera souvent */
/* -1 ou 255 suivant que char est signé ou non */
/* résultat : entier 0 ou 1, suivant l'implémentation */
Notez bien que les deux derniers exemples ne sont pas portables.
Remarque
La notation (==) de l’opérateur d’égalité est souvent la source d’erreurs plus ou moins faciles à déceler.
En effet, l’emploi par mégarde du symbole = (réservé aux affectations) à la place de == ne conduit pas
toujours à un diagnostic de compilation. Cela provient d’une part de la manière dont l’opérateur
d’affection est traité en C (il fournit une valeur), et d’autre part du fait que toute valeur numérique peut
jouer le rôle d’une valeur logique (voir section 6). Par exemple, l’instruction suivante sera toujours
légale :
if (a=b) ….
Elle exécutera la partie relative au « cas vrai » de l’instruction if, si la valeur affectée à a (donc, celle
de b) est non nulle ! Qui plus est, même l’instruction suivante aura un sens :
if (a=b & c<d) …
Elle exécutera la partie relative au cas vrai si à la fois la valeur affectée à a est non nulle et si la valeur
de c est inférieure à celle de d.
4.3 Leur priorité et leur associativité
Le tableau 4.18 de la section 13 liste les priorités et l’associativité de tous les
opérateurs. Il appelle ici quelques commentaires.
Ces six opérateurs relationnels sont moins prioritaires que n’importe quel
opérateur arithmétique. Cela permet d’éviter certaines parenthèses dans des
expressions. Ainsi :
x + y < a + 2
est équivalent à :
( x + y ) < ( a + 2 )
Les quatre premiers opérateurs (<, <=, > et >=) ont la même priorité. Les deux
derniers (== et !=) ont également la même priorité, mais celle-ci est inférieure à
celle des précédents. Ainsi, une expression telle que :
a < b == c < d
est interprétée comme :
( a < b) == (c < d)
ce qui, en C, a effectivement une signification, car les expressions a < b et c < d
sont, finalement, des quantités entières. En fait, cette expression prendra la
valeur 1 lorsque les relations a < b et c < d auront toutes les deux la même valeur,
c’est-à-dire soit lorsqu’elles seront toutes les deux vraies, soit lorsqu’elles seront
toutes les deux fausses. Elle prendra la valeur 0 dans le cas contraire. Voici
quelques exemples de valeur de cette expression pour différentes valeurs de a, b,
c et d (ici supposées entières) :
Tous ces opérateurs sont, comme la plupart des opérateurs, associatifs de gauche
à droite. Cela signifie que, si les règles de priorité et d’emploi de parenthèses ne
suffisent pas à décider de l’ordre d’application de deux opérateurs, on fait
d’abord intervenir celui de gauche (comme on le fait avec les opérateurs de
l’algèbre traditionnelle). Ainsi, une expression telle que :
a < b < c
est évaluée comme :
(a < b) < c
Autrement dit, elle vaut 1 :
• soit si a est inférieur à b (a < b vaut alors 1) et si 1 < c ;
• soit si a n’est pas inférieur à b (a < b vaut alors 0) et si 0 < c.
Quoi qu’il en soit, si l’on souhaite exprimer le fait que la valeur de b est
comprise entre a et b, on fera appel à un opérateur logique (présenté dans la
section suivante) en utilisant l’expression :
(a < b) && (c < d)
De même, une expression telle que :
a <= b > c
serait évaluée comme :
(a <= b) > c
En revanche, on notera bien que dans l’expression :
a == b < c
ce sont les priorités relatives des opérateurs < et == qui permettent de trancher et
de dire qu’elle correspond à :
a == (b < c)
Remarque
D’une manière générale, il est préférable de faire appel à des parenthèses pour rendre plus lisibles
certaines expressions douteuses. Ce conseil est d’autant plus justifié que les priorités relatives des
opérateurs relationnels et des opérateurs arithmétiques ne sont pas les mêmes dans tous les langages.
Dans cet esprit, les formes avec parenthèses de tous les exemples précédents sont préférables aux
formes équivalentes sans parenthèses.
5. Les opérateurs logiques
5.1 Généralités
Dans la plupart des langages, les opérateurs logiques servent à combiner des
expressions logiques (dont la valeur est soit vrai, soit faux) par des opérations
logiques usuelles de type et, ou et de négation. Ces possibilités se retrouvent en
langage C, mais sous une forme assez particulière.
Tout d’abord, C ne dispose pas de type logique ; en particulier, les opérateurs de
comparaison fournissent une valeur entière, ce qui impose aux opérandes des
opérateurs logiques d’être de type entier. Dans ces conditions, plutôt que de
restreindre leur valeur à 0 et 1, les concepteurs du langage ont préféré donner
une signification aux opérateurs logiques dans tous les cas, en considérant
simplement que seul 0 correspond à « faux », tandis que toute autre valeur
correspond à « vrai ».
Ils ont même souhaité élargir ce point de vue à toute valeur scalaire : une valeur
nulle étant considérée comme faux, et toute autre valeur comme vrai. Ainsi, ces
opérateurs logiques acceptent non seulement des opérandes entiers, mais aussi
des opérandes de type caractère, des opérandes flottants, ainsi que des opérandes
pointeurs. On notera que la notion de valeur nulle varie suivant le type
concerné :
• caractère de code nul pour un type caractère ; il s’agit donc ici d’un octet ayant
tous ses bits à zéro ;
• entier nul pour un type entier ; ici encore, il s’agit d’une suite d’octets nuls ;
• flottant nul : il n’y a aucune raison pour que tous les octets soient nuls ;
• pointeur nul, c’est-à-dire ayant la valeur conventionnelle NULL, laquelle ne
correspond pas obligatoirement à des octets tous nuls.
Contrairement à la plupart des autres opérateurs, les opérateurs logiques ne
prévoient aucune conversion de leurs opérandes. En revanche, il est tout a fait
légal de combiner des opérandes de types totalement différents : caractère et
flottant, entier et pointeur…
5.2 Les trois opérateurs logiques du langage C
C dispose de trois opérateurs logiques : && (et), || (ou inclusif) et ! (négation).
Comme dans beaucoup d’autres langages, il n’existe pas d’opérateur
correspondant au « ou exclusif ». On ne confondra pas && avec l’opérateur de
manipulation de bits (&), ni || avec l’opérateur de manipulation de bits (|).
Tableau 4.8 : les opérateurs logiques du langage C
Comme tout opérande nul (caractère de code 0, entier 0, flottant 0 ou pointeur
nul) est considéré comme faux et que toute autre valeur est considérée comme
vrai, le résultat de ces opérateurs peut être défini par la table suivante :
Tableau 4.9 : table de vérité des opérateurs logiques du C
Exemples usuels
Voici des exemples usuels où les opérandes de ces opérateurs sont le résultat de
comparaison (lequel est, par définition, un int de valeur 0 ou 1) :
(a<b) && (c<d)
prend la valeur 1 (vrai) si les deux expressions a<b et c<d sont toutes deux vraies
(de valeur 1), la valeur 0 (faux) dans le cas contraire.
(a<b) || (c<d)
prend la valeur 1 (vrai) si l’une au moins des deux conditions a<b et c<d est vraie
(de valeur 1), la valeur 0 (faux) dans le cas contraire.
! (a<b)
prend la valeur 1 (vrai) si la condition a<b est fausse (de valeur 0) et la valeur 0
(faux) dans le cas contraire. Cette expression est équivalente à a>=b.
Exemples moins usuels
Si n et p sont des entiers et si adr est un pointeur, voici quelques expressions
correctes, accompagnées d’une écriture équivalente plus explicite18 :
n && p /* (n != 0) && (p != 0) */
n || p /* (n != 0) || (p != 0) */
! n /* (n == 0 ) */
! adr /* (adr == NULL) */
adr && n /* (adr != NULL) && (n != 0) */
On rencontrera souvent ce genre d’écriture dans une instruction if. Ainsi :
if (!n) …
pourra apparaître comme plus concise (mais pas forcément plus lisible) que :
if ( n == 0 )
5.3 Leur priorité et leur associativité
Le tableau 4.18 de la section 13 liste les priorités et l’associativité de tous les
opérateurs. Il appelle ici quelques commentaires.
L’opérateur ! a une priorité supérieure à celle de tous les opérateurs
arithmétiques binaires et aux opérateurs relationnels. Ainsi, pour écrire la
condition contraire de :
a == b
il est nécessaire d’utiliser des parenthèses en écrivant :
! ( a == b )
En effet, l’expression :
! a == b
serait interprétée comme :
( ! a ) == b
L’opérateur || est moins prioritaire que &&. Tous deux sont de priorité inférieure
aux opérateurs arithmétiques ou relationnels. Ainsi, les expressions utilisées
comme exemples en début de section auraient pu être écrites sans parenthèses :
a<b && c<d /* équivaut à (a<b) && (c<d) */
a<b || c<d /* équivaut à (a<b) || (c<d) */
Par ailleurs, tous ces opérateurs sont, comme la plupart des opérateurs,
associatifs de gauche à droite, ce qui signifie que si les règles de priorité et de
parenthèses ne suffisent pas à décider de l’ordre d’application de deux
opérateurs, on fait d’abord intervenir celui de gauche (comme on le fait avec les
opérateurs de l’algèbre traditionnelle). Ainsi, une expression (d’usage peu
conseillé) telle que :
n && p && q
est évaluée comme :
(n && p) && q
Remarque
Ici, encore, nous vous recommandons de ne pas hésiter à faire appel à des parenthèses superflues pour
rendre plus lisibles certaines expressions douteuses. Dans les exemples précédents, les formes avec
parenthèses sont préférables aux autres formes équivalentes, avec cependant une exception (mais elle
concerne une forme de toute façon déconseillée) : l’expression n && p && q reste aussi lisible que (n
&& p) && q, d’ailleurs équivalente, ici, à n && (p && q).
5.4 Les opérandes de && et de || ne sont évalués que si
nécessaire
Les deux opérateurs && et || jouissent en C d’une propriété intéressante : leur
second opérande (celui qui figure à droite de l’opérateur) n’est évalué que si la
connaissance de sa valeur est indispensable pour décider si l’expression
correspondante est vraie ou fausse. Par exemple, dans une expression telle que :
a<b && c<d
on commence par évaluer a<b. Si le résultat est faux (0), il est inutile d’évaluer c<d
puisque, de toute façon, l’expression complète aura la valeur faux (0).
La connaissance de cette propriété est indispensable pour maîtriser des
« constructions » telles que :
if ( i<max && ( c=getchar() != ‘\n' ) )
En effet, le second opérande de l’opérateur && :
c = getchar() != ‘\n'
fait appel à la lecture d’un caractère au clavier. Celle-ci n’aura donc lieu que si la
première condition (i<max) est vraie.
Remarque
Peu d’opérateurs imposent un ordre d’évaluation à leurs opérandes. En dehors de && et de ||, on ne
trouve en effet que l’opérateur ?: étudié à la section 10.
6. Les opérateurs de manipulation de bits
6.1 Présentation des opérateurs de manipulation de
bits
Le langage C dispose d’opérateurs qui travaillent directement sur le « motif
binaire » d’une valeur et qui lui procurent ainsi des possibilités
traditionnellement réservées à la programmation en langage assembleur. Ces
opérateurs permettent de réaliser des combinaisons logiques, bit à bit (et, ou
inclusif, ou exclusif, complément à 1) ainsi que des décalages de bits.
A priori, il aurait été agréable de disposer d’un type particulier, non numérique,
permettant d’effectuer ce genre de manipulations binaires, indépendamment
d’une quelconque valeur numérique. Ce n’est pas le cas en C où ces
manipulations porteront en fait sur des opérandes de type entier. Il va de soi que
l’utilisation de types flottants n’aurait guère de signification dans ce cas, dans la
mesure où le motif binaire manipulé serait sans rapport avec la valeur
correspondante.
Tableau 4.10 : les opérateurs de manipulation de bits (opérandes entiers)
Mais, même avec des types entiers, les choses ne sont qu’à demi satisfaisantes
compte tenu des conversions implicites qui pourront modifier plus ou moins le
motif binaire ; on verra toutefois que l’utilisation systématique de types non
signés permettra de bien maîtriser la situation puisque les seules risques de
modifications se limiteront à l’éventuelle apparition de bits supplémentaires à
zéro.
Le tableau 4.10 ci-avant récapitule le rôle de ces opérateurs, qui sont ensuite
décrits en détail.
6.2 Les opérateurs « bit à bit »
6.2.1 Leur fonctionnement
Les trois opérateurs &, | et ^ appliquent en fait la même opération à chacun des
bits des deux opérandes qui seront de même taille compte tenu des éventuelles
conversions numériques mises en jeu. Leur résultat peut ainsi être défini à partir
de la table suivante qui fournit le résultat de cette opération lorsqu’on la fait
porter sur deux bits de même rang de chacun des deux opérandes :
Tableau 4.11 : table de vérité des opérateurs « bit à bit » binaires
L’opérateur unaire ~, dit de « complément à un », est également du type « bit à
bit ». Il se contente d’inverser chacun des bits de son unique opérande (0 donne
1 et 1 donne 0) :
Tableau 4.12 : table de vérité de l’opérateur « bit à bit » ~ (unaire)
Bit opérande 0 1
~ (complément à un) 1 0
6.2.2 Contraintes et conversions implicites des opérandes
Les opérandes de ces opérateurs doivent obligatoirement être d’un type entier ou
caractère, c’est-à-dire char, short, int, long avec ou sans signe, mais les
conversions numériques implicites leurs sont appliquées :
• promotions numériques seulement pour l’opérateur unaire ~ ;
• promotions numériques et conversions d’ajustement de type pour les trois
opérateurs binaires &, | et ^.
Ainsi, en définitive, les opérandes effectivement reçus par ces opérateurs seront
de l’un des types int ou long, signé ou non. Dans tous les cas, le résultat est du
type commun aux opérandes après conversion ; cela signifie notamment que si c1
et c2 sont de type char, une expression telle que c1&c2 a un sens, mais qu’elle est
de type int.
D’une manière générale, compte tenu des règles des conversions implicites, il est
préférable, lorsque l’on a aucune raison de faire autrement, de n’appliquer ces
opérateurs qu’à des opérandes non signés. Dans ce cas, en effet, on est sûr que le
motif binaire est respecté, quitte à être complété par des zéros du côté des bits les
plus significatifs. Dans le cas contraire, seules les implémentations où les entiers
sont codés suivant la représentation du complément à deux assurent cette
conservation du motif binaire (malgré tout, les bits supplémentaires peuvent,
suivant les cas, être à 0 ou à 1).
On objectera que, même en se limitant ainsi à des opérandes non signés, on peut
encore rencontrer des promotions numériques (unsigned char en int et,
éventuellement unsigned short en int) qui perturbent quelque peu le souhait de ne
travailler que sur des quantités non signées. Par exemple, avec :
unsigned short n, p, q ;
l’instruction
n = p & q ;
pourra introduire les conversions de unsigned short en int de p et de q, suivies
d’une conversion de int en unsigned short du résultat. En fait, on peut montrer que,
dans ce cas précis, cette succession de conversions ne change rien au résultat
escompté.
Exemple 1
Nous supposons que les variables n et p sont de type unsigned int et que celui-ci
est codé sur 16 bits ; nous indiquons leur valeur, à la fois sous forme binaire et
sous forme hexadécimale, ainsi que celles d’expressions simples utilisant les
opérateurs précédents.
n 0000010101101110 056E
p 0000001110110011 03B3
_________________________________________
n & p 0000000100100010 0122
n | p 0000011111111111 07FF
n ^ p 0000011011011101 06DD
~ n 1111101010010001 FA91
Exemple 2
On suppose que le type int est codé sur 16 bits et le type unsigned char sur 8 bits.
int main()
{ unsigned char c1 = ‘\x5C', c2 ; /* c1 contient le motif 01011100 */
c2 = c1 | 0x80 ; /* le second opérande est, après conversion en int */
/* 0000000010000000 */
printf ("%4x %4x", c1, c2) ;
}
5c dc /* correspond à 01011100 et 11011100 */
Le premier opérande de | est converti en int, ce qui conduit (quelle que soit
l’implémentation où le type int est codé sur 16 bits) au motif binaire
0000000001011100 ; le second opérande est déjà de type int ; le résultat fournit
par l’opérateur | est l’entier signé 0000000011011100, lequel, après conversion
en unsigned char, conduit (quelle que soit l’implémentation où les caractères sont
codés sur 8 bits) à 11011100. Autrement dit, tout se passe bien comme si
l’opérateur | avait fonctionné directement sur deux motifs binaires de 8 bits.
Remarque
De nombreux autres exemples d’utilisation des opérateurs de manipulation de bits se trouvent à la
section 6.4.
6.3 Les opérateurs de décalage
6.3.1 Leur rôle
Ils permettent de réaliser des « décalages à droite ou à gauche » sur le motif
binaire correspondant à leur premier opérande. L’amplitude du décalage,
exprimée en nombre de bits, est fournie par le second opérande. Par exemple :
n << 2
fournit comme résultat la valeur obtenue en décalant le motif binaire de n de 2
bits vers la gauche ; les 2 bits de gauche sont perdus et 2 bits à zéro apparaissent
du côté des poids faibles (à droite).
De même :
n >> 3
fournit comme résultat la valeur obtenue en décalant le motif binaire de n de 3
bits vers la droite. Cette fois, les bits de droite sont perdus, tandis que 3 bits
apparaissent du côté des poids forts (à gauche). Ces derniers dépendent du
qualificatif signed/unsigned du premier opérande. S’il s’agit de unsigned, les bits
ainsi créés à gauche sont à zéro. S’il s’agit de signed, les bits ainsi créés
dépendent de l’implémentation : sur certaines machines, ils sont identiques au bit
de signe du premier opérande (décalage arithmétique) ; sur d’autres, il s’agit de
zéros (décalage logique).
Cette dernière remarque plaide, une fois de plus, pour l’usage systématique de
types non signés comme opérandes de ces opérateurs.
6.3.2 Contraintes et conversions implicites des opérandes
D’une manière générale, les opérandes de ces deux opérateurs doivent être de
type entier (char, short, int, long avec ou sans signe). Seules les promotions
numériques sont appliquées à chacun des opérandes. En effet, contrairement à ce
qui passe avec la plupart des autres opérateurs, il n’est pas nécessaire ici que les
deux opérandes soient de même type. Par exemple, si q est de type long,
l’expression q>>3 a bien un sens. Le motif binaire à soumettre au décalage
correspond donc toujours à un int ou à un long, signé ou non.
Le résultat sera du type du premier opérande, après promotion numérique ; il
s’agit donc de int ou long, signé ou non. Par exemple, l’expression précédente q
>> 3 est bien de type long. Si c est de type char, l’expression c>>2 sera de type int,
puisque c’est dans ce type que le premier opérande c aura été converti.
On pourrait objecter que, comme dans le cas des opérateurs de manipulation de
bits, même en se limitant à un premier opérande non signé, on peut rencontrer
des promotions numériques (unsigned char en int et éventuellement unsigned short
en int) qui perturbent le souhait initial de ne travailler qu’avec des valeurs non
signées. Par exemple, avec :
unsigned short n, p ;
l’instruction :
n = p >> 3 ;
pourra introduire la conversion de p en int, suivie de la conversion du résultat en
unsigned short. Là encore, on peut montrer que cette succession de conversions
(implicites d’abord, forcées par affectation ensuite) ne change rien au résultat
escompté.
Remarque
Ces opérateurs ne modifient en aucun cas la valeur de leur premier opérande. Par exemple, pour
décaler le motif binaire d’une variable p, on utilisera une affectation de la forme :
p = p >> 3 ; /* ou p >>= 3 ; */
Exemple 1
Voici quelques exemples de résultats obtenus à l’aide de ces opérateurs de
décalage. La variable n est supposée signed int (situation déconseillée, utilisée
uniquement ici comme illustration), tandis que p est supposée unsigned int.
(signed) n 0000010101101110 1010110111011110
(unsigned) p 0000010101101110 1010110111011110
________________________________________________________________
n << 2 0001010110111000 1011011101111000
n >> 3 0000000010101101 1111010110111011
ou 000101011011101119
p >> 3 0000000010101101 0001010110111011
Exemple 2
On suppose que le type int est codé sur 16 bits et le type unsigned char sur 8 bits.
int main()
{ unsigned char c1 = ‘\x5C', c2 ; /* c1 contient le motif 01011100 */
c2 = c1 >> 2 ;
printf ("%4x %4x", c1, c2) ;
}
5c 17
Le premier opérande de >> est converti en int, ce qui conduit (quelle que soit
l’implémentation où le type int est codé sur 16 bits) au motif binaire
0000000001011100. Le résultat fournit par l’opérateur >> est l’entier
0000000000010111, lequel, après conversion en unsigned char, conduit (quelle que
soit l’implémentation où les caractères sont codés sur 8 bits) à 00010111.
Autrement dit, tout se passe comme si le décalage s’était fait directement sur un
motif de 8 bits.
6.4 Applications usuelles des opérateurs de
manipulation de bits
Les opérateurs de manipulation de bits sont très souvent utilisés pour réaliser
l’une des opérations suivantes sur le motif binaire contenu dans un entier de
taille quelconque :
• forcer à 1 ou à 0 un ou plusieurs bits de position donnée ;
• forcer à 1 ou à 0 un bit dont la position est fournie par la valeur d’une variable ;
• connaître la valeur de un ou plusieurs bits de position donnée ;
• connaître la valeur d’un bit dont la position est fournie par la valeur d’une
variable.
Dans ces différents cas, comme il a été dit précédemment, l’utilisation d’un type
non signé permet d’éviter les risques liés à d’éventuelles conversions implicites.
6.4.1 Forcer la valeur d’un ou de plusieurs bits de positions
données
On y parvient facilement en utilisant ce que l’on nomme un masque binaire,
c’est-à-dire un entier non signé dans lequel les bits ayant la même position que
les bits à forcer ont la valeur 1, les autres ayant la valeur 0.
Pour forcer à un les bits correspondant à ce masque, on combinera ce dernier par
l’opérateur | à l’entier concerné. Pour forcer à zéro les mêmes bits, on combinera
le complément à un (opérateur ~) du masque par l’opérateur & à l’entier concerné.
Exemple 1
Pour forcer à 1 les 9 bits de poids faible contenu dans n, supposée de type unsigned
int, on procédera ainsi :
n = n | 0x1FFu ; /* u pour que le masque soit non signé */
ce qui peut éventuellement s’abréger en :
n |= 0x1FFu ;
Pour forcer à 0 ces mêmes bits, on procédera ainsi :
n = n & ~0x1FFu ; /* ou de façon abrégée : n &= ~0x1FFu ; */
Les notations 0x1FFu et ~0x1FFu ont le mérite de fournir une valeur de type unsigned
int, sans qu’il soit nécessaire de connaître la taille exacte de ce type dans
l’implémentation concernée. En revanche, il n’en irait plus de même si l’on
forçait à 0 les bits voulus en fournissant directement le masque voulu, comme
dans :
n = n &FE00u ; /* valable si le type unsigned int occupe 16 bits */
Exemple 2
Pour forcer à un les trois premiers bits de poids fort et le dernier bit de poids
faible d’une variable n, de type unsigned int dans une implémentation où ce type
occupe 16 bits, on procédera ainsi :
n = n | 0xE001u ; /* ou de façon abrégée : n |= 0xE001u ; */
Pour forcer à zéro ces mêmes bits, on procédera ainsi :
n = n & ~0xE001u ; /* ou de façon abrégée : n &= ~0xE001u ; */
/* ou de façon équivalente : n &= 0x1FFEu ; */
On notera que, contrairement à ce qui produisait dans l’exemple précédent, il est
nécessaire ici de connaître la taille du type unsigned int. On peut éventuellement
contourner la difficulté en créant le masque voulu dans une variable, de cette
manière :
unsigned int masque, taille ;
…..
taille = sizeof(unsigned int) * CHAR_BIT /* inclure <limits.h> pour CHAR_BIT */
masque = 0x7u << (taille - 3) ; /* 3 premiers bits de poids fort */
masque |= 0x1u ; /* dernier bit de poids faible */
6.4.2 Forcer la valeur d’un bit de position variable
Supposons que i, de type unsigned int, contienne la position d’un bit d’un entier n,
de type unsigned int, dont on souhaite forcer la valeur à 0 ou à 1. On peut créer le
masque correspondant de cette manière :
1u<<i /* bit de rang 0 décalé i fois à gauche */
Pour forcer le bit de rang i de n à un, on pourra procéder ainsi :
n = n | (1u<<i) ; /* ou n |= (1u<<i) ; */
Pour forcer ce même bit à un, on pourra procéder ainsi :
n = n & ~(1u<<i) ; /* ou n &= ~(1u<<i) ; */
Remarque
Dans les expressions précédentes, les parenthèses ne sont théoriquement pas nécessaires, compte tenu
des priorités relatives des opérateurs concernés. Toutefois, en l’absence de parenthèses, certains
compilateurs fournissent un message d’avertissement, considérant que l’écriture est ambiguë (pour le
lecteur du programme, non pas pour le compilateur !).
6.4.3 Connaître la valeur d’un ou de plusieurs bits de positions
données
On utilise cette fois un masque dans lequel les bits ayant la position des bits à
extraire ont la valeur 1, les autres ayant la valeur 0 ; on combine ce masque par &
avec l’entier concerné. Par exemple, pour extraire dans la variable p, supposée de
type unsigned int, les trois premiers bits de poids fort et le dernier bit de poids
faible d’une variable n de type unsigned int, on procédera ainsi :
p = n & 0xE001u ;
6.4.4 Connaître la valeur d’un bit de position variable
Pour ne conserver d’une variable n, de type unsigned int, que le bit de rang i, on
pourra procéder ainsi :
p = n & (1u<<i) ;
Si l’on s’intéresse à la valeur de ce bit, sous la forme 0 ou 1, on pourra décaler
de façon appropriée le motif binaire obtenu en procédant ainsi :
p = (n & (1u<<i)) >> i ; /* (n & 1u>>i) >> i est correct mais conduit parfois */
/* à un message d'avertissement - voir section 6.4.2 */
ou encore ainsi :
taille = sizeof (unsigned int)*CHAR_BIT ; /* inclure <limits.h> pour CHAR_BIT */
p = (n << (taille-i-1)) >> (taille-1) ;
7. Les opérateurs d’affectation et d’incrémentation
7.1 Généralités
Le langage C possède la particularité de traiter l’affectation comme un opérateur.
Cela signifie qu’une notation telle que :
i = 5
est une expression qui :
• d’une part réalise une action : l’affectation de la valeur 5 à i ;
• d’autre part possède une valeur : celle de i après affectation, c’est-à-dire 5.
Manifestement, le premier opérande de cet opérateur devra être modifiable ; les
expressions :
5 = n
n + 5 = 3
n’auraient aucun sens. La notion de lvalue, présentée à la section 7.2, sert
précisément à désigner des opérandes modifiables.
Par ailleurs, il existe d’autres opérateurs d’affectation qui permettent de
condenser des écritures telles que :
n = n + 3 /* se condensera en n += 3 */
p = p *5 /* se condensera en p *= 5 */
Ces opérateurs se nommeront « opérateurs d’affectation élargie », tandis que = se
nommera « affectation simple ». Bien entendu, ces opérateurs d’affectation
élargie nécessiteront, eux aussi, une lvalue en premier opérande.
Enfin, des opérateurs unaires (à un opérande), ++ et --, dits « opérateurs
d’incrémentation », permettent, dans certains cas, de condenser encore plus
l’écriture :
n = n + 1 /* déjà condensable en n += 1 peut se condenser en n++ */
n = n - 1 /* déjà condensable en n -= 1 peut se condenser en n-- */
Ce paragraphe commencera par étudier en détail la notion de lvalue, avant
d’examiner successivement l’opérateur d’affectation simple, les opérateurs
d’affectation élargie et les opérateurs d’incrémentation.
7.2 La lvalue
Beaucoup d’opérateurs du langage C imposent à certains de leurs opérandes
d’être modifiables ; c’est notamment le cas de l’opérateur d’affectation simple
(=).
Certes, dans tout langage évolué, ce genre de contrainte existe pour l’affectation,
même lorsque cette dernière n’est pas traitée comme un opérateur, c’est-à-dire
lorsqu’elle apparaît comme une instruction à part entière. En général, on se
contente alors de dire que la partie gauche d’une telle affectation doit être une
variable. En C, ce terme de variable est insuffisant puisque :
• il est déjà employé pour un objet désigné par un identificateur figurant dans
une déclaration ;
• certaines variables ne sont pas modifiables, notamment celles ayant reçu le
qualifieur const ;
• il n’est pas adapté à des notations telles que *adr ou *(adr) dans lesquelles adr
désigne un pointeur ou une expression de type pointeur ;
• un nom de structure (variable) pourra apparaître en premier opérande d’une
affectation, un nom de tableau (variable) ne le pourra pas.
Il faut donc disposer d’un nouveau mot désignant la référence à un objet dont on
peut modifier la valeur. Nous utiliserons celui employé par la norme ANSI, à
savoir lvalue : ce mot est l’abréviation de left value, c’est-à-dire valeur à gauche,
sous-entendu à gauche d’un opérateur d’affectation (on pourrait éventuellement
franciser ce mot, en utilisant gvaleur ou encore g-valeur). En définitive :
Une lvalue est une expression désignant un objet modifiable.
On notera que, par définition, à une lvalue sont toujours associés une adresse et
un type. Par ailleurs, ce terme de lvalue s’applique à une expression désignant un
objet, non à l’objet lui-même. On ne peut pas dire si un objet est en soi, une
lvalue ou non. Il n’est d’ailleurs pas rare de pouvoir accéder à un même objet
avec des expressions différentes, l’une étant une lvalue, l’autre ne l’étant pas.
Le tableau 4.13 récapitule les différentes sortes d’expressions en précisant dans
quel cas ce sont des lvalue ; il tient compte de tous les types existants, y compris
ceux qui seront étudiés dans des chapitres ultérieurs.
Tableau 4.13 : les expressions qui sont des lvalue
Expression Conditions
Identificateur de – n’ayant pas reçu le qualifieur const ;
variable – autre que tableau ;
– dans le cas d’une variable d’un type structure ou
union, celle-ci ne doit pas comporter de champs
constants.
Expression de la p étant une variable de type pointeur sur un objet
forme *p ou *(adr) non constant, adr étant une expression d’un type
pointeur sur un objet non constant.
élément de tableau – autre que tableau (cas des tableaux à plusieurs
indices) ;
– autre que structure ou union comportant des
champs constants.
Champ de structure – autre que tableau ;
ou d’union
– autre que structure ou union comportant des
champs constants.
Remarques
1. En toute rigueur, la norme ANSI donne au mot lvalue un sens légèrement différent de celui que
nous utilisons ici, à savoir celui d’une expression désignant un objet (modifiable ou non) ; elle parle
alors de « lvalue modifiable » pour désigner ce que nous nommons, comme la plupart des ouvrages
sur le C, lvalue.
2. Contrairement à l’attribut const, l’attribut volatile n’empêche nullement à une variable d’être une
lvalue. On pourrait même dire qu’une telle variable est encore plus qu’une lvalue puisqu’elle peut
être modifiée par le programme, même à son insu !
3. Un nom de tableau n’est pas une lvalue. Il s’agit certes d’une grande originalité du langage
puisque, par convention, un nom de tableau désigne un pointeur constant sur son premier élément.
Mais il s’agit également d’une grande lacune puisqu’on ne peut pas réaliser d’affectations entre
tableaux ou transmettre les valeurs d’un tableau à une fonction. Qui plus est, ces opérations seront
réalisables avec les autres agrégats que sont les structures.
7.3 L’opérateur d’affectation simple
7.3.1 Rôle, priorité et associativité
Cet opérateur binaire se présente sous la forme :
lvalue = expression
Son rôle est double : d’une part, il affecte à son premier opérande qui doit, bien
sûr, être une lvalue, la valeur de son deuxième opérande ; d’autre part, il fournit
comme résultat, la valeur de son premier opérande après modification (ou, ce qui
revient au même, la valeur de son deuxième opérande).
Sa faible priorité (elle est inférieure à celle de tous les opérateurs arithmétiques
et de comparaison) fait qu’on peut l’utiliser de façon naturelle, sans recourir à
des parenthèses. Par exemple, dans :
c = b + 3
il y a d’abord évaluation de l’expression b + 3. La valeur ainsi obtenue est ensuite
affectée à c. Il n’est pas nécessaire d’écrire (mais ce serait correct) :
c = (b + 3)
Contrairement à la plupart des autres, cet opérateur d’affectation possède une
associativité de droite à gauche. C’est ce qui permet à une expression telle que :
i = j = 5
d’évaluer d’abord l’expression j = 5 avant d’en affecter la valeur (5) à la variable
j. Bien entendu, la valeur finale de cette expression est celle de i après
affectation, c’est-à-dire 5.
7.3.2 Conversions liées aux affectations
Contrairement à ce qui se passe avec la plupart des autres opérateurs, l’opérateur
d’affectation utilise son premier opérande, non pas pour sa valeur, mais pour son
aspect lvalue : il désigne un emplacement dont on doit modifier la valeur. Une
conversion d’un tel opérande n’aurait donc aucun sens.
En ce qui concerne l’expression apparaissant en deuxième opérande, son type est
en partie conditionné par celui du premier opérande. En effet, comme on peut s’y
attendre, il n’est pas possible d’affecter n’importe quoi à n’importe quoi : il
n’aurait aucun sens d’affecter la valeur d’un entier à un pointeur ou encore la
valeur d’une structure à un flottant. Néanmoins, fidèle à ses habitudes, le
langage C n’impose pas pour autant une concordance absolue de type. Plus
précisément, certaines conversions seront possibles au moment de l’affectation :
• dans le cas où les deux opérandes sont de type numérique ;
• dans le cas où au moins le premier opérande est de type pointeur (ces
possibilités, au demeurant, assez restreintes, seront étudiées au chapitre 7).
Dans le cas des opérandes numériques, la valeur de l’expression est bien sûr
évaluée suivant les règles habituelles, avec d’éventuelles conversions implicites
(promotions numériques et conversions d’ajustement de type). Si le type du
résultat ainsi obtenu ne correspond pas à la lvalue, il y a conversion systématique
dans le type de la lvalue, avant affectation. Une telle conversion imposée ne
respecte plus nécessairement la hiérarchie des types qui est de rigueur dans le
cas des conversions implicites. Elle peut donc conduire, selon les cas, à une
dégradation plus ou moins importante de l’information : par exemple lorsque
l’on convertit un double en int, on perd la « partie décimale » du nombre.
De telles conversions interviennent dans des affectations aussi banales que les
suivantes :
int n ;
long p ;
char c1, c2 ;
n = p + 5 ; /* valeur de n correcte si p+5 est représentable en int */
p = n - 3 ; /* n-3 évalué en int, le résultat est converti en long */
c1 = c2 + 1 ; /* c2+1 évalué en int, le résultat est converti en char */
D’une manière générale, le rôle exact de ces différentes conversions numériques
est étudié à la section 9.
7.3.3 Affectation et qualifieurs
Tout naturellement, le premier opérande d’une affectation ne peut pas posséder
le qualifieur const, lequel, par définition, interdit toute tentative de modification.
Par exemple :
const int n = 5 ;
int p ;
…..
p = n + 5 ; /* OK */
n = p + 3 ; /* interdit : n n'est pas une lvalue */
En revanche, le qualifieur volatile, par sa nature même, n’entraîne aucune
interdiction de ce genre.
La même remarque s’appliquera à des variables de type pointeur. Il faudra
cependant bien distinguer les qualifieurs appliqués à la variable elle-même de
ceux appliqués à l’objet pointé. Nous y reviendrons en détail au chapitre 7.
7.4 Tableau récapitulatif : l’opérateur d’affectation
simple
Le tableau 4.14 tient compte de tous les types d’opérandes qu’est susceptible de
recevoir l’opérateur d’affectation simple. Cependant, en dehors de l’aspect
numérique traité dans ce chapitre, il faudra se reporter aux chapitres
correspondants (pointeurs, structures, unions, énumérations) pour trouver la
signification exacte des autres affectations et des éventuelles conversions
correspondantes.
Tableau 4.14 : les opérandes de l’opérateur d’affectation simple
Opérande
Opérande de droite Remarques
de gauche
de
lvalue Expression d’un type numérique Possibilités
type quelconque de
numérique conversions
dégradantes
de
lvalue Expression du même type structure Voir
type chapitre 11
structure
de
lvalue Expression du même type union Voir
type union chapitre 11
de
lvalue
Une des deux possibilités suivantes : Voir section
type 6 du
pointeur – expression du même type pointeur ou de chapitre 7
sur un type void *, la lvalue concernée devant,
objet, autre dans tous les cas, posséder au moins les
que void * mêmes qualifieurs const ou volatile que le
type des objets pointés ;
– NULL ou entier 0.
de
lvalue
Une des deux possibilités suivantes :
type void *
– expression d’un type pointeur quelconque,
y compris void *, la lvalue concernée
devant, dans tous les cas, posséder au moins
les mêmes qualifieurs const ou volatile que
le type des objets pointés ;
– NULL ou entier 0.
de
lvalue Valeur d’un type compatible au sens de la Voir section
type redéclaration des fonctions 11.2 du
pointeur chapitre 8
sur une
fonction
7.5 Les opérateurs d’affectation élargie
L’intérêt de ces différents opérateurs réside uniquement dans la possibilité qu’ils
offrent de condenser certaines affectations. En voici quelques exemples montrant
en parallèle l’affectation simple et l’affectation élargie :
Utilisation équivalente de
Utilisation de l’affectation simple
l’affectation élargie
i = i + k; i += k;
i = i + 2*n; i += 2*n;
a = a * b; a *= b;
a = a * (b+3); a *= b+3;
n = n << 3; n <<= 3;
D’une manière générale, C permet de condenser des affectations de la forme
suivante, dans laquelle la mention lvalue désigne obligatoirement la même lvalue :
lvalue = lvalue operateur expression
en :
lvalue operateur= expression
Cette possibilité concerne tous les opérateurs binaires arithmétiques et de
manipulation de bits, ce qui conduit aux opérateurs +=, -=, *=, /=, %=, |=, ^=, &=, <<= et
>>=. Tous ces opérateurs disposent de la même priorité et de la même
associativité que l’opérateur d’affectation simple. Les contraintes pesant sur
leurs opérandes découlent directement des contraintes affectant à la fois
l’opérateur d’affectation simple et l’opérateur binaire correspondant (par
exemple, + pour +=). De la même manière, tous ces opérateurs peuvent provoquer
une conversion (éventuellement dégradante), au même titre que l’opérateur
d’affectation.
Tableau 4.15 : les opérateurs d’affectation élargie
Opérateurs Opérande de gauche Opérande de droite
+= -=
lvalue de type Expression numérique
numérique Expression numérique
lvalue de type pointeur entière
*= /=
lvalue de type Expression numérique
numérique
%=
lvalue de type entier Expression entière
&= |= ~=
lvalue de type entier Expression entière
<<= >>=
lvalue de type entier Expression entière
Remarque
Si n est de type int et x de type float, une expression telle que :
n += x
est légale, même si un tel mélange de types est rarement utilisé ; elle est rigoureusement équivalente
à :
n = n + x
ce qui signifie que la valeur de l’expression n + x est d’abord évaluée : n est converti en float et le
résultat de l’addition est de type float. Il est ensuite converti dans le type de la lvalue réceptrice,
c’est-à-dire ici en int.
Le même genre de commentaires s’appliquerait à des expressions telles que c %= 5 ou c += 5 (c étant
de type char).
8. Les opérateurs de cast
8.1 Généralités
Il existe deux circonstances dans lesquelles le compilateur met automatiquement
en place des conversions, sans que le programme ne l’ait explicitement
demandé :
• Dans les expressions numériques : il s’agit de promotions numériques et de
conversions d’ajustement de type. C es conversions (voir section 3) sont
généralement intègres, c’est-à-dire qu’elles préservent la valeur d’origine, les
quelques rares exceptions concernant uniquement des situations de mélange
d’attributs de signe.
• Dans certaines affectations (voir section 7) : cette fois, toutes les conversions
numériques sont autorisées, ce qui signifie qu’elles peuvent, éventuellement,
être dégradantes. Il existe quelques autres conversions concernant les
pointeurs.
En outre, il est possible de provoquer explicitement une conversion en utilisant
un opérateur dit de « cast » dont le nom est formé à l’aide de ce que l’on nomme
le « nom » du type voulu. Par exemple, si n et p sont des variables entières,
l’expression :
(double) ( n/p )
aura comme valeur celle de l’expression entière n/p convertie en double. La
notation :
(double)
correspond en fait à un opérateur unaire dont le rôle est d’effectuer la conversion
dans le type double de l’expression le suivant. Notez bien que cet opérateur
(unaire) force la conversion du résultat de l’expression et non celle des
différentes valeurs qui concourent à son évaluation. Autrement dit, ici, il y a
d’abord calcul, dans le type int, du quotient de n par p ; c’est seulement ensuite
que le résultat sera converti en double. Si n vaut 10 et que p vaut 3, cette
expression aura comme valeur 3.
Comme on peut s’y attendre, il n’est pas possible de convertir n’importe quoi en
n’importe quoi. Par exemple, il serait absurde de vouloir convertir un pointeur
sur une fonction en un pointeur sur un entier ou encore un entier en une
structure.
Les seules conversions possibles se limitent en fait aux conversions d’un type
scalaire (numérique ou pointeur) vers un autre type scalaire. Bien entendu, qui
peut le plus peut le moins et toutes les conversions autorisées lors d’une
affectation sont réalisables par cast. La réciproque n’est pas vraie et certaines
conversions réalisables par cast ne sont pas autorisées par affectation.
8.2 Les opérateurs de cast
8.2.1 Syntaxe et rôle
Les opérateurs de cast
(nom_de_type)
nom_de_type
Nom d’un type – nom de type de base = spécificateur de
scalaire (type type éventuellement précédé de
de base ou qualifieurs (voir commentaires à la
pointeur), avec section 8.2.3) ;
d’éventuels
qualifieurs – nom de type pointeur présenté à la
dans le cas section 2.6 du chapitre 7.
d’objets
pointés.
On voit qu’un opérateur de cast se note sous la forme du nom du type placé entre
parenthèses. La notion de nom de type est différente de celle de spécificateur de
type. Dans le cas d’un type de base, elle correspond effectivement à ce
spécificateur de type, éventuellement précédé de qualifieurs, comme nous le
verrons à la section 8.2.3. En revanche, dans le cas des pointeurs, elle fait
également intervenir le déclarateur correspondant, comme nous le verrons à la
section 2.6 du chapitre 7.
Le rôle de l’opérateur de cast dans le cas des types de base est présenté à la
section 9 qui expose le déroulement exact des différentes conversions qu’elles
soient ou non dégradantes. Pour ce qui concerne les pointeurs, on se reportera à
la section 9 du chapitre 7.
Exemples
Voici quelques notations équivalentes, avec des types de base :
(long int) n (long) n (signed long) n
(int) c (signed int) c (signed) c
(short) n (signed short) n (short int) n (signed short int) n
(unsigned) c (unsigned int) c
Voici un exemple de cast servant à expliciter le rôle d’une affectation :
int n ;
float x ;
…
n = (int) x ; /* équivaut à n = x; */
n = (int) (x + 2.3) ; /* équivaut à n = x + 2.3; */
Remarque
D’une manière générale, le nom d’un type n’est utilisé que dans quelques circonstances : opérateur de
cast, opérateur sizeof et prototypes de fonctions. Les deux derniers cas pourront alors faire intervenir
des noms de types non scalaires qui seront présentés dans les chapitres correspondants.
8.2.2 Priorité des opérateurs de cast
La priorité élevée de l’opérateur de cast (voir tableau 4.18, section 13) fait qu’il
est généralement nécessaire de placer entre parenthèses l’expression concernée.
Ainsi, l’expression :
(double) n/p
conduirait d’abord à convertir n en double ; les règles de conversions implicites
amèneraient alors à convertir p en double avant que n’ait lieu la division (en
double). Le résultat serait alors différent de celui obtenu par l’expression proposée
au début de ce paragraphe (avec les mêmes valeurs de n et de p, on obtiendrait
une valeur de l’ordre de 3.33333….).
8.2.3 Opérateurs de cast et qualifieurs
Comme nous le verrons à la section 9du chapitre 7, le rôle d’un qualifieur est
parfaitement clair dans le cas des objets pointés et il fait, en quelque sorte, partie
intégrante du type, donc du nom de type. En revanche, la norme ne précise pas si
un tel qualifieur fait ou non partie du nom de type dans le cas où il porte sur une
variable d’un type de base ou sur la variable pointeur elle-même. En fait, ce
point est de peu d’importance, dans la mesure où, de toute façon, ce qualifieur ne
sert à rien dans le nom de type et en particulier dans le cas de l’opérateur de cast.
Par exemple, avec :
volatile int p ;
int n ;
la plupart des implémentations acceptent :
p = (volatile int) n ; /* accepté dans la plupart des implémentations */
mais ceci peut être avantageusement remplacé par :
p = n ;
puisque la notion de volatilité d’une expression n’a, en soi, aucun sens.
D’une manière comparable, mais plus criante, avec :
const int p = 5 ;
int n ;
la plupart des implémentations acceptent une expression de la forme :
(const int) n
mais celle-ci n’a aucune signification puisque la notion de constance d’une
expression n’a pas de sens. De toute façon, une tentative d’affectation de la
forme suivante sera rejetée :
p = (const int) n ; /* rejeté : p n'est pas une lvalue */
En effet, étant constante, p ne peut être modifiée, même en cherchant
sournoisement à lui affecter quelque chose de constant…
Remarque
En fait, cette tolérance des qualifieurs au premier niveau de l’opérateur de cast pourra s’avérer utile
en cas de définition de types synonymes par typedef. En voici un exemple :
typedef volatile int t_vi ;
t_vi n, q ;
…..
q = (t_vi) n ; /* la présence de volatile dans le type t_vi ne gêne pas */
9. Le rôle des conversions numériques
On appelle conversion numérique la conversion d’un type de base en un autre
type de base. Une telle conversion peut être :
• implicite, dans l’évaluation d’une expression : promotion numérique ou
conversion d’ajustement de type (voir section 3) ;
• forcée par une affectation, comme indiqué dans la section 7 ou par l’opérateur
de cast (voir section 8).
Les conversions implicites sont intègres, c’est-à-dire qu’elles préservent la
valeur initiale, excepté en cas de mélange d’attribut de signe. Elles constituent
un cas particulier de conversions forcées qui, quant à elles, ne sont plus
nécessairement intègres. Notamment, on y trouvera des conversions d’un type
entier/flottant vers un type entier/flottant plus petit ou des conversions de flottant
vers entier20. Qui plus est, certaines de ces conversions, bien qu’acceptées par le
compilateur, pourront, lors de l’exécution, conduire à des situations d’exception
pour lesquelles la norme n’a rien prévu !
Nous allons examiner en détail ces différentes situations de conversion. Elles
feront ensuite l’objet d’un tableau récapitulatif.
9.1 Conversion d’un type flottant vers un autre type
flottant
C’est le cas lorsque les deux types concernés sont choisis parmi les types long
double, double ou float.
Si le type destination est de taille supérieure au type d’origine, aucun problème
particulier ne se pose ; la norme dit simplement que la valeur obtenue doit être
identique21 à la valeur initiale. Cette situation correspond à toutes les conversions
d’ajustement de type.
Si le type destination est de taille inférieure au type d’origine, deux situations
doivent être distinguées :
• si la valeur initiale appartient au domaine correspondant au nouveau type, le
résultat sera la valeur la plus proche de la valeur d’origine, par défaut ou par
excès suivant l’implémentation. Là encore, aucun problème ne se pose, hormis
une perte (normale !) de précision ;
• dans le cas contraire, la norme prévoit que le comportement du programme
est indéterminé. Dans certaines implémentations, on obtiendra alors un
message d’erreur lors de l’exécution : dépassement de capacité, sous-
dépassement de capacité, erreur de domaine…. Dans d’autres, on obtiendra
simplement un résultat sans signification. Dans les implémentations utilisant
les conventions IEEE 754, on obtiendra l’une des deux valeurs +INF ou -INF.
9.2 Conversion d’un type flottant vers un type entier
C’est le cas des conversions de float, double ou long double vers long int, int, short
int ou char (d’attribut de signe quelconque). Rappelons qu’aucune de ces
conversions ne peut apparaître de façon implicite.
Si la valeur initiale ne sort pas des limites correspondant au nouveau type, la
norme prévoit que le résultat de la conversion s’obtient en privant le nombre
flottant de sa partie décimale.
Dans le cas contraire, la norme se contente de dire que le résultat n’est pas
défini. Là encore, seules certaines implémentations fourniront un message
d’erreur à l’exécution. Notez bien que la conversion d’un flottant négatif
(comme -3.36) en un entier non signé est, en principe, un cas d’erreur. Les
implémentations utilisant les conventions IEEE n’apportent rien de particulier à
ce niveau puisque ces conventions ne concernent que les représentations des
nombres flottants.
Remarque
Dans les conversions légales de flottant en entier, on ne perdra pas de vue la précision limitée de la
représentation des flottants. Considérez, par exemple, ces instructions :
float x ;
long q ;
float eps = 0.1 /* valeur généralement représentée en float avec une erreur */
x = eps * 1E10 ;
q = x ; /* q ne vaut peut-être pas exactement 1000000000 */
9.3 Conversion d’un type entier vers un type flottant
Ces conversions font déjà partie des conversions d’ajustement de type étudiées à
la section 3.2.1, dont nous avons vu qu’elles sont intègres. Rappelons
simplement que :
• si la valeur d’origine est représentable dans le type d’arrivée, aucun problème
ne se pose ;
• si la valeur d’origine n’est pas représentable de façon exacte, elle l’est
obligatoirement d’une manière approchée (car, dans tous les cas, elle
appartient au domaine du type d’arrivée) et le résultat sera la valeur la plus
proche (par excès ou par défaut, suivant l’implémentation).
9.4 Conversion d’un type entier vers un autre type
entier
C’est le cas lorsque les deux types concernés sont choisis parmi les types long,
int, short et char, avec leurs attributs éventuels de signe (ne pas oublier que char
est, selon l’implémentation, équivalent à signed char ou à unsigned char !). Il faut
distinguer le cas où la valeur est représentable dans le type destination de celui
où elle ne l’est pas.
Cas A : la valeur initiale est représentable dans le type destination
On notera bien qu’il n’est pas toujours suffisant que le type destination soit de
taille supérieure ou égale au type initial pour que cette condition soit réalisée. En
effet, lorsque le type destination est non signé, il faut de surcroît que la valeur
initiale soit non négative. De même, avec un type destination signé et une valeur
initiale de type non signé de même taille, il est nécessaire que la valeur initiale
ne soit pas trop grande.
Si la condition évoquée est vérifiée, aucun problème particulier se ne pose pour
la valeur qui est, tout naturellement, conservée. Le motif binaire est conservé
pour ce qui est des bits communs aux deux types. Des bits supplémentaires
peuvent apparaître du côté des poids forts si le type destination est plus grand
que le type d’origine. Avec la représentation en complément à deux, ces bits sont
à 0 pour des valeurs positives et à 1 pour des valeurs négatives. Dans les autres
(rares) cas, on ne peut rien affirmer.
Cas B : la valeur initiale n’est pas représentable dans le type destination
Dans ce cas, la norme distingue à nouveau deux cas :
Cas B1
Si le type destination est signé, le résultat22 n’est théoriquement pas défini. La
plupart des implémentations se contentent cependant de fournir un résultat faux,
obtenu en conservant le motif binaire, en ignorant éventuellement les bits les
plus significatifs ou en introduisant des bits supplémentaires. Ces bits sont à 0
pour des valeurs positives et à 1 pour des valeurs négatives dans le cas de la
représentation en complément à deux.
Par exemple, si n est de type int, dans une implémentation où ce type est
représenté sur 16 bits, l’instruction :
n = 0xFFFFu ;
affecte à n une valeur de type unsigned int, manifestement trop grande pour son
type. Dans la plupart des implémentations utilisant la représentation en
complément à deux, on obtiendra en fait la valeur -1 dans n (même si cela n’est
nullement imposé par la norme dans ce cas).
Cas B2
Si le type destination est non signé, le résultat de la conversion est défini par une
formule de modulo généralisant celle déjà rencontrée à la section 3.2.3 pour les
conversions d’ajustement de type. Plus précisément, si n représente la valeur
initiale et nmax le plus grand nombre représentable dans le type destination (mod
désignant l’opérateur mathématique modulo), le résultat de la conversion sera :
n mod (nmax + 1)
Par exemple, la conversion d’un int de valeur -3 en unsigned int conduira
toujours, dans une implémentation où ces types sont représentés sur 16 bits, à la
valeur 65 533 (nmax = 65535, et -3 mod 65536 = 65533).
On peut montrer que, dans les implémentations utilisant la représentation en
complément à deux, cette démarche revient à conserver le motif binaire, en
ignorant les bits les plus significatifs.
9.5 Cas particuliers des conversions d’entier vers
caractère
Le type caractère n’étant qu’un type entier particulier, ces possibilités sont déjà
décrites dans les paragraphes précédents. Toutefois, compte tenu de la dualité
(numérique, caractère) des types char et de l’usage important qui est fait de ces
conversions, nous examinons ici quelques exemples.
Exemple 1
int n = 25, q ;
char c ;
…..
c = n ; /* conversion de n en char et affectation a c */
q = c ; /* on retrouve bien la valeur 25 dans q */
Bien entendu, la conversion de char en int induite par l’affectation c=n est
acceptée par le compilateur. À l’exécution, la valeur de n (ici, 25) est
représentable dans le type char (qu’il soit ou non signé) ; aucun problème ne se
pose. L’affectation q=c entraîne la conversion non dégradante de la valeur 25 dans
le type int. En définitive, q reçoit bien la valeur 25.
Exemple 2
Considérons maintenant ces instructions analogues aux précédentes, avec cette
différence que n est initialisé à 200 :
int n = 200, q ;
char c ;
…..
c = n ; /* conversion de n en char : */
/* si char signé -> 200 non représentable */
/* si char non signé -> 200 est représentable */
q = c ; /* valeur dépendant de l'attribut de signe de char */
/* si char non signé : toujours 200 */
/* si char signé : souvent -56 */
Là encore, la première affectation est acceptée par le compilateur. Lors de
l’exécution, il faut distinguer deux cas :
• si le type char est par défaut signé, la valeur 200 n’est alors pas représentable
dans ce type ; le résultat de la conversion n’est pas défini par la norme mais, en
pratique, on conservera le motif binaire, ce qui conduira la plupart du temps à
un bit de gauche (de l’octet représentant le char) à un. Lors de l’affectation à q,
ce bit de gauche sera (généralement) interprété comme un bit de signe et le
résultat de la conversion de char en int sera négatif. La valeur de q sera donc
négative ;
• si, en revanche, le type char est par défaut non signé, la valeur 200 sera
représentable dans ce type ; aucun problème ne se pose et q recevra bien la
valeur 200.
Exemple 3
signed char c1 = ‘a' ;
unsigned char c2 ;
…..
c2 = c1 ; /* a appartient au jeu minimum d'exécution ; il est représentable */
/* en unsigned int ; le motif binaire et la valeur sont conservés */
Exemple 4
unsigned char c1 = 200 ;
signed char c2 ;
…..
c2 = c1 ; /* 200 n'est pas représentable en signed char ; en théorie, le */
/* résultat est indéterminé ; en pratique, le motif binaire */
/* est conservé */
Exemple 5
signed char c1 = -20 ;
signed char c2 ;
…..
c2 = c1 + 1 ; /* c1 est converti en int, avant d'être ajouté à 1 ; le résultat */
/* -19, de type int, est converti en signed char, ce qui ne */
/* change pas sa valeur */
D’une manière générale, le type signed char est utilisable comme un petit entier,
exactement au même titre que signed short. En revanche, les mélanges d’attributs
de signe posent les mêmes problèmes :
signed char c1 = -6 ;
unsigned char c2 ;
…..
c2 = c1 + 1 ; /* c1 est converti en int, avant d'être ajouté à 1 ; le résultat */
/* -5 est converti en unsigned char, ce qui change sa valeur */
9.6 Tableau récapitulatif des conversions numériques
Le tableau 4.16 récapitule le rôle des différentes conversions légales d’un type
numérique en un autre type numérique. On notera que les types caractère ne sont
rien d’autre que des cas particuliers de types entier. Ainsi, ce que nous nommons
conversion flottant → entier correspond aussi bien à une conversion float → int
qu’à une conversion double → char. De même, ce que nous nommons conversion
entier → entier non signé peut correspondre aussi bien à une conversion int →
unsigned char qu’à une conversion signed char → unsigned long.
Tableau 4.16 : les conversions numériques légales (dégradantes ou non)
Voici ce que deviennent ces règles appliquées à des conversions d’un type
caractère vers un autre type caractère. Ce tableau n’est fourni qu’à titre indicatif
dans la mesure où, comme nous l’avons dit à plusieurs reprises, en pratique, le
motif binaire est toujours conservé dans ce cas.
→
signed char
– valeur conservée pour les codes positifs donc, en
unsigned char
particulier, pour les caractères du jeu standard ; valeur
définie par modulo pour les codes négatifs ;
– motif binaire : d’après la norme, conservé avec la
représentation en complément à deux ; en pratique,
toujours conservé.
→
unsigned char
– d’après la norme : valeur et motif binaire conservés
signed char
uniquement pour les codes pas trop grands et, en
particulier, pour les caractères du jeu standard ;
indéfinis dans les autres cas ;
– en pratique, le motif binaire est toujours conservé.
Toujours à titre indicatif, voici l’effet théorique d’une suite de trois conversions,
partant d’un type char, pour aboutir à un type char, par l’intermédiaire d’un type
int (signé) :
→ int → signed char
signed char Valeur et motif binaire conservés
unsigned char → int → unsigned char Valeur et motif binaire conservés
→ int → unsigned char
signed char Équivaut à signed char → unsigned
char (voir précédemment)
unsigned char → int → signed char Équivaut à unsigned char → signed
char (voir précédemment)
Notez que la troisième suite de conversions intervient dans des situations aussi
banales que :
printf ("%c", c) ; /* c étant de type signed char ou de type char */
/* dans une implémentation ou ce type est signé par défaut */
10. L’opérateur conditionnel
10.1 Introduction
Considérons cette instruction :
if ( a>b )
max = a ;
else
max = b ;
Elle attribue à la variable max la plus grande des deux valeurs de a et de b. La
valeur de max pourrait être définie par : si a>b alors a sinon b. En langage C,
l’opérateur conditionnel permet de traduire presque littéralement cette condition
de la manière suivante :
max = a>b ? a : b
L’expression figurant ici à droite de l’opérateur d’affectation est en fait
constituée de trois expressions (a>b, a et b) qui sont les trois opérandes de
l’opérateur conditionnel, lequel se matérialise par les deux symboles séparés ? et
:.
Voici un autre exemple d’une expression utilisant l’opérateur conditionnel pour
placer dans la variable va la valeur absolue de l’expression 3*a + 1 :
va = 3*a+1 > 0 ? 3*a+1 : -3*a-1
10.2 Rôle de l’opérateur conditionnel
Cet opérateur évalue la première expression qui joue le rôle d’une condition.
Comme toujours en C, celle-ci peut être en fait de n’importe quel type scalaire
(type de base ou pointeur). Si sa valeur est différente de zéro, il y a évaluation du
second opérande, ce qui fournit le résultat. En revanche, si sa valeur est nulle, il
y a évaluation du troisième opérande, ce qui fournit le résultat.
Rien n’empêche que l’expression conditionnelle soit évaluée sans que sa valeur
ne soit utilisée, comme dans cette instruction :
a>b ? i++ : i-- ;
Ici, suivant que la condition a>b est vraie ou fausse, on incrémentera ou on
décrémentera la variable i.
Bien entendu, une expression conditionnelle peut, comme toute expression,
apparaître à son tour dans une expression plus complexe :
z = z * ( a>b ? a : b ) ;
10.3 Contraintes et conversions
Le premier opérande doit être d’un type scalaire (il faut bien pouvoir
l’interpréter comme une condition) ; il est évalué, sans qu’aucune conversion ne
soit nécessaire. En ce qui concerne les deux derniers opérandes et le type du
résultat, ils sont définis par le tableau 4.17 :
Tableau 4.17 : l’opérateur conditionnel
Remarque
Les deux derniers opérandes ne peuvent jamais être tous les deux nuls. En revanche, ils peuvent être
tous les deux de type void * (ce cas correspond simplement au cas « pointeurs de même type ») et le
résultat est alors, lui aussi, de type void *.
10.3.1 Opérandes d’un type de base
Lorsque les deux derniers opérandes sont d’un type de base, ils sont soumis aux
conversions numériques habituelles (promotions numériques et conversions
d’ajustement de type) et le résultat fournit par l’opérateur sera du type commun.
Voici un exemple (n étant de type int) :
n ? 2 * n : 1.5 * n ;
Le second opérande est de type int, tandis que le troisième est de type float. Le
résultat de l’expression précédente sera toujours de type float. Bien entendu, si
les instructions de conversion de 2*n en float sont effectivement prévues dès la
compilation, leur exécution n’aura lieu que dans les cas où n est effectivement
non nul.
10.3.2 Opérandes de type pointeur
Les opérandes de type pointeur n’entraînent de conversions que lorsque l’un des
deux est de type void * (il y a alors conversion en void *) ou de valeur nulle (qui
est alors convertie dans le type de l’autre pointeur). La signification de ces
conversions sera étudiée en détail dans le chapitre consacré aux pointeurs, mais
nous les avons mentionnés dans le tableau précédent de façon à être exhaustif.
Signalons cependant dès maintenant que les qualifieurs const et volatile n’ont pas
besoin d’être les mêmes pour chacun des deux opérandes et que le résultat se
verra attribuer tout qualificatif concernant au moins l’un des opérandes.
Par exemple, avec ces déclarations :
const void * c_vptr ;
void * vptr ;
volatile int * v_iptr ;
voici ce que seraient les types de deux expressions (le type du premier opérande
n’ayant ici aucune importance) :
n ? c_vptr : vptr ; /* type : const void * */
n ? c_vptr : v_iptr /* type : const volatile void * */
10.3.3 Opérandes de type structure ou union
La possibilité de disposer d’opérandes de type structure ou union ne pose pas de
problème particulier, si ce n’est qu’il faut assurer l’identité des types des deux
opérandes. Cette notion d’identité est définie à la section 4.2 du chapitre 11. Elle
correspond en général à des structures ou unions définies suivant un type de
même nom. Par exemple :
int n ;
struct enreg { …. } ; /* définition d'un type structure enreg */
struct enreg e1, e2, e3 ; /* e1, e2 et e3 sont du type struct enreg */
…..
e1 = n ? e2 : e3; /* affecté à e1 : e2 si n non nul, e3 si n nul */
10.3.4 Opérandes de type void
La possibilité de disposer d’opérandes de type void signifie qu’il est possible que
ces opérandes n’aient pas de valeur ; il peut donc s’agir d’appel de fonctions ne
renvoyant pas de résultat, ce qui signifie qu’on utilise alors uniquement
l’opérande pour son « effet de bord » et qu’on ne cherche pas à utiliser sa valeur.
Par exemple :
n ? f1(…) : f2 (…) ;
pourrait remplacer (tout en étant nettement moins lisible) :
if (n != 0) f1 (…) ;
else f2 (…) ;
10.4 La priorité de l’opérateur conditionnel
L’opérateur conditionnel jouit d’une faible priorité (il arrive juste avant
l’affectation), de sorte qu’il est rarement nécessaire d’employer des parenthèses
pour en délimiter les différents opérandes (bien que cela puisse parfois améliorer
la lisibilité du programme). Voici, toutefois, un cas où les parenthèses sont
indispensables :
z = (x=y) ? a : b
Le calcul de cette expression amène tout d’abord à affecter la valeur de y à x.
Puis, si cette valeur est non nulle, on affecte la valeur de a à z. Si, au contraire,
cette valeur est nulle, on affecte la valeur de b à z.
Il est clair que cette expression est différente de :
z = x = y ? a : b
laquelle serait évaluée comme :
z = x = ( y ? a : b )
11. L’opérateur séquentiel
Comme nous l’avons déjà évoqué en introduction de ce chapitre, la notion
d’expression est beaucoup plus générale en C que dans la plupart des autres
langages car elle peut à la fois réaliser une action et posséder une valeur.
L’opérateur dit « séquentiel » va élargir encore un peu plus cette notion
d’expression. En effet, il permet en quelque sorte d’exprimer plusieurs calculs
successifs au sein d’une même expression. Par exemple :
a * b , i + j
est une expression qui évalue d’abord a*b, puis i+j et qui prend comme valeur la
dernière calculée (donc ici celle de i+j). Certes, dans cet exemple « d’école », le
calcul préalable de a*b est inutile puisqu’il n’intervient pas dans la valeur de
l’expression globale et qu’il ne réalise aucune action. En revanche, une
expression telle que :
i++, a + b
peut présenter un intérêt puisque la première expression (dont la valeur ne sera
pas utilisée) réalise en fait une incrémentation de la variable i.
Il en va de même pour l’expression suivante :
i++, j = i + k
dans laquelle, il y a :
• évaluation de l’expression i++, ;
• évaluation de l’affectation j = i + k. Notez qu’alors, on utilise la valeur de i
après incrémentation par l’expression précédente.
Cet opérateur séquentiel, qui jouit d’une associativité de gauche à droite, peut
facilement faire intervenir plusieurs expressions et sa faible priorité évite l’usage
de parenthèses :
i++, j = i+k, j--
Certes, un tel opérateur pourrait théoriquement être utilisé pour réunir plusieurs
instructions en une seule. Ainsi, ces deux formulations sont équivalentes :
i++, j = i+k, j-- ;
i++ ; j = i+k ; j-- ;
Dans la pratique, ce n’est cependant pas là le principal usage que l’on fera de cet
opérateur séquentiel. En revanche, il interviendra fréquemment dans les
instructions de choix ou dans les boucles : là où la syntaxe n’a prévu qu’une
seule expression, l’opérateur séquentiel permet d’en placer plusieurs et, partant,
d’y réaliser plusieurs calculs ou plusieurs actions.
Voici deux exemples fournissant, sur une même ligne, deux formulations
équivalentes, la première avec l’opérateur séquentiel, la seconde sans :
if (i++, k>0) …… i++ ; if (k>0) ……
for (i=1, k=0 ; … ; … ) ……. i=1; for (k=0; … ; … ) ……
Comme l’appel d’une fonction n’est en fait rien d’autre qu’une expression, la
construction suivante est parfaitement valide en C :
for (i=1, k=0, printf("on commence") ; … ; …) ……
Dans le chapitre suivant, nous verrons que dans le cas des boucles
conditionnelles, cet opérateur permet de réaliser des constructions ne possédant
pas d’équivalent simple.
Par sa nature même, cet opérateur n’impose aucune contrainte à ses opérandes et
il n’induit aucune conversion de type.
Remarque
Il ne faut pas confondre cet opérateur séquentiel (,) avec la virgule utilisée (avec d’ailleurs la même
priorité) pour séparer les différents arguments d’une liste dans un appel de fonction, comme dans
l’instruction :
printf ("%d %d", n+2, p) ;
Si l’on souhaite utiliser cet opérateur séquentiel dans une telle liste, il est nécessaire d’en placer le
résultat entre parenthèses. Par exemple :
printf ("%d %d", a, (b=5, 3*b)) ;
• imprime la valeur de a ;
• évalue l’expression : b=5, 3*b, ce qui conduit à affecter 5 à b et à calculer ensuite la valeur de 3*b ;
• affiche la valeur de cette expression, c’est-à-dire finalement la valeur de 3*b.
12. L’opérateur sizeof
L’opérateur sizeof permet de connaître la taille en octets d’un objet ; plus
précisément, il possède un unique opérande qui peut être :
• soit un nom de type ;
• soit une expression.
12.1 L’opérateur sizeof appliqué à un nom de type
L’opérateur sizeof s’emploie simplement sous la forme suivante :
L’opérateur sizeof appliqué à un nom de type
sizeof (nom_de_type)
nom_de_type Nom de type quelconque, – nom d’un type de base =
y compris pointeur sur une spécificateur de type,
fonction, mais pas type éventuellement précédé
fonction. de qualifieurs (voir
commentaires à la
section 12.2.3) ;
– autres noms de types
présentés dans les
chapitres correspondants.
résultat Taille des objets du type, Ce résultat est de type
en octets (tient compte size_t (voir section 12.2.4).
pour les structures ou les
unions, d’octets
d’alignement ou de
remplissage).
La notion de nom de type n’intervient que dans quelques cas : opérateur de cast,
prototype de fonctions, opérateur sizeof. Dans le cas d’une variable d’un type de
base, ce nom de type n’est rien d’autre que le spécificateur de type utilisé pour sa
déclaration. Les autres noms de type (tableaux, pointeurs, structures, unions)
seront présentés dans les chapitres correspondants.
Exemples
sizeof (unsigned long int) /* même valeur que sizeof (long int) */
sizeof (char) /* vaut toujours 1 par définition */
sizeof (struct enreg) /* taille des objets de type struct enreg */
sizeof (int *) /* taille d'un pointeur sur un int */
sizeof (struct enreg *) /* taille d'un pointeur sur une structure de type enreg */
sizeof (int (*)[4]) /* taille d'un pointeur sur un tableau de 4 int */
On notera que, dans une implémentation donnée, différents types pointeurs
auront la même taille ; dans certains cas même, tous les pointeurs auront la
même taille. Aussi, les dernières expressions auront rarement un intérêt.
12.2 L’opérateur sizeof appliqué à une expression
12.2.1 Syntaxe
Dans le cas où l’opérande de sizeof est une expression, il est possible de faire
appel à l’une des deux notations ci-après :
Les deux notations de l’opérateur sizeof appliqué à une expression
sizeof (expression)
sizeof expression
Exemples
int n = 12 ; /* on suppose que dans l'implémentation */
/* concernée, le type int occupe 2 octets */
long q = 25 ; /* et le type long occupe 4 octets */
sizeof (n) /* a pour valeur 2 */
sizeof (n+q) /* a pour valeur 4 */
sizeof n /* a pour valeur 2 */
sizeof n + q /* a pour valeur 27, compte tenu de la */
/* priorité de l'opérateur sizeof */
On trouvera également des exemples de détermination du nombre d’éléments
d’un tableau à la section 3.4 du chapitre 6.
12.2.2 L’opérande de sizeof n’est pas évalué
L’expression utilisée comme opérande de sizeof n’est pas évaluée ; cela peut
avoir plusieurs conséquences :
• L’action éventuelle correspondant à une expression ne sera pas réalisée. Par
exemple :
sizeof (i++) /* i n'est pas incrémenté */
• L’expression en question peut apparaître dans une expression constante (les
expressions constantes sont définies à la section 14). Ce sera le cas, par
exemple, d’une expression comme sizeof(x+1) dont on notera qu’elle n’est pas
équivalente à sizeof(x) lorsque x est de type short ou char, en vertu des règles de
conversion.
short x ;
int t [sizeof (x+1) ] ; /* attention, équivalent, ici, à sizeof(int) */
char tc [sizeof(x)] ; /* plus portable que char tc [sizeof(short)] */
Remarques
1. Manifestement, la notation de sizeof sans parenthèses n’apporte aucun avantage. En revanche, elle
oblige à s’interroger sur la priorité relative de cet opérateur ; d’une manière générale, nous la
déconseillons.
2. Il peut s’avérer judicieux d’appliquer sizeof à une expression plutôt qu’à un type lorsqu’une
adaptation ultérieure d’un programme risque de modifier le type d’une variable. Par exemple, si à
un instant donné, on a déclaré x de type float, l’expression sizeof(x) représentera toujours la
taille de x, même après une modification de l’instruction de déclaration.
12.2.3 L’opérateur sizeof et les qualifieurs
Les éventuels qualifieurs ne modifient pas la taille d’un type. Par exemple, des
objets de type const int et des objets de type int auront toujours la même taille.
Cependant, comme l’indique la section 8.2.3, la norme ne précise pas si les
qualifieurs font ou non partie du nom de type. Ils sont acceptés dans un opérande
de sizeof (lorsqu’il s’agit d’un nom de type) dans la plupart des
implémentations :
sizeof (const int) /* généralement accepté, mais identique à sizeof (int) */
Bien entendu, ce problème ne se pose plus si sizeof porte sur une expression
puisque le type de cette dernière ne comporte plus de qualifieurs.
Dans le cas des objets pointés, leurs qualifieurs sont également inutiles bien que,
comme on le verra à la section 2.5 du chapitre 7, ils fassent partie intégrante du
type et qu’ils soient donc acceptés par la norme :
sizeof (const int *) /* correct, mais identique à sizeof (int *) */
12.2.4 Le type du résultat de sizeof
Manifestement, sizeof fourni un résultat entier ; mais la norme ne précise pas s’il
s’agit de long, unsigned long, int, unsigned int… Plus exactement, chaque
implémentation doit définir, par typedef, dans les fichiers en-tête en ayant
besoin23, le nom size_t correspondant au type entier du résultat fourni par sizeof.
Dans ces conditions, la seule chose qu’on puisse assurer est que le type unsigned
long est toujours suffisant pour accueillir le résultat de sizeof.
Notez qu’il est préférable d’éviter ce genre d’instruction :
printf ("taille flottant : %d\n", sizeof (float)) ;
qui ne fonctionnera que lorsque size_t sera identique à int. Il est préférable de
procéder ainsi :
int taille ; /* ou encore unsined int */
… /* ou unsigned long …. */
taille = sizeof (float) ;
printf ("taille flottant : %d\n", sizeof (float)) ; /* avec %u ou %lu … */
13. Tableau récapitulatif : priorités et associativité des
opérateurs
Le tableau 4.18 fournit la liste complète des opérateurs du langage C, classés par
ordre de priorité décroissante, accompagnés de leur associativité. Lorsque
plusieurs opérateurs figurent (même sur des lignes différentes) dans une même
cellule du tableau, ils ont même priorité.
Tableau 4.18 : priorités et associativités des différents opérateurs
14. Les expressions constantes
14.1 Introduction
Une expression constante est une expression dont la valeur peut être évaluée lors
de la compilation. Certes, il peut s’agir d’expressions telles que :
5 + 2
3 * 8 - 2
mais cela ne présente guère d’intérêt pour le programmeur puisqu’il lui est alors
possible de faire le calcul lui-même.
Les choses deviennent déjà plus intéressantes si l’on tient compte de l’existence
du préprocesseur, lequel traite toutes les directives (commençant par #) avant de
donner le résultat à compiler. Par exemple, avec :
#define LIMITE 20
les expressions :
LIMITE + 1
2 * LIMITE - 3
seront en fait fournies au compilateur sous la forme (le préprocesseur se
contentant de faire des substitutions, mais pas de calculs) :
20 + 1
2 * 20 - 3
En fait, la norme ANSI demande que le compilateur soit en mesure de calculer
des expressions constantes plus générales que celles que nous venons de citer.
On peut en effet y faire intervenir bon nombre d’opérateurs portant eux-mêmes
sur des expressions constantes. Par exemple, avec ces déclarations :
#define N 40
#define P 80
#define MOTIF 0xFFFF
#define DELTA 1.24
les expressions suivantes seront des expressions constantes :
N < P /* entier 0 ou 1 (ici 1) */
N == P /* entier 0 ou 1 (ici 0) */
MOTIF && (N == P) /* entier (ici 0) */
N < P ? 2 : 5 /* entier 2 ou 5 (ici 2) */
(N < 30) & (P < 100) /* entier 0, 1 ou 2 (ici 1 ) */
MOTIF >> 8 /* entier (ici 255) */
~(MOTIF >> 8) /* entier (ici 0) */
2*DELTA /* flottant, valant environ 2,48 */
14.2 Les expressions constantes d’une manière
générale
14.2.1 Les opérateurs utilisables
La plupart des opérateurs peuvent intervenir dans une expression constante. Les
seules exceptions sont parfaitement logiques puisqu’elles concernent des
opérateurs qui peuvent réaliser une action ou qui ne peuvent être évalués qu’au
moment de l’exécution du programme et non plus lors de sa compilation.
Dans une expression constante, tous les opérateurs sont autorisés, sauf les opérateurs d’affectation,
d’incrémentation, d’appel de fonction et l’opérateur séquentiel, à moins que ces opérateurs
n’apparaissent dans un opérande de sizeof.
Exemple
#define N 10
int p ;
int fct (int) ;
…..
p = N /* n'est pas une expression constante car = interdit ; */
/* mais, de toute façon, p n'est déjà pas une constante ! */
sizeof (p=N) /* expression constante, mais identique à sizeof (p) */
fct (N) + 1 /* n'est pas une expression constante, car appel de fonction */
14.2.2 Les trois catégories d’expressions constantes
La norme définit trois catégories d’expressions constantes :
• les expressions constantes entières ;
• les expressions constantes numériques, cas plus général englobant à la fois les
constantes entières précédentes et les constantes flottantes ;
• les expressions constantes correspondant à une adresse.
Selon leur nature, elles pourront intervenir dans des contextes différents. Le
tableau 4.19 en donne les principales caractéristiques. Elles feront ensuite l’objet
de quelques commentaires.
Tableau 4.19 : les trois catégories d’expressions constantes et leur
utilisation24
Catégorie Définition Utilisation
Expressions – résultat de type entier (y compris – dimension
constantes caractère) ; d’un tableau ;
entières – tous les opérandes entiers constants, – taille d’un
sauf pour sizeof (opérande champ de
quelconque) et pour cast (opérande bits ;
flottant, mais résultat entier). – constantes
d’énumération
24
;
– étiquettes de la
forme case xxx.
Expressions – résultat de type numérique (y – initialisation
constantes compris caractère) ; de variables
numériques statiques de
– tous les opérandes numériques
constants, sauf pour sizeof (opérande type
quelconque) et cast (opérandes numérique ;
obligatoirement numériques). – initialisation
de tableaux
numériques
(statiques ou
automatiques).
Expressions L’une des possibilités suivantes : – initialisation
constantes de variables
adresses – pointeur nul (NULL) ;
statiques de
– adresse constante (objet statique ou type pointeur.
fonction) ; on peut y utiliser les
opérateurs [], ., ->, * et cast de
pointeurs ;
– expression obtenue par addition ou
soustraction d’une constante entière à
une adresse constante.
La contrainte imposant aux opérandes apparaissant dans des expressions
constantes entières d’être entiers est manifestement restrictive puisque certaines
opérations sur des flottants conduisent à des résultats entiers. C’est le cas d’une
simple comparaison.
En pratique, bon nombre d’implémentations n’appliquent pas la norme à la lettre
dans ce cas :
#define DELTA 1.24
…..
2*(DELTA<1.5) /* expression entière valant 0 ou 2, théoriquement incorrecte
*/
/* mais acceptée dans beaucoup
d'implémentations */
int t[2*(DELTA<1.5)+5] ; /*généralement accepté : t sera de dimension 5 ou
7 */
Remarques
1. Les constantes de type énumération définies par une instruction enum sont des constantes entières.
C’est le cas de jaune, rouge et vert définies par :
enum couleur { jaune=5, rouge, vert=jaune+4 } ;
2. La notion d’expression constante existe également pour le préprocesseur. On verra à la section 3.2.4
du chapitre 15 qu’elle est légèrement plus restrictive qu’ici.
3. Les variables déclarées avec le qualifieur const ne sont pas considérées comme des constantes.
Elles le seront, en revanche, en C++ :
const int n = 5 ;
…..
int t[n] ; /* incorrect en C ; accepté en C++ *
1. N’oubliez pas de séparer les deux opérateurs + par au moins un espace ; dans le cas contraire, ces deux +
consécutifs seraient interprétés comme l’opérateur d’incrémentation ++ (dont nous parlerons un peu plus
loin).
2. La référence exacte est ANSI/IEEE 754-1985.
3. Et parfois avec l’opérateur -. Lorsqu’il est appliqué à la plus petite valeur (c’est-à-dire à l’entier négatif
de plus grande valeur absolue), il peut conduire, dans les implémentations utilisant la technique du
complément à deux, à un entier opposé non représentable (par exemple, avec 16 bits, -32 768 est
représentable, 32 768 ne l’est pas).
4. Alors même que bon nombre de machines disposent d’un mécanisme permettant de détecter la perte d’un
bit de retenue.
5. Certes, on pourrait parler de sous-dépassement de capacité, mais ce terme est conventionnellement
réservé à l’arithmétique flottante.
6. Généralement N+1 est égal à 2n, n étant le nombre de bits utilisés. Dans les machines utilisant la
technique du complément à deux pour les entiers signés, on retrouve le comportement habituel (mais non
imposé par la norme) du dépassement de capacité des entiers signés.
7. La norme actuelle emploie l’expression « promotions entières », mais « promotions numériques » reste
fort répandue (probablement parce que la description initiale du langage C, effectuée par Kernighan et
Ritchie comportait également une promotion numérique non entière de float en double).
8. En toute rigueur, la norme mentionne également une conversion triviale : conversion d’une lvalue en sa
valeur lorsqu’elle apparaît ailleurs qu’en premier opérande d’une affectation (ce qui va de soi !).
9. Et pas seulement lorsqu’on emploie la représentation en complément à deux !
10. Y compris certaines conversions vers un type de taille inférieure à celui d’origine qui ne concernent pas
les conversions implicites examinées ici, mais qui pourront apparaître dans les conversions explicites par
cast ou par affectation.
11. Cependant, si c1 et c2 n’ont pas le même attribut de signe, il apparaîtra une conversion d’un type
caractère dans un autre. Comme expliqué à la section 9, celle-ci conserve en pratique le motif binaire.
12. D’ailleurs, dans ce cas, la norme ANSI autorise le compilateur à mettre directement en place des
instructions portant sur un octet (incrémentation directe de un) sans passer par les transformations
intermédiaires en entier…
13. Pas plus que ne le pourraient les grandes valeurs du type unsigned int, totalement équivalent dans ces
implémentations à unsigned short.
14. À la section 3.3.3, nous avons rencontré un exemple dans lequel apparaissaient une promotion
numérique pour un opérande et une conversion d’ajustement de type pour l’autre. Mais nous n’avons
rencontré aucune situation dans laquelle un même opérande était soumis aux deux sortes de conversions.
15. Les conversions d’ajustement de type n’ont aucun sens pour un opérateur unaire !
16. Il existe quelques opérateurs binaires qui n’appliquent pas les conversions d’ajustement de type
(opérateur logiques, etc.).
17. 1 dans le cas du code ASCII.
18. Laquelle serait d’ailleurs la seule utilisable dans un langage tel que Pascal.
19. Suivant l’implémentation.
20. N’oubliez pas que les types caractère sont considérés comme des cas particuliers de types entiers.
21. La norme impose bien une identité, et pas seulement une valeur voisine à la précision du type près !
22. Notez bien qu’il ne s’agit que d’un résultat indéfini, non d’un comportement indéterminé ; il ne peut
donc pas y avoir d’erreur d’exécution dans ce cas.
23. Il s’agit de stddef.h, stdio.h, stdlib.h et string.h.
24. Les constantes d’énumération sont les expressions constantes entières qui servent à fixer la valeur d’une
constante de type énumération.
5
Les instructions
exécutables
En C, comme dans bon nombre de langages, il est d’usage de distinguer les
instructions de déclaration des instructions exécutables. Dans cet ouvrage, les
instructions de déclaration, qu’on nomme souvent déclarations tout simplement,
sont étudiées dans différents chapitres : types de base, tableaux, pointeurs,
structures, unions et énumérations. Elles font l’objet d’une récapitulation au
chapitre 16.
Ce chapitre est consacré aux instructions exécutables, qu’on nomme souvent en
C instructions. Nous commencerons par proposer deux classifications de ces
instructions, la première fondée sur la notion très répandue d’instruction de
contrôle, l’autre plus appropriée au langage C. Puis, nous passerons en revue les
différentes instructions du langage. Après l’instruction expression et l’instruction
composée (ou bloc), nous présenterons les instructions de choix if et switch.
Après une présentation générale des particularités des boucles en C, nous
examinerons les instructions do … while, while et for et nous vous prodiguerons
quelques conseils d’utilisation. Puis, après avoir étudié les instructions de
rupture de séquence que sont break et goto, nous vous proposerons quelques
schémas de boucles supplémentaires. Nous terminerons par l’instruction goto.
1. Généralités
Les façons de classifier les instructions du langage C sont nombreuses. Nous
commencerons par rappeler ce que l’on nomme instruction de contrôle dans un
langage évolué, ce qui nous amènera à constater qu’en C, la plupart des
instructions entrent dans cette catégorie. Nous proposerons ensuite une
classification relativement naturelle des différentes instructions exécutables.
1.1 Rappels sur les instructions de contrôle
, dans un programme, les instructions sont exécutées séquentiellement,
A priori
c’est-à-dire dans l’ordre où elles apparaissent. Or la puissance et le
« comportement intelligent » d’un programme proviennent essentiellement :
• de la possibilité d’effectuer des choix (ou sélections ou encore alternatives),
c’est-à-dire de se comporter différemment suivant les circonstances, par
exemple en fonction d’une réponse de l’utilisateur, d’un résultat de calcul… ;
• de la possibilité d’effectuer des boucles (ou itérations), autrement dit de répéter
plusieurs fois un ensemble donné d’instructions.
Pour réaliser ces choix ou ces boucles, tous les langages disposent d’instructions,
nommées « instructions de contrôle ». Autrefois, ces dernières étaient basées
essentiellement sur la notion de « branchement » conditionnel ou inconditionnel
(cas des premiers Basic ou Fortran). Elles ont ensuite reproduit tout ou partie des
structures fondamentales de la programmation structurée (cas de Pascal, puis de
la plupart des langages récents tels que Java, Python, C#, PHP…), tout en
conservant en parallèle quelques possibilités de branchement, utilisés alors plutôt
à titre exceptionnel.
Le langage C dispose d’un riche éventail d’instructions structurées permettant de
réaliser :
• des choix : instructions if et switch ;
• des boucles : instructions do … while, while et for.
Il dispose par ailleurs de quelques instructions de branchement inconditionnel :
goto, break, continue et return.
En C, la plupart des instructions sont des instructions de contrôle. Cela tient à la
richesse de la notion d’expression qui, en incluant les appels de fonction (donc
les entrées-sorties) rend toute action réalisable avec la seule instruction
expression. En effet, en dehors de cette dernière instruction, il n’en existe qu’une
seule autre qui ne soit pas une instruction de contrôle, à savoir l’instruction
composée (ou bloc). Celle-ci est formée d’autres instructions qui, en définitive,
seront soit des instructions expressions, soit des instructions de contrôle.
1.2 Classification des instructions exécutables du
langage C
Hormis la distinction classique entre les instructions de contrôle et les autres
instructions exécutables, présentée dans la section précédente, il existe plusieurs
autres classifications des instructions exécutables du langage C. Nous vous en
proposons ici une dont l’expérience montre qu’elle est relativement naturelle.
On notera que toutes les instructions simples se terminent par un point-virgule.
En revanche, il existe une instruction n’entrant pas dans cette catégorie et se
terminant aussi par un point-virgule, à savoir do … while ! C’est pourquoi la
terminaison par un point-virgule n’est pas utilisable pour caractériser
l’instruction simple dont la véritable caractéristique est, en fait, de ne renfermer
aucune autre instruction.
Tableau 5.1 : classification des instructions exécutables du langage C
Il y a en C, comme dans beaucoup de langages, une sorte de récursivité de la
notion d’instruction. Un bloc ou une instruction structurée peuvent renfermer à
leur tour d’autres instructions de l’une des trois catégories. Seules les
instructions simples échappent à cette règle (d’où leur nom). D’une manière
générale, dans la description de la syntaxe des différentes instructions, nous
utiliserons souvent le terme d’instruction. Celui-ci désignera toujours n’importe
quelle instruction exécutable : simple, structurée ou bloc.
Toute instruction peut comporter une étiquette. Nous verrons qu’il existe deux
sortes d’étiquettes :
• celles de la forme case xxx, décrites à la section 5 : elles ne sont utilisables que
dans une instruction switch ;
• celles formées d’un simple identificateur et décrites à la section 15 : elles
peuvent apparaître devant n’importe quelle instruction.
Remarques
1. La norme ANSI classe les instructions en six catégories : instruction étiquetée, instruction composée
(bloc), instruction expression, instructions de sélection (choix), instructions d’itération (boucles) et
instructions de saut (branchements).
2. Rappelons que dans une instruction expression peuvent apparaître beaucoup d’actions telles qu’une
affectation, un appel de fonction, donc en particulier une entrée-sortie. C’est la raison pour laquelle
on ne trouve pas en C, comme dans beaucoup d’autres langages, d’instructions spécifiques pour
l’affectation, l’appel de procédure, l’écriture ou la lecture.
2. L’instruction expression
Cette instruction est la plus utilisée car elle permet de réaliser la plupart des
actions, en particulier les affectations, les appels de fonctions et donc les entrées-
sorties puisqu’elles sont mise en œuvre en C par un appel d’une fonction
standard.
2.1 Syntaxe et rôle
L’instruction expression
[ expression ] ;
expression
– expression d’un type quelconque – les expressions
(y compris structure ou union) ou numériques sont
de type void, dans le cas d’un étudiées au
appel de fonction sans résultat ; chapitre 4 ;
– si absente, on a affaire à une – les expressions de
instruction vide. type pointeur sont
étudiées au
chapitre 7.
N.B. : les crochets ([ et ]) signifient que leur contenu est facultatif vis-à-vis de la syntaxe.
Cette instruction évalue l’expression mentionnée (si elle est présente).
2.2 Commentaires
La différence entre une expression et une instruction expression n’apparaît que
dans le point-virgule qui termine la seconde. On peut considérer, en quelque
sorte, que la présence de ce point-virgule indique qu’on accepte de perdre la
valeur de l’expression considérée. Par exemple, dans :
printf ("bonjour") ;
on perd effectivement la valeur de retour de printf. De même, dans :
a = 5 ;
on perd finalement la valeur de l’expression, à savoir, valeur de a après
affectation, ce qui n’est nullement gênant !
Cette curiosité peut permettre d’écrire des instructions expression qui ne font
rien, si ce n’est peut-être de prendre un petit peu de temps d’exécution. Il suffit
pour cela de transformer en instruction une expression qui ne réalise aucune
action, autrement dit qui se contente de posséder une valeur. En voici des
exemples :
i ;
i+5 ; /* la valeur i+5 est calculée mais non utilisée */
i, j, k ;
En revanche, une simple instruction telle que :
i++ ;
possède bien un intérêt, celui d’incrémenter i de un, alors qu’on n’utilise pas sa
valeur (celle de i avant incrémentation).
Par ailleurs, on notera qu’un appel de fonction est une expression, même lorsque
la fonction ne possède pas de valeur de retour. D’ailleurs, dans ce dernier cas,
l’instruction expression constitue le seul et unique moyen d’appeler la fonction.
Si la fonction possède une valeur de retour, on peut l’utiliser dans une expression
mais ce n’est pas obligatoire ; si on ne le fait pas, on retrouve un appel de la
forme instruction expression. Considérons ces exemples :
void f1(int) ;
int f2(float) ;
…..
f1(5); /* appel de f1, fonction sans valeur */
y = f2(x) + 5; /* appel de f2 dont on utilise la valeur */
f2(x); /* appel de f2 dont on n'utilise pas la valeur */
L’expression est facultative. Si elle est absente, on dit qu’on a affaire à une
instruction vide. Une telle instruction peut apparaître là où une instruction simple
est permise. Par exemple, on peut écrire :
i++ ; ; /* autorisé, mais sans intérêt */
Ici, l’instruction vide est inutile. En revanche, elle pourra présenter un intérêt à
l’intérieur de certaines instructions structurées. Nous en verrons des exemples
dans ce chapitre.
3. L’instruction composée ou bloc
3.1 Syntaxe d’un bloc
Un bloc est une suite d’instructions exécutables placées entre accolades ({ et }).
Ces instructions sont absolument quelconques. Il peut donc s’agir d’instructions
simples, structurées ou même d’autres blocs, un bloc pouvant donc apparaître à
son tour dans un autre bloc, soit tel quel, soit au sein d’une instruction structurée.
De plus, un bloc peut comporter des déclarations – depuis C99, leur
emplacement est libre ; en C90, elles doivent précéder toute instruction
exécutable.
Instruction composée ou bloc
{ [ declarations ]
[ 0, 1 ou plusieurs instructions executables quelconques ]
}
N.B. : les crochets ([…]) signifient que leur contenu est facultatif vis-à-vis de la syntaxe.
3.2 Commentaires
Le corps d’une fonction, donc en particulier celui de la fonction main, n’est rien
d’autre qu’un bloc.
Un bloc peut se réduire à une seule instruction exécutable, comme :
{ i = 1 ; }
On peut objecter que cela présente peu d’intérêt puisque ce bloc peut toujours
être remplacé par l’instruction qu’il contient :
i = 1 ;
Toutefois, cette façon de procéder peut faciliter l’adaptation ultérieure d’un
programme dans le cas où cette unique instruction risque, par la suite, d’être
complétée par d’autres. C’est ce qui se passe lorsqu’elle correspond au cas vrai
ou au cas faux d’une instruction if ou au corps d’une boucle. Un exemple en est
présenté à la section 4.3.2, dans laquelle nous verrons également que le recours à
des blocs permet de mettre en évidence la règle relative aux imbrications des
instructions if, voire à l’outrepasser.
Un bloc peut être vide :
{ }
Il joue théoriquement le même rôle qu’une instruction simple vide :
;
Il est souvent employé pour améliorer la lisibilité des boucles à corps vide. Par
exemple, on utilisera souvent :
do {} while (condition) ;
plutôt que :
do ; while (condition) ;
Notez que :
{ ; }
est un bloc constitué d’une seule instruction vide, ce qui est « syntaxiquement »
correct mais plutôt inutile.
Toutes les instructions structurées sauf do … while nécessitent l’emploi d’un bloc,
à partir du moment où elles contiennent plus d’une instruction. Mais un bloc
peut toujours être utilisé en dehors de ces instructions et autrement que comme
bloc principal d’une fonction. En général, on procédera ainsi en vue de
bénéficier des possibilités de déclarations spécifiques à un bloc étudiées à la
section 3.3.
Remarque
Toute instruction simple est toujours terminée par un point-virgule. Ainsi le bloc suivant est incorrect
car il manque un point-virgule à la fin de la seconde instruction qu’il contient :
{ i = 5 ; k = 3 } /* incorrect */
Par ailleurs, un bloc joue le même rôle syntaxique qu’une instruction simple (point-virgule compris). Il
faut donc éviter d’ajouter des points-virgules intempestifs à la suite d’un bloc. Dans le meilleur des
cas, cela s’avère inutile. Ainsi, dans l’instruction suivante, le point-virgule revient à ajouter une
instruction vide :
while (…) {…} ; /* ; superflu mais ne nuit pas */
En revanche, dans d’autres cas, cela peut conduire à une erreur de syntaxe. Par exemple, avec :
if (a < b) { min = a ;
max = b ;
} ; /* ; superflu et conduisant à une erreur de syntaxe */
else …..
le point-virgule placé à la fin du bloc met fin à l’instruction if, et le else qui suit conduit à une erreur.
Bien entendu, ce point-virgule n’aurait pas été gênant si l’instruction if n’avait pas contenu de else.
3.3 Déclarations dans un bloc
Lorsqu’un bloc comporte des déclarations, ces dernières sont soumises aux
règles habituelles de portée, de classe d’allocation et d’initialisation qui
s’appliquent à n’importe quel bloc, qu’il s’agisse d’un bloc interne à une
fonction ou du bloc servant de définition de la fonction. Ces règles sont décrites
en détail à la section 9 du chapitre 8. Ici, nous n’en donnerons qu’un bref extrait
assorti de quelques exemples.
3.3.1 Classe d’allocation
Par défaut, la classe d’allocation des variables déclarées dans un bloc est la
classe automatique. Ces variables voient donc leur emplacement alloué à l’entrée
dans le bloc et libéré lors de la sortie du bloc. Il est possible d’utiliser cette
particularité pour économiser certains emplacements qu’on s’arrange pour
allouer le temps nécessaire. Par exemple, si pour effectuer le traitement noté
instructions, on a besoin d’un tableau de 100 entiers, on pourra très bien procéder
ainsi :
{ int t[100] ; /* tableau alloué pour ce bloc */
/* instructions */ /* ici t est connu et utilisable */
} /* l'emplacement alloué à t est libéré à la sortie du bloc */
….. /* ici t n'est plus connu */
Notez bien que cette démarche s’applique même si le bloc en question ne fait pas
partie d’une instruction structurée : il peut très bien être précédé et/ou suivi
d’instructions simples… D’ailleurs, il est tout à fait envisageable de créer ainsi
artificiellement un bloc, uniquement en vue d’allouer un emplacement pour le
seul temps où il est nécessaire.
3.3.2 Initialisation
Les variables automatiques sont initialisées à chaque entrée dans le bloc, comme
dans :
for (i=0 ; i<20 ; i++)
{ int k = 2*i + 3 ; /* k est initialisé à chaque entrée dans le bloc */
…..
}
Au premier tour de boucle, k sera initialisée à 3, au deuxième tour à 5, etc.
On peut déclarer dans un bloc des variables de classe statique : elles ne sont
initialisées qu’une seule fois, avant l’exécution de l’ensemble du programme, et
elles conservent leur valeur d’une exécution du bloc à la suivante. Voici un
exemple dans lequel on comptabilise le nombre de passages dans un bloc :
{ static int ctr = 0 ; /* initialisation à 0 en début de programme */
ctr++ ; /* +1 sur ctr, à chaque exécution du bloc */
…..
}
3.4 Cas des branchements à l’intérieur d’un bloc
Comme nous le verrons à la section 15 consacrée à l’instruction goto, il est
théoriquement permis de se brancher depuis l’extérieur d’un bloc vers une
instruction située à l’intérieur du bloc et ce, soit par un goto, soit par le biais
d’une étiquette case xxx d’un switch. Il s’agit d’une situation fortement
déconseillée pour les risques qu’elle comporte. En effet, dans ce cas, les
initialisations des variables automatiques ne sont pas effectuées ! Considérez, par
exemple :
for (i=0 ; i<20 ; i++)
{ int k = 2*i + 3 ;
…..
suite :
…..
}
Si l’on exécute, depuis l’extérieur de ce bloc, une instruction :
goto suite ;
la valeur de k ne sera pas définie (pas plus d’ailleurs que celle de i).
4. L’instruction if
4.1 Syntaxe et rôle de l’instruction if
L’instruction if permet de programmer une structure dite de « choix » (ou
sélection ou encore alternative), permettant de choisir entre deux instructions,
suivant la valeur d’une expression numérique jouant le rôle de condition. La
seconde partie, introduite par le mot-clé else, est facultative, de sorte que
l’instruction if présente deux formes.
Les deux formes de l’instruction if
if (expression) if (expression)
instruction_1 instruction_1
else
instruction_2
expression
Expression quelconque de type scalaire (numérique
ou pointeur)
instruction_1 ou Instruction exécutable quelconque, c’est-à-dire
instruction_2
simple, structurée ou bloc
Cette instruction évalue l’expression mentionnée à la suite de if. Si elle est non
nulle, on exécute instruction_1 ; si elle est nulle, on exécute instruction_2 si cette
dernière est présente. Puis, dans tous les cas, on passe à l’instruction suivant
cette instruction if.
Remarques
1. Les parenthèses entourant l’expression jouant le rôle de condition font partie de la syntaxe de
l’instruction et sont donc obligatoires.
2. La syntaxe de cette instruction n’impose en soi aucun point-virgule, si ce n’est ceux qui terminent
naturellement les instructions simples (ou do … while) qui peuvent y figurer.
4.2 Exemples d’utilisation
4.2.1 Exemples liés à la généralité de la notion d’instruction
Les instructions_1 et instruction_2 figurant dans la syntaxe de if sont quelconques.
Voici une première instruction if dans laquelle les deux instructions concernées
sont des instructions simples :
if (a > b) max = a ;
else max = b ;
Voici une autre instruction if dans laquelle les deux instructions concernées sont
des blocs :
if (a > b) { max = a ;
printf ("maximum en a\n") ;
}
else { max = b ;
printf ("maximum en b\n") ;
}
Voici la même instruction présentée avec des indentations moins importantes,
plus adaptées à des programmes un peu complexes :
if (a > b)
{ max = a ;
printf ("maximum en a\n") ;
}
else
{ max = b ;
printf ("maximum en b\n") ;
}
On trouvera à la section 4.3 des exemples dans lesquels l’instruction if contient
elle-même d’autres instructions if.
4.2.2 Exemples sans else
max = b ;
if (a > b) max = a ;
printf ("maximum : %d", max) ;
On notera bien qu’après l’instruction :
max = a ;
le compilateur acceptera indifféremment le mot-clé else ou une autre instruction.
Dans ce dernier cas, il conclut à la seconde forme d’instruction if. Il n’est alors
plus possible (hormis les situations de if imbriqués décrits à la section 4.3) qu’un
else apparaisse plus tard comme dans :
max = b ;
if (a > b) max = a ;
printf ("maximum : %d", max) ;
else max = b ;
4.2.3 Attention aux adaptations de programmes
La remarque précédente est particulièrement sensible en cas d’adaptation de
programmes existants. Si, par exemple, on part de la forme suivante dans
laquelle instruction_1 et instructions_2 sont des instructions simples (donc
terminées par un point-virgule) :
if (…) instruction_1
else instruction_2
et qu’on souhaite par la suite introduire une seconde instruction dans le cas
« vrai », il ne faudra pas procéder ainsi :
if (…) instruction_1
instruction /* cette instruction force la seconde forme de if */
else instruction_2
Une manière d’éviter ce type de problème consiste à placer systématiquement
des blocs dans l’instruction if, quitte à ce que certains d’entre eux soient réduits
à des instructions simples, ce qui peut amener à écrire des choses telles que :
if (a > b) { max = a ; }
else { max = b ; }
4.2.4 Conséquences de la généralité de la notion d’expression en C
L’expression conditionnant le choix est quelconque. La richesse de la notion
d’expression en C fait que celle-ci peut elle-même réaliser certaines actions.
Ainsi :
if ( ++i < limite) printf ("OK") ;
est équivalent à :
i = i + 1 ;
if ( i < limite ) printf ("OK") ;
Par ailleurs :
if ( i++ < limite ) ……
est équivalent à :
i = i + 1 ;
if ( i-1 < limite ) ……
De même :
if ( ( c=getchar() ) != ‘\n' ) ……
peut remplacer :
c = getchar() ;
if ( c != ‘\n' ) ……
En revanche :
if ( ++i<max && ( (c=getchar()) != ‘\n') ) ……
n’est pas équivalent à :
++i ;
c = getchar() ;
if ( i<max && ( c!= ‘\n' ) ) ……
car l’opérateur && n’évalue son second opérande que lorsque cela est nécessaire.
Autrement dit, dans la première formulation, l’expression :
c = getchar()
n’est pas évaluée lorsque la condition ++i<max est fausse. En revanche,, elle l’est
dans la deuxième formulation.
Enfin, l’expression gouvernant le choix pourra être d’un type pointeur. Par
exemple, si p est un pointeur de type quelconque :
if (p) …..
est équivalent à :
if (p != NULL) …..
4.3 Cas des if imbriqués
4.3.1 La règle
Les instructions figurant dans chaque partie du choix d’une instruction if sont
absolument quelconques. En particulier, elles peuvent à leur tour renfermer
d’autres instructions if. Or, comme cette instruction peut comporter ou ne pas
comporter de else, il existe certaines situations qui peuvent paraître ambiguës.
C’est le cas dans cet exemple :
if (a<=b) if (b<=c) printf ("ordonne") ;
else printf ("non ordonne") ;
Est-il interprété comme le suggère cette présentation ?
if (a<=b) if (b<=c) printf ("ordonne") ;
else printf ("non ordonne") ;
Ou bien comme le suggère celle-ci ?
if (a<=b) if (b<=c) printf ("ordonne") ;
else printf ("non ordonne") ;
La première interprétation conduirait à afficher « non ordonné » lorsque la
condition a<=b est fausse, tandis que la seconde n’afficherait rien dans ce cas. La
règle adoptée par le langage C pour lever une telle ambiguïté est la suivante :
Un else se rapporte toujours au dernier if rencontré (dans le même bloc) auquel un else n’a pas
encore été attribué.
Ainsi, dans notre exemple, c’est la seconde présentation qui suggère le mieux ce
qui se passe.
4.3.2 Pour outrepasser la règle
Signalons tout d’abord que l’utilisation systématique de blocs (éventuellement
réduits à une instruction simple) évite d’avoir à se poser la question du
rattachement d’un else, comme dans :
if (a<=b) { if (b<=c) printf ("ordonne") ;
else printf ("non ordonne") ;
}
ou même, dans :
if (a<=b) { if (b<=c) { printf ("ordonne") ;
}
else { printf ("non ordonne") ;
}
}
Cette même technique, employée de façon adéquate, permet d’outrepasser
facilement la règle précédente, par exemple :
if (a<=b) { if (b<=c) printf ("ordonne") ;
}
else printf ("non ordonne") ;
Cette formulation est plus agréable que la formulation équivalente suivante :
if (a<=b) if (b<=c) printf ("ordonne") ;
else ; /* else suivi d'une instruction vide : correct */
else printf ("non ordonne") ;
4.3.3 Attention à certaines erreurs cachées
Considérez la construction suivante :
if (…) while (…)
if (…) instruction_1
else instruction_2
Quelle que soit la nature de instruction_1 (simple, structurée ou bloc), le else est
rattaché au if situé à l’intérieur de while. Une présentation plus suggestive de la
réalité serait :
if (…) while (…)
if (…) instruction_1
else instruction_2
Pour forcer le rattachement au premier if, on peut procéder ainsi :
if (…) while (…)
{ if (…) instruction_1 /* bloc réduit à une seule instruction */
} /* structurée */
else instruction_2
4.4 Traduction de choix en cascade
Il est fréquent d’avoir à exprimer ce que l’on nomme souvent des choix en
cascade, c’est-à-dire des schémas tels que celui de la figure 5-1, qui comporte
trois sélections (il va de soi que ce nombre pourrait être plus élevé) :
Figure 5-1
Choix en cascade
Un tel schéma se traduit presque littéralement en langage C de la manière
suivante, les instructions concernées pouvant être des instructions simples (donc
suivies d’un point-virgule) ou des blocs :
if (cond_1) instruction_1
else if (cond_2) instruction_2
else if (cond_3) instruction_3
else instruction_4
Exemple
Voici un exemple d’utilisation de choix en cascade. Il s’agit d’un programme de
facturation avec remise. Il lit en donnée un simple prix hors taxes et calcule le
prix TTC correspondant (avec un taux de TVA constant de 19,6 %). Il établit
ensuite une remise dont le taux dépend de la valeur ainsi obtenue, à savoir :
• 0 % pour un montant inférieur à 1 000 € ;
• 1 % pour un montant supérieur ou égal à 1 000 € et inférieur à 2 000 € ;
• 3 % pour un montant supérieur ou égal à 2 000 € et inférieur à 5 000 € ;
• 5 % pour un montant supérieur ou égal à 5 000 €.
Exemple de choix en cascade : facturation avec remise
#define TAUX_TVA 19.6
int main()
{
double ht, ttc, net, tauxr, remise ;
printf("donnez le prix hors taxes : ") ;
scanf ("%lf", &ht) ;
ttc = ht * ( 1. + TAUX_TVA/100.) ;
if ( ttc < 1000.) tauxr = 0 ;
else if ( ttc < 2000 ) tauxr = 1. ;
else if ( ttc < 5000 ) tauxr = 3. ;
else tauxr = 5. ;
remise = ttc * tauxr / 100. ;
net = ttc - remise ;
printf ("prix ttc %10.2lf\n", ttc) ;
printf ("remise %10.2lf\n", remise) ;
printf ("net a payer %10.2lf\n", net) ;
}
5. L’instruction switch
Compte tenu de l’aspect peu structuré de l’instruction switch, ainsi que de la
nature très particulière de sa syntaxe, nous commencerons par l’introduire grâce
à un exemple simple. Nous présenterons ensuite ce que nous nommons sa
syntaxe usuelle qui correspond à la manière courante d’utiliser cette instruction.
Alors seulement nous présenterons la forme théorique de switch telle qu’elle est
prévue par la norme.
5.1 Exemple introductif
La principale vocation de l’instruction switch est de permettre de programmer ce
que l’on nomme usuellement une structure de choix multiple (ou de sélection
multiple), c’est-à-dire un choix entre plusieurs possibilités, chaque possibilité
s’exprimant par une ou plusieurs instructions.
Voici un exemple d’école accompagné de trois exemples d’exécution ; il a pour
but d’illustrer le fonctionnement de switch et il ne faut pas chercher à lui attribuer
une signification pratique :
Exemple d’utilisation de l’instruction switch
#include <stdio.h>
int main()
{ int n ;
printf ("donnez un entier : ") ;
scanf ("%d", &n) ;
switch (n)
{ case 0 : printf ("nul\n") ;
case 1 :
case 2 : printf ("petit\n") ;
break ;
case 3 : printf ("moyen\n") ;
break ;
case 4 :
case 5 : printf ("grand\n") ;
break ;
default : printf ("hors norme\n") ;
break ; /* facultatif mais bonne précaution */
}
printf ("fin programme\n") ;
}
donnez un entier : 0
nul
petit
fin programme
donnez un entier : 4
grand
fin programme
donnez un entier : 10
hors norme
fin programme
Si n vaut 0, on se branche à l’étiquette case 0, si elle existe (ce qui est le cas ici) et
on exécute les instructions en séquence à partir de là. On affiche donc le texte nul
puis le texte petit. C’est seulement la rencontre de l’instruction break qui met fin à
l’exécution de l’instruction switch en passant à l’instruction suivante laquelle, ici,
affiche le texte fin programme.
Si n vaut 4, on se branche à l’étiquette case 4, ce qui amène à l’affichage du texte
grand. L’instruction break suivante met fin à l’instruction switch.
Lorsque la valeur de n n’est pas trouvée dans les différentes étiquettes de la
forme case xxx, on se branche à l’étiquette default (si elle n’existait pas, on serait
tout simplement sorti du switch sans rien faire).
5.2 Syntaxe usuelle et rôle de switch
La syntaxe théorique de l’instruction switch est présentée à la section 5.4.2. Nous
en donnons ici une forme un peu plus particulière mais beaucoup plus
significative quant à la manière dont on emploie cette instruction en pratique,
c’est-à-dire, en définitive, pour programmer une structure de choix multiple.
L’instruction switch (forme usuelle)
switch (expression)
{ case constante_1 : [ suite_d_instructions_1 ]
case constante_2 : [ suite_d_instructions_2 ]
…………..
case constante_n : [ suite_d_instructions_n ]
[ default : suite_d_instructions ]
}
expression
Expression de type
entier (char, short, int ou
1
long ) signé ou non
constante_i
Expression constante Les expressions
entière constantes sont définies
à la section 14 du
chapitre 4
suite_d_instructions_i
Séquence d’instructions
Attention, il ne s’agit
quelconques pas nécessairement
d’un bloc
1. Certaines anciennes implémentations peuvent ne pas accepter d’entiers longs.
N.B. : les crochets ([ et ]) signifient que leur contenu est facultatif.
Le compilateur prévoit l’évaluation de l’expression selon les règles habituelles et
la soumet éventuellement à une promotion numérique. Au final, le résultat est de
type int ou long (signé ou non). Par ailleurs, il convertit systématiquement les
expressions constantes dans le type final de l’expression et s’assure qu’aucune
valeur n’apparaît en double.
Lors de l’exécution, l’expression est évaluée et il y a branchement à l’étiquette
case xxx correspondante si elle existe. Dans le cas contraire, il y a branchement à
l’étiquette default si elle existe, à la suite de l’instruction switch sinon.
Remarques
1. Les parenthèses entourant l’expression font partie de la syntaxe de l’instruction et sont donc
obligatoires.
2. Les étiquettes de la forme case xxx doivent obligatoirement comporter un ou plusieurs espaces
entre case et la valeur entière xxx. En revanche, cela n’est pas nécessaire entre xxx et les deux-
points suivants, même si, en pratique, cela améliore la lisibilité.
3. En théorie, rien n’interdit que certaines étiquettes de la forme case xxx apparaissent à l’intérieur de
blocs englobés dans le bloc gouverné par switch. Cette possibilité est vivement déconseillée mais
elle sera néanmoins étudiée à la section 5.4.
4. La syntaxe de l’instruction switch justifie qu’on la classe traditionnellement dans les instructions
structurées. Elle n’en reste pas moins une instruction relativement hybride, dans la mesure où :
– elle se contente de mettre en place un « aiguillage » basé sur la valeur d’une expression ;
– elle laisse le programmeur décider de mettre fin quand bon lui semble au traitement d’un cas
donné. En général, il utilise pour ce faire des instructions break mais celles-ci ne font pas
vraiment partie de la syntaxe de l’instruction switch elle-même. On pourrait à la limite écrire une
instruction switch ne comportant aucune instruction break…
5.3 Commentaires
5.3.1 Type des constantes suivant case
Les constantes suivant case ne peuvent pas être d’un type flottant. Une telle
contrainte est parfaitement justifiée. En effet, il ne faut pas oublier que la
comparaison d’égalité entre flottants est relativement aléatoire, compte tenu de la
précision limitée des calculs. De surcroît, la nature même des étiquettes rendrait
assez difficile la prise en compte de valeurs flottantes…
En revanche, ces constantes peuvent être d’un type caractère puisqu’elles seront,
de toute façon, converties dans le type de l’expression, laquelle sera de l’un des
types int ou long. On voit que la construction suivante, dans laquelle c est de type
char, est parfaitement légale :
switch(c)
{ case ‘a' : ……
case 132 : …..
……
}
Il en va de même pour celle-ci, où n est supposée de type int :
switch (n)
{ case ‘A' : …..
case 559 : …..
case 4023 : …..
…….
}
Notez que si n était de type char, l’instruction précédente serait acceptée, mais il
est fort probable que certaines des étiquettes ne pourraient jamais être atteintes.
5.3.2 Après case, on peut trouver une expression constante
Rappelons qu’on nomme « expression constante », une expression qui peut être
évaluée lors de la compilation. Les différentes formes possibles pour les
expressions constantes entières ont été définies à la section 14 du chapitre 4.
Bien entendu, il ne sert à rien d’écrire case 5+ 2 plutôt que case 7. En revanche,
l’utilisation de symboles définis par la directive #define présente un intérêt. En
voici un exemple :
#define LIMITE 20
…..
switch (n)
{ …..
case LIMITE-1 : ……
case LIMITE : ……
case LIMITE+1 : ……
}
À la compilation, les expressions LIMITE-1, LIMITE et LIMITE+1 seront effectivement
remplacées par les valeurs 19, 20 et 21.
Cette façon de procéder permet un certain « paramétrage » des programmes.
Ainsi, dans cet exemple, une modification de la valeur de LIMITE se résume à une
seule intervention au niveau de la directive #define. Notez bien qu’une variable
initialisée à 20 au sein du programme ne pourrait pas être utilisée puisque alors
les étiquettes de l’instruction switch ne seraient plus des expressions constantes. Il
en va de même pour une constante symbolique déclarée par1 :
const int N = 20 ; /* N n'est pas une expression constante en C */
5.4 Quelques curiosités de l’instruction switch
Les sections précédentes ont décrit la manière usuelle d’utiliser l’instruction
switch. La norme autorise des possibilités théoriquement plus larges, mais en
réalité fort dangereuses. En effet :
• les étiquettes concernées peuvent se trouver à l’intérieur de blocs eux-mêmes
inclus dans le bloc gouverné par cette instruction ;
• l’instruction concernée par le switch peut être une instruction quelconque et non
seulement un bloc, comme nous l’avons laissé entendre à la section 5.2.
5.4.1 Étiquettes enfouies dans des blocs
La seule contrainte pesant sur les étiquettes case xxx concernées par switch est que
ces étiquettes sont recherchées à l’intérieur du bloc régi par cette instruction.
Cela revient à dire qu’on autorise tout branchement ayant lieu vers l’intérieur du
bloc, quelle qu’en soit la profondeur. En particulier, on peut ainsi provoquer des
branchements vers l’intérieur d’un bloc avec les conséquences décrites à la
section 15.
Par exemple, cette construction serait admise :
switch (n)
{ case 1 : …..
if (…) { …..
case 2 : …..
break ;
}
else { …..
case 5 :
default :
}
for (i=0 ; i<5 ; i++)
{ case 6 : …..
}
case 4 :
}
Quant n vaut -6, cette instruction provoque un branchement à l’intérieur du bloc
régit par l’instruction for, la valeur de i étant alors indéfinie !
Il existe toutefois une limitation à un branchement vers l’intérieur d’un bloc régit
par switch, à savoir que la recherche d’étiquette n’est pas effectuée dans les blocs
régis par d’éventuelles instructions switch englobées dans l’instruction switch
concernée. Par exemple :
switch(n)
{ case 1 : …..
switch(p)
{ case 5 : …..
…..
}
…..
}
Si, lors de l’entrée sur le premier switch, n vaut 5, il n’y aura pas branchement sur
le case 5 du deuxième switch, même si aucune étiquette case 5 n’apparaît pas dans
le premier switch ; dans ce dernier cas, il y aura simplement branchement à
l’étiquette default si elle existe ou passage à la suite du switch.
5.4.2 La forme théorique de switch
La syntaxe usuelle présentée à la section 5.2 suppose que l’instruction switch fait
intervenir un bloc apparaissant à la suite de la condition. En fait, la norme
prévoit une syntaxe beaucoup plus simple :
La syntaxe théorique de switch
switch (expression)
instruction
Cela autorise des constructions aussi stupides que (le if est ici légal mais
inutile) :
switch (n)
if (…) { case 1 : …..
case 2 : …..
}
else …..
En combinant cette syntaxe avec la possibilité précédente, on peut aboutir à des
constructions légales telles que :
switch (n)
default : if ( a<b ) { case 1 : ….. /* effectué si n=1 ou si a<b */
case 2 : ….. /* effectué si n=2 ou si a<b */
}
else { case 0 : ….. /* effectué si n=0 ou si a>=b */
}
6. Choix entre if et switch
On démontre qu’il suffit théoriquement de disposer de deux structures de base
(choix et répétition) pour traduire tout programme. Dans ces conditions, le switch
peut apparaître comme une structure redondante par rapport à if. Il n’en reste pas
moins que, dès qu’on a affaire à une énumération de cas, switch peut s’avérer plus
pratique que if et conduire à des programmes plus lisibles. Cependant,
l’instruction switch semble souffrir de limitations sévères, dans la mesure où,
pour l’utiliser, il faut :
• que les conditions de sélection puissent, au bout du compte, porter sur des
valeurs entières ;
• que chaque partie de la sélection soit associée à un nombre pas trop grand de
valeurs pour qu’on puisse les énumérer facilement sous la forme case xxx.
En fait, on peut toujours « préparer le terrain » en initialisant préalablement, par
des instructions de choix, une variable entière avec un nombre restreint de
valeurs entières.
Par exemple, supposons qu’on souhaite effectuer un traitement sur des pièces
cylindriques en fonction de leur diamètre (nombre flottant) :
float diametre ;
…..
if (diametre < 1.5) printf ("hors norme\n" ) ;
if (diametre >= 1.5) && (diametre < 1.7) printf ("second choix\n) ;
if (diametre >= 1.7) && (diametre < 1.8) printf ("premier choix\n) ;
if (diametre >= 1.8) && (diametre < 2.0) printf ("second choix\n) ;
if (diametre >2.0) printf ("hors norme\n") ;
Voici une façon (parmi d’autres) d’utiliser un switch :
float diametre ;
int choix ;
…..
choix = 0 ;
if (diametre >= 1.5) && (diametre < 1.7) choix = 2 ;
if (diametre >= 1.7) && (diametre < 1.8) choix = 1 ;
if (diametre >= 1.8) && (diametre < 2.0) choix = 2 ;
switch (choix)
{ case 1 : printf ("premier choix\n") ;
case 2 : printf ("second choix\n") ;
default : printf ("hors norme\n") ;
}
D’une manière générale, le choix entre ces deux formes d’expression n’est pas
évident. Il dépend étroitement de l’habileté du programmeur et de la nature du
problème à résoudre : plus les valeurs associées aux différentes constantes case
xxx auront un caractère artificiel et moins l’utilisation du switch sera justifiée.
7. Les particularités des boucles en C
7.1 Rappels concernant la programmation structurée
Traditionnellement, en programmation structurée, on distingue trois sortes de
boucles :
• Les boucles dites « tant que » : on y répète des instructions tant qu’une certaine
condition est réalisé[Link] condition, dite « de poursuite », est examinée avant
chaque nouveau tour de boucle.
• Les boucles dites « jusqu’à » : on y répète des instructions jusqu’à ce qu’une
certaine condition soit réalisée. Cette condition, dite « condition d’arrêt » est
examinée après chaque tour de boucle.
• Les boucles dites « avec compteur » : on y répète des instructions en faisant
évoluer une variable particulière nommée compteur depuis une valeur initiale
jusqu’à une valeur finale.
On parle souvent de boucles indéfinies dans les deux premiers cas car le nombre
de tours n’est pas nécessairement connu au moment de l’entrée dans la boucle.
On parle de boucle définie dans le dernier cas, car le nombre de tours de boucle
est alors parfaitement déterminé lors de l’entrée dans la boucle par la valeur
initiale et la valeur finale.
La figure 5-2 présente les organigrammes correspondant à ces trois types de
boucles.
Figure 5-2
Les trois sortes de boucles en programmation structurée
7.2 Les boucles en C
Théoriquement, le langage C dispose de trois instructions évoquant plus ou
moins les trois structures précédentes :
• L’instruction while : elle correspond exactement à une boucle de type « tant
que ».En particulier, la condition est bien examinée avant chaque tour de
boucle.
• L’instruction do … while : elle correspond à une boucle de type « jusqu’à » dans
laquelle on exprime simplement une condition de poursuite au lieu d’une
condition d’arrêt (il s’agit de la condition inverse). La condition est bien
examinée après chaque tour de boucle.
• L’instruction for : malgré son nom, elle apparaît en fait comme un canevas de
type « tant que », à compléter en précisant :
– les actions à réaliser avant d’entrer dans la boucle ;
– les actions à réaliser à la fin de chaque tour ;
– la condition de poursuite.
Certes, elle est souvent utilisée pour programmer une boucle avec compteur
(en complétant le canevas comme il faut), mais il ne s’agit là que d’une
utilisation particulière.
Ces trois instructions pourront s’écarter plus ou moins de l’aspect structuré pour
deux raisons.
La première de ces raisons est que la condition régissant la poursuite d’une
boucle est en fait, comme toute condition en C, une expression quelconque. Elle
peut donc éventuellement réaliser une action après le test de sortie de boucle (cas
de for et while) ou après le test de poursuite (cas de do … while). On aboutira ainsi à
ce qu’on nomme parfois des « boucles à sortie intermédiaire ». Bien comprise,
cette possibilité pourra simplifier les choses ; mal utilisée, elle pourra conduire à
des programmes peu lisibles et peu adaptables.
La seconde des raisons qui fait échapper ces trois instructions à l’aspect structuré
est que, à l’intérieur du corps de la boucle (instructions à répéter), on pourra
trouver des instructions de rupture de séquence, en vue :
• soit de forcer le passage au tour de boucle suivant (instruction continue) ; on
verra que cette possibilité pourra constituer une formulation concise et lisible à
un problème usuel alors même que le pur respect de la programmation
structurée conduirait à une formulation plus lourde ;
• soit de mettre fin prématurément à la boucle ; deux sortes d’instructions
peuvent intervenir :
– break : on met fin à la boucle pour passer simplement à l’instruction
suivante. Là encore, malgré son aspect non structuré, cette possibilité reste
intéressante pour réaliser des boucles à sorties multiples ou pour gérer des
situations extraordinaires qui, en programmation structurée pure,
conduiraient à des formulations plus lourdes.
– goto ou return : on met fin à la boucle en se branchant ailleurs qu’à
l’instruction suivante. Cette fois cette possibilité ne sera conseillée que dans
des circonstances très particulières.
Ici, nous étudierons tout d’abord chacune des trois instructions do … while, while et
for, en montrant leurs ressemblances et leurs différences avec les trois structures
de boucle proposées par la programmation structurée. Puis, après avoir présenté
l’instruction break, nous verrons comment l’exploiter pour créer de nouveaux
schémas de boucle.
8. L’instruction do … while
Comme l’indique la section 7, l’instruction do … while permet de réaliser des
boucles de type « jusqu’à », mais la richesse de la notion d’expression en C peut
amener à la dénaturer quelque peu.
8.1 Syntaxe
L’instruction do … while
do instruction
while (expression) ;
instruction
instruction quelconque :
simple, structurée ou
bloc
expression
expression quelconque Nommée parfois
de type scalaire « expression de
(numérique ou contrôle de la boucle »
pointeur) ou condition de
poursuite
Remarques
1. Notez bien, d’une part la présence de parenthèses autour de l’expression de contrôle de la boucle et
d’autre part, la présence d’un point-virgule à la fin de cette instruction (qui se trouve être la seule ne
faisant pas partie des instructions simples à posséder un point-virgule).
2. Lorsque l’instruction à répéter se limite à une seule instruction simple, n’omettez pas le point-
virgule qui la termine. Ainsi, la syntaxe :
do c = getchar() while ( c != ‘x') ;
est incorrecte. Il faut absolument écrire :
do c = getchar() ; while ( c != ‘x') ;
8.2 Rôle
Cette instruction exécute l’instruction, puis elle évalue l’expression de contrôle
suivant les règles habituelles. Si cette dernière est nulle, elle passe à l’instruction
suivant do … while et l’exécution de la boucle est terminée. Dans le cas contraire
(expression non nulle), on reprend le processus d’exécution de instruction et ainsi
de suite.
L’exécution d’une telle boucle peut prendre fin :
• de manière naturelle : la valeur de l’expression de contrôle est devenue nulle ;
• de manière prématurée : une instruction de rupture de séquence (break, goto ou
return) a été exécutée de l’intérieur vers l’extérieur du corps de boucle.
Il est fréquent de traduire le rôle de do … while par l’organigramme suivant :
Figure 5-3
L’instruction do … while (en l’absence de branchements dans instruction)
On peut alors dire qu’au sens de la programmation structurée, elle réalise une
boucle de type « jusqu’à » (voir § 7.1) dans laquelle la condition de poursuite
serait ! expression. Autrement dit, instruction est répétée jusqu’à ce que l’expression
de contrôle soit fausse. Cette affirmation doit cependant être nuancée pour au
moins deux raisons :
• l’instruction correspondant au corps de boucle peut très bien contenir des
branchements non exprimés par cet organigramme (break, continue, goto) ;
• la notion d’expression, en C, dépasse largement celle de simple condition.
8.3 Exemples d’utilisation
8.3.1 Utilisation naturelle de do … while
Comme indiqué à la section 7, do … while peut servir à programmer une boucle
« jusqu’à », pour peu qu’on se limite à l’arrêt naturel et qu’on n’introduise pas
d’actions dans la condition de poursuite. Voici un exemple naturel d’emploi de do
… while :
Exemple d’utilisation naturelle d’instruction do… while
int main()
{ int n ; donnez un nb >0 : -3
do vous avez fourni -3
{ printf ("donnez un nb >0 : ") ; donnez un nb >0 : -9
scanf ("%d", &n) ; vous avez fourni -9
printf ("vous avez fourni %d\n", n) ; donnez un nb >0 : 12
} vous avez fourni 12
while (n <= 0) ; reponse correcte
printf ("reponse correcte\n") ;
}
8.3.2 Utilisation artificielle d’actions dans la condition
Il est théoriquement possible d’utiliser l’opérateur séquentiel au sein de
l’expression de contrôle pour juxtaposer, au bout du compte, plusieurs
expressions. Ainsi, l’exemple précédent peut également s’écrire :
do { printf ("donnez un nb > 0 : ") ;
scanf ("%d", &n) ;
}
while ( printf("vous avez fourni %d\n", n), n <= 0 ) ;
ou encore :
do printf ("donnez un nb >0 : ") ;
while ( scanf("%d", &n), printf ("vous avez fourni %d\n", n), n <= 0 ) ;
ou même :
do { }
while ( printf ("donnez un nb > 0 : "), scanf ("%d", &n),
printf ("vous avez fourni %d\n", n), n <= 0 ) ;
La dernière formulation utilise un corps vide, ce qui n’empêche nullement la
boucle de « réaliser quelque chose » !
De la même manière, la formulation suivante :
do { } while ( (c=getchar()) != ‘$' ) ;
est équivalente à :
do c = getchar() ; while ( c != ‘$' ) ;
D’une manière générale, on peut dire que le schéma suivant :
do instruction
while (expression_1, expression_2) ;
est équivalent à :
do
{ instruction
expression_1 ;
}
while (expression_2) ;
Cela montre bien que cette possibilité de déplacement d’une expression du corps
de boucle vers la condition ne présente guère d’intérêt en pratique ; elle a même
tendance à obscurcir le programme !
8.3.3 Boucles d’apparence infinie
La construction :
do { } (1) ;
représente une « boucle infinie ». Elle est syntaxiquement correcte, bien qu’elle
ne présente en pratique aucun intérêt. En revanche :
do instruction while (1) ;
pourra présenter un intérêt dans la mesure où il est possible d’en sortir
éventuellement par une instruction break figurant dans instruction. Cette
formulation sera d’ailleurs équivalente à :
while (1) instruction
C’est cette dernière forme que nous exploiterons à la section 14 pour créer des
schémas de boucles à sortie intermédiaire ou à sorties multiples.
9. L’instruction while
Comme vu à la section 7, l’instruction while permet de réaliser des boucles de
type « tant que », mais la richesse de la notion d’expression en C peut la
dénaturer quelque peu.
9.1 Syntaxe
L’instruction while
while (expression)
instruction
instruction
Instruction quelconque :
simple, structurée ou bloc
expression
Expression quelconque de Nommée parfois
type scalaire (numérique ou « expression de contrôle de
pointeur) la boucle » ou condition de
poursuite
Remarque
Notez bien la présence de parenthèses autour de l’expression de contrôle de la boucle. En revanche,
contrairement à ce qui se passe pour do … while, la syntaxe n’impose ici aucun point-virgule de fin.
9.2 Rôle
Cette instruction évalue l’expression de contrôle suivant les règles habituelles. Si
cette dernière est nulle, elle passe à l’instruction suivant while et l’exécution de la
boucle est terminée. Dans le cas contraire (expression non nulle), on exécute
l’instruction gouvernée par while et on reprend le processus d’évaluation de
l’expression et ainsi de suite.
On notera bien qu’une telle boucle peut prendre fin :
• de manière naturelle : la valeur de l’expression de contrôle est devenue nulle ;
• de manière prématurée : une instruction de rupture de séquence (break, goto ou
return) a été exécutée de l’intérieur vers l’extérieur du corps de boucle.
Il est fréquent de traduire le rôle de while par l’organigramme de la figure 5-4
Figure 5-4
L’instruction while (en l’absence de branchements dans instruction)
On peut alors dire qu’au sens de la programmation structurée, elle réalise une
vraie boucle de type « tant que » (voir section 7.1), la condition de poursuite
étant simplement mentionnée dans expression (alors que pour do … while, il fallait
inverser la condition pour aboutir à une vraie boucle de type « jusqu’à »). Cette
affirmation doit cependant être nuancée pour au moins deux raisons :
• l’instruction correspondant au corps de boucle peut très bien contenir des
branchements non exprimés par cet organigramme ;
• la notion d’expression, en C, dépasse largement celle de simple condition.
9.3 Lien entre while et do … while
En théorie de la programmation, on montre qu’une seule structure de boucle
(tant que ou jusqu’à) permet d’écrire n’importe quel programme. Effectivement,
on peut toujours remplacer :
do instruction while (expression) ;
par :
instruction
while (expression) do instruction
mais au prix de la duplication de instruction.
De même, on peut toujours remplacer :
while (expression) do instruction
par :
if (expression) do instruction while (expression) ;
au prix d’un test supplémentaire.
Remarques
1. Lorsque le corps de boucle est vide, do … while et while sont équivalentes :
do {} while (expression) ;
while (expression) {}
Par exemple, ces deux instructions sont équivalentes :
do {} while ( (c=getchar()) != ‘*') ;
while ( (c=getchar()) != ‘*') do {}
2. Contrairement à ce qui se produit pour do … while, si l’expression de contrôle est nulle lors de
l’entrée dans la boucle, l’instruction constituant le corps de boucle n’est pas exécutée.
De même, la valeur de l’expression de contrôle doit être définie avant l’entrée dans la boucle.
Dans le cas de do … while, elle peut ne pas l’être pour peu que les instructions gouvernées par la
boucle lui donnent effectivement une valeur.
9.4 Exemples d’utilisation
9.4.1 Utilisation naturelle de while
Voici un programme qui demande à l’utilisateur de fournir des nombres entiers
jusqu’à ce que la somme de ces nombres atteigne ou dépasse la valeur 100 :
Exemple d’instruction while
int main()
{
int n, som ; donnez un nombre : 15
som = 0 ; donnez un nombre : 25
while (som<100) donnez un nombre : 12
{ printf ("donnez un nombre : ") ; donnez un nombre : 60
somme obtenue : 112
scanf ("%d", &n) ;
som += n ;
}
printf ("somme obtenue : %d", som) ;
}
9.4.2 Utilisation artificielle d’actions dans la condition
Comme dans le cas de do … while, il est possible d’utiliser pour while une
expression de contrôle faisant appel à l’opérateur séquentiel pour « juxtaposer »
au bout du compte plusieurs expressions. Par exemple :
while (exp1, exp2, exp3) instruction
Dans ce cas, il faut bien voir que toutes les expressions (exp1, exp2 et exp3) sont
évaluées avant d’effectuer le test de poursuite qui, quant à lui, ne porte que sur la
valeur de la dernière expression (ici exp3). Les autres expressions n’interviennent
en fait que par les actions qu’elles provoquent.
Ainsi, l’instruction précédente n’est équivalente à aucune de ces formulations :
while (exp1) exp1 ; while(exp3)
{ exp2 ; exp2 ; { exp1 ;
exp3 ; while (exp3) exp2 ;
instruction instruction instruction
} }
ni même à celle-ci :
do instruction
while (exp1, exp2, exp3) ;
En fait, elle correspond à une boucle à sortie intermédiaire dont nous reparlerons
à la section 14.1 :
while (1)
{ exp1 ;
exp2 ;
if (!exp3) break ;
instruction
}
En ce sens, et contrairement à ce qui passait pour do … while (voir section 8.3.2),
une telle construction présente un intérêt manifeste.
9.4.3 Boucles d’apparence infinie
La construction :
while (1) { } /* ou encore while (1) ; */
représente une boucle infinie. Elle est syntaxiquement correcte, bien qu’elle ne
présente en pratique aucun intérêt. En revanche :
while (1) instruction
pourra présenter un intérêt dans la mesure où il est éventuellement possible d’en
sortir par une instruction break. Nous l’exploiterons à la section 14 pour créer de
nouveaux schémas de boucle à sortie intermédiaire ou à sorties multiples.
Rappelons que, comme indiqué à la section 8.3.3, cette forme est équivalente à :
do {} while (1) ;
10. L’instruction for
Nous avons vu à la section 7 que l’instruction for permet de réaliser des boucles
avec compteur mais sa structure même, jointe à la richesse de la notion
d’expression en C peut la dénaturer très profondément.
10.1 Introduction
La principale vocation de l’instruction for est de permettre de programmer ce que
l’on appelle des boucles avec compteur (voir section 7.1) dans lesquelles une
variable particulière dite variable de contrôle ou compteur sert à compter les
tours de boucle. En voici un exemple, dans lequel on place la valeur 0 dans les
différents éléments d’un tableau de 10 entiers, le compteur i évoluant ici de 0 à
9 :
int t[10] ;
int i ;
…..
for (i=0 ; i<10 ; i++) t[i]= 0 ;
Cependant, par sa nature même, for est en réalité une boucle de type « tant que »
(analogue à while), dans laquelle on peut préciser, par le biais d’expressions
appropriées :
• les actions à réaliser avant l’entrée dans la boucle ; dans notre exemple, nous
n’y trouvons qu’une seule initialisation (i=0) mais rien n’interdit à l’instruction
for d’en introduire plusieurs, voire d’introduire d’autres actions que des
affectations ;
• les actions à réaliser à la fin de chaque tour ; dans notre exemple, il s’agit de i++
mais, là encore, rien n’interdit d’introduire plusieurs actions ;
• la condition de poursuite ; dans notre exemple, il s’agit de i<10.
Notez que nous parlons d’actions bien que celles-ci soient provoquées par des
expressions. Mais une expression qui ne réaliserait aucune action n’aurait aucun
intérêt à ce niveau.
10.2 Syntaxe
L’instruction for
for ( [ expression_1 ] ; [ expression_2 ] ; [ expression_3 ] )
instruction
instruction
Instruction
quelconque : simple,
structurée ou bloc
expresion_1 et Expressions de type Pas nécessairement
expression_3
quelconque scalaire ici
expression_2
Expression de type Si cette expression est
scalaire (numérique ou omise, tout se passe
pointeur) comme si elle avait
pour valeur 1 (vrai)
N.B : les crochets ([ et ]) signifient ici que leur contenu est facultatif.
10.3 Rôle
Cette instruction réalise les étapes suivantes :
1. On évalue expression_1 si elle est présente.
2. On évalue expression_2 (si elle est absente, cela revient à lui attribuer la valeur
1). Si sa valeur est nulle, l’exécution de la boucle for est terminée. Dans le cas
contraire, on exécute l’instruction et on évalue expression_3 si cette dernière est
présente.
3. On recommence le processus à l’étape 2.
On notera bien qu’une telle boucle peut prendre fin :
• de manière naturelle : la valeur de expression_2 est devenue nulle ;
• de manière prématurée : une instruction de rupture de séquence (break, goto ou
return) a été exécutée de l’intérieur vers l’extérieur du corps de boucle formé
par instruction.
Il est fréquent de traduire le rôle de for par l’organigramme de la figure 5-5 :
Figure 5-5
L’instruction for (en l’absence de branchements dans instruction)
Toutefois, on n’oubliera pas qu’en langage C, la notion d’expression dépasse
celle de simple condition. Aussi, l’instruction correspondant au corps de boucle
peut très bien contenir des branchements non exprimés par cet organigramme !
Et même en l’absence de branchements, on n’aboutira à une véritable « boucle
avec compteur » (au sens de la programmation structurée) que lorsque :
• expression_1 se réduit à une affectation d’une valeur initiale à une variable
compteur ;
• expression_2 se réduit à une comparaison entre ce compteur et une valeur limite ;
• expression_3 se réduit à une incrémentation du compteur.
10.4 Lien entre for et while
L’instruction for est une instruction while qui s’ignore. D’ailleurs, comme le
précise formellement la norme ANSI, l’instruction :
for (expression_1 ; expression_2 ; expression_3) instruction
est équivalente, en l’absence de branchements dans instruction, à :
expression_1 ;
while (expression-2)
{ instruction
expression-3 ;
}
Avec notre exemple d’introduction, cela conduirait à :
i = 0 ;
while (i < 10)
{ t[i] = 0 ;
i++ ;
}
10.5 Commentaires
Les expressions 1 et 3 n’ont d’intérêt que pour leur action
Manifestement, les valeurs de expression_1 ou de expression_3 ne jouent aucun rôle
dans le déroulement de l’instruction for. Ainsi, cette construction (artificielle) :
for (2*i ; i<10 ; i>5) instruction
n’a pas plus d’intérêt que :
for ( ; i<10 ; ) instruction
Chacune des trois expressions est facultative
Ainsi, ces trois constructions sont équivalentes :
for (i=1 ; i<=5 ; i++) /* construction la plus usuelle et recommandée */
{ instructions
}
i = 1 ;
for ( ; i<=5 ; i++) /* initialisation absente de for */
{ instructions
}
i = 1 ; /* incrémentation de fin de boucle absente */
for ( ; i<=5 ;)
{ instructions
i++ ; /* attention à l'emplacement de cette incrémentation */
} /* dans le cas où i est utilisé dans instructions */
Attention aux compteurs à valeurs réelles
L’instruction for est souvent utilisée pour programmer une boucle avec compteur.
Certains langages imposent à de telles boucles d’utiliser des compteurs de type
entier, ce qui évite tout risque lié aux approximations de valeurs flottantes. En C,
aucune limitation de ce genre n’existe et pour cause, la notion de compteur n’y
apparaît même pas ! Il est tout à fait envisageable d’y écrire des choses telles
que :
float x ;
…..
for (x=0 ; x<1.0 ; x+=0.1) /* on pourrait, par exemple, ici, calculer la valeur */
{ ….. } /* d'une fonction, pour les différentes valeurs de x */
Or cette construction n’est pas portable car, suivant les implémentations, elle
conduira :
• à 11 tours de boucle, x prenant les valeurs : 0 ; 0,1 (environ) ; 0,2 (environ)…
0,9 (environ) et environ 1, si le résultat des 10 premières incrémentations de x
conduit à une valeur (voisine de 1) légèrement inférieure à 1 ;
• à 10 tours de boucle seulement, x prenant les valeurs 0 ; 0,1 (environ) ; 0,2
(environ)… et 0,9 (environ) si le résultat des 10 premières incrémentations de x
conduit à une valeur (voisine de 1) légèrement supérieure à 1.
En fait, il est plus judicieux de procéder ainsi :
float x ; int i ;
…..
for (i=0, x=0 ; i<10 ; i++, x+=0.1 )
{ ….. }
ou encore ainsi :
for (i=0 ; i<10 ; i++ )
{ x = i * 0.1 ;
…..
}
10.6 Exemples d’utilisation
10.6.1 Exemples liés à la généralité de la notion d’expression en C
Comme expression_1 n’intervient que par son action, elle peut être indifféremment
placée dans for ou avant (voir le schéma section 10.4). Là encore, il est possible
d’utiliser l’opérateur pour juxtaposer, au bout du compte, plusieurs expressions.
Dans ce cas, une instruction telle que :
for (j=1, k=5, i=0 ; … ; … )
est équivalente à :
j=1 ; k=5 ;
for ( i=0 … ; … )
ou encore à :
j=1 ; k=5 ; i=0 ;
for ( ; … ; …)
D’une manière générale, il ne faut user de cette liberté que pour placer
éventuellement plusieurs initialisations dans for, à condition que ces dernières
soient logiquement liées entre elles.
Une remarque similaire s’applique à expression_3, qui n’intervient que par son
action et qui peut donc être éventuellement glissée à la fin des instructions à
répéter. Ainsi :
for ( i=1 ; i <= 5 ; printf("fin de tour"), i++ ) { instructions }
est équivalent à :
for ( i=1 ; i<=5 ; i++ )
{ instructions
printf ("fin de tour") ;
}
En revanche, une telle remarque ne s’applique pas à expression_2. En effet,
comme le montre le schéma de la section 10.4, lorsque expression_2 est formée de
deux expressions exp_2a et exp_2b :
for (expression_1 ; exp_2a, exp_2b ; expression_3 ) { instructions }
elle est alors équivalente à :
expression_1 ;
while (exp_2a, exp_2b)
{ instructions
expression_3 ;
}
c’est-à-dire en fait à :
expression_1 ;
while (1)
{ exp_2a ;
if (!exp_2b) break ;
instructions
expression_3 ;
}
Cette construction correspond à une boucle à sortie intermédiaire, exposée en
détail à la section 14.1. Cependant, il n’y a aucun intérêt à utiliser for dans ce
cas, while convenant beaucoup mieux.
Notez bien, à ce propos, que :
for ( i=1, printf("on commence") ; printf("debut de tour"), i<=5 ; i++)
{ instructions }
n’est pas équivalent à :
printf ("on commence") ;
for ( i=1 ; i<=5 ; i++ )
{ printf ("debut de tour") ;
instructions
}
En effet, dans la première construction, le message debut de tour est affiché après
le dernier tour tandis qu’il ne l’est pas dans la seconde construction.
10.6.2 Boucles d’apparence infinie
La construction :
for ( ; ; ;) {} /* ou éventuellement for (; ; ;) ; */
représente une boucle infinie. Elle est syntaxiquement correcte, bien qu’elle ne
présente en pratique aucun intérêt. En revanche, la construction suivante :
for ( ; ; ;) instruction
pourra présenter un intérêt dans la mesure où il est possible d’en sortir
éventuellement par une instruction break. Néanmoins, la forme équivalente
suivante :
while (1) instruction
est préférable car nettement plus explicite. C’est d’ailleurs cette dernière que
nous exploiterons à la section 14 pour créer de nouveaux schémas de boucle à
sortie intermédiaire ou à sorties multiples.
11. Conseils d’utilisation des différents types de
boucles
Comme nous l’avons déjà mentionné, en théorie de la programmation, on
montre qu’il suffit d’un seul type de boucle pour écrire n’importe quel
programme. Dans ces conditions, on pourrait se contenter de n’utiliser qu’une
seule des trois instructions while, do … while et for. Néanmoins, suivant la nature du
problème à résoudre, telle ou telle forme de boucle s’avérera plus adaptée en
conduisant à des formulations plus simples et plus naturelles. Ici, nous
examinons quelques critères de choix de la bonne instruction, ainsi que quelques
règles de bon usage.
Notez qu’en C, comme nous le verrons à la section 14, il sera possible, si on le
désire, de compléter les trois schémas de boucle de la programmation structurée
par des schémas mieux adaptés aux situations dites de boucle à sortie
intermédiaire ou de boucle à sorties multiples. Leur usage ne contredira pas ce
qui exposé ici, il le complétera simplement.
11.1 Boucle définie
Tout d’abord, si le problème conduit à une boucle définie, c’est-à-dire si le
nombre de tours de boucle est connu au moment de l’entrée dans la boucle, il est
plus naturel d’utiliser une boucle avec compteur, et donc de faire appel à for sous
la forme :
for (i=debut ; i<=fin ; i++) …..
Les valeurs de debut et de fin pourront tout à fait résulter d’un calcul préalable,
autrement dit être des variables et non obligatoirement des constantes. On notera
que si la valeur de debut est strictement supérieure à celle de fin, on n’effectuera
simplement aucun tour de boucle…
On évitera la modification de la valeur du compteur à l’intérieur de la boucle. En
effet, une telle action revient à contredire le nombre de tours annoncé par
l’instruction for. Outre le fait qu’elle rende difficile la lecture du programme, elle
présente l’énorme risque de conduire à des boucles infinies… Quoi qu’il en soit,
si vraiment le problème semble insoluble sans ce genre d’action sur le compteur,
c’est que probablement il ne doit pas s’exprimer sous la forme d’une boucle
définie, mais d’une boucle indéfinie (while ou do … while). Signalons que cette
contrainte relative à l’absence de modification du compteur n’est
malheureusement pas imposable en C, pour la bonne raison que la notion de
compteur est absente de l’instruction for. Une telle contrainte existe dans la
plupart des autres langages.
De manière similaire, si fin est une variable, on évitera d’en modifier la valeur à
l’intérieur de la boucle. S’il s’agit d’une expression, on évitera de modifier la
valeur des variables qui y interviennent. Signalons que, dans la plupart des autres
langages, la valeur de l’expression fin n’est calculée qu’une seule fois, avant le
premier tour (éventuel) de boucle, ce qui évite les risques évoqués.
Dans la mesure du possible, on limitera expression_1 à la seule initialisation du
compteur ou, à la rigueur, à d’éventuelles initialisations fortement liées à celles
du compteur. La même remarque vaut pour expression_3 qu’on limitera à
l’incrémentation du compteur ou, à la rigueur, à l’incrémentation de variables
devant s’incrémenter en parallèle avec lui. Dans cet esprit, la construction :
for (i=1, j=1 ; i<=10 ; i++, j++) …..
est acceptable à condition que la valeur de j ne soit pas modifiée à l’intérieur de
la boucle (pas plus que ne doit l’être celle de i !).
On évitera l’exploitation abusive de l’opérateur séquentiel à l’intérieur des trois
expressions régissant la boucle. Par exemple, plutôt que :
for (printf ("on commence\n"), i=1 ; i<10 ; i++) …..
on préférera tout simplement :
printf ("on commence\n") ;
for (i=1 ; i<10 ; i++) …..
11.2 Boucle indéfinie
S’il n’est pas possible de connaître le nombre de tours avant l’entrée dans la
boucle, on fera appel à l’une des instructions while ou do … while. Le choix entre
les deux pourra être guidé par les considérations suivantes :
• l’instruction while peut ne faire aucun tour de boucle, tandis que do … while en
fait toujours au moins un ;
• la condition de poursuite de la boucle doit être définie avant l’entrée dans while,
alors qu’elle peut l’être au cours du premier tour dans le cas de do … while ; ce
point est crucial si la condition de poursuite découle d’une valeur dépendant
des informations lues en cours de boucle…
Si le problème se prête indifféremment à l’utilisation de while ou de do … while, il
semble préférable d’employer while, qui a le mérite de faciliter la relecture du
programme, en présentant d’emblée la condition de poursuite.
12. L’instruction break
Couramment utilisée avec l’instruction switch, l’instruction permet
break
également de provoquer la fin prématurée d’une boucle.
12.1 syntaxe et rôle
L’instruction break
break ;
Cette instruction peut être utilisée dans deux contextes différents, avec des rôles
semblables.
• Dans une instruction switch, elle met fin à l’exécution de l’instruction. Elle est
quasiment indispensable pour faire du simple aiguillage induit par switch un
véritable choix multiple (voir section 5).
• Dans une instruction de boucle (for, while ou do … while), elle provoque la sortie
(prématurée) de la boucle.
L’usage de break est naturellement bien plus répandu dans la première situation
que dans la seconde.
En cas d’imbrication d’instructions de boucles ou d’instructions switch, break ne
met fin qu’à l’instruction la plus interne le contenant. On notera que l’instruction
structurée if n’est pas concernée par break.
12.2 Exemple d’utilisation
Voici un exemple d’école montrant le fonctionnement de break à l’intérieur d’une
boucle :
Exemple d’instruction break dans une boucle for
int main() debut tour 1
{ bonjour
int i ; fin tour 1
for ( i=1 ; i<=10 ; i++ ) debut tour 2
{ printf ("debut tour %d\n", i) ; bonjour
printf ("bonjour\n") ; fin tour 2
if ( i==3 ) break ; debut tour 3
printf ("fin tour %d\n", i) ; bonjour
} apres la boucle
printf ("apres la boucle") ;
}
On trouvera d’autres exemples d’utilisations, plus réalistes, à la section 14, où
break est utilisé pour réaliser des boucles à sortie intermédiaire ou des boucles à
sorties multiples.
12.3 Commentaires
12.3.1 L’instruction structurée if n’est pas concernée par break
Toute utilisation de break en dehors de switch, for, while ou do … while conduit à une
erreur de compilation. On notera bien cependant que les constructions suivantes
sont syntaxiquement correctes :
for (…..)
{ …..
if (…) { …..
break ; /* met fin à for */
}
…..
}
switch( …..)
{ …..
if (…..) { …..
case xxx : break ; /* met fin à switch */
…..
}
}
Cependant, l’instruction concernée par break n’est pas l’instruction if mais bel et
bien l’instruction for englobante dans le premier cas, l’instruction switch
englobante dans le second.
12.3.2 break ne fait sortir que du niveau le plus interne
Considérons cet exemple :
for (…..)
{ …..
while (…)
{ …..
if (…) break ; /* on met fin au while */
…..
} /* fin while */
….. /* pour venir ici */
} /* fin for */
….. /* et non là */
Si on souhaite provoquer la sortie du for depuis l’intérieur du while, on voit qu’il
est impossible d’y parvenir directement. Il faut :
• soit passer par l’intermédiaire d’indicateurs booléens (de type vrai/faux) qu’on
positionne dans while et qu’il faudra tester, peut-être plusieurs fois, dans for ;
• soit utiliser l’instruction goto (voir section 15), en procédant ainsi :
for (…..)
{ …..
while (…)
{ …..
if (…) goto fin ; /* on met fin au while */
…..
} /* fin while */
…..
} /* fin for */
fin : ….. /* pour venir là */
13. L’instruction continue
Alors que break permet de mettre fin prématurément à une boucle, continue permet
de forcer le passage au tour suivant.
13.1 Syntaxe et rôle
L’instruction continue
continue ;
Cette instruction ne s’utilise que dans une boucle et son exécution force
simplement le passage au tour de boucle suivant, en ignorant les instructions
situées entre continue et la fin de la boucle. Elle ne concerne que la boucle de
niveau le plus interne la contenant.
13.2 Exemples d’utilisation
Voici deux exemples d’utilisation de continue, l’un avec do … while, l’autre avec
for :
Exemple d'instruction continue dans une boucle do … while
int main()
{ donnez un nb>0 : 4
int n ; son carre est : 16
do donnez un nb>0 : -5
{ printf ("donnez un nb>0 : ") ; svp >0
scanf ("%d", &n) ; donnez un nb>0 : 2
if (n<0) { printf ("svp >0\n") ; son carre est : 4
continue ; donnez un nb>0 : 0
} son carre est : 0
printf ("son carre est : %d\n", n*n) ;
}
while(n) ;
}
Exemple d’instruction continue dans une boucle for
int main()
{ debut tour 1
int i ; debut tour 2
for ( i=1 ; i<=5 ; i++ ) debut tour 3
{ printf ("debut tour %d\n", i) ; debut tour 4
if (i<4) continue ; bonjour
printf ("bonjour\n") ; debut tour 5
} bonjour
}
13.3 Commentaires
13.3.1 S’il fallait remplacer continue par un goto
Pour être plus précis, on peut dire (comme l’exprime d’ailleurs formellement la
norme ANSI) que, dans chacun des trois cas suivants, le rôle de continue est
parfaitement équivalent à un branchement à l’étiquette suite, située à la suite de
la dernière instruction régie par la boucle, c’est-à-dire, en fait, devant une
instruction vide :
while (…)
{ …..
suite : ;
}
do
{ …..
suite : ;
}
while (…) ;
for (…)
{ …..
suite : ;
}
On notera que, dans le cas de :
for (expression_1 ; expression_2 ; expression_3) instruction
l’instruction continue provoque bien un branchement sur l’évaluation de
expression_3 et non après. Ainsi, si l’on remplaçait for par sa forme équivalente
avec while, il faudrait placer l’étiquette suite ainsi :
expression_1 ;
while (expression_2)
{ instruction
suite : expression_3 ;
}
C’est ce que l’on constate en examinant le deuxième exemple de la section 13.2.
Après exécution de continue, il y a bien incrémentation de la valeur de i.
13.3.2 Seules les boucles sont concernées par continue
Toute utilisation de continue en dehors d’une boucle conduit à une erreur de
compilation. On notera bien cependant que la construction suivante est
syntaxiquement correcte :
for (…)
{ …..
if (…) { …..
continue ; /* passe au tour suivant du for */
…..
}
…..
}
13.3.3 L’instruction continue ne concerne que la boucle la plus
interne
Considérons cet exemple :
for (…)
{ …..
while (…)
{ …..
if (…) continue ; /* on passe au tour suivant du while */
….. /* et non au tour suivant du for */
}
…..
}
Si on souhaite provoquer le passage au tour suivant du for, on voit qu’il est
impossible d’y parvenir directement. On peut :
• utiliser break pour sortie de while. Il faudra alors disposer d’un mécanisme
approprié pour sauter à la suite de la dernière instruction du for. Que ce soit par
continue ou par goto, il faudra déclencher ce mécanisme de façon conditionnelle,
par exemple en testant un booléen qu’on aura positionné juste avant le break…
• utiliser goto (voir plus loin, section 15) en procédant ainsi :
for (…)
{ …..
while (…)
{ …..
if (…) goto fin ; /* on passe au tour suivant du for */
…..
}
…..
fin : ; /* en se branchant à une instruction vide */
} /* qui est la dernière du bloc régi par for */
14. Quelques schémas de boucles utiles
On éprouve parfois le besoin de disposer de schémas plus élaborés que les trois
schémas de base offerts par la programmation structurée. Face à un tel besoin, on
peut adopter deux attitudes opposées :
• chercher à tout prix à se limiter aux schémas de base ; c’est toujours possible
puisqu’en théorie, un seul type de boucle est suffisant pour traiter tout
problème. On notera cependant qu’en C, certaines utilisations des instructions
de boucle conduisent déjà à s’écarter des schémas de base (voir sections 8.3.2,
8.3.3, 9.4.2, 9.4.3 et 10.6) ;
• essayer de profiter de la liberté offerte par le langage C, notamment par
l’utilisation d’instructions de rupture de séquence à l’intérieur des boucles.
Aucune de ces deux attitudes n’est vraiment meilleure que l’autre. La première
peut conduire à une complexification artificielle des programmes, notamment
par un recours à des booléens qu’il est nécessaire de tester fréquemment. En
abusant de la seconde, on peut retrouver les structures (ou plutôt les absences de
structures) inextricables évoquant les « plats de spaghettis » et dont la
programmation structurée nous avait heureusement débarrassés.
Ici, nous examinerons quelques situations usuelles et nous verrons comment
faire un usage modéré des possibilités offertes par le C, en particulier de
l’instruction break.
14.1 Boucle à sortie intermédiaire
Il est très fréquent de se trouver face à un besoin de ce genre :
Figure 5-6
Boucle à sortie intermédiaire (1)
En programmation structurée pure, il est nécessaire d’utiliser une variable
auxiliaire booléenne, ce qui conduit au schéma de la figure 5-7 :
Figure 5-7
Boucle à sortie intermédiaire (2)
Voyons comment, d’une manière générale, se programment ces schémas en C,
avant d’en examiner des cas particuliers.
14.1.1 Cas général
En C, on peut indifféremment programmer les deux schémas précédents, par
exemple :
Les deux façons de programmer une boucle à sortie intermédiaire
while (1) sortie = 0 ;
{ Instructions_1 do
if (Condition) break ; { Instructions_1
Instructions_2 if (Condition) sortie = 1 ;
} else { Instructions_2
}
}
while (sortie)
Avec des commentaires appropriés, la première solution peut être nettement plus
lisible :
while (1) /* sortie en cours de boucle quand Condition sera réalisée */
{ Instructions_1
if (Condition) break ;
Instructions_2
} /* fin répétition jusqu'à condition */
Remarques
1. On pourrait penser à do … while aussi bien qu’à while :
do
{ Instructions_1
if (Condition) break ;
Instructions_2
}
while (1) ;
Les deux formulations sont ici équivalentes. Cependant, while paraît plus lisible, dans la mesure où
la répétition infinie apparaît immédiatement à la relecture du programme. Toutefois, là encore, des
commentaires appropriés peuvent améliorer les choses. Quoi qu’il en soit, il est bon de s’astreindre
à utiliser toujours la même formulation dans l’ensemble d’un même programme.
2. On pourrait remplacer while (1) par :
for ( ; ;)
Mais cela n’améliore en rien la lisibilité du programme et nous conseillons de limiter l’usage de
for aux vraies boucles avec compteur.
14.1.2 Cas particuliers
Lorsque, dans les schémas précédents, instructions_1 représente une instruction
simple, c’est-à-dire une instruction de la forme :
expression-1 ;
on peut envisager de l’associer à la condition du while :
while (expression_1, !condition)
{ instructions_2
}
Par exemple, on pourra préférer :
while (c=getchar(), c != ‘\n') { instruction } /* qui peut se condenser en : */
/* while ((c=getchar())!= ‘\n') */
à :
while (1)
{ c = getchar () ;
if (c == ‘\n') break ;
instruction
}
En revanche, dès que instructions représente plus d’une instruction simple, il est
préférable de conserver le schéma général. Ainsi, à cette construction :
while (printf ("donnez un nombre\n"), scanf ("%d", &n), n>0)
{ /* instructions de traitement de la valeur n */
}
on préférera celle-ci :
while (1)
{ printf ("donnez un nombre\n") ;
scanf ("%d", &n) ;
if (n <= 0) break ;
/* instructions de traitement de la valeur n */
}
14.2 Boucles à sorties multiples
Voici une autre situation fréquente dans laquelle le nombre de conditions (ici 3)
peut être quelconque :
Figure 5-8
Boucle à sortie multiple
14.2.1 Cas général
Si on ne se préoccupe pas de l’aspect non (totalement) structuré de ce schéma,
on peut le programmer textuellement en C :
while (1) /* trois sorties intermédiaires : Cond_1, Cond_2 et Cond_3 */
{ Instructions_1
if (Cond_1) break ;
Instructions_2
if (Cond_2) break ;
Instructions_3
if (Cond_3) break ;
Instructions_4
}
Une solution parfaitement structurée telle que la suivante serait manifestement
moins lisible :
sortie = 0 ;
do
{ Instructions_1
if (Cond_1) sortie = 1 ;
if (! sortie) { Instructions_2 }
if (Cond_2) sortie = 1 ;
if (! sortie) { Instructions_3 }
if (Cond_3) sortie = 1 ;
if (! sortie) { Instructions_4 }
}
while (! sortie)
14.2.2 Quand il existe une condition principale et des conditions
secondaires
Le schéma précédent et les diverses façons de le programmer en C placent les
différentes sorties de boucle sur un même plan. Dans certains problèmes, on
pourra avoir affaire à une sortie principale correspondant à un déroulement
relativement naturel du programme et à une ou plusieurs sorties à caractère plus
exceptionnel.
Dans ce cas, on pourra avoir intérêt, ne serait-ce que pour des questions de
lisibilité, à mettre en évidence cette condition principale, en procédant ainsi :
while (Condition_principale) /* boucle tant que Condition_principale avec */
/* sorties intermédiaires Cond_sec1 et Cond_sec2 */
{ Instructions_1
if (Cond_sec1) break ;
Instructions_2
if (Cond_sec2) break ;
Instructions_3
}
15. L’instruction goto et les étiquettes
15.1 Les étiquettes
Toute instruction exécutable peut être précédée d’une étiquette, c’est-à-dire d’un
identificateur suivi de deux-points (avec ou sans espaces de part et d’autre de ces
deux-points).
Voici un exemple :
trait : if (…) { …..
suite : b = a ;
}
boucle : for (…) { …..
ici : while (…) { ….. }
}
Ici, trait est une étiquette pour une instruction if, suite est une étiquette pour une
instruction expression, boucle est une étiquette pour une instruction for et, enfin,
ici est une étiquette pour une instruction while.
Remarques
1. Il existe une deuxième sorte d’étiquette, de la forme case xxx ; elle peut, elle aussi, précéder
n’importe quelle instruction exécutable mais elle n’est utilisée qu’à l’intérieur d’une instruction
switch.
2. Les étiquettes décrites ici ne peuvent être utilisées que par goto. Il est possible d’introduire dans un
programme une étiquette qui n’est pas utilisée. La norme ne précise pas si le compilateur doit
fournir un diagnostic dans ce cas : en général, on obtient un message d’avertissement. Cette
possibilité reste déconseillée, dans la mesure où certains compilateurs peuvent en tenir compte pour
supprimer certaines optimisations de boucles…
3. À l’intérieur d’une même portée, un identificateur d’étiquette peut être identique à un identificateur
d’une autre sorte (variable, type, fonction…) comme dans :
int fin ; /* variable entière nommée fin */
…..
fin : ….. /* étiquette nommée fin : correct */
On traduit souvent cela en disant que les étiquettes disposent d’un espace de noms séparé des
espaces de noms des autres identificateurs.
15.2 Syntaxe et rôle
L’instruction goto
goto etiquette ;
etiquette
Identificateur quelconque devant exister à l’intérieur de la
fonction où apparaît l’instruction goto
L’exécution de l’instruction goto entraîne le branchement à l’instruction portant
l’étiquette indiquée.
Contrairement aux étiquettes relatives à une instruction switch, la norme n’impose
aucune contrainte sur les emplacements relatifs de goto et de l’instruction portant
l’étiquette, hormis le fait qu’ils doivent figurer dans le corps de la même
fonction. Cette liberté peut avoir parfois des conséquences fâcheuses (voir
section 15.3.3).
15.3 Exemples et commentaires
Nous vous proposons deux exemples fort différents d’utilisation de goto : le
premier, peu usité, où l’on reste à l’intérieur d’un même bloc ; le second, plus
répandu, où l’on réalise un branchement vers l’extérieur d’un bloc afin de traiter
une circonstance exceptionnelle. Nous examinerons ensuite la situation, fort
déconseillée, de branchement de l’extérieur d’un bloc vers l’intérieur. Nous
terminerons par quelques conseils.
15.3.1 goto à l’intérieur d’un même bloc
Voici une autre façon de programmer le premier exemple section 13.2 :
do
{ printf ("donnez un nb>0 : ") ;
scanf ("%d", &n) ;
if (n<0) { printf ("svp >0\n") ;
goto suite ;
}
printf ("son carre est : %d\n", n*n) ;
suite : ;
}
while(n)
Bien entendu, il s’agit là d’un exemple d’école, dans la mesure où :
• l’usage de continue, comme dans l’exemple initial, est nettement plus adapté à la
situation ;
• tant qu’il s’agit d’un branchement à l’intérieur d’une même boucle, l’usage de
goto est rarement justifié ; la plupart du temps en effet, une instruction if
appropriée conviendra mieux.
15.3.2 Pour traiter une circonstance exceptionnelle
Considérons ces deux exemples :
for (…)
{ …..
if (…) goto erreur ;
…..
}
…..
erreur : …..
exit (-1) ;
for (…)
{ …..
while (…)
{ …..
if (…) goto erreur ;
…..
}
…..
}
…..
erreur : …..
Ici, l’usage de goto peut se justifier si le branchement à erreur n’a lieu que dans
des circonstances très particulières qui compromettent le bon déroulement de la
suite.
On notera qu’en général break ne serait pas facilement utilisable dans ce type de
situation.
15.3.3 Branchement de l’extérieur d’un bloc vers l’intérieur
Voici des exemples de constructions admises, bien que fortement déconseillées :
goto suite ;
…..
if (…) { …..
suite : …..
…..
}
else …..
…..
goto suite ;
…..
do
{ …..
scanf ("…", &n) ;
suite : …..
}
while (n != 0) ;
goto suite ;
…..
for (i=0 ; i<10 ; i++)
{ int n = 15 ;
…..
suite : …..
…..
}
La première construction est certainement la moins risquée des trois. Dans la
deuxième, on voit qu’on entre dans la boucle après la lecture de n ; le test de
poursuite portera donc, au mieux, sur une précédente valeur de n, au pire sur une
valeur imprévisible. Enfin, dans la troisième construction, le même problème se
pose pour la valeur de i (ancienne valeur ou valeur imprévisible). Mais de plus,
la variable n n’aura pas été initialisée : en effet, la norme insiste largement sur le
fait que l’initialisation des variables d’un bloc n’a lieu que lors de l’entrée
normale… Malgré tout, l’allocation mémoire aura bien eu lieu, mais le contenu
de n sera tout simplement imprévisible.
Remarque
Dans beaucoup d’autres langages que le C, les constructions précédentes sont formellement interdites.
15.3.4 Intérêt de goto
En général, on recommande d’utiliser goto pour faciliter la programmation des
traitements de situations exceptionnelles dont la prise en compte au niveau de
l’algorithmique de base, risquerait d’obscurcir le programme : anomalie
d’entrée-sortie, échec d’une allocation mémoire… La section 15.3.2, expose
cette situation.
Cependant, même dans ce cas, il est fréquent qu’après un traitement succinct, on
aboutisse à un arrêt du programme. L’utilisation de goto entre alors en
concurrence avec l’appel d’une fonction de terminaison. Ainsi, le premier
exemple de la section 15.3.2 peut être avantageusement remplacé par :
for (…)
{ …..
if (…) erreur (…) ;
…..
}
…..
void erreur (…)
{ …..
exit (-1) ;
}
Il est même possible de paramétrer le traitement à l’aide d’arguments transmis à
erreur.
15.3.5 Limitations de goto
Par sa nature même, l’instruction goto ne peut provoquer de branchement qu’à
l’intérieur du bloc régi par une fonction. Or, de même qu’on peut avoir besoin de
sortir d’une imbrication d’instructions structurées, on peut avoir besoin de sortir
d’une imbrication d’appels de fonctions. Plus généralement, de même qu’on peut
se brancher d’un point à un autre d’une même fonction, on peut souhaiter se
brancher d’un point d’un programme à un autre point situé dans une portée
différente (dans une autre fonction, voire dans un autre fichier source).
Les fonctions standards setjmp et longjmp, étudiées au chapitre 25, offriront une
solution à ce problème.
1. En revanche, N sera bien une expression constante en C++.
6
Les tableaux
Comme tous les langages évolués, C permet de définir et d’utiliser des tableaux.
Rappelons qu’un tableau est un ensemble d’éléments de même type désignés par
un identificateur unique ; chaque élément y est repéré par une valeur entière
nommée indice indiquant sa position au sein de l’ensemble, de façon analogue à
ce que l’on fait pour un vecteur en mathématique.
Comme dans la plupart des langages récents, la notion de tableau à plusieurs
indices est mise en œuvre par « composition » de la notion de tableau : on parle
de tableaux dont les éléments sont eux-mêmes des tableaux, ou encore de
tableaux de tableaux.
Le type des éléments d’un tableau pourra être aussi varié qu’on le désire, de
sorte qu’on pourra aboutir à des structures de données relativement complexes :
tableaux dont les éléments sont eux-mêmes des structures, structures dont
certains champs sont des tableaux dont les éléments sont eux-mêmes des
unions…
Une fois de plus, le langage C fait preuve d’originalité en introduisant une très
forte corrélation entre la notion de tableau et celle de pointeur : d’une part, un
identificateur de tableau est un pointeur constant ; d’autre part, l’opérateur []
possède comme premier opérande un pointeur, lequel peut éventuellement être
un identificateur de tableau.
Ici, nous étudions essentiellement ce que l’on pourrait nommer la manière
usuelle d’exploiter des tableaux, en dehors de tout contexte pointeur. Après un
exemple de programme utilisant un tableau, nous ferons le point sur la
déclaration d’un tableau. Nous verrons ensuite comment utiliser les différents
éléments d’un tableau. Sur ce plan, C sera similaire aux autres langages. Puis,
après avoir précisé comment les tableaux sont organisés en mémoire, nous
examinerons les problèmes induits par les éventuels débordements d’indice.
Nous étudierons en détail le cas des tableaux de tableaux et nous terminerons sur
la façon d’initialiser un tableau au moment de sa déclaration.
Le lien entre tableau et pointeur sera quant à lui examiné à la section 7 du
chapitre 7. Nous y ferons également le point sur l’opérateur []. Par ailleurs, la
transmission de tableaux en argument d’une fonction sera étudiée au chapitre 8.
Enfin, les possibilités dites parfois de « tableaux dynamiques » – c’est-à-dire de
tableaux dont l’emplacement est alloué dynamiquement (par une fonction telle
que malloc) – seront étudiées au chapitre 14.
1. Exemple introductif d’utilisation d’un tableau
Voici un exemple simple montrant comment s’utilise un tableau en langage C. Il
s’agit d’un programme déterminant, à partir de 20 notes (entières) d’élèves
fournies en données, combien sont supérieures à la moyenne de la classe. On
notera qu’un tel problème nécessite le recours à un tableau, dès lors qu’on
souhaite éviter de lire deux fois les mêmes notes.
Exemple d’utilisation d’un tableau
#include <stdio.h>
int main()
{
int i, som, nbm ;
float moy ;
int t[20] ; /* déclaration d'un tableau t de 20 int */
for (i=0 ; i<20 ; i++)
{ printf ("donnez la note numero %d : ", i+1) ;
scanf ("%d", &t[i]) ; /* lecture de l'élément de rang i de t */
}
for (i=0, som=0 ; i<20 ; i++) som += t[i] ;
moy = (float)som / 20 ;
printf ("\n\n moyenne de la classe : %f\n", moy) ;
for (i=0, nbm=0 ; i<20 ; i++ )
if (t[i] > moy) nbm++ ; /* comparaison de l'élément de rang i avec moy */
printf ("%d eleves ont plus de cette moyenne", nbm) ;
}
La déclaration :
int t[20] ;
réserve l’emplacement pour 20 éléments de type int. Chaque élément est repéré
par sa position dans le tableau, nommée « indice ». Conventionnellement, en
langage C, la première position porte le numéro 0. Ici, nos indices vont donc de
0 à 19. Le premier élément du tableau sera désigné par t[0], le troisième par t[2],
le dernier par t[19].
Plus généralement, une notation telle que t[i] désigne un élément dont la
position dans le tableau est fournie par la valeur de i. Elle joue le même rôle
qu’une variable scalaire de type int. La notation &t[i] désigne l’adresse de cet
élément t[i].
On remarquera que, dans ce programme, on a été amené à plusieurs reprises à
appliquer la même opération aux différents éléments du tableau, en utilisant une
instruction for. En effet, il n’existe en C aucune opération susceptible de porter
globalement sur l’ensemble des éléments d’un tableau, qu’il s’agisse de lecture,
d’écriture ou d’affectation.
2. Déclaration des tableaux
Le tableau 6.1 récapitule les différents éléments intervenant dans la déclaration
des tableaux. Ils seront ensuite détaillés dans les sections mentionnées.
Tableau 6.1 : déclaration des tableaux
Type des Type quelconque, hormis fonction, Voir section 2.2
éléments mais on peut définir des tableaux de
d’un tableau pointeurs sur des fonctions
De la forme : – rappels sur
declarateur [ dimension ]
les
déclarations
en C à la
Déclarateur section 2.1 ;
de tableau – description
du
déclarateur et
exemples à la
section 2.3.
– extern : pour les redéclarations de – étude
tableaux globaux ; détaillée de
– auto : pour les tableaux locaux la classe de
(superflu) ; mémorisation
dans les
– static : tableau rémanent ; sections 8, 9
– register : peu d’intérêt en général. et 10 du
Classe de chapitre 8 ;
mémorisation
– exemple
static à la
section
2.5.1 ;
– discussion
register à la
section 2.5.2.
– s’appliquent à tous les éléments du – définition des
tableau ; qualifieurs à
– un tableau constant doit en général la section 6.3
Qualifieurs être initialisé, sauf s’il est volatile du chapitre
(const, volatile) ou s’il s’agit de la redéclaration 3 ;
d’un tableau global. – discussion à
la section
2.6.
Utile pour sizeof et pour le prototype Voir section 2.7
Nom de type
d’une fonction ayant un argument de
d’un tableau
type tableau.
2.1 Généralités
La déclaration d’un tableau permet de préciser tout ou partie des informations
suivantes :
• le nom donné au tableau : il s’agit d’un identificateur usuel ;
• le type de ses éléments ;
• éventuellement, un ou deux qualifieurs (const, volatile) ;
• le nombre de ses éléments, lorsque cette information est utile au compilateur ;
• éventuellement, une classe de mémorisation.
Cependant, la nature même des déclarations en C disperse ces différentes
informations au sein d’une même instruction de déclaration. Par exemple, dans :
static const unsigned int *ad, x, t[10] ;
t est un tableau de 10 éléments constants de type unsigned int. On peut dire aussi
que t est un tableau de 10 éléments de type const unsigned int ou encore que t est
un tableau constant de 10 éléments de type unsigned int.
D’une manière générale, on peut dire qu’une déclaration en C associe un ou
plusieurs déclarateurs (ici *ad, x et t[10]) à une première partie commune à tous
ces déclarateurs et comportant effectivement :
• un spécificateur de type (ici, il s’agit de unsigned int) ;
• un éventuel qualifieur (ici const) ;
• une éventuelle classe de mémorisation (ici static).
Les déclarations en C peuvent devenir complexes compte tenu de ce que :
• un même spécificateur de type peut être associé à des déclarateurs de nature
différente ;
• les déclarateurs peuvent se « composer » : il existe des déclarateurs de
tableaux, de pointeurs et de fonctions ;
• la présence d’un déclarateur de type donné ne renseigne pas précisément sur la
nature de l’objet déclaré. Par exemple, un pointeur sur un tableau comportera,
entre autres, un déclarateur de tableau ; ce ne sera pas un tableau pour autant.
Pour tenir compte de cette complexité et de ces dépendances mutuelles, le
chapitre 16 fait le point sur la syntaxe des déclarations, la manière de les
interpréter et de les rédiger. Ici, nous examinerons de manière moins formelle les
déclarations correspondant aux situations les plus usuelles.
2.2 Le type des éléments d’un tableau
Le langage C est très souple puisqu’en fait, les éléments d’un tableau peuvent
être d’un type quelconque. La seule restriction est qu’ils doivent être des objets,
ce qui exclut simplement les tableaux de fonctions, tout en autorisant les
tableaux de pointeurs sur des fonctions. Comme certains types sont eux-mêmes
construits à partir d’autres types, et ce d’une façon éventuellement récursive, on
voit qu’on peut créer des types relativement complexes, même si ces derniers ne
sont pas toujours indispensables !
En dehors des tableaux d’objets d’un type de base, on sera amené à recourir à
des tableaux dont les éléments sont eux-mêmes des tableaux, simplement pour
disposer de ce que l’on nomme souvent, dans d’autres langages, des tableaux à
plusieurs indices. Les tableaux de structures pourront remplacer
avantageusement plusieurs tableaux de types différents, comme on le verra au
chapitre 11. Les tableaux de pointeurs, quant à eux, pourront faciliter certaines
manipulations d’objets volumineux, notamment des structures, en remplaçant
leur copie par celle de simples pointeurs, plus économiques en temps. Nous en
verrons des exemples au chapitre 14.
2.3 Déclarateur de tableau
Comme indiqué à la section 2.1, dans une déclaration, le type des éléments d’un
tableau est défini par l’association d’un déclarateur à un spécificateur de type,
éventuellement complété par des qualifieurs, l’éventuelle classe de mémorisation
n’ayant pas d’incidence sur le type même du tableau.
La déclaration d’un tableau fait toujours intervenir un déclarateur de la forme
suivante (attention, les crochets en gras font partie de la syntaxe, tandis que ceux
en romain précisent, comme d’habitude, que leur contenu est facultatif) :
Déclarateur de forme tableau
declarateur [ [ dimension ] ]
declarateur
Déclarateur quelconque
dimension
– expression constante – voir discussion à la
entière positive, sans section 2.4 ;
signe ; – définition expression
– peut être omise dans constante à la section
certains cas. 14 du chapitre 4.
Notez que nous parlons de « déclarateur de forme tableau » plutôt que
« déclarateur de tableau » car la présence d’un tel déclarateur de tableau ne
signifie pas que l’identificateur correspondant soit un tableau. Elle prouve
simplement que la définition de son type fait intervenir un tableau. Par exemple,
il pourra s’agir d’un pointeur sur un tableau, comme nous le verrons dans les
exemples ci-après.
Par ailleurs, les possibilités de composition des déclarateurs font que le
déclarateur mentionné dans cette définition peut être éventuellement complexe,
même si, dans les situations les plus simples, il se réduit à un identificateur.
Exemples
Voici des exemples de déclarateurs que, par souci de clarté, nous avons introduit
dans des déclarations complètes. Lorsque cela est utile, nous indiquons en regard
les règles utilisées pour l’interprétation de la déclaration, telles que vous les
retrouverez à la section 4 du chapitre 16. La dernière partie constitue un contre-
exemple montrant que la présence d’un déclarateur de tableau ne correspond pas
nécessairement à la déclaration d’un tableau.
Cas simples : éléments d’un type de base, structure, union ou défini par
typedef
unsigned int t[5];
est un tableau de 5 éléments de type int
t
struct point { char nom;
int x; courbe est un tableau de 10 éléments de type
int y; struct point
};
struct point courbe [10];
union u { float x;
char z[4]; est un tableau de 25 éléments de type union u
t
};
union u t[25];
typedef int * ptr;
ptr tab1[4], tab2 [8]; ptr est un synonyme de int *
tab1 est un tableau de 4 pointeurs sur int
tab2 est un tableau de 8 pointeurs sur int
typedef int vecteur [3];
vecteur mat [5]; vecteur est synonyme de int [3]
mat est un tableau de 5 éléments, eux-mêmes
tableaux de 3 int
Éléments de type pointeur
int *chose [10];
est un int
*chose[10]
→ chose [10] est un pointeur sur un int
→ chose est un tableau de 10 pointeurs sur un int
Éléments de type tableau
float mat [10] [5];
est un float
mat [10][5]
→ mat [10] est un tableau de 5 éléments de type
float
→ mat est un tableau de 10 éléments qui sont
eux-mêmes des tableaux de 5 float
Un déclarateur de forme tableau ne correspond pas toujours à un tableau
int (*chose) [10]
est un int
(*chose) [10]
→ (*chose) est un tableau de 10 int
→ *chose est un tableau de 10 int (on a
simplement enlevé les parenthèses)
→ chose est un pointeur sur un tableau de 10 int
int chose * [10];
syntaxiquement incorrect
2.4 La dimension d’un tableau
Comme l’indique la syntaxe d’un déclarateur de forme tableau, la dimension
possède deux propriétés particulières :
• il doit s’agir d’une expression constante (sauf en C99 ou en C11, comme
expliqué dans l’annexe B consacrée aux normes C99 et C11) ;
• elle peut être omise dans certaines conditions.
2.4.1 Il s’agit d’une expression entière constante
Cette condition est à la fois large et restrictive. Elle est large puisqu’elle ne
limite pas la dimension à une (vraie) constante, mais autorise des déclarations
telles que :
#define N_LIG 30
#define N_COL 25
…..
float somme_col [N_COL] ;
float somme_lig [N_LIG] ;
float mat [N_COL] [N_LIG] ;
float coef [N_COL * N_LIG] ;
float truc [2 * N_COL + 3 * N_LIG -2 ] ;
En revanche, elle est restrictive puisqu’il ne peut pas s’agir d’une expression
variable. Or, dans le cas de tableaux de classe automatique – c’est-à-dire dont
l’allocation mémoire est réalisée lors de chaque entrée dans une fonction ou dans
un bloc –, il aurait été techniquement possible que cette dimension puisse varier
d’un appel à un autre. Cela n’a pas été prévu par les concepteurs du C, ni par la
norme ANSI. Les instructions suivantes seraient rejetées en compilation :
void f(int n) ;
{ float t[n] ; /* incorrect */
…..
}
On notera que la forme autorisée pour la dimension d’un tableau est la même,
que l’on ait affaire à des déclarations globales ou locales.
Par ailleurs, on n’oubliera pas qu’en C, un objet ayant reçu le qualifieur const ne
peut pas apparaître dans une expression constante (il le pourra en C++) :
const int n = 5 ;
…..
int t[n] ; /* incorrect, quelle que soit la classe d'allocation de t */
2.4.2 Elle peut parfois être omise
La dimension d’un tableau peut ne pas figurer dans un déclarateur lorsqu’elle
n’est pas utile au compilateur, c’est-à-dire dans l’un des deux cas suivants.
a) Le compilateur peut en définir la valeur
C’est ce qui se produit lorsque le compilateur peut lui-même déduire cette
dimension de l’initialiseur qui suit le déclarateur (l’initialisation des tableaux est
décrite à la section 6) :
int t[] = {3, 5, 8 } ; /* correct et équivalent à int t[3] */
b) L’emplacement mémoire correspondant a été réservé, indépendamment
de la déclaration concernée
C’est ce qui se produit lorsqu’un déclarateur de tableau apparaît dans la
déclaration d’un argument muet dans un en-tête de fonction :
void fct (int x[]) /* le tableau réellement utilisé sera celui dont */
/* on transmettra l'adresse au moment de l'appel */
C’est également le cas lors de la redéclaration d’un tableau global supposé défini
dans un autre fichier source (extern) :
extern float coeff [] ; /* le tableau correspondant est déjà réservé dans */
/* un autre fichier source */
Dans de telles situations, il reste possible de fournir un nombre d’éléments.
Quelle que soit sa valeur, il ne sera pas pris en compte. Dans ces conditions, il
est préférable, soit de ne rien fournir, soit de fournir la valeur exacte si elle est
connue et qu’on est certain qu’elle ne sera pas amenée à varier au fil de la mise
au point ou de l’évolution du programme !
Remarques
1. La possibilité d’omission de la dimension d’un tableau dans le déclarateur ne s’applique pas lorsque
ce déclarateur figure lui-même à l’intérieur d’un autre déclarateur de tableau. Si l’on raisonne, dans
ce cas, en termes de tableau à plusieurs dimensions, cela revient à dire que seule la première
dimension peut être omise. Nous y reviendrons en détail dans le cas des tableaux transmis en
arguments (voir section 6 chapitre 8).
De manière comparable, la dimension d’un tableau ne peut pas être omise s’il est lui-même
membre d’une structure ou d’une union.
2. Certaines implémentations peuvent imposer des restrictions à la taille maximale d’un objet, ce qui
implique indirectement des limites aux nombre d’éléments d’un tableau de type donné. Parfois,
cette taille maximale peut différer suivant la classe d’allocation de l’objet, la classe automatique
étant limitée par la taille de la pile. Cette taille maximale peut parfois être très nettement inférieure
à la taille mémoire dont on dispose pour l’ensemble des objets gérés par le programme.
2.5 Classe de mémorisation associée à la déclaration
d’un tableau
Comme toute déclaration, une déclaration faisant intervenir un déclarateur de
forme tableau peut commencer par un mot-clé dit « classe de mémorisation »,
choisi parmi : extern, static, auto ou register. Il faut bien noter qu’alors ce mot-clé
se trouve obligatoirement associé à tous les déclarateurs de la même déclaration,
comme dans :
static int n, t[10] ; /* t et n ont la classe de mémorisation static */
Par ailleurs, dans les rares cas où ce mot-clé est présent, il sert généralement à
modifier la classe d’allocation de la variable correspondante mais ce n’est pas
toujours le cas. En particulier, l’application du mot-clé static à une variable
globale la « cache » à l’intérieur d’un fichier source, tout en la laissant de classe
statique.
Le rôle et la signification de ces différents mots-clés dans les différents contextes
possibles sont étudiés en détail aux sections 8, 9 et 10 du chapitre 8. Ici, nous
nous contentons d’apporter quelques compléments ou quelques illustrations
propres aux tableaux.
2.5.1 Le mot-clé static pour les tableaux rémanents
Comme n’importe quel objet, les tableaux locaux à un bloc ou à une fonction,
déclarés avec le mot-clé static, sont de classe statique, c’est-à-dire que :
• leur emplacement est réservé une fois pour toutes, avant le début de l’exécution
du programme ;
• ils conservent leur valeur d’une exécution du bloc ou de la fonction à la
suivante.
Voici un exemple d’utilisation d’un tableau rémanent qu’on initialise au premier
appel de la fonction dans lequel il est défini :
void fct (…..)
{ static int initialise = 0 ;
static int t [100] ;
…..
if ( ! initialise) { int k ; /* indice local à ce bloc */
for (k=0 ; k<100 ; k++) t[k] = 0 ;
initialise = 1 ;
}
…..
}
2.5.2 Le mot-clé register
En théorie, la norme n’interdit pas d’appliquer ce mot-clé à un tableau local à un
bloc (ou à une fonction), comme dans :
register int n, t[10] ; /* n et t ont la classe de mémorisation register */
Mais, dans ce cas, on demande que les objets en question soient, dans la mesure
du possible, placés dans un registre. Cela présente généralement peu d’intérêt
dans le cas d’un tableau car il y a peu de chance que ce soit possible1, excepté
peut-être pour des tableaux de quelques caractères ! En plus, une variable de
classe register n’a pas d’adresse précise : dans le cas d’un tableau, il est alors
impossible d’accéder à l’adresse des ses éléments par &, ou même d’accéder
simplement à un élément de ce tableau par l’opérateur [], compte tenu de
l’équivalence tableau - pointeur décrite à la section 3.2 du chapitre 7.
En pratique, il est donc déconseillé d’utiliser register pour un tableau.
Remarque
Certains environnements acceptent quand même d’appliquer les opérateurs & et [] dans le cas de
tableaux ayant reçu la classe de mémorisation register.
2.6 Les qualifieurs const et volatile
La signification générale des qualifieurs const et volatile a été présentée à la
section 6.3 du chapitre 3. Lorsqu’un tel qualifieur précède le spécificateur de
type d’une déclaration comportant un déclarateur de tableau, il concerne
l’ensemble des éléments de ce tableau. Par exemple, avec :
const int v[3] = {2, 8, 15} ;
v est un tableau de trois entiers constants. L’instruction suivante sera rejetée en
compilation :
v[2] = 0 ; /* incorrecte */
En général, un tableau constant devra être initialisé au moment de sa déclaration,
puisqu’il ne pourra plus être plus être modifié ensuite. Il existe cependant deux
exceptions :
• le tableau possède en plus le qualifieur volatile : il pourra être modifié
indépendamment du programme ; son initialisation n’est donc pas
indispensable mais elle reste possible ;
• il s’agit de la redéclaration d’un tableau global (par extern) ; l’initialisation a dû
être faite par ailleurs ; elle est alors interdite à ce niveau.
Remarque
Comme on le verra à la section 6 du chapitre 8, le cas des qualifieurs appliqués à un tableau
apparaissant en argument muet sera quelque peu ambigu.
2.7 Nom de type correspondant à un tableau
En ce qui concerne les tableaux, il existe deux cas où il est nécessaire
d’expliciter ce que l’on nomme leur « nom de type », à savoir2 pour le fournir en
opérande de l’opérateur sizeof ou pour préciser le type d’un argument dans un
prototype.
On a vu à la section 8 du chapitre 4 que, pour un type de base, le nom de type
était simplement le spécificateur de type, éventuellement précédé de qualifieurs,
sans signification à ce niveau. Dans le cas d’un tableau, les choses sont moins
simples. Par exemple, avec :
volatile unsigned long q, tab [12] ;
le nom de type de tab s’obtient en juxtaposant les éventuels qualifieurs (ici
volatile), le spécificateur de type (ici unsigned long) et le déclarateur (ici tab[12])
privé de l’identificateur correspondant (soit ici, [12]). Le nom de type
correspondant au tableau tab sera donc en définitive :
volatile unsigned long [12]
De même, avec cette déclaration :
const unsigned char mes[10], *adr[5] ;
les noms de types correspondant à mes et à adr seront :
const unsigned char [10] /* nom de type correspondant à mes */
const char * [5] /* nom de type correspondant à adr */
Remarques
1. Lorsqu’un nom de type de tableau est utilisé dans un prototype de fonction, le nombre d’éléments
du tableau est facultatif. En revanche, il va de soi qu’il est indispensable lorsque ce nom de type
apparaît en opérande de l’opérateur sizeof. Une expression telle que sizeof (int[]) sera rejetée
en compilation.
2. On verra au chapitre 7 que, la plupart du temps, un nom de tableau est converti en un pointeur sur
son premier élément. Fort heureusement, cette conversion n’est pas réalisée dans le cas d’un nom
de type tableau apparaissant en opérande de sizeof : sizeof (int[10]) et sizeof (int *) ne
seront pas équivalents.
3. Dans le cas des type de base, nous avons vu que le qualifieur ne jouait aucun rôle dans le nom de
type, quelle que soit la manière de l’employer. Dans le cas des tableaux, cela n’est vrai que pour
l’opérateur sizeof. En effet, dans le cas des prototypes, le rôle des qualifieurs sera quelque peu
ambigu. Nous y reviendrons à la section 6 du chapitre 8.
3. Utilisation d’un tableau
L’utilisation d’un tableau ne présente guère de difficultés, comme nous allons le
voir ici. Le tableau 6.2 résume les différents points qui sont étudiés en détail aux
emplacements indiqués.
Tableau 6.2 : utilisation d’un tableau
Expression entière Voir section 3.1
quelconque, soumise aux
Indice promotions numériques
Aucun contrôle sur la Les risques de débordement
valeur sont étudiés à la section 4
Utilisation Impossible, un nom de Voir section 3.2
globale tableau n’est jamais une
lvalue
d’un
tableau
Utilisation Comme n’importe quel Voir section 3.3
d’un objet du type
élément de
tableau
sizeof Fournit la taille en octets du Voir section 3.4
appliqué à tableau, non pas son
un tableau nombre d’éléments
3.1 Les indices
La façon usuelle d’utiliser un tableau consiste à désigner ses éléments à l’aide
d’un indice, c’est-à-dire en utilisant une notation de la forme :
nom_de_tableau [ indice ]
L’indice peut être fourni sous la forme de n’importe quelle expression entière,
pas nécessairement constante. Toute expression de l’un des types char, short, int
ou long, avec ou sans signe, convient donc.
Par exemple, avec ces déclarations :
float x [20], y ;
int i, p ;
char c1, c2 ;
les notations suivantes sont correctes :
x[i] x[i+1] x[2*i+1] x[i+p]
x[(int)y]
x[c1] x[c1+5] x[c2-c1] x[2*c1+c2-3]
Remarque
En fait, [] est un opérateur à deux opérandes dont l’un (pas nécessairement le premier) est un pointeur
et l’autre une expression entière. Il est étudié en détail dans le chapitre relatif aux pointeurs. Nous
verrons que son opérande numérique est toujours au moins soumis aux promotions numériques, de
sorte qu’il reçoit en fait soit un int, soit un long…
3.2 Un identificateur de tableau n’est pas une lvalue
Il n’existe aucune possibilité d’affectation globale de la valeur d’un tableau à un
autre tableau. Par exemple, si les tableaux t1 et t2 ont été déclarés ainsi :
int t1[10], t2[20] ;
Il n’est pas possible d’écrire :
t1 = t2 ; /* interdit */
On traduit généralement cela en disant qu’un identificateur de tableau n’est pas
une lvalue, puisqu’il ne peut pas apparaître en premier opérande d’une
affectation. On peut également dire qu’il n’existe aucune expression de type
tableau.
Cette impossibilité résulte du choix fait par les concepteurs du langage de
convertir presque toujours un nom de tableau en un pointeur constant, comme
nous le verrons dans le chapitre relatif aux pointeurs. Bien entendu, il n’est alors
plus possible d’affecter quelque chose à un pointeur constant…
On notera bien le manque d’homogénéité de C en ce qui concerne sa façon de
traiter les agrégats (structures et tableaux). En effet, alors que l’affectation
globale de tableaux est impossible, celle de structures de même type ne pose
aucun problème. Il est assez cocasse de penser qu’on peut artificiellement
réaliser des affectations globales de tableaux en les rendant membres (uniques)
d’une structure !
3.3 Utilisation d’un élément d’un tableau
Un élément de tableau peut être soumis à n’importe quelle opération applicable à
un objet ayant le même type. Ainsi, étant donné qu’un élément d’un type de base
est une lvalue, avec la déclaration suivante :
int t[10] ;
tout élément de t sera bien une lvalue. Comme, en plus, un objet d’un type de
base peut apparaître dans une expression arithmétique, on pourra utiliser un
élément de t dans des expressions telles que :
t[3]++ /* équivalent à (t[3])++ compte tenu des priorités relatives de[] et ++ */
--t[i] /* équivalent à --(t[i]) compte tenu des priorités relatives de[] et -- */
Dans le cas des tableaux de structures, leurs éléments restent bien des lvalue
(puisqu’une structure est une lvalue) mais, en revanche, ils ne peuvent plus
apparaître dans une expression. Par exemple, avec :
struct fiche tab [20] ; /* tableau de 20 éléments de type struct fiche */
on pourra écrire :
tab [i] = tab [j] ; /* correct : affectation globale entre structures */
mais pas :
tab[i]++ /* incorrect - et de toute facon sans signification */
Remarque
Dans le cas des tableaux de tableaux (ou tableaux à plusieurs indices), nous verrons que leurs
éléments, qui seront eux-mêmes des tableaux, ne seront pas des lvalue (voir section 5).
3.4 L’opérateur sizeof et les tableaux
Cas usuels
Comme on l’a vu à la section 12 du chapitre 4, l’opérateur sizeof s’applique à
une expression ou à un nom de type. Il fournit en résultat la taille totale en octets
d’un objet du type correspondant. Cela s’applique notamment aux tableaux,
même si, dans ce cas, on aurait souhaité obtenir un nombre d’éléments. Par
exemple, avec :
double t [10] ;
les expressions suivantes fourniront la même valeur (80 dans une
implémentation où le type double occupe 4 octets) :
sizeof (t) /* taille de t en octets */
(sizeof) t /* deuxième notation de sizeof appliqué à une expression */
/* même signification que sizeof (t) */
10 * sizeof (double) /* 10 fois la taille d'un double */
sizeof (double[10]) /* taille d'un tableau de 10 double */
10 * sizeof (t[0]) /* 10 fois la taille du premier élément */
/* pratique si le type de t est modifié par la suite */
Si l’on souhaite obtenir le nombre d’éléments d’un tableau t, on pourra procéder
ainsi :
sizeof (t) / sizeof (t[0]) /* nombre d'éléments du tableau t */
/* formule valable, quel que soit le type de t */
Cette formule peut s’avérer très utile :
• lorsque l’on profite des possibilités d’omission de la taille d’un tableau comme
dans :
int t[] = { 1, 8, 12, 3, 9 } ;
• lorsque la taille ou le type du tableau en question sont susceptibles d’être
modifiés lors d’une adaptation du programme.
Cas particulier des tableaux en argument
La formule précédente de détermination du nombre des éléments d’un tableau
avec sizeof ne fonctionne plus dans le cas d’un tableau en argument muet d’une
fonction. Considérons, par exemple, deux fonctions d’en-têtes :
void fct1 (int t1[]) /* équivalent en fait à : void fct (int * t) */
void fct2 (int t2[5]) /* toujours équivalent à : void fct (int * t) */
Comme nous le verrons au chapitre 8, les arguments muets t1 et t2 seront
convertis dans le type int *. Ainsi, dans fct1, sizeof(t1) est accepté, mais est
équivalent à sizeof(int *) ; de même, dans fct2, sizeof(t2) est toujours équivalent à
sizeof(int *). Quoi qu’il en soit, on ne pouvait guère s’attendre, ici, à accéder à
l’information de taille du tableau, laquelle, lorsqu’on en aura besoin dans la
fonction, devra être transmise sous la forme d’un argument supplémentaire. Par
ailleurs, on notera bien que, dans le corps de fct2, sizeof(t1) fournit un résultat
différent de sizeof(int[5]).
Quant au cas encore plus particulier des tableaux de tableaux, il sera examiné à
la section 5.
4. Arrangement d’un tableau et débordement d’indice
Nous fournissons ici quelques indications sur la manière dont les éléments d’un
tableau doivent être arrangés en mémoire, ce qui nous permettra de mieux cerner
les risques encourus en cas de débordement d’indice.
4.1 Les éléments d’un tableau sont alloués de manière
consécutive
Les éléments d’un tableau sont toujours alloués en mémoire de manière
consécutive ; c’est d’ailleurs cette propriété, au demeurant naturelle, qui
permettra de définir une équivalence entre l’opérateur [] et l’arithmétique de
pointeurs. Cependant, la norme ne précise pas si cet arrangement se fait dans le
sens des adresses croissantes ou des adresses décroissantes ; en pratique, on
rencontre les deux situations. Cela n’a généralement guère d’importance, dans la
mesure où l’arithmétique des pointeurs est conçue de manière à éliminer cette
incertitude, comme nous le verrons au chapitre 7. On ne sait pas si t[i+1] est
placé après ou avant t[i] mais on est sûr que ces deux éléments sont consécutifs.
4.2 Aucun contrôle n’est effectué sur la valeur de
l’indice
La norme ANSI n’impose pas à une implémentation d’effectuer un quelconque
contrôle sur la valeur de l’indice utilisé pour accéder ou pour modifier un
élément d’un tableau. De toute façon, quelle que soit la bonne volonté d’une
implémentation, ce genre de contrôle n’est pas possible à mettre en place de
manière systématique ; en particulier, au sein d’une fonction recevant un tableau
en argument, la taille du tableau n’est pas connue ; d’ailleurs, on verra que la
fonction recevra, non pas un tableau, mais simplement un pointeur sur le premier
élément…
En fait, la norme se contente de dire que tout accès à un élément situé en
dehors d’un tableau conduit à un comportement indéfini du programme.
Cette règle est quelque peu commentée à la section 3 du chapitre 7, car elle est
en fait liée à l’arithmétique des pointeurs, par le biais de l’opérateur []. En
pratique, dans le cas de l’utilisation naturelle des tableaux à laquelle est consacré
le présent chapitre, cela se traduit de la façon suivante :
Tableau 6.3 : conséquences d’un débordement d’indice
– la plupart du temps, valeur imprévisible : le calcul d’adresse
d’un tel élément fonctionne mais conduit simplement à un
En emplacement situé en dehors du tableau ;
lecture – exceptionnellement, erreur d’exécution liée au fait que
l’adresse en question se situe en dehors de la mémoire
accessible au programme.
– exceptionnellement, dans le meilleur des cas, erreur
d’exécution liée au fait que l’adresse en question se situe en
dehors de la mémoire accessible au programme ;
En
écriture – la plupart du temps, écrasement d’informations indépendantes
du tableau avec des conséquences difficilement prévisibles
allant d’une absence d’incidence à une erreur d’exécution, en
passant par des résultats faux.
Dans ces conditions, on a généralement intérêt à prévoir, au sein du programme,
des instructions de vérification aux endroits opportuns, en distinguant :
• celles qui sont simplement utiles à la bonne mise au point du programme ;
• celles qui sont utiles pour vérifier des valeurs d’indices calculées à partir de
données.
Les secondes resteront indispensables en permanence. Les premières pourront
être supprimées ou, mieux, désactivées, après mise au point du programme. On
pourra faire appel, pour cela, aux possibilités de compilation conditionnelle
examinées au chapitre 15.
Remarques
1. Ce besoin de vérification s’appliquera avec la même acuité dans le cas où l’accès aux éléments d’un
tableau se fera par l’intermédiaire d’un pointeur, comme nous le verrons à la section 3.4 du chapitre
7.
2. L’arrangement en mémoire des tableaux dits à plusieurs indices qui ne sont, en C, que des tableaux
de tableaux, se déduira de celui défini ici. Nous en étudions les conséquences dans la section 5.
5. Cas des tableaux de tableaux
Le langage C ne dispose pas intrinsèquement de la notion de tableau à plusieurs
indices. En revanche, un élément d’un tableau peut être lui-même d’un type
tableau. Nous étudions ici cette situation et nous verrons comment elle se
généralise à plus de deux indices.
5.1 Déclaration des tableaux à deux indices
Comme cela a été vu au paragraphe 2.3, un déclarateur de tableau est de la forme
suivante, dans laquelle declarateur_1 représente un déclarateur quelconque :
declarateur_1 [nombre_d_elements_1]
Si déclarateur_1 est lui-même un déclarateur de tableau, il peut être de la forme :
declarateur_2 [nombre_d_elements_2]
ce qui fait que, globalement, on aboutit à la forme de déclarateur suivante :
declarateur_2 [nombre_d_elements_2] [nombre_d_elements_1]
Voici un exemple simple de déclaration, dans lequel le déclarateur est réduit à un
identificateur :
int tab [5] [3] ;
Elle s’interprète de la façon suivante :
• tab [5][3] est un int ;
• tab[5] est un tableau de tableau de 3 int ;
• tab est un tableau dont chaque élément est lui-même un tableau de 3 int.
On peut dire de tab, soit qu’il s’agit d’un tableau dont chaque élément est un
tableau de 3 int, soit plus naturellement qu’il s’agit d’un tableau à 2 indices, le
premier variant de 0 à 4, le second de 0 à 2.
Remarque
On verra à la section 7 du chapitre 8 que dans le cas d’un tableau apparaissant en argument muet d’une
fonction, seul nombre_d_elements_1 pourra être omis de la déclaration précédente ;
nombre_d_elements_2 devra toujours figurer.
5.2 Utilisation d’un tableau à deux indices
Voyons comment utiliser le tableau tab précédent, dont nous rappelons la
déclaration :
int tab [5] [3] ;
Nous distinguerons ce que nous nommons l’utilisation classique, telle qu’on la
trouve dans les autres langages, d’une utilisation moins classique, plus
spécifique au C.
5.2.1 Utilisation classique
Un élément du tableau tab se repère par deux indices, à l’aide d’une notation de
la forme :
tab [2] [1]
laquelle, compte tenu de l’associativité de l’opérateur [] s’interprète comme :
(tab[2]) [1]
Elle désigne le troisième élément du deuxième élément (lui-même tableau de 3
int) du tableau tab.
Par exemple, pour placer la valeur 0 dans tous les éléments du tableau tab, on
pourra procéder ainsi :
int i, j ;
…..
for (i=0 ; i<5 i++)
for (j=0 ; j<3 ; j++)
tab [i] [j] = 0 ;
Toutefois, rien n’empêche de procéder ainsi :
for (j=0 ; j<3 ; j++)
for (i=0 ; i<5 ; i++)
tab [i] [j] = 0 ;
5.2.2 Utilisation moins classique
Pour plus de commodité, rappelons la déclaration de notre tableau :
int tab [5] [3] ;
Assez curieusement, une notation telle que :
tab[2]
a une signification en C. Elle désigne le troisième élément du tableau tab : il
s’agit donc d’un tableau de trois int. Certes, il ne s’agit plus d’une lvalue
puisqu’elle désigne un tableau. À la section 3.2 du chapitre 7, nous verrons
qu’une telle expression est convertie en un pointeur sur le premier élément du
tableau concerné, c’est-à-dire ici en un pointeur sur l’élément tab[2][0]. Là
encore, une exception a lieu dans le cas où cette expression apparaît en opérande
de sizeof. Par exemple :
sizeof(tab[2])
correspondra à la taille d’un tableau de trois int et sera équivalente à sizeof
(int[3]) ou encore à 3 * sizeof (int).
En revanche, une expression telle que tab[2]++ sera illégale, tab[2] n’étant pas une
lvalue car, bien qu’élément d’un tableau, c’est lui-même un tableau, lequel sera
d’ailleurs, à son tour, converti en un pointeur constant sur son premier élément.
Or un pointeur constant n’est manifestement plus une lvalue.
De manière comparable, l’affectation suivante serait illégale :
tab[3] = tab[1] ; /* illégale tab[3] n'est pas une lvalue */
Nous n’insisterons pas plus ici sur ces possibilités, qui s’écartent du cadre de ce
chapitre consacré à l’utilisation classique des tableaux (voir notamment la
section 3.2 du chapitre 7).
5.3 Peut-on parler de lignes et de colonnes d’un
tableau à deux indices ?
D’une manière imagée, on pourrait dire que tab est la représentation informatique
d’un tableau usuel de 5 lignes formées chacune de 3 entiers, ou encore que tab
est la représentation d’un tableau rectangulaire de 5 lignes et de 3 colonnes.
Cependant, rien n’empêcherait, hormis l’habitude, de considérer que tab est la
représentation d’un tableau de 5 colonnes formées chacune de 3 entiers, ou
encore la représentation d’un tableau rectangulaire de 5 colonnes et de 3 lignes.
En effet, la notion de ligne ou de colonne est manifestement absente de la notion
de tableau de tableau du langage C. Seul l’usage veut que, quel que soit le
langage, dans la manipulation de tableaux à deux indices, on convienne que le
premier indice correspond à une position de ligne, le second à une position de
colonne.
On notera que, avec cette terminologie usuelle, on peut désigner une ligne d’un
tableau (par exemple, tab[2]), alors qu’on ne peut pas désigner une colonne. Cette
remarque n’a en fait que peu d’importance puisque, de toute façon, la ligne en
question n’est pas une lvalue3… Tout au plus, comme on le verra dans le chapitre
suivant, pourra-t-on parler de l’adresse d’une ligne d’un tableau à deux indices,
alors qu’on ne pourra pas parler de l’adresse d’une colonne.
5.4 Arrangement en mémoire d’un tableau à deux
indices
Il découle directement de ce qui a été dit à la section 4. Ainsi, les éléments de
notre tableau tab (déclaré int tab [5][3];) sont-ils arrangés de la manière
suivante :
tab [0] [0]
tab [0] [1]
tab [0] [2]
tab [1] [0]
tab [1] [1]
tab [1] [2]
…..
tab [3] [2]
tab [4] [0]
tab [4] [1]
tab [4] [2]
Cet ordre a une incidence dans différentes circonstances.
Débordement d’indice
Lorsque l’un des indices déborde, il pourra y avoir, selon l’indice concerné et les
valeurs qu’il prend, débordement d’indice sans « sortie » du tableau. Par
exemple, toujours avec notre tableau tab de 5 × 3 éléments, la notation tab[0][5]
désigne en fait l’élément tab[1][2]. En revanche, la notation tab[5][0] désigne un
emplacement situé juste au-delà du tableau.
Accès par pointeur aux différents éléments du tableau
Dans certains cas, on aura besoin d’accéder aux différents éléments d’un tableau
à l’aide d’un pointeur. Les calculs d’adresse correspondants nécessiteront alors
la connaissance de la deuxième dimension. Par exemple, avec le tableau
précédent, on pourra accéder à l’élément tab[i][j] par un pointeur de type int *
ayant comme valeur :
&tab[0][0] + i*5 + j
Tableau utilisé en argument muet d’une fonction
Comme on le verra au chapitre 8, on pourra dans ce cas ne pas préciser la
première dimension du tableau dans la mesure où elle n’est pas utile au
compilateur pour localiser correctement n’importe quel élément du tableau.
5.5 Cas des tableaux à plus de deux indices
Les déclarateurs peuvent se composer à volonté4, de sorte qu’il est possible de
créer notamment des tableaux de tableaux de tableaux… Nous nous contenterons
d’examiner ici un exemple de tableau à trois indices.
Déclaration
La déclaration suivante :
double tf[2] [3] [2] ;
s’interprète ainsi :
• tf [2] [3] [2] est un double ;
• tf [2] [3] est un tableau de 2 double ;
• tf [2] est un tableau de 3 tableaux de 2 double ;
• tf est un tableau de 2 tableaux de 3 tableaux de 2 double.
Utilisation classique
On peut considérer t, de façon usuelle, comme un tableau à trois indices. Par
exemple, pour placer la valeur 1 dans tous ses éléments, on pourra procéder ainsi
(l’ordre des boucles pourrait être inversé sans problème) :
int i, j, k ;
…..
for (i=0 ; i<2 ; i++)
for (j=0 ; j<3 ; j++)
for (k=0 ; k<2 ; k++)
tf [i] [j] [k] = 1.0 ;
Utilisation moins classique
Mais, comme indiqué à la section 5.2.2, on peut noter que des notations telles
que tf[i][j] ou tf[i] auront un sens, compte tenu des conversions de référence à
un tableau en un pointeur. Ainsi :
• tf [i][j] désignera un tableau de 2 éléments de type double ;
• tf[i] désignera un tableau de 3 éléments, eux-mêmes tableaux de 2 éléments de
type double.
Arrangement en mémoire
Voici comment le tableau tf précédent serait arrangé en mémoire :
tf[0][0][0]
tf[0][0][1]
tf[0][1][0]
tf[0][1][1]
tf[0][2][0]
tf[0][2][1]
tf[1][0][0]
tf[1][0][1]
tf[1][1][0]
tf[1][1][1]
tf[1][2][0]
tf[1][2][1]
6. Initialisation de tableaux
Comme toute variable, un tableau de classe statique est initialisé par défaut avec
des valeurs nulles. Par ailleurs, quelle que soit sa classe d’allocation, un tableau
peut toujours être initialisé explicitement au moment de sa déclaration.
Tableau 6.4 : initialisation des tableaux
– classe automatique (ou registre) : Voir section
aucune initialisation 6.1
Initialisation
→ valeurs imprévisibles ;
implicite
– classe statique → valeurs nulles dans
les éléments terminaux
Initialisation Utilise une syntaxe appropriée – exemples aux
explicite d’initialiseur ( {…..}) qui fournit les sections
éléments terminaux sous forme 6.2.1 et
d’expressions constantes (quelle que 6.2.2 ;
soit la classe d’allocation).
– syntaxe
initialiseur
aux sections
6.2.3 et
6.2.4.
Les tableaux de caractères peuvent Voir section
bénéficier d’une syntaxe simplifiée 6.2.4
(chaîne constante).
6.1 Initialisation par défaut des tableaux
Les tableaux sont soumis aux règles d’initialisation s’appliquant à tous les types
de variables, en fonction de leur classe d’allocation. La notion de classe
d’allocation a été présentée au chapitre 1 et les règles exactes permettant sa
détermination sont étudiées aux sections 8, 9 et 10 chapitre 8.
Les tableaux de classe automatique ou registre ne sont pas initialisés : cela
revient à dire que leur valeur est imprévisible. On ne perdra pas de vue que les
tableaux déclarés dans la fonction main sont, comme tout tableau local à une
fonction, de classe automatique, même si leur allocation et leur initialisation
n’ont lieu qu’une seule fois, à l’entrée dans la fonction main.
Les tableaux de classe statique voient leurs éléments initialisés avec des valeurs
nulles. Une telle valeur nulle ne doit pas être systématiquement assimilée à une
mise à zéro de tous les octets de la variable. En effet, il s’agit bien :
• d’un entier nul pour les types entiers (caractères inclus) ; dans ce cas précis, il
s’agit bien d’octets à zéro ;
• d’un flottant nul pour les types float, double ou long double ; dans ce cas, sur la
plupart des machines, le motif binaire correspondant n’est pas formé d’octets
tous nuls ;
• d’un pointeur nul (NULL) ; sa valeur exacte dépend de l’implémentation.
Lorsque les éléments d’un tableau sont eux-mêmes des tableaux, ce sont les
éléments de ce dernier qui sont eux-mêmes initialisés à zéro. Lorsque les
éléments d’un tableau sont des structures, ce sont les champs de ces dernières
qui sont initialisés à zéro. Dans le cas des unions, c’est le premier champ qui est
initialisé à zéro. On notera bien que le processus est totalement récursif, de sorte
que, dans tous les cas de composition, aussi compliqués soient-ils, on finit
toujours par aboutir à des éléments « terminaux » de type scalaire (numérique ou
pointeur).
6.2 Initialisation explicite des tableaux
Comme n’importe quelle variable d’un type de base, un tableau peut être
initialisé explicitement au moment de sa déclaration, moyennant l’utilisation
d’un initialiseur d’une forme appropriée. Nous allons tout d’abord analyser
quelques exemples d’initialisation de tableaux à un ou deux indices avant
d’examiner les règles générales. Signalons que des exemples de tableaux de
structures ou de structures comportant des tableaux se trouvent au chapitre 11.
6.2.1 Exemples usuels d’initialisation explicite de tableaux à un
indice
Comme on peut s’y attendre, la déclaration suivante :
int tab[5] = { 10, 20, 5, 0, 3 } ;
place initialement les valeurs 10, 20, 5, 0 et 3 dans chacun des cinq éléments du
tableau tab. Mais il est possible de ne mentionner entre les accolades que les
premières valeurs, comme dans ces exemples :
int tab[5] = { 10, 20 } ;
int tab[5] = { 10, 20, 5 } ;
Les valeurs manquantes seront initialisées en fonction de la classe d’allocation
du tableau, c’est-à-dire à des valeurs nulles pour les tableaux de classe statique,
et à des valeurs imprévisibles pour les tableaux de classe automatique ou
registre.
De plus, il est possible d’omettre la dimension du tableau, celle-ci étant alors
déterminée par le compilateur en fonction du nombre de valeurs énumérées dans
l’initialiseur. Ainsi, la première déclaration de ce paragraphe est équivalente à la
suivante :
int tab[] = { 10, 20, 5, 0, 3 } ; /* comme si on avait déclaré tab[5] */
Remarque
1. Assez curieusement, la norme accepte qu’une liste d’initialisation d’un tableau se termine par une
seule virgule :
int tab[5] = {10, 20, } ;
On notera que, avec la déclaration légale suivante :
int tab[] = {10, 20, } ;
le tableau tab comportera deux éléments (et non 3 !).
2. La liste d’initialisation d’un tableau ne peut pas être vide :
int t[5] = {} ; /* incorrect ; on peut toujours écrire : int t[5] ; */
6.2.2 Exemples usuels d’initialisation de tableaux à plusieurs
indices
Considérons ces deux exemples équivalents (nous avons volontairement choisi
des valeurs consécutives pour qu’il soit plus facile de comparer les deux
formulations) :
int tab [3] [4] = { { 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9,10,11,12 } } ;
int tab [3] [4] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 } ;
La première forme revient à considérer notre tableau comme formé de trois
tableaux de quatre éléments chacun. La seconde exploite la manière dont les
éléments sont rangés en mémoire et se contente d’énumérer les valeurs du
tableau suivant cet ordre.
À chacun des deux niveaux, les dernières valeurs peuvent être omises. Voici des
déclarations correctes :
int tab [3] [4] = { { 1, , 2 }, { 3, 4, 5 }, { 6 } } ;
int tab [3] [4] = { { 1 }, { 2, 3 } } ;
6.2.3 Règles d’écriture d’un initialiseur de tableau
Les exemples précédents correspondent aux situations les plus usuelles. D’une
manière générale, voici les règles concernant la syntaxe précise d’un initialiseur
de tableau :
1. L’initialiseur d’un tableau se place entre accolades ({}). Celles-ci peuvent
théoriquement être omises dans le cas où le tableau en question est lui-même
un élément d’un tableau (voir section 6.2.2) ou membre d’une structure.
2. Il ne doit pas y avoir plus d’éléments qu’on en attend au niveau
correspondant. Par exemple, les déclarations suivantes entraîneront une erreur
de compilation :
int t[3] = { 4, 3, 8, 12} ; /* incorrect : trop de valeurs pour t */
int t [3] [2] = { {1, 2}, {3, 4, 5}, {6} } ; /* incorrect : trop de valeurs */
/* pour t[1] */
La dernière déclaration ne devra pas être confondue avec :
int t [3] [2] = { 1, 2, 3, 4, 5, 6 } ; /* correct */
3. Si l’initialiseur pour un tableau qui est lui-même élément d’un autre tableau
(ou membre d’une structure) ne comporte pas d’accolades, on utilise les
valeurs présentes jusqu’à la prochaine accolade ouvrante ou fermante ; dans
ce cas (contrairement à ce qui se passe en présence d’accolades), il ne doit pas
manquer de valeurs. En revanche, s’il reste des valeurs, on les utilise pour
l’élément (ou le membre) suivant. Ce sont bien ces règles que nous avons
utilisées dans le dernier exemple du paragraphe 6.2.2. Mais elles peuvent
conduire à des situations moins évidentes. Voici des exemples corrects5 :
int t1 [3][2] = { {1, 2}, 3, 4, 5 } ; /* équivaut à : { 1, 2, 3, 4, 5 } */
/* ou à : { {1, 2}, {3, 4}, {5} } */
int t2[3][2] = {1,2, {3, 4}, 5} ; /* équivaut à : {1, 2, 3, 4, 5 } */
/* ou à : { {1, 2}, {3, 4}, {5}} */
int t3[3][2] = {1,2, 3, 4, {5,6} } ; /* équivaut à : {1, 2, 3, 4, 5, 6 } */
/* ou à : { {1, 2}, {3, 4}, {5, 6}} */
Et voici d’autres exemples qui, bien que proches des précédents, sont incorrects :
int t4[3][2] = {1, {2,3}, 4} ; /* incorrect, car l'initialiseur pour t1[0] se */
/* termine à la rencontre de { et est incomplet */
int t5[3][2] = {1,2,3, {2,3}} ; /* incorrect, à cause de l'initialiseur de t1[1]*/
D’une manière générale, nous recommandons l’usage systématique des
accolades.
6.2.4 Les valeurs terminales de l’initialiseur
Les valeurs terminales utilisées dans l’initialiseur d’un tableau (qui sont donc
toujours de type scalaire) doivent être des expressions constantes, d’un type
acceptable par affectation avec le type de l’élément à initialiser. Cette déclaration
est correcte :
int t[4]= { 3.4, 2.8, 1.25, 5 } ; /* équivaut à { 3, 2, 1, 5 } */
Les expressions constantes scalaires sont décrites en détail à la section 14 du
chapitre 4. Elles se classent en expressions constantes entières, arithmétiques et
pointeur. Ici, il s’agit des expressions constantes :
• numériques pour tous les types de base, puisque tout type numérique peut être
affecté à un élément d’un type numérique quelconque ;
• pointeur pour les éléments de ce type ; dans ce dernier cas, il n’existe que peu
de conversions légales ; si nécessaire, l’opérateur de cast peut être utilisé.
On notera qu’il peut s’avérer très pratique d’utiliser des symboles définis par
#define :
#define N 10
…
int tab[5] = { 2*N-1, N-1, N, N+1, 2*N+1} ;
En revanche, on n’oubliera pas que les variables déclarées avec le qualifieur
const ne peuvent pas intervenir en C dans des expressions constantes (elles le
pourront en C++). Ainsi, la déclaration précédente de tab ne serait pas correcte si
N avait été définie par :
const int N = 10 ;
Remarque
Il est logique que la norme impose aux valeurs terminales d’être connues à la compilation dans le cas
de tableaux de classe statique. En revanche, la contrainte n’est pas justifiée dans le cas des tableaux
automatiques.
6.2.5 Cas des tableaux de caractères
Un tableau de caractères peut être initialisé selon les règles habituelles, par
exemple :
char message [12] = { ‘b', ‘o', ‘n', ‘j', ‘o', ‘u', ‘r' } ;
ou, si l’on souhaite pouvoir l’utiliser comme une chaîne de caractères :
char message [12] = { ‘b', ‘o', ‘n', ‘j', ‘o', ‘u', ‘r', ‘\0' } ;
Mais la seconde déclaration peut s’abréger de la façon suivante :
char message [12] = "bonjour" ; /* ou encore { "bonjour" } */
Dans ce cas, la chaîne constante utilisée comme initialiseur est recopiée dans les
différents éléments du tableau, à condition qu’elle ne soit pas trop longue. Plus
précisément :
1. Si la taille du tableau est supérieure à la longueur de la chaîne, aucun
problème particulier ne se pose ; les différents caractères de la chaîne sont
recopiés dans le tableau, y compris le caractère de fin de chaîne (\0). Les
caractères excédentaires, s’il y en a, sont, comme à l’accoutumée, initialisés à
0 dans le cas d’un tableau statique et indéfinis dans le cas d’un tableau
automatique.
2. Si la taille du tableau est égale à la longueur de la chaîne, les différents
caractères de la chaîne sont recopiés dans le tableau mais, cette fois, sans le
caractère de fin de chaîne.
3. Si la taille du tableau est inférieure à la longueur de la chaîne, il y a erreur de
compilation.
4. Si la taille du tableau n’est pas précisée dans sa déclaration, elle est déduite de
la chaîne servant d’initialiseur. Elle est alors égale à la longueur de la chaîne
augmentée de 1 unité, de sorte que le caractère de fin de chaîne est bien
présent.
On notera que la différence de comportement entre le cas 2 d’une part, et les cas
1 et 4 d’autre part peut être source de quelques surprises, voire de difficultés
algorithmiques.
Exemples
Voici quelques exemples de déclarations :
char mes1 [5] = "bonjour" ; /* erreur de compilation (règle 3) */
char mes2 [7] = "bonjour" ; /* accepté (règle 2) mais pas de 0 de fin dans mes2 */
char mes3 [9] = "bonjour" ; /* OK (règle 1) */
char mes4 [] = "bonjour" ; /* équivaut (règle 4) a la déclaration suivante */
char mes4 [8] = "bonjour" ; /* équivaut (règle 4) a la déclaration précédente */
Voici un programme utilisant un tableau de caractères à deux dimensions,
chaque « ligne » étant initialisée de la manière indiquée. Il montre clairement
comment l’absence du caractère de fin de chaîne peut s’avérer gênante.
On initialise un tableau de caractères avec une chaîne constante et le 0 de fin est
absent
#include <stdio.h>
int main()
{ char mes [3][7] = { "bon", "bonjour", "salut" } ;
int i ;
for (i=0 ; i<3 ; i++)
printf ("message numero %d : %s\n", i, mes[i]) ;
}
message numero 0 : bon
message numero 1 : bonjoursalut
message numero 2 : salut
Le schéma suivant montre le contenu de notre tableau mes :
1. Du moins, en l’état actuel de la technologie !
2. D’une manière générale, un nom de type peut également intervenir dans l’opérande de l’opérateur de
cast, mais cette possibilité ne s’applique pas aux tableaux.
3. En revanche, dans certains langages comme Java ou C#, la même remarque s’applique avec beaucoup
plus d’acuité car les lignes en question sont des lvalue alors que les colonnes n’en sont pas. On peut
alors manipuler globalement les lignes d’un tableau, ce qui n’est pas possible avec les colonnes.
4. En toute rigueur, la norme impose un minimum de 12 niveaux de composition des déclarateurs, ce qui est
toujours largement suffisant.
5. Certains compilateurs fournissent cependant un message d’avertissement pour signaler une imbrication
douteuse d’accolades.
7
Les pointeurs
Comme certains autres langages, C permet de manipuler des adresses d’objets ou
de fonctions, par le biais de ce que l’on nomme des pointeurs. Pour ce faire, on
peut définir des variables dites de type pointeur, destinées à contenir des
adresses. Plus généralement, on peut faire appel à des expressions de type
pointeur, dont l’évaluation fournit également une adresse. Le terme de pointeur
tout court peut indifféremment s’appliquer à une variable ou à une expression. Il
existe différents types pointeur, un type précis se définissant par le type des
objets ou des fonctions pointés. Ce dernier aspect sera d’autant plus important
que certaines opérations ne seront possibles qu’entre pointeurs de même type.
En matière de pointeurs, une fois de plus, C fait preuve d’originalité à la fois au
niveau du typage fort (type défini à la compilation) qu’il impose aux pointeurs,
des opérations arithmétiques qu’on peut leur faire subir et du lien qui existe entre
tableau et pointeur.
Les pointeurs sont fortement typés, ce qui signifie qu’on ne pourra pas
implicitement placer par exemple dans un pointeur sur des objets de type double,
l’adresse d’un entier. Cette interdiction sera toutefois pondérée par l’existence de
« l’opérateur de cast » qui autorise, avec plus ou moins de risques, des
conversions d’un pointeur d’un type donné en un pointeur d’un autre type. En
outre, les « pointeurs génériques » permettent de véhiculer des adresses sans
type.
Par ailleurs, on peut, dans une certaine mesure, effectuer des opérations
arithmétiques sur des pointeurs : incrémentation, décrémentation, soustraction.
Ces opérations vont plus loin qu’un simple calcul d’adresse, puisqu’elles
prennent en compte la taille des objets pointés.
Enfin, il existe une corrélation très étroite entre la notion de tableau et celle de
pointeur, un nom de tableau étant assimilable à un pointeur (constant) sur son
premier élément. Ceci a de nombreuses conséquences :
• au niveau de l’opérateur d’indiçage [] qui pourra indifféremment s’appliquer à
des tableaux ou à des pointeurs ;
• dans la transmission de tableau en argument d’une fonction.
Après une introduction générale à la notion de pointeur, nous examinerons en
détail la déclaration des pointeurs. Nous étudierons ensuite l’arithmétique des
pointeurs, ce qui permettra d’établir le lien avec la notion de tableau et de faire le
point sur les opérateurs & et []. Après la présentation de la valeur pointeur
particulière NULL, nous aborderons l’affectation à des lvalue de type pointeur. Puis
nous étudierons ce que l’on nomme les pointeurs génériques (type void *). Nous
verrons enfin comment comparer des pointeurs et comment les convertir.
Signalons que C permet de manipuler des pointeurs sur des fonctions. Cet aspect
sera étudié à la section 11 du chapitre 8. Il apparaîtra cependant, par souci
d’exhaustivité, dans certains tableaux récapitulatifs de ce chapitre.
1. Introduction à la notion de pointeur
Considérons cette déclaration :
int *adr ; /* on peut aussi écrire : int * adr; */
Elle précise que adr est une variable de type pointeur sur des objets de type int.
1.1 Attribuer une valeur à une variable de type
pointeur
Puisque la déclaration précédente de adr ne comporte pas d’initialiseur, la valeur
de adr est, pour l’instant, indéfinie. Pour assigner une valeur à adr et, donc, la
faire « pointer » sur un entier précis, il existe deux démarches de nature très
différente.
La première consiste à affecter à adr l’adresse d’un objet ou d’une partie d’objet
existant (à ce moment-là). Par exemple, si l’on suppose ces déclarations :
int n ;
int t[5] ;
on peut écrire :
adr = &n ;
ou :
adr = &t[2] ;
La seconde démarche consiste à utiliser des fonctions d’allocation dynamique
pour créer un emplacement pour un objet du type voulu dont on affecte l’adresse
à adr, par exemple (pour plus d’informations concernant ces possibilités, on se
reportera au chapitre 14) :
adr = malloc (sizeof (int)) ;
Mais on pourra aussi :
affecter une valeur d’une variable pointeur à une autre variable pointeur :
int *ad1, *ad2 ;
…..
ad1 = ad2 ; /* ad1 et ad2 pointent maintenant sur le même objet */
Cette démarche apparaît comme une variante de l’une ou l’autre des
précédentes, suivant la nature de l’objet pointé par ad2 ;
• utiliser, comme nous le verrons à la section 3, les propriétés de l’arithmétique
des pointeurs pour décrire différentes parties d’un même objet ou encore pour
accéder à des objets voisins, pour peu qu’ils soient de même type.
1.2 L’opérateur * pour manipuler un objet pointé
En C, le pointeur peut bien sûr servir à manipuler simplement des adresses.
Toutefois, son principal intérêt est de permettre d’appliquer à l’objet pointé des
opérations comparables à celles qu’on applique à un objet contenu dans une
variable. Ceci est possible grâce à l’existence de l’opérateur de déréférenciation
noté *. Plus précisément, si ad est un pointeur, *ad désigne l’objet pointé par ad.
Voyez ces instructions :
int * ad ;
int n = 20 ;
…..
ad = &n ; /* ad pointe sur l'entier n qui contient actuellement la valeur 20
*/
printf ("%d", *ad) ; /* affiche la valeur 20 */
*ad = 30 ; /* l'entier pointe par ad prend la valeur 30 */
printf ("%d", *ad) ; /* affiche la valeur 30 */
Bien sûr, ici, l’avant dernière instruction est équivalente à :
n = 30 ;
et elle n’a donc que peu d’intérêt dans le présent contexte. En revanche, elle peut
en avoir dès lors que la valeur de ad n’est pas toujours la même, comme dans cet
exemple :
if (…) ad = &n ;
else ad = &p ;
…..
*ad = 30 ; /* place la valeur 30 dans n ou dans p suivant le cas */
ou encore dans celui-ci :
ad = malloc (sizeof (int)) ;
*ad = 30 ; /* place la valeur 30 dans l'emplacement préalablement alloué */
Voici quelques autres exemples d’utilisation de l’opérateur * :
*ad += 3 /* équivaut à : *ad = *ad + 3 */
/* augmente de 3 la valeur de l'entier pointé par ad */
(*ad)++ ; /* équivaut à : *ad = *ad + 1 */
/* augmente de 1 la valeur de l'entier pointé par ad */
/* attention aux parenthèses : */
/* *ad++ serait équivalent à *(ad++) */
Là encore, ces possibilités seront enrichies par les propriétés arithmétiques des
pointeurs qui permettront d’accéder non seulement à l’objet pointé, mais à
d’éventuels voisins.
Remarques
1. Il faut éviter de dire qu’une expression telle que *ad désigne la valeur de l’objet pointé par ad, pas
plus qu’on ne dit, par exemple, que le nom d’une variable n désigne sa valeur. En effet, suivant le
cas, la même notation peut désigner la valeur ou l’objet :
int n, p ;
int *ad ;
n = p ; /* ici, p désigne la valeur de p, n désigne la variable n */
n = n + 12 ; /* à gauche, n désigne la variable n, à droite, sa valeur */
p = *ad + 3 ; /* *ad désigne la valeur de l'entier pointé par ad */
*ad = p+5 ; /* *ad désigne l'entier pointe par ad */
Cette différence d’interprétation en fonction du contexte ne pose aucun problème pour les variables
usuelles. En revanche, l’expérience montre qu’elle est souvent source de confusion dans le cas des
objets pointés. En particulier, dans le cas de pointeurs de pointeurs, elle peut amener à oublier ou à
ajouter une déréférenciation.
2. En général, une expression telle que *ad est une lvalue dans la mesure où elle est utilisable comme
opérande de gauche d’une affectation. Il existe cependant une exception, à savoir lorsque l’objet
pointé est constant. Nous y reviendrons en détail à la section suivante.
2. Déclaration des variables de type pointeur
Le tableau 7.1 récapitule les différents éléments intervenant dans la déclaration
des pointeurs. Ils seront ensuite décrits de façon détaillée dans les sections
indiquées.
Tableau 7.1 : déclaration des pointeurs
Type des Objet de type quelconque (type de Voir section 2.2
éléments base, structures, unions, tableaux,
pointés pointeurs…) ou fonction.
De la forme : – rappels sur
* [qualifieurs] declarateur
les
déclarations à
la section
Déclarateur
2.1 ;
de pointeur
– description du
déclarateur et
exemples à la
section 2.3.
Concerne la variable pointeur, pas – étude
l’objet pointé : détaillée de la
– extern : pour les redéfinitions de classe de
pointeurs globaux ; mémorisation
Classe de aux sections
mémorisation – auto : pour les pointeurs globaux 8, 9 et 10 du
(superflu) ; chapitre 8 ;
– static : pointeur rémanent ; – discussion à
– register : demande d’attribution de la section 2.4.
registre.
– peuvent s’appliquer à la fois à la – définition des
variable pointeur et à l’objet qualifieurs à
pointé ; la section 6.3
Qualifieurs – un pointeur constant doit être du chapitre
3 ;
(const, volatile) initialisé, sauf s’il s’agit de la – discussion à
redéclaration d’une variable la section 2.5.
globale ou si l’objet pointé est
volatile.
Utile pour sizeof, pour le prototype Voir section 2.6
Nom de type
d’une fonction ayant un argument de
d’un
type pointeur, pour l’opérateur de
pointeur
cast.
2.1 Généralités
La déclaration d’une variable de type pointeur permet de préciser tout ou partie
des informations suivantes :
• son nom, il s’agit d’un identificateur usuel ;
• le type des éléments pointés avec d’éventuels qualifieurs (const, volatile)
destinés aux objets pointés ;
• éventuellement, une classe de mémorisation, destinée à la variable pointeur
elle-même ;
• éventuellement, un qualifieur, destiné cette fois à la variable pointeur elle-
même.
Cependant, la nature même des déclarations en C disperse ces différentes
informations au sein d’une même instruction de déclaration. Par exemple, dans :
static const unsigned long p, *adr1, * const adr2 ;
• adr1 est un pointeur sur des objets constants de type unsigned long ;
• adr2 est un pointeur constant sur des objets constants de type unsigned long.
D’une manière générale, on peut dire qu’une déclaration en C associe un ou
plusieurs déclarateurs (ici p, *adr1, * const adr2) à une première partie commune à
tous ces déclarateurs et comportant effectivement :
• un spécificateur de type (ici, il s’agit de unsigned long) ;
• un éventuel qualifieur (ici, const) ;
• une éventuelle classe de mémorisation (ici, static).
Les déclarations en C peuvent devenir complexes compte tenu de ce que :
• un même spécificateur de type peut être associé à des déclarateurs de nature
différente ;
• les déclarateurs peuvent se « composer » : il existe des déclarateurs de
tableaux, de pointeurs et de fonctions ;
• la présence d’un déclarateur de type donné ne renseigne pas précisément sur la
nature de l’objet déclaré ; par exemple, un tableau de pointeurs comportera,
entre autres, un déclarateur de pointeur ; ce ne sera pas pour autant un pointeur.
Pour tenir compte de cette complexité et de ces dépendances mutuelles, le
chapitre 16 fait le point sur la syntaxe des déclarations, la manière de les
interpréter et de les rédiger. Ici, nous nous contenterons d’examiner, de manière
moins formelle, des déclarations correspondant aux situations les plus usuelles.
2.2 Le type des objets désignés par un pointeur
Le langage C est très souple puisqu’il permet de définir des pointeurs sur
n’importe quel type d’objet, aussi complexe soit-il, ainsi que des pointeurs sur
des fonctions. Comme certains types sont eux-mêmes construits à partir d’autres
types, et ce d’une façon éventuellement récursive, on voit qu’on peut créer des
types relativement complexes, même si ces derniers ne sont pas toujours
indispensables.
En dehors des pointeurs sur des objets d’un type de base, le C fait largement
appel à des pointeurs sur des structures ; l’une des raisons est qu’ils accélèrent
l’échange d’informations de ce type avec des fonctions, en évitant d’avoir à les
recopier. On en trouvera des exemples d’utilisation aux chapitres 11 et 14. En ce
qui concerne les autres agrégats que sont les tableaux, les pointeurs sont moins
utilisés pour la principale raison qu’un nom de tableau est déjà un pointeur. Ils
peuvent cependant intervenir dans le cas de tableaux à plusieurs indices. Ppar
exemple, on peut être amené à créer un tableau de pointeurs sur les lignes d’un
tableau à deux indices. On en rencontrera un exemple à la section 7.2 du chapitre
8. Enfin, on peut avoir besoin de disposer de pointeurs sur des pointeurs. On en
trouvera un exemple dans la gestion de liste chaînée présentée au chapitre 14.
2.3 Déclarateur de pointeur
Comme indiqué au paragraphe 2.1, le type d’un pointeur (donc celui des objets
ou des fonctions pointées) est défini par une déclaration qui associe un
déclarateur à un spécificateur de type, éventuellement complété par des
qualifieurs.
Ce déclarateur, quelle que soit sa complexité, est toujours de la forme suivante :
Déclarateur de forme pointeur
* [ qualifieurs ] declarateur
declarateur
Déclarateur
quelconque
qualifieurs const
volatile – attention, ce qualifieur s’applique au
const
volatile pointeur ; il ne doit pas être confondu avec
volatile
const
celui qui accompagne éventuellement le
spécificateur de type et qui s’applique, quant
à lui, aux objets pointés ;
– discussion à la section 2.5.
N.B : les crochets signifient que leur contenu est facultatif.
Notez que nous parlons de « déclarateur de forme pointeur » plutôt que
« déclarateur de pointeur », car la présence d’un tel déclarateur de pointeur ne
signifie pas que l’identificateur correspondant est un pointeur. Elle prouve
simplement que la définition de son type fait intervenir un pointeur. Par exemple,
il pourra s’agir d’un tableau de pointeurs, comme nous le verrons dans les
exemples ci-après.
Par ailleurs, on notera bien que la récursivité de la notion de déclarateur fait que
le déclarateur mentionné dans cette définition peut être éventuellement
complexe, même si, dans les situations les plus simples, il se réduit à un
identificateur.
Exemples
Voici des exemples de déclarateurs que, par souci de clarté, nous avons introduit
dans des déclarations complètes. Lorsque cela est utile, nous indiquons en regard
les règles utilisées pour l’interprétation de la déclaration, telles que vous les
retrouverez à la section 4 du chapitre 16. La dernière partie constitue un contre-
exemple montrant que la présence d’un déclarateur de pointeur ne correspond
pas nécessairement à la déclaration d’un pointeur. Ici, aucune de ces déclarations
ne comporte de qualifieur. Vous trouverez de tels exemples à la section 2.5.
Cas simples : pointeurs sur des objets d’un type de base, structure, union ou
défini par typedef
Les déclarations sont faciles à interpréter lorsque le déclarateur concerné
correspond à un simple identificateur :
unsigned int n, *ptr, q, – ad et ptr sont des pointeurs sur des éléments de
*ad;
type unsigned int ;
– notez qu’il est nécessaire de répéter le
symbole * dans chaque déclarateur de pointeur ;
ici n et q sont de simples variables de type
unsigned int.
struct point { char nom; – ads est un pointeur sur des structures de type
int x;
int y; struct point ;
};
struct point *ads, s;
– s est de type struct point.
union u { float x;
char z[4]; adu est un pointeur sur des unions de type union
}; u.
union u *adu;
typedef int vecteur [3];
vecteur *adv; vecteur est synonyme de int [3].
adv est un pointeur sur des tableaux de 3 int.
Pointeur de pointeur
On peut naturellement composer deux fois de suite le déclarateur de pointeur :
float x, *adf, * *adadf;
(pour mémoire : x est un float, adf est un
pointeur sur un float)
* *adadf est un float
→ *adadf est un pointeur sur un float
→ adadf est un pointeur sur un pointeur sur un
float
int (* *chose)[10];
(* *chose)[10] est un int
(* *chose) est un tableau de 10 int
* *chose est un tableau de 10 int
*chose est un pointeur sur un tableau de 10 int
chose est un pointeur sur un pointeur sur un
tableau de 10 int
Notez la présence des parenthèses ; en leur
absence, la signification serait différente (voir
plus loin).
Pointeurs sur des tableaux
Les déclarateurs peuvent se composer à volonté. On peut ainsi composer un
déclarateur de tableau (voir chapitre 6) et un déclarateur de pointeur. Cependant,
il faut alors tenir compte de l’ordre dans lequel se fait la composition des
déclarateurs et le recours aux parenthèses est indispensable :
int n, *ad, (*chose)[10];
est un int
(*chose)[10]
→ (*chose) est un tableau de 10 int
→ *chose est un tableau de 10 int (on s’est
contenté de supprimer les parenthèses)
→ chose est un pointeur sur un tableau de 10 int
Un déclarateur de forme pointeur ne correspond pas toujours à un pointeur
int *chose [10];
est un int
*chose[10]
→ chose[10] est un pointeur sur un int (on doit
interpréter le déclarateur pointeur en premier)
→ chose est un tableau de 10 pointeurs sur un int
int * *chose[10];
est un int
**chose[10]
→ *chose[10] est un pointeur sur un int (on doit
interpréter le déclarateur pointeur en premier)
→ chose[10] est un pointeur sur un pointeur sur
un int
→ chose est un tableau de 10 pointeurs sur un
pointeur sur un int
int * f(float);
est un int
*f (float)
→ f(float) est un pointeur sur un int (on doit
interpréter le déclarateur pointeur en premier)
→ f est une fonction recevant un float et
renvoyant un pointeur sur un int
2.4 Classe de mémorisation associée à la déclaration
d’un pointeur
Comme toute déclaration, une déclaration faisant intervenir un déclarateur
pointeur peut commencer par un mot-clé dit « classe de mémorisation ». Ce
dernier peut être choisi parmi : extern, static, auto ou register. Il faut bien noter
que ce mot-clé est associé à tous les déclarateurs d’une même déclaration,
comme dans :
static int n, *ad, t[10] ;
Par ailleurs, dans les rares cas où ce mot-clé est présent, il sert généralement à
modifier la classe d’allocation de la variable correspondante, mais ce n’est pas
toujours le cas. En particulier, l’application du mot-clé static à une variable
globale la « cache » à l’intérieur d’un fichier source, tout en la laissant de classe
statique.
D’une manière générale, le rôle et la signification de ces différents mots-clés
dans les divers contextes possibles sont étudiés en détail aux sections 8, 9 et 10
du chapitre 8. Ici, nous nous contentons d’apporter une précision relative aux
pointeurs, à savoir que :
La classe de mémorisation concerne la variable pointeur elle-même, non les objets pointés.
Ainsi, dans la déclaration précédente, si la variable ad est locale à une fonction,
elle sera de classe d’allocation static. En revanche, aucune restriction ne pèse sur
les entiers pointés par ad qui pourront être tantôt de classe automatique, tantôt de
classe dynamique1. Cette dernière possibilité présente d’ailleurs le risque de voir
une variable pointer sur un objet dont l’emplacement mémoire a été désalloué.
2.5 Les qualifieurs const et volatile
La signification générale des qualifieurs const, volatile, const volatile ou volatile
const a été présentée à la section 6.3 du chapitre 3. Ici, nous apportons quelques
précisions concernant les pointeurs, car ces qualifieurs peuvent intervenir à deux
niveaux différents :
• à un niveau collectif, c’est-à-dire associé au spécificateur de type et concernant
donc tous les déclarateurs, qu’il s’agisse ou non de pointeurs ; dans le cas des
pointeurs, il concerne alors l’objet pointé et non la variable pointeur ;
• au niveau d’un déclarateur pointeur ; dans ce cas, il concerne alors la variable
pointeur et non l’objet pointé.
Rappelons que const est, de très loin, le qualifieur le plus utilisé et que son
rapprochement avec volatile n’est qu’une pure affaire de syntaxe.
2.5.1 Qualifieur utilisé à un niveau collectif
Le qualifieur const
Avec la déclaration :
const int n, *p ;
le qualifieur const s’applique à la fois à n et à *p. Dans le second cas, il
s’interprète ainsi :
• *p est un int constant ;
• donc p est un pointeur sur un int constant.
Cela signifie notamment que la modification de l’objet pointé par p est interdite :
*p = … ; /* interdit */
En revanche, la modification de p reste permise :
p = … ; /* OK */
Ces règles s’appliquent également aux pointeurs constants reçus en argument.
L’usage de const, dans ce cas permet une certaine protection dans l’écriture de la
fonction correspondante :
void fct (const int * adr)
{ … /* ici, la modification de *adr est interdite */
}
La protection d’un objet constant pointé n’est cependant pas plus absolue que ne
l’est celle d’un objet constant en général. Hormis dans le cas des machines
implémentant les objets constants dans une zone protégée (et qui déclenchent
alors une erreur d’exécution en cas de tentative de modification), il reste toujours
possible de transmettre l’adresse d’un tel objet (ici p), à une fonction qui modifie
l’objet qui s’y trouve, ne serait-ce que scanf :
scanf ("%d", p) ; /* toujours accepté car le compilateur n'a aucune */
/* connaissance du rôle de scanf */
Cette dernière remarque plaide d’ailleurs en faveur de l’usage des prototypes,
dès que cela est possible. Ainsi, dans l’exemple suivant, on voit qu’il n’est pas
possible de transmettre l’adresse d’un objet constant à une fonction attendant
une adresse d’un objet non constant :
void f (int * adr ) ; /* f attend un pointeur sur un int qu'elle peut modifier */
…..
int * adi ;
const int * adci ;
f (adi) ; /* OK : adi est du type attendu */
f (adci) ; /* incorrect : la conversion de const int * en int * */
/* n'est pas autorisée par affectation */
Le qualifieur volatile
Le qualifieur volatile peut apparaître de la même façon que const. Dans :
volatile int n, *p ;
le qualifieur volatile s’applique à la fois à n et à p. Dans le second cas, il signifie
que p est un pointeur sur un entier volatile, autrement dit que la valeur d’un tel
objet peut être modifiée, indépendamment des instructions du programme. Par
exemple, en présence d’une construction comme la suivante, dans laquelle n est
supposée être une variable de type int :
while (…)
{ …..
n = *adr ;
…..
}
l’usage de volatile devrait interdire à un compilateur de « sortir » l’affectation de
la boucle while. En pratique, même en l’absence du qualifieur volatile, il est peu
probable qu’un compilateur réalise ce genre d’optimisation, dans la mesure où il
lui est difficile d’être sûr que l’objet pointé n’est pas éventuellement modifié par
le biais d’un autre pointeur…
2.5.2 Qualifieur associé au déclarateur
Dans ce cas, le qualifieur concerne la variable pointeur elle-même et non plus
l’objet pointé.
Le qualifieur const
Avec la déclaration :
int n, * const p ; /* en général, p devra être initialisé */
• * const p est un int ;
• const p est un pointeur sur un int ;
• p est un pointeur constant sur un int.
Cela signifie, de façon usuelle cette fois, que la modification de la valeur de p est
interdite :
p = … ; /* interdit */
En revanche, la modification de l’objet pointé par p reste permise :
*p = … ; /* OK */
Remarque
La plupart du temps, une variable pointeur ayant reçu le qualifieur const devra, comme n’importe
quelle autre variable constante, être initialisée lors de sa déclaration. On utilisera une expression
constante de type adresse telle qu’elle a été définie à la section 14 du chapitre 4, comme dans :
float x ;
float * const adf1 = &x ; /* initialisation de adf1 avec l'adresse de x */
float * const adf2 = NULL ; /* initialisation de adf2 à une valeur nulle */
/* (voir &5) */
Il existe cependant des exceptions :
• La variable possède en plus le qualifieur volatile : elle pourra donc être modifiée indépendamment
du programme. Son initialisation n’est donc pas indispensable mais elle reste possible.
• Il s’agit de la redéclaration d’une variable globale (par extern). L’initialisation a dû être faite par
ailleurs, l’initialisation est alors interdite à ce niveau.
Le qualifieur volatile
De même, avec cette déclaration :
int n, * volatile p ;
est un pointeur volatile sur un int.
p
Remarque
La place dans la déclaration d’un qualifieur destiné à un identificateur de type pointeur n’est pas la
même que pour les autres identificateurs, puisqu’il est alors associé au déclarateur et non plus à
l’ensemble de la déclaration :
const int n ; /* n est constant */
const float t[12] ; /* t (donc ses éléments) est constant */
int * const p ; /* p est constant */
2.5.3 Utilisation des deux sortes de qualifieurs
Bien entendu, rien n’empêche de faire appel à une déclaration telle que :
const int n, * const p ; /* en général, p devra être initialisé */
Dans ce cas, hormis le fait que n est un entier constant :
• * const p est un int constant ;
• const p est un pointeur sur un int constant ;
• p est un pointeur constant sur un int constant.
Cette fois, ni p, ni l’objet pointé par p ne peuvent être modifiés ;
p = … /* interdit */
*p = … /* interdit */
Bien entendu, on peut combiner à loisir const et volatile (voire les deux), ce qui
conduit théoriquement à 16 combinaisons différentes (en comptant l’absence de
qualifieur)2 ! Mais seules celles utilisant const sont vraiment employées.
2.5.4 Cas des pointeurs de pointeurs
Dans le cas de pointeurs de pointeurs, on aura affaire à trois sortes de qualifieurs.
Dans le cas de pointeurs de pointeurs de pointeurs, on aura affaire à quatre sortes
de qualifieurs et ainsi de suite. Par exemple, avec :
const int n, *const ad1, *const *const ad2, **const ad3 ;
• n est un int constant ;
• ad1 est un pointeur constant sur un int constant ;
• ad2 est un pointeur constant sur un pointeur constant sur un int constant ;
• ad3 est un pointeur constant sur un pointeur (non constant) sur un int constant :
ad3 n’est pas modifiable, pas plus que **ad3 ; en revanche, *ad3 est modifiable.
2.6 Nom de type correspondant à un pointeur
Dans le cas des pointeurs, il est nécessaire de disposer de ce que l’on nomme le
« nom de type », dans les trois cas suivants :
• comme opérande de l’opérateur de cast ;
• comme opérande de l’opérateur sizeof, lorsqu’on l’applique à un type pointeur
(et non à une variable de type pointeur) ;
• dans un prototype de fonction.
Dans le cas des types de base (voir section 8 du chapitre 4), ce nom de type n’est
rien d’autre que le spécificateur de type, éventuellement précédé de qualifieurs.
Par exemple, avec :
unsigned int p ;
const float x ;
le nom de type de la variable p est tout simplement unsigned int ; celui de x est
const float. Toutefois, le qualifieur ne joue aucun rôle dans ce cas et quel que soit
l’usage qu’on fasse du nom de type de x (cast, sizeof, argument), on peut
indifféremment utiliser unsigned int ou const unsigned int.
Dans le cas d’un pointeur, les choses sont moins simples. Par exemple, avec :
const unsigned long q, * const ad ;
le nom de type de ad s’obtient en juxtaposant les éventuels qualifieurs (ici const),
le spécificateur de type (ici unsigned long) et le déclarateur (ici * const ad) privé de
l’identificateur correspondant (soit, ici, * const). Le nom de type correspondant
au pointeur ad sera donc, en définitive :
const unsigned long * const
Là encore, le qualifieur associé au déclarateur ne joue aucun rôle, pour les
mêmes raisons que le qualifieur associé à une variable d’un type de base3 ne
jouait aucun rôle dans son nom de type (il n’intervient que dans les opérations
applicables à une lvalue), de sorte que ce nom de type peut aussi s’écrire :
const unsigned long * /* équivalent à const unsigned long * const */
En revanche, le qualifieur associé au spécificateur de type fait bien partie
intégrante du type, donc aussi du nom de type.
Remarques
1. Dans le cas de pointeurs de pointeurs, seul le dernier qualifieur pourra être omis, sans que cela ne
modifie le type. Par exemple, avec cette déclaration, dans laquelle adadf est un pointeur constant
sur un pointeur constant sur un float constant :
const float x, * const * const adadf ;
Le nom de type de adadf est const float * const * const, mais on pourra tout aussi bien
utiliser const float * const *.
2. Lorsque le nom de type est utilisé comme opérande de sizeof, les qualifieurs, quel que soit leur
niveau, n’ont aucun rôle et ils peuvent être omis.
3. Les propriétés des pointeurs
Non seulement le langage C autorise la manipulation de pointeurs, mais il
permet également d’effectuer des calculs basés sur des pointeurs. Plus
précisément, on peut ajouter ou soustraire un entier à un pointeur ou faire la
différence de deux pointeurs. Dans ces calculs, on adopte alors comme unité,
non pas l’octet, mais la taille des objets pointés. On traduit souvent cela en
parlant des propriétés arithmétiques des pointeurs. Par ailleurs, il existe un lien
très étroit entre l’opérateur [] et les pointeurs. Ce sont ces différents aspects que
nous étudions ici. Ils nous permettront, à la section suivante, de faire le point sur
tout ce qui concerne les opérateurs faisant intervenir des pointeurs : +, -, [], & et *.
Tableau 7.2 : les propriétés des pointeurs
– unité utilisée : taille de l’objet pointé ; Voir
section
– on peut ajouter ou soustraire un entier à un 3.1
pointeur ; les qualifieurs de l’objet pointé Voir
Propriétés
sont répercutés sur l’expression ; section
arithmétiques
– on peut soustraire deux pointeurs ; 3.3
– l’ordre des pointeurs ne coïncide pas
obligatoirement avec celui des adresses.
– une référence à un tableau est convertie en Voir
un pointeur constant sur son premier section
Lien pointeur élément (excepté avec & ou sizeof) ; 3.2
tableau
– l’opérateur [] reçoit un opérande pointeur et
un opérande entier (ordre indifférent) et :
exp1[exp2] ó * (exp1 + exp2)
Dans une expression faisant intervenir des Voir
pointeurs, les différents objets pointés section
Restrictions doivent pouvoir être considérés comme 3.4
éléments d’un même tableau (l’un des deux
pouvant être situé juste au-delà de la fin).
3.1 Les propriétés arithmétiques des pointeurs
3.1.1 Addition ou soustraction d’un entier à un pointeur
Si une variable ad a été déclarée ainsi :
int *ad ;
une expression telle que :
ad + 1
a un sens en C. Elle est de même type que ad (ici, pointeur sur int) et sa valeur est
celle de l’adresse de l’entier suivant l’entier pointé actuellement par ad.
On voit donc que la notion de pointeur diffère de celle d’adresse. En effet, la
différence entre les adresses correspondant à ad et ad + 1 est de sizeof (int) octets.
Si ad était déclaré :
double *ad ;
la différence entre ad et ad + 1 serait de sizeof(double) octets. Une autre différence,
plus subtile, apparaîtra au niveau du sens dans lequel se fait la progression,
lequel peut être différent de celui de la progression des adresses. Nous y
reviendrons à la section 3.3.
D’une manière comparable, avec la déclaration :
int *ad ;
l’expression :
ad + 3
pointerait sur un emplacement situé « 3 entiers plus loin » que celui pointé
actuellement par ad.
Enfin, une expression telle que :
ad++
incrémente l’adresse contenue dans ad, de façon qu’elle désigne l’entier suivant.
3.1.2 Qualifieurs et expressions pointeur
Les éventuels qualifieurs relatifs à l’objet pointé sont répercutés dans le type de
l’expression. Par exemple, avec :
const int * ad ;
une expression telle que ad + 3 est bien de type const int *, et non seulement de
type int *. En particulier, l’affectation suivante resterait interdite :
*(ad+3) = … /* interdit : ad + 3 est de type const int * */
Cette remarque s’avérera particulièrement précieuse dans le cas de pointeurs sur
des tableaux et notamment dans le cas des pointeurs sur des chaînes de
caractères.
En revanche, les qualifieurs relatifs à la variable pointeur elle-même n’ont
aucune influence sur le type de l’expression. Par exemple, avec :
int * const adci ; /* adci est un pointeur constant sur un int */
adci+3 est une expression de type pointeur sur un int, et non pointeur constant sur
un int. Notez qu’il en va exactement de même pour une variable usuelle. Par
exemple, avec :
const int n = 5 ;
la notion de constance de l’expression n+3 n’aurait aucun sens.
3.1.3 Soustraction de deux pointeurs
Il est possible de soustraire les valeurs de deux pointeurs de même type. La
signification d’une telle opération découle de celle de l’addition d’un pointeur et
d’un entier présentée précédemment.
Le résultat est un entier correspondant au nombre d’éléments (du type commun)
situés entre les deux adresses en question. Par exemple, avec :
int t[10] ;
int * ad1 = &t[3] ;
int * ad2 = &t[8] ;
l’expression :
ad2 - ad1
a pour valeur 5.
En fait, on peut dire que cette soustraction n’est rien d’autre que l’opération
inverse de l’incrémentation : si p1 et p2 sont des pointeurs de même type tels que
l’on ait p2 = p1+ i, alors p2 - p1 = i ou encore p1 - p2 = -i.
3.2 Lien entre pointeurs et tableaux
Soit ces déclarations :
int t[10] ;
int *adr = … /* on suppose que adr pointe sur une suite */
/* d'objets de type int */
En général, on accède aux différents éléments de t par des expressions de la
forme t[i] et aux objets pointés par adr par des expressions de la forme *adr ou *
(adr+i).
Cependant, les concepteurs du C ont fait en sorte que :
• une notation telle que *(t+i) soit totalement équivalente à t[i] ;
• une notation telle que adr[i] soit totalement équivalente à *(adr+i).
Cette équivalence est en fait la conséquence de deux choix fondamentaux
concernant d’une part les noms de tableaux (et même, plus généralement, les
références à des tableaux, c’est-à-dire les expressions de type tableau) et d’autre
part, l’opérateur []. Ce sont ces choix que nous allons étudier maintenant en
détail.
3.2.1 Équivalence entre référence à un tableau et pointeur
Cette équivalence se manifeste par une règle générale comportant deux
exceptions.
Règle générale
Lorsqu’un nom de tableau apparaît dans un programme, il est converti en un
pointeur constant sur son premier élément. Ainsi, avec :
int t[10] ;
lorsque t apparaît dans une instruction, il est remplacé par l’adresse de t[0]. Par
exemple, cette instruction serait correcte (même si elle est rarement utilisée
ainsi) :
scanf ("%d", t) ; /* équivaut à scanf ("%d", &t[0]); */
En outre, t étant un pointeur (constant) de type int *, des expressions telles que
celles-ci ont un sens :
t + 1 /* équivaut à &t[1] */
t + i /* équivaut à &t[i] */
Voici un exemple d’instructions utilisant ces remarques :
int * adr ;
int t[10] ;
…..
adr = t + 3 ; /* adr pointe sur le troisième élément de t */
*adr = 50 ; /* équivaut à : t[3] = 50; */
/* ou encore à : *(t+3) = 50; */
D’une manière générale, ce sont non seulement les noms de tableaux qui sont
convertis en un pointeur, mais également toutes les références à un tableau. Ceci
aura surtout des conséquences dans le cas des tableaux à plusieurs indices que
nous examinerons en détail à la section 3.2.4. Signalons que les tableaux
apparaissant en argument d’une fonction seront soumis à cette règle (voir section
6 du chapitre 8).
Les exceptions à la règle
Il existe deux exceptions naturelles à la règle précédente qui demande de
remplacer une référence à un tableau par un pointeur sur son premier élément :
lorsque le nom de tableau apparaît comme opérande de l’un des deux opérateurs
sizeof et &.
Avec sizeof (voir section 3.4 du chapitre 6), l’opérateur sizeof appliqué à un nom
de tableau fournit bien la taille du tableau, non pas celle du pointeur, ce qui est,
somme toute, plus satisfaisant !
Avec & : si t est un tableau d’éléments de type T, l’expression &t fournit bien
l’adresse du premier élément de t, non l’adresse de cette adresse, ce qui n’aurait
aucun sens. Elle est du type « pointeur sur T ».
Remarque
Les qualifieurs des éléments du tableau se retrouvent dans le type du pointeur correspondant à son
nom. Par exemple, avec :
int t1[10] ;
const int t2[10] ;
t1 est du type int *, tandis que t2 est du type const int *. Comme en réalité, t1 et t2 sont des
pointeurs constants, on pourrait dire également que t1 est du type int const *, tandis que t2 est du
type const int const *, ce qui ne change rien au type effectif.
En résumé
Toute référence à un tableau d’objets de type T, à l’exception de l’opérande de sizeof ou de &, est
convertie en un pointeur constant (du type pointeur sur T) sur le premier élément du tableau.
3.2.2 L’opérateur []
Pour assurer l’équivalence entre t[i] et *(t+i) annoncée en introduction, les
concepteurs du C ont prévu que l’accès par indice à un élément de tableau
s’effectue en fait à l’aide d’un opérateur binaire noté [] recevant :
• un opérande de type pointeur (un identificateur de tableau adéquat, puisque
remplacé par un pointeur…) ;
• un opérande de type entier.
Il fournit comme résultat la référence de l’objet ayant comme adresse la valeur
de la somme des deux opérandes (somme d’un pointeur et d’un entier).
Par exemple, avec :
int t[10] ;
t[i] est en fait interprété comme suit4 :
• t est converti en un pointeur sur t[0] ;
• on ajoute i au résultat ;
• on considère l’objet ainsi pointé ;
• t[i] est donc bien équivalent à *(t+i).
De même, avec :
int * adr ;
adr[i] est en fait interprété comme suit :
• on ajoute i à la valeur de adr (notez qu’il n’y a plus de conversion de tableau en
pointeur cette fois) ;
• on considère l’objet ainsi pointé ;
• adr[i] est donc bien, là encore, équivalent à *(t+i).
Remarques
1. L’ordre des deux opérandes de l’opérateur [] est indifférent. Ainsi, dès lors que la notation t[i] est
légale, la notation i[t] l’est aussi et elle représente la même chose. En pratique, la seconde forme
est très rarement utilisée, ne serait-ce qu’à cause de son aspect inhabituel et déroutant.
2. Le résultat de l’opérateur [] n’est pas toujours une lvalue. En effet, l’objet correspondant peut ne
pas être une lvalue. L’exemple le plus typique étant (en dehors des tableaux constants) le cas où
ces objets sont eux-mêmes des tableaux. Nous en verrons des exemples à la section 3.2.4.
En résumé
L’opérateur [] reçoit deux opérandes (dans un ordre indifférent), l’un de type pointeur sur des objets,
l’autre de type entier. Son résultat est l’objet pointé par la somme de ses deux opérandes, de sorte qu’il
y a équivalence entre exp1[exp2] et *(exp1+exp2).
3.2.3 Application à des tableaux à un indice
Examinons maintenant en détail toutes les conséquences des règles précédentes,
en supposant que t est un tableau d’éléments de type T, T étant un type
quelconque, autre que tableau : il peut donc s’agir d’un type de base, d’un type
structure ou union, ces agrégats pouvant éventuellement comporter des tableaux.
En fait, tout cela revient à dire qu’on se place dans le cas où les éléments de t
sont des lvalue. Quant au cas des tableaux de tableaux ou tableaux à plusieurs
indices, il sera examiné à la section suivante.
Dans la suite du programme (aux deux exceptions près évoquées
précédemment), la notation t est donc convertie en un pointeur sur le premier
élément de t. Autrement dit, elle est identique à &t[0]5 et elle est de type
« pointeur sur T ».
Voici, sur une même ligne, quelques notations équivalentes, même si la première
paraît plus naturelle :
Rappelons que cette équivalence entre notation pointeur et notation indicielle
reste valable lorsque l’on a affaire à un banal pointeur et non plus à un nom de
tableau. Ainsi, si p est une variable de type « pointeur sur T », les notations
suivantes sont équivalentes, même si la première paraît parfois la plus naturelle
dans ce nouveau contexte :
Bien entendu, cela ne préjuge pas de l’existence effective des objets pointés, qui
ne faisait aucun doute dans le cas précédent puisque t avait fait l’objet d’une
allocation mémoire. Ici, on doit supposer qu’une telle allocation a bien été faite,
indépendamment de la déclaration du pointeur p.
Par ailleurs, on ne perdra pas de vue que, bien que t et p soient de même type, t
est une constante alors que p est une variable. En particulier, t n’est pas une
lvalue, tandis que p en est une. En revanche, les expressions *(t+i) et *(p+i) sont
bien toutes les deux des lvalue, du moins tant que t n’est pas un tableau constant
et que p n’est pas un pointeur sur des objets constants.
Exemple
Pour illustrer ces différentes possibilités de notation, voici plusieurs façons de
placer la valeur 1 dans chacun des 10 éléments d’un tableau t de 10 entiers :
int t[10], i ;
for (i=0 ; i<10 ; i++)
t[i] = 1 ;
int t[10], i ;
for (i=0 ; i<10 ; i++)
*(t+i) = 1 ;
int t[10], i ;
int *ad : /* pointeur courant */
for (ad=t, i=0 ; i<10 ; i++, ad++)
*ad = 1 ;
Dans la troisième façon, nous avons dû recopier la « valeur » représentée par t
dans un pointeur nommé ad. En effet, il ne faut pas perdre de vue que le symbole
t représente une adresse constante (t est une constante de type pointeur sur des
int). Autrement dit, une expression telle que t++ aurait été invalide, au même titre
que, par exemple, 3++. Un nom de tableau est un pointeur constant, ce n’est pas
une lvalue.
En revanche, ce problème ne se poserait plus si l’on travaillait avec une variable
pointeur p. On pourrait incrémenter directement la valeur de p (ce qui ne serait
pas nécessairement judicieux, dans la mesure où la valeur initiale serait perdue) :
int * p ; /* p est l'adresse d'un entier supposé appartenir à un tableau */
int i ;
for (i=0 ; i<10 ; i++, p++)
*p = 1 ;
3.2.4 Application à des tableaux à deux indices
Supposons, cette fois, que t est un tableau à deux indices d’éléments de type T, T
étant un type quelconque autre que tableau. Pour fixer les idées, nous admettrons
que le déclarateur de t est de la forme :
t[3][4]
Là encore, dans la suite du programme (aux deux exceptions près évoquées
précédemment), la notation t est donc convertie en un pointeur sur le premier
élément de t. Autrement dit, elle est identique à &t[0] et elle est du type
« pointeur sur des tableaux de 4 éléments de type T ». Une expression telle que
t+1 est évaluée en incrémentant cette adresse d’une taille égale à celle d’un
tableau de 4 T.
Ces deux points n’apportent rien de plus à ce qui a été dit précédemment sur des
tableaux à un indice. En revanche, des nouveautés apparaissent si l’on considère
une expression telle que :
t[1]
Certes, son évaluation se passe comme auparavant. Autrement dit :
• t est converti en un pointeur sur un tableau de 4 T ;
• on ajoute 1 au résultat, ce qui fournit un pointeur sur le deuxième élément de t ;
son type est « pointeur sur des tableaux de 4 T ».
Comme précédemment donc, t[1] désigne bien le deuxième élément du tableau t.
Mais cette fois, il s’agit à nouveau d’une référence à tableau. Ce résultat est donc
à son tour converti en un pointeur sur le premier élément, c’est-à-dire en une
valeur de type « pointeur sur T » pointant sur l’élément t[1][0]. C’est précisément
l’aspect nouveau par rapport au cas des tableaux à un indice : on obtient un
résultat de type « pointeur sur T », alors que l’on s’attendait (peut-être) à un
résultat de type « pointeur sur des tableaux de 4 T ».
D’une manière semblable, l’expression :
*(t+1)
représente, elle aussi, une référence à un tableau (dont les éléments sont de type
T). Elle est donc convertie en un pointeur sur son premier élément.
En définitive, les notations t[1] et *(t+1) sont donc toujours équivalentes, comme
elles l’étaient dans le cas des tableaux à un indice, mais leur type n’est plus celui
qu’on attendait. Qui plus est, ce ne sont plus des lvalue puisqu’il s’agit de
pointeurs constants.
Par ailleurs, comme t[1] est l’adresse de t[1][0], il est clair que *t[1] est
équivalent à t[1][0]. En outre, comme *(t+1) est équivalent à t[1], **(t+1) est
équivalent à *t[1], c’est-à-dire finalement à t[1][0]. Ces expressions restent bien
des lvalue, tant que que t n’a pas été déclaré constant.
Enfin, et fort heureusement, une expression telle que :
t[i][j]
s’interprète comme (t[i])[j], c’est-à-dire, au bout du compte, comme :
*( *(t+i) + j)
En résumé, voici, sur une même ligne, quelques notations équivalentes (compte
tenu de ce que l’ordre des opérandes de [] est quelconque, lorsque cet opérateur
apparaît deux fois, il existe quatre façons différentes de les combiner.
Manifestement, seule la notation usuelle est compréhensible !) :
&t[0] t &0[t] /* pointeur sur des tableaux de 4T */
&t[i] t + i &i[t] /* pointeur sur des tableaux de 4T */
t[1] *(t + 1) 1[t] /* pointeur sur T */
t[i] *(t + i) i[t] /* pointeur sur T */
t[i][0] *t[i] **(t+i) i[t][0] 0[t[i]] /* type T */
t[i][j] (t[i])[j] (*(t+i))[j] j[*(t+i)] j[i[t]] *(*(t+i)+j) /* type T */
Là encore, ces notations resteraient équivalentes si t était, non plus un nom de
tableau, mais un pointeur sur des tableaux de 4 T.
3.3 Ordre des pointeurs et ordre des adresses
Parler, par exemple, de l’entier suivant un entier donné, sous-entend que l’on
progresse en mémoire selon une direction donnée. Certes, il n’y a que deux
directions possibles : suivant les adresses décroissantes ou croissantes.
La norme ne précise nullement quel est l’ordre choisi. En général, ce n’est pas
gênant, tant qu’on ne s’intéresse pas véritablement aux adresses
correspondantes. On notera que la même incertitude existe quant à l’arrangement
des éléments d’un tableau en mémoire : ils peuvent être placés selon l’ordre des
adresses croissantes ou décroissantes.
Cependant, dans tous les cas, la norme assure que si ad pointe sur l’entier de rang
i d’un tableau, alors ad + 1 pointera sur l’entier de rang i+1, que ce dernier ait une
adresse supérieure ou inférieure au précédent. Autrement dit, il y a bien
cohérence entre l’arithmétique des pointeurs et l’arrangement des tableaux.
3.4 Les restrictions imposées à l’arithmétique des
pointeurs
Supposons que l’on ait déclaré :
int *adi ;
Si adi pointe sur un tableau comportant suffisamment d’éléments de type int, une
expression telle que adi+3 pointe sur un élément de ce tableau, donc sur un entier
précis.
En revanche, si tel n’est pas le cas, par exemple :
int n ;
int *adi = &n ;
il n’est pas du tout certain qu’à l’adresse contenue dans adi+3, on trouve un entier.
Dans le cas le plus défavorable, il se peut même que l’adresse correspondante ne
soit pas valide.
D’une manière similaire, si les adresses correspondantes sont situées au sein
d’un même tableau, la différence de deux pointeurs p2 - p1, ramenée à l’unité
utilisée (ici, la taille d’un int) a bien un sens. Dans le cas contraire, rien ne dit
que cette différence soit un multiple de la taille d’un int.
Pour tenir compte de ces difficultés, la norme impose une contrainte théorique
aux expressions de type pointeur, à savoir l’appartenance à un même tableau des
objets concernés. Comme on peut s’y attendre, une telle contrainte sera rarement
vérifiable à la compilation et d’ailleurs la norme prévoit un comportement
indéterminé lors de l’exécution. Examinons cela plus en détail en considérant
séparément le cas des expressions de type pointeur et celui des soustractions de
pointeurs.
3.4.1 Cas des expressions de type pointeur
La règle
Considérons des expressions de la forme :
pointeur + entier
pointeur - entier
La norme précise que le comportement du programme est indéterminé si pointeur
et l’expression à évaluer ne pointent pas sur des objets appartenant à un même
tableau. Il s’agit cependant d’une condition à la fois floue et restrictive.
Cette condition est floue car elle ne précise pas vraiment que le tableau en
question a besoin d’avoir été déclaré comme un tableau. Heureusement
d’ailleurs, puisque c’est ce qui permettra de travailler correctement avec des
tableaux dont l’emplacement aura été alloué dynamiquement :
int * adr ;
…..
adr = malloc (100 * sizeof (int)) ;
…..
/* adr[i] ou *(adr+i) sont utilisables dans ce contexte */
Par ailleurs, cette condition est restrictive dans la mesure où, par définition, en
C, tout objet de type T peut être considéré comme une suite de sizeof (T) octets,
c’est-à-dire finalement comme un tableau de sizeof (T) caractères. Or il est tout à
fait licite de parcourir les différents octets d’un objet comme s’il s’agissait d’un
tableau de caractères !
En fait, nous préférons exprimer cette contrainte en disant que :
Les objets concernés doivent pouvoir être considérés comme des éléments d’un même tableau.
Cette condition est notamment vérifiée dans les cas évoqués précédemment.
Remarque
Considérons une construction telle que :
int t[10] ;
int *p ;
…..
for (p=t, i=0 ; i<=10 ; i++, p++)
À la fin du dernier tour, la valeur de p dépasse de une unité l’adresse du dernier élément du tableau t.
La construction proposée ne respecte pas la contrainte évoquée précédemment, concernant
l’appartenance à un même tableau. Pour rendre légale une construction aussi répandue, la norme a dû
faire une exception pour le cas des éléments situés un élément plus loin que la fin du tableau.
En fait, cette règle n’est utile qu’aux réalisateurs de compilateurs. Il leur faut en effet accepter le calcul
d’une telle adresse, même dans le cas où elle serait invalide, c’est-à-dire dans le cas (rare) où le
tableau se trouverait juste en limite de l’espace mémoire alloué au programme.
Comportement du programme en cas d’exception
Examinons les différentes situations d’exception qui peuvent se produire dans
les calculs liés aux pointeurs.
En pratique, la plupart des compilateurs ne cherchent pas à vérifier que la
contrainte d’appartenance à un tableau est vérifiée, même quand cela est
possible. Ce n’est pas pour autant qu’il est possible d’utiliser impunément
n’importe quelle expression de type pointeur.
En effet, il ne faut pas oublier qu’un calcul d’adresse de la forme pointeur + entier
ou pointeur - entier peut toujours conduire à une adresse invalide, c’est-à-dire à
une adresse inexistante ou, pour le moins, située en dehors de la mémoire
allouée au programme. Dans ce cas, le comportement du programme n’est pas
défini. Dans la plupart des implémentations, on obtient une erreur d’exécution.
Cependant, il existe des implémentations où un tel calcul peut conduire non pas
à une adresse invalide, mais à une adresse fausse ; dans ce cas, les conséquences
peuvent être plus graves…
Par ailleurs, même si l’expression concernée fournit une adresse existante, il est
possible que dans sa déréférenciation, c’est-à-dire dans un calcul de la forme :
*(adresse + entier) adresse [entier] *(adresse - entier)
on aboutisse à une erreur d’exécution liée au fait que le motif binaire trouvé à
cette adresse n’est pas légal pour le type correspondant ; par exemple, il peut
s’agir d’un flottant non normalisé.
D’une manière générale, par le biais de l’équivalence entre tableau et pointeur,
ces risques sont identiques à ceux évoqués en cas de débordement d’indice dans
un tableau (voir section 4 du chapitre 6).
Exemple
int main()
{ float x[5] ;
float *adf = x ; /* adf pointe sur le premier élément de x */
while (1) printf ("%f", *adf++) ;
}
Ce programme peut générer plusieurs sortes de situations d’exception :
• L’adresse résultant du calcul adf++ correspond à un élément situé au-delà de
l’élément suivant le dernier élément du tableau x. Il y a non respect de la
norme et donc, en théorie, comportement indéterminé. En pratique, le
programme se poursuivra, tant que l’une des exceptions suivantes n’apparaîtra
pas.
• L’adresse obtenue par adf++ est invalide ou se situe en dehors de l’emplacement
alloué au programme. La plupart du temps, cette anomalie sera
convenablement détectée et conduira à une erreur d’exécution. Si tel n’est pas
le cas, les conséquences peuvent être diverses et conduire à un « plantage » de
la machine.
• La valeur située à l’adresse adf (et qu’on cherche à imprimer) ne correspond
pas à un flottant normalisé. Selon les implémentations, on pourra obtenir un
message et/ou un arrêt de l’exécution.
3.4.2 Cas de la soustraction de pointeurs : le type ptr_difft
De façon comparable à ce qui se passe pour la somme d’un pointeur et d’un
entier, la norme ANSI prévoit que le comportement de l’évaluation de
l’expression p2 - p1 n’est pas défini si les objets pointés n’appartiennent pas à un
même tableau (avec une exception pour l’élément suivant immédiatement le
dernier). En pratique, le résultat de cet opérateur est évalué en calculant la
différence entre les adresses de p2 et de p1, et en divisant la valeur obtenue par la
taille des objets pointés (avec une incertitude sur la manière dont se fait l’arrondi
lorsque l’on n’aboutit pas à un multiple exact de la taille).
La norme demande à l’implémentation de définir un type entier signé nommé
ptrdiff_t (synonyme défini par typedef dans le fichier en-tête stddef.h) et de
l’utiliser pour recueillir le résultat de la soustraction. On notera que la norme
n’impose pas à ce type d’être assez grand pour contenir la différence de deux
pointeurs quelconque. Elle n’impose d’ailleurs même pas qu’il suffise à
recueillir la différence de pointeurs sur deux éléments d’un même tableau de
taille quelconque ! En pratique, on peut raisonnablement supposer qu’un tel
problème ne se pose pas au sein d’un « vrai » tableau, quelle que soit sa taille,
dans la mesure où ptrdiff_t devrait au moins être défini en fonction de la taille du
plus grand objet manipulable par un programme. En revanche, rien n’empêche
qu’on rencontre ce problème lorsque l’on est amené à soustraire deux pointeurs
sur des objets, certes de même type, mais n’ayant aucun lien entre eux. Dans ce
cas, on risque d’aboutir soit à une erreur d’exécution, liée à un dépassement de
capacité, soit, plus fréquemment, à une valeur fausse.
4. Tableaux récapitulatifs : les opérateurs +, -, &, * et
[]
Cette section n’apporte pas d’éléments nouveaux. Elle récapitule simplement
tout ce qui concerne les opérateurs faisant intervenir des pointeurs sur des objets,
le cas des pointeurs sur des fonctions étant, quant à lui, examiné au chapitre 8.
Tableau 7.3 : les opérateurs + et - dans un contexte pointeur
Tableau 7.4 : les opérateurs réciproques & et * (cas des objets)
Opérateur Opérande Résultat et comportement
&exp exp est une expression – résultat : pointeur, de type
désignant un objet de « pointeur constant sur T »
type T quelconque, (avec les éventuels qualifieurs
autre que champ de de T) contenant l’adresse de
bits, et n’étant pas de l’objet.
classe register (il peut
s’agir d’un tableau).
*adr est un pointeur sur
adr
– résultat : l’objet pointé ; s’il n’a
un objet de type pas le type prévu, la norme
quelconque. prévoit un comportement
indéterminé ; en pratique, si
l’on place quelque chose à
l’adresse indiquée, on court les
mêmes risques qu’avec scanf et
un code de format incorrect ; si
l’on utilise la valeur concernée,
on cours les mêmes risques
qu’avec printf et un code de
format incorrect ;
– comportement : si l’opérande a
la valeur nulle ou s’il
correspond à une adresse
invalide, la norme prévoit un
comportement indéterminé ; en
pratique, on obtient
généralement une erreur
d’exécution.
N.B : le cas des pointeurs sur des fonctions est étudié à la section 11 du chapitre 8.
Tableau 7.5 : l’opérateur []
5. Le pointeur NULL
Il existe un symbole noté NULL, défini dans certains fichiers en-tête (stddef.h,
stdio.h et stdlib.h), dont la valeur représente conventionnellement un pointeur ne
pointant sur rien, c’est-à-dire auquel n’est associée aucune adresse. Cette valeur
peut être affectée à un pointeur de n’importe quel type, par exemple :
int *adi ; /* pointeur sur des int */
double (* adt) [10] /* pointeur sur des tableaux de 10 double */
…..
adi = NULL ;
adt = NULL ;
Cette valeur NULL ne doit pas être confondue avec une valeur de pointeur
indéfinie, même si on l’utilise conventionnellement pour indiquer qu’un pointeur
ne pointe sur rien. Il s’agit au contraire d’une valeur bien définie. En particulier,
on peut tester l’égalité (opérateur ==) ou la non-égalité (opérateur !=) de
n’importe quel pointeur avec NULL. En revanche, les comparaisons d’inégalité (<,
<=, >, >=) sont théoriquement interdites, même si certaines implémentations les
acceptent. Dans ce cas, le résultat obtenu, probablement basé sur l’ordre des
adresses, n’est pas portable.
Exemples d’utilisation de NULL
Pour l’initialisation d’une variable pointeur
Dès qu’on utilise des variables de type pointeur, il est raisonnable :
d’initialiser avec NULL tous les pointeurs pour lesquels il n’existe pas de meilleure
initialisation :
int * adi = NULL ; /* par précaution */
• de tester, au moment de son utilisation, tout pointeur qui risque de n’avoir pas
reçu de valeur (autre que NULL) :
if (adi != NULL) *adi = … /* on est sûr que adi a reçu une valeur */
/* on peut aussi écrire : */
/* if (adi) *adi = … */
Dans des listes chaînées
Dans des listes chaînées, pour indiquer qu’un pointeur ne pointe sur rien, la
valeur de NULL s’avère parfaitement adaptée. Ce sera notamment le cas d’un
pointeur de fin de liste. On en trouvera un exemple au chapitre 14.
En valeur de retour d’une fonction
Les fonctions standard qui fournissent un pointeur comme résultat renvoient
souvent NULL en cas de problème. C’est notamment le cas de toutes les fonctions
d’allocation dynamique telles que malloc ou calloc. Il est conseillé de procéder de
la même manière avec ses propres fonctions.
Remarque
On pense souvent que la valeur associée à NULL est l’entier 0. Si l’on examine la norme à ce propos,
elle n’est pas aussi précise :
• d’une part, elle dit que la macro NULL fournit un pointeur nul, dont la valeur dépend de
l’implémentation ;
• d’autre part, elle indique que l’entier 0, converti en void *, est un pointeur nul.
En toute rigueur donc, la valeur associée à NULL n’est pas totalement définie : on est sûr que c’est
(void *) 0, mais on n’est pas sûr que ce soit un objet avec tous les bits à 0 (comme l’est l’entier 0).
En pratique, hormis peut-être dans des situations de mise au point un peu particulières, il n’est pas
indispensable d’en savoir plus. En effet, quelle que soit la façon d’employer NULL, la valeur entière 0
reste utilisable grâce aux règles concernant l’affectation, les conversions et la comparaison de
pointeurs présentées sections 6 à 9. Ainsi, avec :
int *adi ;
vous pouvez indifféremment écrire :
adi = NULL ; /* écriture conseillée */
adi = 0 ; /* 0 sera converti en NULL par conversion implicite */
/* de int en int * */
adi = (void *) 0 ;
adi = (int *) 0 ;
De même, dans le test de nullité d’un pointeur, les deux écritures :
if (adi != NULL)
if (adi)
restent identiques et portables, car l’interprétation logique d’une expression de type pointeur se fait
bien par rapport au pointeur nul (sous-entendu ayant la valeur NULL) et non par rapport à l’entier 0.
6. Pointeurs et affectation
On peut affecter à une variable de type pointeur sur un objet la valeur d’une
expression de même type, par exemple :
int *ad1, *ad2 ;
…..
ad1 = ad2 + 1 ; /* ad2 est de type int *, ad2+1 est de type int * */
Il faut cependant préciser comment interviennent les qualifieurs des objets
pointés. En outre, il existe quelques rares possibilités de conversion par
affectation :
• d’une expression de type void * en un pointeur quelconque et réciproquement ;
• de la valeur entière 0 en un pointeur quelconque.
6.1 Prise en compte des qualifieurs des objets pointés
Comme indiqué dans la section 3.1.2, les qualifieurs de l’objet pointé sont
répercutés sur le type d’une expression de type pointeur. Par exemple, avec :
const int *ad ;
la variable ad est du type « pointeur sur un int constant ». Une expression telle
que ad+3 sera également du type « pointeur sur un int constant », et pas seulement
du type « pointeur sur int ». La même remarque s’appliquerait à volatile.
En cas d’affectation d’une expression de type pointeur, on pourrait penser qu’il
est nécessaire que la lvalue réceptrice possède les mêmes qualifieurs pour les
objets pointés. En fait, la règle est un peu moins restrictive :
En cas d’affectation d’une expression de type pointeur à une lvalue, cette dernière doit posséder au
moins les mêmes qualifieurs que l’objet pointé par l’expression.
Nous allons justifier cette règle en examinant séparément le cas de const et celui
de volatile.
6.1.1 Cas de const
Considérons par exemple ces déclarations :
const int *adc ; /* adc est un pointeur sur un int constant */
int *ad ; /* ad est un pointeur sur un int */
D’après la règle évoquée, les affectations suivantes sont interdites :
ad = adc ; /* incorrect */
ad = adc + 3 ; /* incorrect */
Ceci est logique car si elles étaient acceptées, on risquerait par la suite de
modifier un objet constant par une simple instruction telle que :
*ad = …
Rappelons, en effet, qu’une telle instruction ne pourrait plus être rejetée par le
compilateur puisqu’il fonde sa décision sur le type de ad et en aucun cas sur le
type effectif de l’objet pointé par ad au moment de l’exécution (C est un langage
à typage statique).
En revanche, ces affectations seront acceptées :
adc = ad ; /* correct */
adc = ad + 5 ; /* correct */
Là encore, les choses sont logiques, dans la mesure où aucun risque de
modification intempestive n’existe ici. En effet, il n’est pas possible d’écrire une
affectation de la forme :
*adc = …
et quand bien même elle le serait, l’objet pointé n’est de toute façon pas
constant !
On peut interpréter la règle en disant qu’on peut donner l’adresse d’un objet
variable, là où on s’attend à l’adresse d’un objet constant, mais non l’inverse. On
peut aussi dire qu’on peut faire à un objet non constant tout ce qu’on a prévu de
faire sur un objet constant, mais non l’inverse.
Remarque
Il ne faut pas confondre les qualifieurs des objets pointés avec les éventuels qualifieurs de la lvalue.
Dans nos précédents exemples, avec :
const int * const adc ;
int *adi ;
l’instruction adc = ad serait rejetée car adc n’est plus une lvalue.
6.1.2 Cas de volatile
Considérons par exemple ces déclarations :
volatile int *adv ; /* adv est un pointeur sur un int volatile */
int *ad ; /* ad est un pointeur sur un int */
D’après la règle évoquée, les affectations suivantes sont interdites :
ad = adv ; /* incorrect, mais accepté par certaines implémentations */
ad = adv + 3 ; /* incorrect, mais accepté par certaines implémentations */
Ceci est logique, car si elles étaient acceptées, le compilateur risquerait, par
exemple dans certains cas d’optimisation, de sortir d’une boucle une instruction
telle que :
… = *ad ;
sous prétexte que cette valeur ne change pas. Cette conclusion peut se révéler
erronée puisque la valeur réellement pointée est en fait volatile. Rappelons, là
encore, qu’une telle instruction ne peut plus être rejetée par le compilateur, qui
fonde sa décision uniquement sur le type de ad et en aucun cas sur le type effectif
de l’objet pointé par ad au moment de l’exécution (C est un langage à typage
statique).
En revanche, ces affectations seront acceptées :
adv = ad ; /* correct */
adv = ad + 5 ; /* correct */
Là encore, les choses sont logiques, dans la mesure où aucun risque
d’optimisation abusive n’existe puisque, cette fois, le compilateur ne risque plus
de sortir d’une boucle une instruction telle que :
… = *adv ;
En effet, cette fois adv est censé pointer sur des objets volatiles et, qui plus,
l’objet réellement pointé n’est pas volatile.
On peut interpréter la règle en disant qu’on peut donner l’adresse d’un objet non
volatile, là où on s’attend à l’adresse d’un objet volatile, mais non l’inverse. On
peut aussi dire qu’on peut faire à un objet non volatile tout ce qu’on a prévu de
faire sur un objet volatile, mais non l’inverse.
Remarque
Là encore, il ne faut pas confondre les qualifieurs des objets pointés avec les éventuels qualifieurs de
la lvalue. Par exemple, avec :
volatile int * adv ; /* adv est un pointeur sur un int volatile */
int * volatile ad ; /* ad est un pointeur volatile sur un int */
l’instruction adv = ad serait correcte, bien que la lvalue adv ne possède pas le qualifieur volatile
que possède ad.
6.2 Les autres possibilités d’affectation
Le type void * et les affectations
Le type générique void * est présenté à la section 7. Il s’agit du seul type
compatible par affectation avec tous les types de pointeurs : un pointeur de type
peut être affecté à n’importe quel autre et, réciproquement, un pointeur de
void *
n’importe quel type peut être affecté à un pointeur de type void *. On verra
toutefois que seules les affectations d’un pointeur de type void * à un pointeur
quelconque fournissent l’assurance de conserver l’adresse d’origine.
Le pointeur NULL
Il a été présenté à la section 5. Il correspond à une valeur particulière ne
coïncidant jamais avec une véritable adresse en machine et il a été conçu pour
pouvoir être affecté à n’importe quelle variable pointeur.
L’entier 0
D’une manière générale, les conversions d’entier en pointeur ne sont pas
permises par affectation (elles pourront s’obtenir par l’opérateur de cast, comme
indiqué à la section 9). Une exception existe cependant pour l’entier 0. Elle est
simplement justifiée par le fait que, quel que soit le type de pointeur concerné, sa
conversion fournit le pointeur NULL. D’ailleurs, comme vu à la section 5, il y a
équivalence entre NULL et (void *) 0 :
int *adi ;
float *adf ;
…..
adi = 0 ; /* équivaut à la forme conseillée : adi = NULL; */
adf = 0 ; /* équivaut à la forme conseillée : adf = NULL; */
Les pointeurs sur des fonctions
Ces possibilités sont étudiées à la section 11.2 du chapitre 8.
6.3 Tableau récapitulatif
Le tableau 7.6 récapitule tout ce qui concerne l’affectation de pointeurs, y
compris dans le cas des pointeurs sur des fonctions. Il s’agit en fait d’un extrait
du tableau de la section 7.4 du chapitre 4, concernant l’affectation en général, et
il n’est fourni ici qu’à titre de commodité.
Tableau 7.6 : les affectations à des lvalue de type pointeur
Opérande de
Opérande de droite Remarques
gauche
de type
lvalue
Une des deux possibilités – justification
pointeur sur un suivantes : de la règle
objet, autre que – expression du même type pointeur des
void *
ou de type qualifieurs à
void *, la lvalue concernée devant la section
dans tous les cas posséder au 6.1 ;
moins les mêmes qualifieurs const – présentation
ou volatile que le type des objets du pointeur
pointés ; NULL à la
– NULL ou entier 0. section 5.
lvalue de type Une des deux possibilités – justification
void *
suivantes : de la règle
– expression d’un type pointeur des
quelconque, y compris void *, la qualifieurs à
lvalue concernée devant dans tous
la section
les cas posséder au moins les 6.1 ;
mêmes qualifieurs const ou – présentation
volatile que le type des objets du type void *
pointés ; à la section
– NULL ou entier 0. 7 ;
– présentation
du pointeur
NULL à la
section 5.
de type
lvalue Valeur d’un type compatible au sens Voir section
pointeur sur une de la redéclaration des fonctions. 11.2 du chapitre
fonction 8
6.4 Les affectations élargies += et -= et les
incrémentations ++ et --
Bien entendu, les opérateurs += et -= restent utilisables dans un contexte pointeur,
leur signification se déduisant de celle de l’affectation et de la somme (ou de la
différence) d’un pointeur et d’un entier. Quant à ++ et --, ils incrémentent ou
décrémentent tout simplement de une unité la valeur d’un pointeur. Voici
quelques exemples :
int *ad ;
…..
ad += 3 ; /* équivaut à : ad = ad + 3; */
ad -= 8 ; /* équivaut à : ad = ad - 8; */
ad++ ; /* équivaut à : ad += 1; ou encore : ad = ad + 1; */
ad-- ; /* équivaut à : ad -= 1; ou encore : ad = ad - 1; */
7. Les pointeurs génériques
7.1 Généralités
En C, un pointeur possède un type défini par le type des objets pointés. On peut
dire, en quelque sorte, que la valeur d’un pointeur est formée de l’association
d’une adresse et d’un type. La connaissance de ce type est indispensable dans
des situations telles que :
• calculs arithmétiques : l’unité utilisée étant définie par la taille de l’objet
pointé ;
• déréférenciation de pointeur, c’est-à-dire utilisation d’expression de la forme *p
(p étant un pointeur) ; le type est ici utile pour connaître la taille de l’objet à
considérer et, éventuellement, pour utiliser la valeur correspondante au sein
d’une expression.
Néanmoins, la connaissance de ce type n’est pas toujours indispensable. En
particulier, l’adresse contenue dans un pointeur a toujours un sens,
indépendamment de la nature de l’objet pointé. Dans certains cas, il peut être
utile, voire indispensable de pouvoir manipuler de simples adresses, sans avoir à
se préoccuper d’un quelconque type. C’est ce qui se produit lorsque :
• une fonction doit traiter des objets de différents types, alors qu’elle en a reçu
l’adresse en argument ;
• on souhaite effectuer un traitement sur des pointeurs, sans avoir à tenir compte
(ou sans connaître) le type des objets pointés ; c’est par exemple le cas
lorsqu’on souhaite simplement échanger les valeurs de deux pointeurs ;
• on doit traiter une suite d’octets, à partir d’une adresse donnée.
Dans la première définition du langage C, la seule solution à ce type de
problème consistait à faire appel au type char *, lequel permet en théorie de
représenter n’importe quelle adresse d’octet, donc, a fortiori, n’importe quelle
adresse d’objet. Cependant, cette démarche s’avérait peu satisfaisante dans
certains cas, notamment lorsqu’on souhaitait transmettre une telle adresse à une
fonction. En effet, il fallait alors prévoir des conversions explicites du pointeur
concerné dans le type char *, à l’aide d’un opérateur de cast.
La norme ANSI a introduit un type particulier souvent appelé « pointeur
générique » et noté :
void *
Ce type void * présente les avantages suivants sur le type char * :
• il évite les confusions : dans la première version du langage C, le type char *
pouvait aussi bien désigner :
– un « vrai pointeur » sur un caractère ou, ce qui revient au même, sur une
chaîne de caractères ;
– la représentation artificielle d’un pointeur générique ;
• il interdit l’arithmétique et la déréférenciation, afin de rendre les programmes
plus sûrs.
Néanmoins, même dans sa version normalisée, le C continue à souffrir de
certaines lacunes en matière de pointeurs. En effet, il lui manque toujours un
pointeur sur des octets, le type void * ne pouvant pas être utilisé, par exemple,
pour parcourir les différents octets d’un objet. Dans ce cas, il faudra encore
recourir au type char *.
7.2 Déclaration du type void *
On déclare des pointeurs de ce type, comme n’importe quel autre pointeur, avec
d’éventuels qualifieurs. Par exemple, avec :
const void *p1, * const p2 ;
• p1 est un pointeur générique sur des objets constants de type quelconque. On
notera que le fait que les objets en question soient constants n’apporte rien de
plus au compilateur puisque, le pointeur p1 ne pouvant pas être déréférencé, il
est, de toutes façons, impossible d’écrire, par exemple :
*p1 = … /* interdit car p1 est de type void * */
/* que les objets pointés soient constants ou non */
• p2 est un pointeur générique constant sur des objets constants. Là encore,
comme pour p1, le fait que les objets pointés par p2 soient constants n’apporte
rien de plus au compilateur. En revanche, le fait que p2 soit lui même constant
en interdit la modification.
On notera que l’utilisation du mot void peut prêter à confusion. En effet, si l’on
peut affirmer que p1 est de type void *, on ne peut pas pour autant affirmer,
comme on le ferait pour n’importe quel autre type de pointeur, que *p1 est de
type void, car il n’existe pas de type void. En fait, l’ambiguïté réside
essentiellement dans le désir des concepteurs du C de limiter le nombre de mots
clés, ce qui les a conduits à employer le mot void dans des contextes différents,
avec des significations différentes : dans les en-têtes de fonctions, il signifie
« absence de », tandis que, associé à un déclarateur de pointeur, il signifie
« n’importe quel type » !
Remarque
Pour se convaincre de ce que, dans une déclaration, void est vraiment un spécificateur de type
différent des autres, on peut aussi comparer les deux déclarations suivantes :
void *ad, n ; /* incorrect : n ne peut pas être du type void */
int *ad, n ; /* ad est de type int *, n et de type int */
7.3 Interdictions propres au type void *
Contrairement aux autres types de pointeurs, un pointeur de type void * ne peut
pas :
• être soumis à des calculs arithmétiques ;
• être déréférencé (utilisation de l’un des opérateurs * ou []).
7.3.1 Le type void * ne peut pas être soumis à des calculs
arithmétiques
Par exemple :
void *ad1, *ad2 ;
int dif ;
…..
ad1++ ; /* interdit */
ad2 = ad1 + 5 ; /* l'expression ad1 + 5 est illégale */
dif = ad2 - ad1 ; /* l'expression ad2 - ad1 est illégale */
On notera que ces interdictions sont justifiées si l’on part du principe qu’un
pointeur générique est simplement destiné à être manipulé en tant que tel, par
exemple pour être transmis d’une fonction à une autre. En revanche, elles le sont
moins si l’on considère qu’un tel pointeur peut aussi servir à manipuler des
octets successifs ; dans ce cas, il faudra quand même recourir au type char *.
7.3.2 Le type void * ne peut pas être déréférencé
Par exemple, avec :
void *adr ;
il est impossible d’utiliser l’expression *adr. On notera que, cette fois, une telle
interdiction est logique puisque la valeur de l’expression en question ne peut être
déterminée que si l’on connaît le type de l’objet pointé.
Bien entendu, il reste toujours possible de convertir par cast la valeur de adr dans
le type approprié, pour peu qu’on le connaisse effectivement ! Par exemple, si
l’on sait qu’à l’adresse contenue dans adr, il y a un entier, on pourra utiliser
l’expression :
* (int *) adr /* équivautt à : * ( (int *) adr) */
/* valeur de l'objet pointé par adr */
/* en supposant qu'il s'agit d'un entier */
Si l’adresse contenue dans adr n’a pas véritablement été obtenue comme celle
d’un entier, il est possible que sa conversion en int * la modifie pour tenir
compte de certaines contraintes d’alignement (voir section 9.1.1).
7.4 Possibilités propres au type void *
Par rapport aux autres types de pointeurs, le type void * dispose de possibilités
supplémentaires, au niveau :
• des comparaisons d’égalité ;
• des conversions par affectation.
7.4.1 Comparaisons d’égalité ou d’inégalité
Comme nous le verrons à la section 8 on ne peut pas tester l’égalité ou
l’inégalité de deux pointeurs de types différents. Une exception a lieu pour le
type void *, qui peut être comparé par == ou != avec n’importe quel autre type de
pointeur (voir section 8.2). Nous verrons alors que cette comparaison se ramène
finalement à la comparaison des adresses correspondantes.
void * ad ;
int * adi ;
…..
if (ad == adi) ….. /* ad et adi contiennent la même adresse */
else ….. /* ad et adi ne contiennent pas la même adresse */
7.4.2 Conversions par affectation
Tout d’abord, un pointeur de n’importe quel type peut être converti par
affectation en void *, pour peu que la règle relative aux qualifieurs, présentée à la
section 6, soit vérifiée. Cette possibilité n’a rien de très surprenant puisqu’elle
revient à ne conserver du pointeur d’origine que l’information d’adresse, ce qui
correspond bien à la notion de pointeur générique.
Réciproquement, un pointeur de type void * peut être converti par affectation en
un pointeur sur un objet de type quelconque, moyennant le respect de la règle
relative aux qualifieurs. Cette fois, une telle possibilité est relativement
discutable, dans la mesure où elle présente certains risques. En effet, il est
possible que l’adresse d’origine soit modifiée pour tenir compte d’éventuelles
contraintes d’alignement du type d’arrivée.
On notera toutefois que la conversion de void * en char * ne présente pas les
risques évoqués dans la mesure où aucune contrainte d’alignement ne pèse sur le
type caractère, lequel correspond à un octet.
En C++
En C++, seule la conversion par affectation d’un pointeur en void * sera légale ; la conversion inverse
ne sera possible qu’en recourant explicitement à un opérateur de cast, y compris dans le cas sans
risque de la conversion de void * en char *.
8. Comparaisons de pointeurs
Le langage C permet de comparer des pointeurs sur des objets. Il faut
distinguer :
• les comparaisons basées sur un ordre, c’est-à-dire celles qui font appel aux
opérateurs <, <=, > et >= ;
• les comparaisons d’égalité ou d’inégalité, c’est-à-dire celles qui font appel aux
opérateurs == ou !=.
Quant à la comparaison de pointeurs sur des fonctions, elle ne peut se faire que
par égalité ou inégalité (voir section 11 du chapitre 8). Elle ne figurera que par
souci d’exhaustivité dans les tableaux récapitulatifs.
8.1 Comparaisons basées sur un ordre
Tout d’abord, ces comparaisons ne sont théoriquement définies par la norme que
pour des pointeurs de même type, les qualifieurs du pointeur ou de l’objet pointé
n’intervenant pas. Si cette condition n’est pas vérifiée, on obtient une erreur de
compilation6.
En outre, la norme prévoit une contrainte théorique assurant que ces
comparaisons ont une signification, en induisant une contrainte théorique
d’appartenance des objets pointés à un même agrégat (tableau, structure ou
union). Le cas des structures et des unions présente peu d’intérêt, il sera étudié à
la section 4 du chapitre 11.
En ce qui concerne les tableaux, la contrainte imposée correspond à celle
exposée à la section 3.4, à propos des expressions de type pointeur et que nous
préférons traduire en disant que les éléments doivent pouvoir être considérés
comme éléments d’un même tableau. Dans le cas contraire, le résultat de la
comparaison est simplement indéterminé. On notera bien qu’ici, il ne s’agit plus
de comportement indéterminé, ce qui signifie que, pour peu que les expressions
comparées soient de même type et valides, on obtiendra toujours un résultat.
Exemple 1
Avec :
int t[10] ;
int *adr1, *adr2, *adr3 ;
…..
adr1 = &t[1] ;
adr2 = &t[4] ;
adr3 = &t[10] ; /* pointe sur l'élément suivant le dernier du tableau */
on peut assurer que les deux conditions suivantes sont vraies :
adr1 < adr2 /* vrai */
adr1 <= adr2 /* vrai */
adr1 < adr3 /* vrai */
En revanche, si l’on considère ces trois conditions après une affectation telle
que :
adr2 = adr1 + 100 ; /* ou encore : adr2 = &t[101] */
deux situations anormales apparaissent :
Tout d’abord, au niveau du calcul de l’expression adr1+100 elle-même, dont la
norme prévoit que le comportement du programme est alors indéfini. En
pratique, dès lors que l’adresse correspondante est valide, on obtiendra bien un
résultat.
Ensuite, au niveau des comparaisons elles-mêmes. La norme précise simplement
que le résultat est alors indéterminé. En pratique cependant, dès lors que le
calcul de l’expression a pu se faire, il est fort probable que le résultat des
comparaisons restera le même que précédemment.
Exemple 2
Avec :
int n, p ;
int *adr1 = &n, *adr2 = &p ;
les comparaisons suivantes fournissent un résultat indéfini :
adr1 < adr2 /* indéfini */
adr1 <= adr2 /* indéfini */
En pratique, le résultat de la comparaison dépendra simplement de
l’implémentation des variables n et p en mémoire (en tenant compte de l’ordre
imposé aux pointeurs qui n’est pas nécessairement celui des adresses, comme
l’explique la section 3.3)7. Une telle information aura cependant généralement
peu d’intérêt.
8.2 Comparaisons d’égalité ou d’inégalité
Les comparaisons précédentes imposaient des restrictions permettant de donner
un sens à une relation d’ordre. Ici, en revanche, le langage va s’avérer plus
tolérant puisqu’il ne s’agit que de détecter l’identité d’emplacement de deux
objets pointés et non plus leur position relative.
C’est ainsi qu’on pourra tester l’égalité de deux pointeurs de même type (aux
qualifieurs près). Il y aura égalité s’ils pointent sur les mêmes objets ; ils
contiendront donc la même adresse. On notera bien que, réciproquement, deux
pointeurs contenant la même adresse ne pourront être trouvés égaux que s’ils
sont de même type. Ils ne pourront pas, de toute façon, être comparés
directement, c’est-à-dire sans conversion par cast.
De plus, tout pointeur peut être comparé à NULL. Par exemple, avec :
long *adl ;
char *adc ;
les comparaisons suivantes sont légales et ont un sens (certaines sont utilisées à
la section 5) :
adl == NULL /* correct */
adc != NULL /* correct */
adl == 0 /* correct : il y aura conversion de 0 en (void *) 0 */
/* c'est-à-dire en NULL */
Un pointeur de type void * peut être comparé par égalité ou inégalité avec
n’importe quel autre pointeur, quel que soit son type, la comparaison se faisant
après conversion en void *. Au bout du compte, cela revient donc simplement à
comparer les adresses correspondantes, sans tenir compte de la véritable nature
de l’objet pointé par le premier pointeur.
8.3 Récapitulatif : les comparaisons dans un contexte
pointeur
Tableau 7.7 : les comparaisons de pointeurs basées sur un ordre
Tableau 7.8 : les comparaisons d’égalité et d’inégalité des pointeurs
L’une des contraintes
suivantes doit être
Opération satisfaite (vérifiée en Remarques
compilation)
p1 == p2 p1 et p2 sont des pointeurs
p1 != p2
sur des objets de même type
(aux qualifieurs près).
Un des deux pointeurs au Conversion de l’autre
moins est de type void *. opérande en void *.
Un des deux opérandes est
NULL ou 0.
p1 et p2 sont des pointeurs Étude détaillée à la section
sur des fonctions de type 11.6 du chapitre 8.
compatible.
9. Conversions de pointeurs par cast
Comme nous l’avons vu dans la section 6, le langage C est assez restrictif en ce
qui concerne les conversions implicites autorisées lors d’une affectation entre
pointeurs : hormis celle de void * en un pointeur quelconque, ces conversions ne
présentent aucun risque. En revanche, il est beaucoup plus tolérant en ce qui
concerne les conversions forcées par l’opérateur de cast. En effet, il est possible
de convertir :
• tout type pointeur sur un objet en n’importe quel autre type pointeur sur un
objet ;
• un entier en un pointeur ;
• un pointeur en un entier ;
• tout type pointeur sur une fonction en n’importe quel autre type pointeur sur
une fonction.
Le dernier point est étudié à la section 11.7 du chapitre 8. Nous nous
contenterons de le citer dans les tableaux récapitulatifs.
9.1 Conversion d’un pointeur en un pointeur d’un
autre type
D’une manière générale, on pourrait penser que ce genre de conversion revient à
conserver l’adresse du pointeur initial, en se contentant de modifier la nature de
l’objet pointé et, donc l’arithmétique correspondante. En fait, il n’en va pas
toujours ainsi, compte tenu de l’existence, sur certaines machines, de contraintes
d’alignement, dont nous allons parler ici.
Par ailleurs, l’opérateur de cast autorise la conversion d’un pointeur sur un objet
constant en un pointeur sur un objet non constant, de sorte qu’il devient possible,
par ce biais, de modifier la valeur d’un objet constant ! Nous vous en
proposerons un exemple plutôt dissuasif.
9.1.1 Contraintes d’alignement et conversions de pointeurs
Pour des questions d’efficacité, il est fréquent qu’une implémentation impose à
certains types de données des contraintes sur leurs adresses. Voici des exemples
de situations usuelles :
• alignement des entiers de deux octets sur des adresses paires, ce qui, sur des
machines à 16 bits, permet d’accéder en une fois à l’entier correspondant ;
• alignement d’objets de 4 octets sur des adresses multiples de 4, ce qui, sur des
machines à 32 bits, permet d’accéder en une seule fois à l’objet correspondant.
Dans ces conditions, l’utilisation comme adresse d’un objet de type donné d’une
adresse ne respectant pas ces contraintes d’alignement pose parfois problème.
C’est pourquoi la norme autorise qu’une conversion de pointeur puisse modifier
l’adresse correspondante, afin que le résultat vérifie toujours la contrainte du
nouvel objet pointé.
Par exemple, si on suppose que l’implémentation aligne les int sur des adresses
paires, avec :
char *adc ;
int *adi ;
l’affectation :
adi = (int *) adc ;
est légale, mais l’adresse figurant dans adr pourra être :
• celle de adc si cette dernière était paire ;
• celle de adc augmentée ou diminuée de un, si cette dernière était impaire, de
façon que le résultat soit pair.
Ainsi, le cycle de conversions suivant peut, dans certains cas, modifier de une
unité la valeur initiale figurant dans adc :
adi = (int *) adc ;
adc = (char *) adi ;
D’une manière générale, dans une implémentation donnée, les contraintes
d’alignement des différents types d’objets peuvent être classées :
• de la plus faible, c’est-à-dire en fait de l’absence de contrainte ; les caractères
sont obligatoirement dans ce cas car tout objet doit pouvoir être décrit comme
une succession continue d’octets, c’est-à-dire de char ;
• à la plus forte.
La norme impose que, dans le cas où T et U sont deux types de données tels que la
contrainte sur T soit égale ou plus forte que la contrainte sur U, la conversion de
« pointeur sur T en pointeur sur U » sera acceptable et qu’elle ne dénaturera pas
l’adresse correspondante. Autrement dit, la conversion inverse permettra de
retrouver l’adresse d’origine8.
En particulier, on est toujours certain que les conversions dans le type char * ou
dans le type générique void * ne dénatureront jamais l’adresse correspondante. Ce
sont d’ailleurs des pointeurs de ce type qui sont utilisés lorsqu’il s’agit de
transmettre l’adresse d’un objet dont ne se préoccupe pas du type (ou dont on ne
connaît pas le type).
Remarque
Dans certaines implémentations, les contraintes d’alignement sont paramétrables. Cette souplesse
possède une contrepartie notoire : un même code, exécuté sur une même implémentation, peut
produire des résultats différents, selon la manière dont il a été compilé ! Par ailleurs, la norme C11
introduit des outils de gestion de ces contraintes d’alignement (voir l’annexe B consacrée aux normes
C99 et C11).
9.1.2 Qualifieurs et conversions de pointeurs
Comme indiqué dans la section 2.5, il existe deux types de qualifieurs (voire
davantage en cas de pointeurs de pointeurs) concernant les variables de type
pointeur :
• le qualifieur appartenant au déclarateur de pointeur lui-même ; il concerne la
variable pointeur ; par exemple :
int p, const *adi ; /* adi est un pointeur constant sur des int */
• le qualifieur accompagnant le spécificateur de type dans la déclaration du
pointeur ; il concerne l’objet pointé ; par exemple :
const int n, *adic ; /* adic est un pointeur sur des int constants */
/* alors que n est un int constant */
Le qualifieur d’une variable pointeur joue le même rôle que celui des variables
usuelles ; il n’intervient donc pas dans les conversions et il ne fait pas partie du
nom de type correspondant.
Le qualifieur de l’objet pointé, en revanche, fait partie intégrante du nom de type
et il intervient donc dans l’opérateur de cast. C’est ainsi qu’il est possible de
réaliser des conversions :
• de int * en const int *, c’est-à-dire de « pointeur sur int » en « pointeur sur int
constant » ;
• de const int * en int *, c’est-à-dire de « pointeur sur int constant » en « pointeur
sur int ».
La première conversion fait partie des conversions autorisées par affectation et
ne présente guère de risque : elle permettra de traiter un entier comme un entier
constant, ce qui revient à dire qu’elle interdira certaines affectations. En
revanche, la seconde conversion, non autorisée par affectation, n’est à utiliser
qu’avec précaution. En effet, elle permettra de traiter un entier constant comme
un entier non constant et, par suite, d’en modifier peut-être la valeur. Signalons
qu’une telle modification ne sera cependant pas possible dans une
implémentation qui place les objets constants dans une zone protégée en écriture
puisqu’alors une tentative de modification provoquera une erreur d’exécution.
De façon semblable, il est possible de réaliser des conversions :
• de int * en volatile int *, c’est-à-dire de « pointeur sur int » en « pointeur sur int
volatile » ;
• de volatile int * en int *, c’est-à-dire de « pointeur sur int volatile » en
« pointeur sur int ».
Là encore, la première conversion, déjà autorisée par affectation, ne présente pas
de risque particulier, tandis que la seconde doit être utilisée avec précaution.
9.2 Conversions entre entiers et pointeurs
Hormis les conversions déjà autorisées de façon implicite (NULL ou 0 en pointeur),
les conversions entre entiers et pointeurs ont un caractère relativement désuet et
nous ne les exposons que par souci d’exhaustivité.
La norme accepte les conversions d’entier en pointeur et de pointeur en entier.
Néanmoins, elle reste floue sur un certain nombre de points.
En particulier, elle laisse à l’implémentation toute liberté dans le choix d’un type
entier de taille suffisante pour recevoir le résultat de toute conversion d’un
pointeur en un entier, et elle indique que le comportement du programme sera
indéfini si l’on tente une conversion d’un pointeur dans un entier trop petit. De
plus, quand l’entier est de taille suffisante, elle prévoit que la valeur obtenue
dépend de l’implémentation.
En ce qui concerne les conversions inverses, c’est-à-dire d’entier en pointeur, la
norme se contente de préciser que le résultat dépend de l’implémentation ;
autrement dit, il ne peut y avoir de comportement indéfini dans ce cas.
D’une manière générale, nous conseillons de n’utiliser ce genre de conversions
que dans des circonstances exceptionnelles.
9.3 Récapitulatif concernant l’opérateur de cast dans
un contexte pointeur
Cette section n’apporte pas d’éléments nouveaux. Elle récapitule simplement
tout ce qui concerne l’opérateur de cast, utilisé dans un contexte pointeur, y
compris certains éléments qui seront examinés en détail au chapitre 8.
Tableau 7.9 : les conversions autorisées par cast dans un contexte pointeur
Type
Type résultant Remarques
initial
Pointeur Pointeur sur un Si la contrainte d’alignement du type
sur un objet de type résultant est supérieure à celle du type
objet quelconque initial, l’adresse obtenue peut être différente
de l’adresse initiale.
Pointeur Pointeur sur – possibilités étudiées à la section 11.7 du
sur une une fonction de chapitre 8 ;
fonction type
quelconque – adresse toujours conservée ;
– effet indéterminé si le pointeur résultant
est utilisé pour appeler une fonction d’un
type différent (en pratique, conséquences
usuelles de non-correspondance
d’arguments).
Pointeur Entier Résultat dépendant de l’implémentation (si
taille entier insuffisante → comportement
indéfini)
Entier Pointeur Résultat dépendant de l’implémentation
1. Ils ne pourront cependant pas être de classe registre, car un objet placé dans un registre ne possède pas
d’adresse.
2. Et à 256 dans le cas des pointeurs de pointeurs, etc.
3. Attention aux emplacements des qualifieurs (voir la remarque de la section 2.5.2.
4. Attention, on ne doit pas considérer l’opérateur [] comme formé d’un seul symbole, mais bien de deux
symboles différents [ et ], entre lesquels vient se glisser le second opérande.
5. En fait, nous utilisons cette notation parce que son interprétation intuitive ne pose pas de problème. En
toute rigueur, si l’on s’en tient aux règles précédentes, cette dernière s’interprète comme : l’adresse de
l’élément ayant comme adresse celle de t, augmentée de 0…
6. Toutefois, certaines implémentations se contentent d’un message d’avertissement n’interdisant pas
l’exécution.
7. En fait, on peut penser que les contraintes imposées par la norme permettent à une implémentation qui le
souhaiterait d’utiliser pour les éléments de tableaux un ordre différent de celui utilisé pour les structures.
8. Compte tenu des technologies actuelles, les contraintes d’alignement sont « emboîtées » les unes dans les
autres. Par exemple, on rencontre des alignements sur des multiples de 2, 4, 8… Il est donc assez naturel
de satisfaire à la condition dictée par la norme. En revanche, les choses seraient moins simples pour le
concepteur du compilateur si, dans une même implémentation, on trouvait, par exemple, à la fois des
alignements sur des multiples de 2 et des alignements sur des multiples de 3.
8
Les fonctions
Comme tous les langages, C permet de découper un programme en plusieurs
parties souvent appelées « modules ». Cette programmation dite « modulaire »
se justifie pour de multiples raisons dont nous rappelons ici les principales.
Tout d’abord, un programme écrit d’un seul tenant devient difficile à comprendre
dès qu’il dépasse une ou deux pages de texte. Une écriture modulaire permet de
le scinder en plusieurs parties et de regrouper dans le « programme principal »
(la fonction main en C) les instructions en décrivant les enchaînements. Chacune
de ces parties peut d’ailleurs, si nécessaire, être décomposée à son tour en
modules plus élémentaires. Ce processus de décomposition peut être répété
autant de fois que nécessaire, comme le préconisent les méthodes de
programmation structurée.
Par ailleurs, la programmation modulaire permet d’éviter des séquences
d’instructions répétitives, et cela d’autant plus que la notion d’argument permet
de paramétrer certains modules.
Enfin, la programmation modulaire permet le partage d’outils communs qu’il
suffit d’avoir écrits et mis au point une seule fois. Cet aspect sera d’autant plus
marqué que C autorise effectivement la compilation séparée, sinon de tels
modules, du moins d’un ensemble de modules contenus dans un fichier source.
Ici, nous commencerons par montrer qu’en C, comme dans la plupart des
langages récents, la notion de module est mise en œuvre à l’aide de ce que l’on
nomme des « fonctions », lesquelles ont un rôle plus large que la fonction
mathématique. Puis, nous fournirons un exemple simple introduisant les
principales notions relatives à ces fonctions (définition, arguments, déclaration et
appel), avant de procéder à leur étude détaillée.
Nous examinerons ensuite, pour les différents types existants, les conséquences
de la transmission par valeur des arguments. En particulier, nous verrons
comment simuler une transmission par adresse par le biais de pointeurs et nous
traiterons en détail le cas des tableaux en distinguant les situations de dimensions
fixes de celles de dimensions dites variables.
Nous verrons ensuite comment utiliser des variables globales, aussi bien au sein
d’un seul fichier source que depuis plusieurs fichiers source différents. Nous
examinerons tout ce qui concerne leur classe d’allocation, la notion de lien et
leur initialisation. Puis, nous ferons le point sur les variables locales en ce qui
concerne leur classe d’allocation, leur portée et leur initialisation. Bien que le
présent chapitre soit consacré aux fonctions, nous y traiterons en même temps
des variables locales à un bloc dont les variables locales à une fonction ne
constituent, somme toute, qu’un cas particulier.
Enfin, nous verrons comment utiliser des pointeurs sur des fonctions et,
éventuellement, les transmettre en argument. Le cas dit « des arguments
variables » sera examiné au chapitre 21.
1. Les fonctions en C
En matière de module, le langage C fait preuve, ici encore, d’une certaine
originalité, comme le montre cette section plutôt destinée aux connaisseurs
d’autres langages. Résumés dans le tableau 8.1, les différents aspects sont
examinés en détail dans les sections indiquées.
Tableau 8.1 : les particularités des fonctions en langage C
1.1 Une seule sorte de module en C : la fonction
Dans les anciens langages évolués, on trouve deux sortes de modules : les
fonctions et les procédures.
Les fonctions sont assez proches de la notion mathématique correspondante.
D’une part, une fonction dispose d’arguments qui correspondent à des
informations qui lui sont transmises. D’autre part, elle fournit un résultat de type
scalaire qu’on désigne alors par le nom même de la fonction et qui peut
apparaître dans une expression. On dit d’ailleurs que la fonction possède une
valeur (qu’on nomme parfois « valeur de retour ») et qu’un appel de fonction est
assimilable à une expression.
Les procédures (terme Pascal) ou sous-programmes (terme Fortran 90)
élargissent la notion de fonction. La procédure ne possède plus de valeur à
proprement parler et son appel ne peut plus apparaître au sein d’une expression.
En revanche, elle dispose toujours d’arguments. Parmi ces derniers, certains
peuvent, comme pour la fonction, correspondre à des informations qui lui sont
transmises. Mais d’autres peuvent correspondre à des informations qu’elle
produit en retour de son appel. De plus, une procédure peut réaliser une action1
(par exemple, afficher un message).
En C, il n’existe qu’une seule sorte de module, nommé « fonction ». Il en ira de
même, non seulement en C++, C# ou Java, langages dont la syntaxe est très
proche de celle du C, mais aussi dans des langages comme PHP ou Python à la
syntaxe plus éloignée. Ce terme de fonction pourrait laisser croire que les
modules du C sont moins généraux que ceux des anciens langages. Or il n’en est
rien. Certes, la fonction pourra y être utilisée comme l’était la fonction des
anciens langanges, c’est-à-dire recevoir des arguments et fournir un résultat
scalaire qu’on utilisera dans une expression, par exemple, dans :
y = sqrt(x)+3 ;
Ici, la fonction se nomme sqrt ; elle reçoit un seul argument x et son résultat,
désigné par l’expression sqrt(x) est à son tour utilisé dans une expression.
Mais en C, la fonction pourra prendre des aspects différents, pouvant s’éloigner
complètement de la notion de fonction mathématique. Par exemple :
• La valeur d’une fonction pourra ne pas être utilisée. C’est ce qui se passe
fréquemment lorsque l’on utilise printf ou scanf. Bien entendu, cela n’a
d’intérêt que parce qu’une telle fonction réalise une action, ce qui, dans les
anciens langages, était généralement réservé au sous-programme ou à la
procédure.
• Une fonction pourra ne fournir aucune valeur. Là encore, cela n’aura d’intérêt
que si cette fonction réalise une action.
• Une fonction pourra fournir un résultat non scalaire, plus précisément de type
structure ou union.
• Une fonction pourra modifier les valeurs de certains objets transmis en
arguments, pour peu qu’on utilise des pointeurs de façon appropriée.
1.2 Fonction et transmission des arguments par valeur
Traditionnellement, il existe trois modes de transmission d’arguments à un
module :
• par valeur : les valeurs des arguments de l’appel sont recopiées dans des
emplacements locaux à la fonction, qui travaille donc sur des copies. Toute
modification de la copie n’a aucune incidence sur la valeur de l’original ;
• par adresse : les adresses des arguments de l’appel sont transmises à la fonction
qui travaille donc directement sur les « originaux » et qui peut alors,
éventuellement, en modifier la valeur.
• par résultat : les valeurs des arguments sont produites dans des emplacements
locaux à la fonction et elles ne sont recopiées qu’à la fin dans les arguments
fournis à l’appel ;
Le dernier mode de transmission est relativement peu usité pour les arguments
d’un module. En revanche, il l’est, dans tous les langages, pour le résultat fourni
par une fonction2.
En ce qui concerne le langage C, ses concepteurs ont prévu que la transmission
des arguments se fasse systématiquement par valeur. Un tel mécanisme semble
interdire à une fonction de modifier la valeur d’un objet transmis en argument.
En réalité :
• une telle modification d’argument pourra s’obtenir en transmettant simplement
à la fonction un pointeur sur l’objet concerné, la fonction s’arrangeant pour
placer l’information voulue à l’adresse indiquée ; autrement dit, bien qu’il
n’existe pas de transmission par adresse en C, il est possible de la programmer
explicitement, par le biais de pointeurs ;
• dans le cas des tableaux, la valeur effectivement transmise sera, assez
curieusement, celle de son adresse, de sorte qu’il sera facile de modifier la
valeur des éléments du tableau ; qui plus est, il sera même impossible de
transmettre un tableau par valeur !
1.3 Les variables globales
D’une manière générale, la notion de variable globale permet à plusieurs
modules de partager des informations, autrement que par passage d’arguments.
Cette possibilité est de plus en plus absente dans les langages récents comme
Java ou C#. Lorsqu’elle est présente sa mise en œuvre peut différer à la fois :
• par la manière de structurer ou de ne pas structurer les informations globales :
en Basic, une information globale est accessible à tout module ; en Fortran 90,
on peut former des ensembles disjoints identifiés par un nom ; en PHP, on
trouve deux niveaux de globales (usuelles et super-globales)…
• par la hiérarchisation qui peut apparaître entre les modules : en Pascal, un
module peut être inclus dans un autre…
En langage C :
• toutes les fonctions sont au même niveau : il n’y a aucune imbrication de leurs
définitions (bien entendu, les appels peuvent, heureusement, s’imbriquer…) ;
• il n’y a qu’un seul niveau de variables globales et elles sont accessibles à tous
les modules de tous les fichiers source, sauf si l’on a volontairement limité leur
portée à un fichier source.
1.4 Les possibilités de compilation séparée
On parle de compilation séparée lorsqu’un langage permet de découper un
programme source en différentes parties qu’il est possible de compiler
indépendamment les unes des autres. Certains langages comme Java, C# ou
Fortran 90 compilent séparément chacun des modules eux-mêmes. D’autres,
comme Pascal, sont très restrictifs : même la notion d’unité introduite par Turbo
Pascal pour combler cette lacune ne permettait que des compilations partielles.
Enfin, dans les langages interprétés (PHP, Python), il va de soi qu’une telle
fonctionnalité ne peut plus exister.
En C, un programme source est découpé en un ou plusieurs fichiers source
contenant chacun une ou plusieurs fonctions. Ce sont les différents fichiers
source qui sont compilés séparément les uns des autres. Cette démarche très
souple offre en quelque sorte deux niveaux de structuration d’un programme : le
niveau du module (fonction) et le niveau du fichier source.
Ces possibilités de compilation séparée constituent d’ailleurs l’un des atouts
majeurs du langage. En contrepartie apparaîtront nécessairement quelques
contraintes, notamment au niveau de l’utilisation des variables globales.
2. Exemple introductif de la notion de fonction en
langage C
Nous rappelons ici les principales notions intervenant dans la création et
l’utilisation d’une fonction en langage C en commentant un exemple simple de
fonction correspondant à l’idée usuelle que l’on se fait d’un tel module, c’est-à-
dire recevant des arguments et fournissant une valeur. Ici, nous supposerons que
le programme source est formé d’un seul fichier source.
Exemple de définition et d’utilisation d’une fonction
#include <stdio.h>
/***** le programme principal (fonction main) *****/
int main()
{ float poly (float, int, int) ; /* déclaration de fonction poly */
float x = 1.5 ;
float y, z ;
int n = 3, p = 5, q = 10 ;
/* appel de poly avec les arguments effectifs x, n et p */
y = poly (x, n, p) ;
printf ("valeur de y : %e\n", y) ;
/* appel de poly avec les arguments effectifs x+0.5, q et n-1 */
z = poly (x+0.5, q, n-1) ;
printf ("valeur de z : %e\n", z) ;
}
/***** définition de la fonction poly *****/
float poly (float x, int b, int c)
{ float val ; /* déclaration d'une variable " locale " à poly */
val = x * x + b * x + c ;
return val ;
}
Nous y trouvons très classiquement un programme principal formé d’un bloc
d’instructions précédé d’un en-tête de la forme main(). À sa suite apparaît la
définition d’une fonction. Celle-ci possède une structure voisine de la fonction
main, à savoir un en-tête et un bloc. Mais l’en-tête est plus élaboré que l’en-tête
usuel3 de la fonction main puisque, outre le nom de la fonction (poly), on y trouve
une liste précisant le nom et le type des arguments (ou paramètres), ainsi que le
type de la valeur qui sera fournie par la fonction (on la nomme indifféremment
résultat, valeur de la fonction, valeur de retour…) :
Les noms des arguments n’ont d’importance qu’au sein du corps de la fonction.
Ils servent à décrire le travail que devra effectuer la fonction quand on
l’appellera en lui fournissant trois valeurs.
Si on s’intéresse au corps de la fonction, on y rencontre tout d’abord une
déclaration :
float val ;
Celle-ci précise que, pour effectuer son travail, notre fonction a besoin d’une
variable de type float nommée val. On dit que val est une variable locale à la
fonction poly, de même que les variables telles que n, p, y… sont finalement des
variables locales à la fonction main.
L’instruction suivante de notre fonction poly est une affectation classique (faisant
toutefois intervenir les valeurs des arguments x, n et p). Enfin, l’instruction return
val précise la valeur que fournira la fonction à la fin de son travail.
En définitive, on peut dire que poly est une fonction telle que l’expression poly (x,
b, c) fournisse la valeur de l’expression x2 + b x+ c. Notez bien l’aspect arbitraire
du nom des arguments, qui justifie qu’on les qualifie de « muets » ou de
« formels ». On obtiendrait la même définition de fonction avec, par exemple :
float poly (float z, int coef, int n)
{
float val ; /* déclaration d'une variable " locale " à poly */
val = z * z + coef * z + n ;
return val ;
}
Dans la fonction main se trouve une déclaration :
float poly (float, int, int) ;
Elle sert à indiquer au compilateur que poly est une fonction et elle lui précise le
type de ses arguments ainsi que celui de sa valeur de retour.
Quant à l’utilisation de notre fonction poly au sein de la fonction main, elle est
classique et comparable à celle d’une fonction prédéfinie telle que scanf ou sqrt.
Ici, nous nous sommes contentés d’appeler notre fonction à deux reprises avec
des arguments différents, certains se présentant sous forme d’expressions. Notez
qu’on parle alors d’arguments effectifs dans ce cas, par opposition aux
arguments muets de la définition.
Remarque
L’instruction return peut mentionner n’importe quelle expression. Ainsi, nous aurions pu définir notre
fonction d’une manière plus concise :
float poly (float x, int b, int c)
{
return (x*x + b*x + c) ;
}
3. Définition d’une fonction
On appelle « définition d’une fonction » l’ensemble des instructions qui
précisent les actions à exécuter lors de son appel. Cet ensemble est constitué de
deux parties :
• un en-tête ;
• un corps formé d’un bloc d’instructions parmi lesquelles peuvent
éventuellement apparaître une ou plusieurs instructions return.
Le tableau 8.2 récapitule les propriétés de ces différents éléments, qui sont
ensuite étudiés de façon détaillée dans les sections mentionnées, à l’exception de
la notion de bloc étudiée au chapitre 5.
Tableau 8.2 : les éléments intervenant dans la définition d’une fonction
– peut prendre deux formes : ancienne Voir section
(déconseillée) ou moderne (conseillée et 3.1
indispensable en cas d’arguments
variables) ; Voir section
3.2
– indique le nom et le type (objet
quelconque) des arguments muets s’ils Voir section
existent à l’aide de déclarations ; 3.3
En-tête – utilise un mécanisme d’association d’un
déclarateur à un spécificateur de type pour
définir le type de la valeur de retour si elle
existe ; il peut s’agir d’un type objet Voir section
quelconque, excepté tableau, 3.4
éventuellement qualifié dans le cas
d’objets pointés ;
– peut comporter une classe de
mémorisation extern ou static.
Formé, comme n’importe quel bloc : Voir section
3
– d’instructions de déclarations (depuis Voir
C99, leur emplacement est libre ; chapitre 5
Bloc auparavant elles devaient précéder toute
instruction exécutable) ;
– d’instructions exécutables parmi
lesquelles peuvent figurer éventuellement
une ou plusieurs instructions return.
– peut mentionner une expression de type Voir section
quelconque (excepté de type tableau) 3.5
compatible par affectation avec la valeur
Instruction précisée dans l’en-tête ;
return
– peut apparaître zéro, une ou plusieurs
fois ;
– peut faire intervenir une conversion.
3.1 Les deux formes de l’en-tête
Il existe deux formes d’en-tête :
• la forme introduite par la norme ANSI, dite parfois « forme moderne » ; c’est
celle que nous avons utilisée dans l’exemple de la section 2 ;
• la forme prévue initialement par les concepteurs du C, toujours acceptée par la
norme, mais qui sera interdite en C++.
Voici à quoi correspondent ces deux formes dans le cas de l’exemple précédent :
Forme moderne (à gauche) et forme ancienne (à droite) de la définition d’une
fonction
float poly (float x, int b, int c) float poly (x, b, c)
float x ;
int b ; int c ; /* ou int b, c ; */
{ /* corps de la fonction */ {
/* corps de la fonction */
} }
D’une manière générale, ces deux formes sont presque équivalentes sur le plan
des informations qu’elles fournissent au compilateur. On peut passer de l’une à
l’autre par une simple transformation d’écriture, à une exception près : les en-
têtes des fonctions, dites « à arguments variables », ne s’expriment que dans la
forme moderne. Dans la suite, nous tiendrons compte de l’existence de ces deux
formes mais, pour plus de clarté, dans la description de l’en-tête, nous avons
séparé l’étude de la déclaration des arguments de celle de la valeur de retour.
Remarque
Généralement, on a tendance à placer les déclarations d’arguments en ligne dans la première forme,
tandis qu’on les place en colonne dans la deuxième forme. Il ne s’agit bien sûr que d’une habitude et
rien n’empêcherait, par exemple, de faire exactement l’inverse :
float poly
(float x, float poly (x, b, c) float x ; int b ; int c ;
int b, int c)
{ /* corps de la fonction */ { /* corps de la fonction */
} }
3.2 Les arguments apparaissant dans l’en-tête
3.2.1 Les types autorisés
Une fonction peut recevoir un argument d’un type objet quelconque.
Ceci exclut donc les fonctions, mais pas les pointeurs sur des fonctions. Un
argument pourra ainsi être d’un type de base quelconque, d’un type pointeur,
structure, union ou tableau. Le cas des tableaux est cependant assez particulier,
compte tenu de l’équivalence qui existe entre un identificateur de tableau et un
pointeur. En effet, comme on le verra section 6, la fonction recevra au bout du
compte la « valeur » de l’adresse du tableau.
3.2.2 Leurs déclarations
Dans la forme ancienne de l’en-tête, comme on peut le voir dans l’exemple de la
section 3.1, les déclarations des arguments n’étaient rien d’autre que des
déclarations usuelles. Dans la forme moderne, il en va presque de même, si ce
n’est qu’on ne déclare plus qu’un seul argument à la fois, les différentes
déclarations étant séparées par des virgules. Ainsi, quelle que soit la forme de
l’en-tête, le mécanisme permettant de définir le type d’un argument est le même
que celui qui permet de définir le type d’une variable : il associe un
« spécificateur de type » à un « déclarateur ». Avec la forme ancienne, un même
spécificateur de type pouvait correspondre à plusieurs déclarateurs, comme dans
une déclaration de variables, tandis que dans la forme moderne, il ne peut
correspondre qu’à un seul déclarateur.
Dans les deux cas, le type de l’argument peut être complété par des qualifieurs
(const et volatile). Ceux-ci jouent le même rôle que pour une variable locale
usuelle, à savoir que :
• Un argument déclaré avec le qualifieur const ne doit pas être modifié à
l’intérieur de la fonction. Bien entendu, les contrôles du compilateur ne
peuvent être exhaustifs, pas plus qu’ils ne pouvaient l’être pour une variable
locale.
• Un argument déclaré avec le qualifieur volatile peut voir sa valeur évoluer à
l’intérieur de la fonction, sans que soit exécutée une instruction le
mentionnant.
Rappelons que, dans le cas des pointeurs (voir section 2.5 du chapitre 7), les
qualifieurs const et volatile peuvent apparaître à différents niveaux, selon qu’ils
portent sur l’objet pointeur lui-même ou sur l’objet pointé.
En ce qui concerne la classe de mémorisation, elle est rarement utilisée dans les
déclarations de type d’argument ; il ne peut, de toute façon, s’agir que de auto ou
register, avec la même signification que pour les variables locales (voir section
9.2). On notera que, de toute évidence, extern n’aurait aucun sens ici, pas plus
que static. La classe de mémorisation par défaut est tout naturellement auto, ce
qui correspond à une classe d’allocation automatique.
Exemple
Voici un exemple de forme moderne d’en-tête :
float fct
(register int p, /* p est un entier à placer si possible dans un
registre */
auto const int n, /* n est un entier constant ; auto est superflu
ici */
const float * adfc, /* adfc est un pointeur sur un flottant
constant */
float * const adcf, /* adcf est un pointeur constant sur un
flottant */
const int * const adcic) ; /* adcic est un pointeur constant
sur */
/* un entier
constant */
Ici, la valeur de adcf ne pourra pas être modifiée dans la fonction, mais la valeur
de *adcf pourra l’être. De même, la valeur de adfc pourra être modifiée dans la
fonction ; celle de *adfc ne pourra pas l’être.
3.2.3 Cas d’une fonction sans arguments
La norme ANSI a prévu que le mot-clé void pouvait être utilisé en lieu et place de
la liste d’arguments pour indiquer une fonction sans arguments. Voici l’en-tête
d’une fonction ne recevant aucun argument et renvoyant une valeur de type float
(il pourrait s’agir, par exemple, d’une fonction fournissant un nombre
aléatoire !) :
float tirage (void) /* la fonction tirage ne reçoit aucun argument */
{ ….. }
Cependant, la norme accepte également la forme ancienne de l’en-tête. Dans ce
cas, il n’y a aucune déclaration avant le bloc de définition de la fonction, ce qui
conduit alors à :
float tirage ()
{ ….. }
En C++
Bien que C++ impose la forme moderne des en-têtes, une fonction sans arguments devra se déclarer
avec une liste vide et non plus avec le mot-clé void.
3.3 La valeur de retour
3.3.1 Les types autorisés
La valeur de retour d’une fonction peut être d’un type quelconque, hormis tableau ou fonction. Elle ne
peut pas être qualifiée par const ou volatile (mais s’il s’agit d’un pointeur, les objets pointés peuvent
l’être). Elle peut ne pas exister.
Faisons quelques commentaires à propos de ces règles très larges, qui découlent
directement du fait que le résultat d’une fonction est toujours transmis à la
fonction appelante par recopie de sa valeur.
Le cas où la valeur de retour est d’un type de base correspond à l’idée usuelle
qu’on se fait d’une fonction.
Lorsqu’il s’agit d’un type pointeur, la norme n’impose aucune restriction à la
nature de l’objet ou de la fonction pointée. Cependant, on ne perdra pas de vue,
dans ce cas, que lorsqu’une fonction renvoie un pointeur sur un objet, l’objet
pointé, quant à lui, ne verra pas sa valeur recopiée au sein de la fonction
appelante. Par conséquent, un risque potentiel d’erreur existe dès lors que l’objet
en question est local à la fonction appelée, puisqu’il sera détruit à la sortie.
La possibilité de renvoyer une valeur de type structure n’existait pas dans la
première définition du C. L’ajout de cette possibilité par la norme fait d’autant
plus ressentir comme une lacune l’impossibilité pour une fonction de fournir un
résultat de type tableau, bien que ce dernier soit, comme la structure, un agrégat
de données. Cette lacune était cependant impossible à combler, compte tenu du
choix fondamental fait à la conception du langage, consistant à remplacer
presque systématiquement la référence à un tableau par son adresse. Si l’on tient
absolument à disposer de cette possibilité, il reste toujours possible de tricher, en
fournissant comme valeur de retour une structure ne comportant qu’un seul
champ : le tableau concerné ! On en verra un exemple dans la remarque de la
section 3.5.6.
La valeur de retour d’une fonction ne peut pas être constante ou volatile. Cette
interdiction est logique, dans la mesure où une valeur de retour n’est pas, en soi,
une lvalue – ces notions n’ont donc aucun sens. Signalons, au passage, qu’il en
va exactement de même pour toute expression et que la valeur de retour d’une
fonction, désignée par son appel, n’est rien d’autre qu’une expression
particulière.
En revanche, lorsque la valeur de retour est un pointeur, il est tout à fait possible
aux objets pointés d’être constants ou volatiles. Cette remarque contribue
d’ailleurs à compliquer quelque peu l’usage des qualifieurs const ou volatile au
sein de l’en-tête d’une fonction, comme nous le verrons à la section 3.3.2.
3.3.2 La déclaration du type de la valeur de retour
Considérons ces deux en-têtes de la même fonction, l’une sous forme moderne,
l’autre sous forme ancienne :
const int *f (int p, double v) /* en-tête forme moderne */
const int *f (p, v) int p ; double v ; /* en-tête forme ancienne */
Dans les deux cas, le type de la valeur de retour de f se définit par l’association
d’un spécificateur de type (ici int), éventuellement complété par des qualifieurs
(ici const) à un déclarateur approprié (ici *f(int p, double v) dans le premier cas ou
*f(p,v) dans le second). Le mécanisme d’association est analogue à celui
employé pour déclarer une variable. Ici :
• *f (…) est une fonction renvoyant un int constant ;
• f est une fonction renvoyant un pointeur sur un int constant.
Les quelques différences entre la déclaration du type d’une variable et la
déclaration du type de la valeur de retour d’une fonction dans son en-tête sont les
suivantes :
• en raison de la nature même de l’en-tête, on ne peut associer qu’un seul
déclarateur à un spécificateur de type ;
• le déclarateur de fonction possède deux formes différentes ;
• il existe un spécificateur de type particulier (void) correspondant à une absence
de valeur de retour ; ce mot-clé est déjà utilisé pour les pointeurs, mais avec
une signification différente.
D’une manière générale, ce mécanisme peut devenir complexe, compte tenu de
l’existence de trois sortes de déclarateurs qui peuvent se composer à volonté.
C’est pourquoi nous avons prévu un chapitre séparé (voir chapitre 16) pour faire
le point sur la syntaxe des déclarations (y compris les en-têtes de fonctions), la
manière de les interpréter et de les rédiger. Ici, nous nous contenterons
d’examiner de manière moins formelle quelques situations usuelles, en
remarquant cependant qu’un en-tête fait toujours intervenir un déclarateur de la
forme suivante :
Déclarateur de valeur de retour
declarateur (LISTE_de_declarations_de_types_de_parametres)
declarateur
Déclarateur quelconque
LISTE_de_declarations_de_types_de_parametres
– forme moderne : liste de
déclarations individuelles
d’arguments de fonctions ou
mot-clé void ;
– forme ancienne : liste,
éventuellement vide,
d’identificateurs d’arguments.
Remarque
Au chapitre 6, on ne verra pas apparaître de « déclarateur de valeur de retour », mais seulement un
« déclarateur de forme fonction ». Il s’agit d’une notion plus générale liée au fait que les en-têtes et les
déclarations des fonctions s’inscrivent dans un même cadre. Cependant, on verra qu’il est alors
nécessaire d’introduire certaines contraintes dépendant des circonstances. En se plaçant dans le cas
présent des en-têtes de fonctions, ces contraintes conduiront en fait à la forme décrite ici (il faut, pour
cela, conjuguer de façon appropriée, les sections 3.1, 3.2 et 2.5 de ce chapitre).
Exemples
Voici quelques exemples de forme moderne d’en-têtes. Lorsque cela est utile,
nous indiquons, en regard, les règles utilisées pour leur interprétation, telles que
vous les retrouverez à la section 4 du chapitre 16.
Cas simples : déclarateur réduit à un identificateur
C’est le cas lorsque la valeur de retour est d’un type de base, structure, union ou
synonyme (défini par typedef).
float poly (float x, float y)
fonction renvoyant une valeur de
type float
struct point f (double z)
fonction renvoyant une valeur de
(on suppose la déclaration suivante type structure point
accessible) :
struct point { char nom ;
int x ;
int y ;
} ;
ptr fct (int, double)
fonction renvoyant une valeur de
(on suppose la déclaration suivante type int *
accessible) :
typedef int * ptr ;
Composition d’un déclarateur de fonction et de pointeur
Il faut alors tenir compte de l’ordre dans lequel on doit composer ces
déclarateurs.
int *f (double n)
est un int
*f (double n)
→ f(double n) est un pointeur sur un
int
→ f est une fonction renvoyant un
« pointeur sur un int »
int (*f (int, char) ) [5]
est un int
(*f (int, char) ) [5]
→ (* f (int, char)) est un tableau de
5 int
→ * f (int, char) est un tableau de 5
int
→ f (int, char) est un pointeur sur
un tableau de 5 int
→ f est une fonction renvoyant un
pointeur sur un tableau de 5 int
Utilisation de void
La présence du mot-clé void n’indique pas toujours une absence de valeur de
retour :
void fct (int, double)
fonction sans valeur de retour
void *fct (int, double)
fonction renvoyant un pointeur de type void
*
Valeur de type pointeur sur un objet constant ou volatile
L’attribution du qualifieur const ou volatile à la valeur de retour d’une fonction
n’a pas de sens (voir section 3.3.1). En revanche, lorsque la valeur de retour est
elle-même un pointeur sur un objet, ce dernier peut, éventuellement, être
constant ou volatile. Dans ce cas seulement, des qualifieurs peuvent précéder le
spécificateur de type.
const int *f(double, int)
f renvoie un pointeur sur un int constant
int * const * f (char, float)
f renvoie un pointeur sur un pointeur
constant sur un int
const int * const * f (char,
int) f renvoie un pointeur sur un pointeur
constant sur un int constant
En-têtes incorrects
Un en-tête peut être correct sur le plan de la syntaxe tout en étant incompatible
avec certaines restrictions imposées à une fonction.
const float f (char)
Incorrecte : une fonction ne peut pas
renvoyer une valeur constante.
int * const f (char, int)
Incorrecte : f ne peut pas renvoyer une
valeur constante, même de type pointeur.
int f (double) [10]
Incorrecte car : f (double) [10] est un int
→ f(double) est un tableau de 10 int
→ f serait une fonction renvoyant un
tableau
int f [10] (double)
Il ne peut pas s’agir d’un en-tête car :
f [10] (double) serait un int
→ f[10] serait une fonction recevant un
double
→ f serait un tableau de fonction
3.4 Classe de mémorisation d’une fonction : extern et
static
Contrairement à ce qui se produit pour un objet, la notion de classe d’allocation
n’a pas de signification pour une fonction. Il n’en reste pas moins que l’en-tête
d’une fonction peut comporter un mot-clé dit « classe de mémorisation » dont la
signification n’a alors plus rien à voir avec la notion de classe d’allocation. Les
valeurs possibles se limitent cependant à extern et static, les mots-clés register ou
auto étant interdits ici (et sans signification).
En l’absence de classe de mémorisation, tout se passe comme si l’on avait utilisé
extern, de sorte que ce mot peut toujours être omis. Par exemple, ces deux en-
têtes sont parfaitement équivalents :
extern int fct (char c, float x)
int fct (char c, float x)
Dans ce cas, la fonction sera utilisable à l’extérieur du fichier source où elle est
définie. En particulier, son nom deviendra ce qu’on nomme un identificateur
externe, c’est-à-dire qu’il sera accessible à l’éditeur de liens. Il s’agit là de
l’usage habituel d’une fonction dans un contexte de compilation séparée. On
emploie rarement extern dans ce cas.
Le mot static, quant à lui, empêche précisément que l’identificateur de la
fonction soit utilisable à l’extérieur du fichier source où elle est définie, et ce
indépendamment de la manière dont elle pourra éventuellement être déclarée.
On parle alors de fonction cachée ou privée. Voici un exemple d’une telle
définition :
static int fct (int n, float x) { ….. }
Remarque
La norme parle de lien externe ou de lien interne pour qualifier les deux situations que nous venons
d’évoquer. Cette distinction a, en fait, peu d’importance dans le cas des fonctions. Nous y reviendrons
plus en détail dans le cas des variables globales et nous verrons qu’en pratique cette notion concerne
précisément l’éditeur de liens.
Tableau 8.3 : la classe de mémorisation de la définition d’une fonction
Classe de
Lien correspondant
mémorisation
aucune ou extern Externe : identificateur accessible à l’ensemble du
programme source
static
Interne : identificateur accessible uniquement au fichier
source contenant la définition de la fonction
3.5 L’instruction return
3.5.1 Syntaxe et rôle
L’instruction return
return [ expression ] ;
expression
Expression quelconque d’un type Voir aux sections 3.5.2
compatible par affectation avec le à 3.5.6
type prévu pour la valeur de retour
Cette instruction évalue expression, si elle est présente, et la convertit, s’il y a lieu,
dans le type de la valeur de retour tel qu’il est indiqué dans l’en-tête de la
fonction puis, dans tous les cas, provoque le retour à la fonction appelante.
3.5.2 Nombre d’instructions return
L’instruction return peut apparaître à plusieurs reprises dans la définition d’une
fonction, comme dans cet exemple :
double absom (double u, double v)
{
double s ;
s = u + v ;
if (s>0) return (s) ;
else return (-s) ;
}
Une fonction ne renvoyant pas de résultat peut indifféremment :
• comporter une ou plusieurs instructions return sans expression ;
• ne pas comporter d’instruction return. Dans ce cas, un retour sera mis en place
automatiquement par le compilateur à la fin de la fonction. On notera
d’ailleurs que beaucoup de fonctions main sont dans ce cas.
En revanche, dès lors qu’une fonction renvoie un résultat, il est nécessaire que sa
définition comporte au moins une instruction return pour en définir la valeur.
Remarque
On n’oubliera pas que toute fonction (et pas seulement main) peut mettre fin à l’exécution du
programme par appel de la fonction standard exit.
3.5.3 Conversions induites par return
Si le type de l’expression associée à return est différent de celui précisé dans
l’en-tête, le compilateur prévoira des instructions de conversion, à condition
qu’il s’agisse de conversions autorisées par affectation. Celles-ci sont présentées
en détail à la section 7.3.2 du chapitre 4. Rappelons que toutes les conversions
numériques, y compris les dégradantes, sont légales. Par exemple, à l’intérieur
de la définition suivante :
int fct (…..)
{ int n ; float x ;
double * adr ;
…..
}
voici quelques exemples d’instructions correctes :
return 2*n ; /* OK 2*n est de type int */
return x-3 ; /* OK : x-3, de type float, sera converti en int */
return (int) adr ; /* OK l'expression mentionnée est de type int */
/* déconseillé toutefois et non portable */
En revanche, celle-ci serait incorrecte4 :
return adr /* erreur de compilation : la conversion de pointeur */
/* en int n'est pas autorisée par affectation */
3.5.4 Lorsque l’on oublie la valeur de retour
Théoriquement, la norme ANSI autorise la présence d’une instruction return sans
expression, alors que l’en-tête de la fonction indique une valeur de retour. Elle
précise simplement que le comportement du programme est indéfini si on
cherche alors à utiliser la valeur de retour. Voici un exemple d’école :
int bizarre (int n)
{ if (n) return n ; /* ici, on renvoie bien une valeur */
else return ; /* mais, ici, on n'en renvoie pas */
}
int main()
{ int bizarre (int) ; /* déclaration (conseillée) de la fonction bizarre */
int n, p ;
n = 5 ;
p = bizarre (n) ; /* même rôle ici que p = n; */
n = 0 ;
bizarre (n) ; /* légal au sens de la norme ANSI - pas de problème */
/* à l'exécution puisqu'on n'utilise pas la valeur */
/* renvoyée par la fonction */
p = bizarre (n) ; /* accepté en compilation (le compilateur ne connaît */
/* pas la valeur de n - en revanche, lors de l'exécution */
/* la norme prévoit un comportement indéterminé - en */
/* pratique, on obtiendra une valeur peu prévisible */
/* (la dernière valeur de la pile (ici 0 !) si les */
/* variables automatiques sont gérées par une pile) */
}
On notera qu’aussi bien la définition que l’utilisation de bizarre sont toujours
acceptées des compilateurs (certains, cependant, peuvent fournir un
« avertissement »). Les situations d’utilisation anormales ne se révèlent qu’à
l’exécution et peuvent donc être difficiles à mettre en évidence. D’une manière
générale, nous déconseillons fortement l’emploi d’une telle possibilité.
3.5.5 En cas de mauvaise utilisation du résultat d’une fonction
Ce n’est pas parce qu’une fonction produit un résultat qu’on est obligé de
l’utiliser dans la fonction appelante. C’est d’ailleurs ce que l’on fait
fréquemment avec printf ou avec scanf. Bien entendu, cela n’a d’intérêt que si la
fonction fait autre chose que de calculer un résultat.
En revanche, comme on peut s’y attendre, il est interdit d’utiliser la valeur d’une
fonction ne fournissant pas de résultat. La norme se contente de dire qu’alors le
comportement du programme est indéterminé. En principe, on diminue le risque
d’erreur en prenant l’habitude de déclarer systématiquement toute fonction
qu’on utilise, comme on le verra à la section 4, puisqu’on fournit alors au
compilateur les informations permettant de déceler l’anomalie. En pratique,
cependant, on rencontre certains compilateurs qui ne la détectent pas dans ce cas,
ce qui conduit généralement, lors de l’exécution, à une valeur aléatoire.
3.5.6 Attention à ne pas renvoyer un tableau
Rappelons qu’une fonction ne peut pas fournir un résultat qui soit un tableau.
Cependant, considérons cette définition :
int * f (…..)
{ int t[20] ; /* tableau t local à la fonction */
…..
return t ; /* renvoie l'adresse de t, c'est-à-dire &t[0] */
}
Elle est théoriquement légale puisque l’expression t est convertie
systématiquement en &t[0] (voir section 3.2 du chapitre 7) qui est bien du type int
* précisé dans l’en-tête de f. Malgré tout, cette façon de procéder comporte une
erreur potentielle, dans la mesure où la fonction renvoie l’adresse d’un objet
local, c’est-à-dire d’un objet dont l’emplacement mémoire (de classe
automatique) a été désalloué lors de la sortie de la fonction !
Remarque
Si l’on tient absolument à ce qu’une fonction fournisse un tableau comme résultat, on peut toujours
renvoyer artificiellement une structure contenant un seul champ correspondant au tableau en question,
comme dans :
struct s { int t[20] ; } ;
…..
struct s f (…..)
{ struct s resultat ;
…..
return resultat ;
}
Bien sûr, il faudra tenir compte de cette particularité au moment de l’appel de f. Par exemple, avec :
struct s infos ;
…..
infos = f(…..) ;
l’élément de rang i du pseudo tableau renvoyé par f sera dans infos.t[i].
4. Déclaration et appel d’une fonction
L’utilisation d’une fonction fait intervenir deux aspects : son appel et sa
déclaration.
L’appel de la fonction est relativement naturel en C et on l’utilise sans problème
pour les différentes fonctions de la bibliothèque standard. Il peut cependant faire
intervenir des conversions des arguments effectifs suivant des règles qui
dépendent de la manière dont la fonction a été déclarée.
La déclaration de la fonction est une instruction fournissant au compilateur un
certain nombre d’informations concernant une fonction qu’on envisage d’appeler
par la suite. Il existe une forme recommandée d’une telle déclaration, dite
prototype, mais, malheureusement, C tolère une forme incomplète de
déclaration, voire une absence totale de déclaration. En outre, la définition d’une
fonction joue également un rôle de déclaration, ce qui, dans certains cas, peut
contribuer à obscurcir les choses.
Cette section examine les différentes façons de déclarer ou non une fonction et
les conséquences qui en découlent sur les conversions mises en place lors de son
appel. Le tableau 8.4 récapitule les points étudiés.
Tableau 8.4 : déclaration et appel d’une fonction
Déclaration totale dite « prototype » Voir
(conseillée en C et obligatoire en section 4.1
C++) : type valeur de retour, type des
arguments.
Possibilités Déclaration partielle : type valeur de Voir
de déclaration retour seulement. section 4.2
Absence de déclaration → valeur de Voir
retour supposée de type int section 4.6
Une définition de fonction tient lieu de Voir
déclaration. section 4.5
– comme pour toute déclaration : bloc Voir
ou fonction pour une déclaration section 4.3
Portée d’une locale, la partie du fichier source
suivant la déclaration pour une
déclaration déclaration globale ;
– ne pas confondre la notion de portée
avec celle de lien.
– autorisée par la norme, moyennant Voir
d’évidentes conditions de section 4.4
Redéclaration compatibilité ;
d’une fonction – utile pour tenir compte de ce qu’une
définition de fonction tient lieu de
déclaration.
– en présence de prototype : conversion Voir
dans le type imposé par le prototype section 4.7
si cette conversion est acceptable par
Conversions
affectation ;
arguments
effectifs – en l’absence de prototype ou en cas
d’arguments dits « variables » :
promotions numériques usuelles +
promotion float → double.
– en présence d’un prototype, la Voir
fonction reçoit toujours des section 4.8
arguments corrects en nombre et en
type ;
Correspondance
entre arguments – en l’absence de prototype : risques de
non-concordance en nombre et en
type dont les conséquences peuvent
aller de valeurs fausses à un plantage
du programme.
– contiennent, entre autres, les Voir
prototypes des fonctions standards ; section 4.9
Fichiers en-tête – leur oubli n’est généralement pas
standards détecté et on aboutit aux
conséquences de non-concordance
d’arguments.
4.1 Déclaration sous forme de prototype
La déclaration complète d’une fonction, dite prototype, précise à la fois :
• le nom de la fonction ;
• le type de sa valeur de retour (avec d’éventuels qualifieurs dans le cas d’objets
pointés) ;
• le type des différents arguments avec d’éventuels qualifieurs ;
• éventuellement, le mot-clé extern en lieu et place d’une classe de mémorisation.
Ce mot-clé ne joue en fait aucun rôle particulier et sa présence à ce niveau est
totalement équivalente à son absence. On ne le confondra pas avec la classe de
mémorisation qui peut apparaître dans une définition de fonction (voir section
3.4) ; en particulier, static à ce niveau n’aurait aucune signification.
Cette déclaration n’est en fait rien d’autre que la forme moderne de l’en-tête,
éventuellement débarrassée des identificateurs d’arguments (et de l’éventuel
mot-clé static). Par exemple, à une fonction définie par :
float *fct (int n, char c, double * ad) { ….. } /* définition de fct */
correspondra à l’un des prototypes suivants :
float *fct (int n, char c, double * ad) ; /* prototype complet */
float *fct (int, char, double * ) ; /* prototype réduit (usuel) */
Les identificateurs mentionnés dans un prototype complet n’ont absolument
aucune signification et, en particulier, ils ne risquent pas d’interférer avec
d’autres variables locales ou globales apparaissant dans la même portée que le
prototype. Leur principal intérêt est de faciliter l’introduction de commentaires
décrivant le rôle des arguments, à proximité du prototype, notamment lorsque ce
dernier figure dans un fichier en-tête. En théorie, il est possible de « panacher »
les deux sortes de prototypes, en ne faisant figurer que certains identificateurs.
En pratique, cette dernière formule est peu utilisée.
En fait, de telles déclarations ressemblent à une déclaration de variable ; elles
associent un déclarateur (ici *fct(…)) à un spécificateur de type (ici float),
complété d’éventuels qualifieurs et d’une éventuelle classe de mémorisation
(obligatoirement extern). D’ailleurs, ces instructions s’inscrivent dans le cadre
général des déclarations, telles qu’elles sont récapitulées au chapitre 16. En
théorie, rien n’interdit, dans une même déclaration, d’associer à un même
spécificateur de type plusieurs déclarateurs, éventuellement de nature différente.
Cela ne pose généralement pas trop de problèmes dans le cas de variables. En
revanche, il est déconseillé de mêler la déclaration de fonction à celle d’autres
variables, comme dans :
int x, f(float) ; /* légal mais déconseillé */
double z, *g(float) ; /* légal mais déconseillé */
int *adr, f(int, double), *h(float, char) ; /* légal mais déconseillé */
On préférera par exemple :
int x, *adr ;
double z ;
int f(float) ;
double *g(float) ; /* prototype de g */
int f(int, double) ; /* prototype de f */
int *h(float, char) ; /* prototype de h */
Par ailleurs, le fait que la signification de la classe de mémorisation puisse
différer selon la nature des objets déclarés constitue un frein naturel à ce mixage
de déclarateurs :
static int n, f(void) ; /* static a une signification pour n */
/* il n'en a pas pour f */
4.2 Déclaration partielle (déconseillée)
La norme ANSI a introduit la notion de déclaration complète sous forme de
prototype, mais elle continue d’accepter une forme dite « de déclaration
partielle », laquelle était la seule prévue dans la première définition du langage
C.
Une déclaration partielle se contente d’indiquer :
• le nom de la fonction ;
• le type de sa valeur de retour (avec d’éventuels qualifieurs dans le cas d’objets
pointés) ;
• éventuellement, le mot-clé extern en lieu et place d’une classe d’allocation ; ce
mot-clé ne joue en fait aucun rôle particulier.
Elle ne fournit donc aucune information sur le type des arguments.
Comme la précédente, cette déclaration associe un spécificateur de type,
accompagné d’un éventuel qualifieur et d’une éventuelle classe de
mémorisation, à un déclarateur, ce dernier étant alors d’une forme plus simple
que dans le prototype. Lorsque l’on utilise une déclaration distincte par fonction,
cette déclaration partielle n’est rien d’autre que le prototype, débarrassé de sa
liste de déclarations d’arguments. Ici encore, il est théoriquement possible, mais
fort déconseillé, d’associer plusieurs déclarateurs, éventuellement de type
différent, à un même spécificateur de type.
Exemples
Voici en parallèle quelques exemples de déclarations sous forme de prototypes et
partielles (la dernière correspond à une fonction à arguments variables) :
Déclaration
Prototype complet Prototype réduit
partielle
int *f (double z); int * f(double); int * f ();
void g (float x, int n, char * void g (float, int, char void g ();
ch); *);
int h (void); int h (void); int h ();
float fvar (int n, …); int fvar (int, …) int fvar ();
Remarque
Le chapitre 16 fait le point sur l’ensemble des déclarations et il tient compte, dans le cas des fonctions,
des différentes formes possibles : déclaration totale, déclaration partielle, mixage de déclarateurs.
4.3 Portée d’une déclaration de fonction
La déclaration d’une fonction est soumise aux mêmes règles de portée que toute
déclaration, ce qui signifie qu’elle est valable :
• pour la fonction correspondante en cas de déclaration locale à une fonction ;
• pour toute la partie du fichier source suivant cette déclaration dans le cas d’une
déclaration globale.
Voici trois exemples (indépendants les uns des autres), dans lesquels on suppose
que f n’est pas préalablement définie dans le fichier source concerné (le cas où f
est définie dans le même fichier source sera examiné à la section 4.5) :
float f (int, char) ; /* déclaration de f à un niveau global */
int main()
{ /* la déclaration de f est connue ici */
}
void fct (int)
{ /* la déclaration de f est aussi connue ici */
}
int main()
{ /* la déclaration de f n'est pas connue ici */
}
float f (int, char) ; /* déclaration de f à un niveau global */
int fct (float)
{ /* mais la déclaration de f est connue ici */
}
int main()
{ float f (int, char) ; /* déclaration de f à un niveau local à main */
/* la déclaration de f est connue ici */
}
void fct (int)
{ /* mais la déclaration de f n'est pas connue ici */
}
On prendra garde à ne pas confondre la notion de portée de la déclaration d’une
fonction avec la notion de lien relative à sa définition. Une fonction, dès lors
qu’elle n’a pas été cachée dans un fichier source par le mot-clé static, est à lien
externe, c’est-à-dire utilisable depuis n’importe quel fichier source du
programme. En revanche, la portée de la déclaration d’une fonction se limite
toujours à un bloc ou à un fichier source. Il est d’ailleurs malheureusement
possible en C (mais pas en C++) d’utiliser une même fonction en des endroits
différents avec des déclarations différentes (compatibles ou non). En voici un
exemple :
int main()
{ void f() ; /* ici, on a fait une déclaration partielle de f */
…..
}
int fct1 (float)
{ void f(int) ; /* tandis qu'ici, on a fourni son prototype */
…..
}
void fct2 (double)
{ int f(char, int) ; /* et qu'ici on a fourni un autre prototype */
…..
}
Aucun diagnostic ne sera obtenu en compilation. En revanche, il est probable
qu’au moins un des appels de f ne fonctionnera pas comme prévu lors de
l’exécution.
En C++
En C++, les trois identificateurs f du précédent exemple correspondront à des fonctions différentes
(grâce à un mécanisme dit de surdéfinition).
4.4 Redéclaration d’une fonction
La norme est très large, puisque non seulement elle autorise qu’on utilise une
fonction sans la déclarer, mais elle permet également qu’on la déclare plusieurs
fois dans une même portée. Ce point peut paraître de peu d’importance si on
estime qu’une déclaration multiple correspond plutôt à une étourderie de
programmation. En fait, il pourra intervenir dans le cas où une fonction est
utilisée moyennant une déclaration (comme il est conseillé de le faire), dans le
fichier source contenant sa définition. En effet, comme on le verra à la section
4.5, cette définition tiendra lieu de déclaration ; elle viendra donc s’ajouter à la
(vraie) déclaration de la fonction.
La seule contrainte que la norme impose à différentes déclarations d’une même
fonction est d’être compatibles entre elles, c’est-à-dire de ne pas conduire à des
contradictions en ce qui concerne la manière dont un appel ultérieur sera traduit.
Voici un exemple :
void f1(int) ;
….
void f1 () ; /* redéclaratioin de f1, compatible avec la déclaration précédente */
int f2 (char) ;
…..
int f2 () ; /* redéclaration de f2 incompatible avec la déclaration précédente */
/* car dans le premier cas, f1 reçoit toujours un argument de type */
/* char alors que dans le second cas, les conversions mises en */
/* place ne pourront jamais conduire au type char */
4.5 Une définition de fonction tient lieu de déclaration
La définition d’une fonction tient lieu de déclaration (globale) pour toute la suite
du fichier source. Tout se passe comme si l’on accompagnait cette définition
d’une déclaration :
• complète (prototype), si l’en-tête de la définition est de la forme moderne ;
• partielle, si l’en-tête de la définition est de la forme ancienne.
Considérons cet exemple :
float f(double)
{ ….. /* définition de f : elle tient lieu de déclaration */
}
int main()
{ ….. /* ici, f est connue du compilateur, comme si l'on avait déclaré : */
/* float f (double); */
}
Il semble qu’une telle situation présente peu de risques, puisque tout se passe
comme si l’on avait procédé à une déclaration complète. Cependant, tout dépend
de l’ordre dans lequel les différentes fonctions sont définies dans le fichier
source. De plus, on risque d’aboutir à une absence totale de déclaration si, par la
suite, on est amené à découper le fichier source en plusieurs parties. Cela
reviendra, comme nous le voyons ci-après, à considérer que f fournit une valeur
de retour de type int, avec les conséquences liées à la non-concordance de type.
En définitive, on conseille, dans un tel cas, d’exploiter les possibilités de
redéclaration (voir section 4.4) en continuant à déclarer la fonction f, soit à un
niveau local, soit à un niveau global, et ceci d’autant plus que cette déclaration
deviendra obligatoire en C++ :
float f(double)
{ ….. /* définition de f */
}
int main()
{ float f(double) ; /* (re)déclaration conseillée de f */
…..
}
Remarque
Lorsque, dans un même fichier, on utilise une fonction avant sa définition, on voit qu’il est de toute
façon utile de la déclarer. Dans ce cas, c’est la définition de la fonction qui introduit une redéclaration.
Voici ce que deviendrait l’exemple précédent en inversant les définitions de main et de f :
int main()
{ float f(double) ; /* déclaration de f */
…..
}
float f(double)
{ ….. /* définition de f qui devient également une redéclaration */
}
4.6 En cas d’absence de déclaration
Bien qu’il soit fortement recommandé de déclarer systématiquement toute
fonction utilisée sous forme d’un prototype, la norme ne l’impose pas et il reste
malheureusement possible d’appeler une fonction sans qu’elle n’ait été déclarée.
Il faut alors distinguer deux situations très différentes :
• si la fonction a fait l’objet d’une définition préalable au sein du même fichier
source, celle-ci joue le rôle de déclaration et aucun problème particulier ne se
pose (du moins tant qu’on ne change pas la place de cette définition !) ;
• dans le cas contraire, on considère que la fonction appelée possède une valeur
de retour de type int.
En aucun cas, le compilateur ne signale d’erreur (alors qu’il le fera en C++).
Exemple
int main()
{ …..
x = g(a+2) ; /* g n'a pas été déclarée - tout se passe comme si l'on avait */
….. /* fait la déclaration partielle : int g(); */
}
S’il s’agit effectivement d’un oubli de déclaration, et que la fonction attend zéro
ou plusieurs arguments ou qu’elle renvoie une valeur d’un type autre que int, les
conséquences seront les mêmes que celles d’une mauvaise déclaration (voir
section 4.8).
4.7 Utilisation de la déclaration dans la traduction
d’un appel
Dans tous les cas, la déclaration d’une fonction est utilisée par le compilateur
pour en traduire l’appel en mettant en place un certain nombre de conversions.
Pour faciliter l’étude, nous examinerons à part le cas où des qualifieurs
apparaissent dans le type des arguments muets ou des arguments effectifs.
4.7.1 Mise en place de conversions
Considérons l’instruction suivante :
y = f (n+3, 2*x) ;
Pour la traduire convenablement, le compilateur doit connaître le type de la
valeur de retour de f. On notera que celui-ci est toujours connu, même en
l’absence de déclaration, puisqu’alors il est, par défaut, égal à int. Si ce type est
différent du type de y, le compilateur mettra en place une conversion de int dans
le type de y, dans la mesure où celle-ci est autorisée par affectation.
Par ailleurs, s’il connaît le type des arguments attendus par f – autrement dit, si
la déclaration a été faite sous la forme conseillée d’un prototype – il pourra
mettre en place les conversions correspondantes si elles sont légales (autorisées
par affectation), ou refuser l’appel si elles ne le sont pas. On n’oubliera pas que
certaines conversions légales sont dégradantes ; c’est le cas, par exemple, d’une
conversion de long en int ou en char.
Si le compilateur ne connaît pas le type des arguments attendus, les valeurs des
arguments effectifs seront soumises aux conversions systématiques présentées à
la section 3.5 du chapitre 4, à savoir :
• les promotions numériques usuelles : char, signed char, unsigned char et short en
5
int, unsigned short en int ou unsigned int ;
• une promotion numérique spécifique de float en double ; sa présence dans la
norme ANSI ne se justifie que pour des raisons historiques.
Les arguments variables que nous étudierons au chapitre 20 sont, par nature, de
type non précisé à la compilation. Dans ce cas, quelle que soit la manière dont la
fonction a été déclarée (prototype avec la mention … ou déclaration partielle),
les arguments effectifs sont soumis aux promotions numériques évoquées
précédemment. Rappelons que c’est ce qui se produit pour toutes les valeurs
transmises à une fonction telle que printf.
Remarque
Seule la déclaration complète sous forme de prototype permet à une fonction de recevoir des valeurs
de type char, short ou float puisque, dans le cas contraire, ces valeurs feront automatiquement
l’objet de conversions.
Exemple 1
float f (float, char) ; /* déclaration complète (conseillée) */
int g () ; /* déclaration partielle (déconseillée) */
int n, p ;
int *adr ;
float x, y ;
char c ;
/* quelques appels avec f */
y = f (x, c) ; /* appel " normal " : aucune conversion */
n = f (n, p) ; /* avant appel : conversion de n de int en float et */
/* de p de int en char (conversion dégradante mais légale) */
/* après appel : la valeur de retour de f est convertie */
/* de float en int pour être affectée à n */
y = f (x, adr) ; /* rejetée en compilation car la conversion */
/* de int * en char n'est pas légale par affectation */
y = f (x, c+1) ; /* l'expression c+1, de type int, sera convertie en char */
/* les mêmes appels avec g */
y = g (x,c) ; /* x est converti en double et c en int */
n = g (n, p) ; /* aucune conversion des arguments */
y = g (x, adr) ; /* x est converti en double et adr reste inchangé */
y = g (x, c+1) ; /* x est converti en double et c+1 reste en int */
Remarques
1. Contrairement à une idée fort répandue en langage C, le prototype ne provoque pas un contrôle
strict du type des arguments effectifs. Il ne sert qu’à forcer d’éventuelles conversions,
éventuellement dégradantes, pour peu que celles-ci soient légales.
2. Compte tenu de la manière dont le C utilise les déclarations de fonctions, on voit que si l’on
mentionne, par mégarde, un type erroné dans un prototype, il n’est pas du tout certain qu’on
aboutisse à une erreur de compilation. Lors de l’exécution, on se retrouvera face aux conséquences
d’une absence de correspondance de type entre arguments muets et arguments effectifs, étudiées
section 4.8.
4.7.2 Cas particulier des qualifieurs const et volatile
Rappelons que lorsqu’ils sont appliqués aux arguments muets d’une fonction, les
qualifieurs const et volatile peuvent jouer un rôle relativement différent, suivant
qu’ils interviennent :
au premier niveau, c’est-à-dire sur l’argument correspondant, comme dans :
void f (const int n) ; /* n est un int constant */
float * const adf) ; /* adf est un pointeur constant sur un float */
au niveau de l’objet pointé, comme dans :
void f (const int * adi) ; /* adi est un pointeur sur un int constant */
Dans le premier cas, ils n’interviennent pas véritablement dans le type de
l’argument (ils ne servent qu’à interdire ou autoriser certaines actions sur cet
argument au sein de la fonction) et ils n’induiront aucune conversion au niveau
de l’argument effectif correspondant6. Dans le second cas, en revanche, ils font
vraiment partie du type de l’argument pointeur et ils pourront intervenir dans
l’acceptation ou le refus d’éventuelles conversions. Par exemple, si une fonction
attend un pointeur sur un objet non constant, on ne pourra pas lui transmettre un
pointeur sur un objet constant.
Exemple
void f1 (const int) ;
void f2 (int) ;
void f3 (const int *) ;
void f4 (int *) ;
const int nc ;
int n ;
int * adi ;
const int * adic ;
…..
f1 (n) ; /* OK */
f1 (nc) ; /* OK */
f2 (n) ; /* OK */
f2 (nc) ; /* OK car const n'intervient pas ici */
f3 (adic) ; /* OK : adic est du type attendu : const int * */
f3 (adi) ; /* OK : adi est converti de int * en const int * */
f4 (adi) ; /* OK : adi est du type attendu : int * */
f4 (adic) ; /* incorrect : la conversion de const int * en int * est illégale */
4.8 En cas de non-concordance entre arguments muets
et arguments effectifs
Tant que l’on utilise des prototypes, une tentative d’appel d’une fonction avec un
nombre d’arguments différent de celui qui est attendu est, bien entendu, décelée
par le compilateur. En revanche, un appel avec un argument effectif d’un type
différent de celui prévu dans le prototype ne sera décelé que s’il conduit à une
conversion illégale. Mais, dans tous les cas acceptés par le compilateur, les
valeurs finalement reçues par la fonction seront du type attendu. Si, en revanche,
aucun prototype n’existe, aucune anomalie ne pourra être décelée, y compris un
nombre incorrect d’arguments. Par exemple, si une fonction a été définie ainsi :
int f (char c, float x) { /* corps de la fonction */ }
et qu’elle est utilisée dans un autre fichier source avec ces déclarations :
int f() ; /* déclaration partielle de f */
char c ;
float x ;
l’appel suivant :
f (c, x)
conduira à l’application des promotions numériques décrites à la section 4.7,
c’est-à-dire à la conversion de c en int et de x en double.
De même, si l’on déclarait à tort (ce qui est possible, dès lors que f est définie
dans un fichier source différent) :
int f (short, float) ;
le même appel f (c, x) conduirait à la conversion de c en short.
Dans une telle situation, la norme se contente de dire que le comportement du
programme est indéterminé. En pratique, effectivement, beaucoup de choses
peuvent se produire. Dans le meilleur des cas, on peut se trouver en présence de
valeurs imprévisibles (ou, plutôt, différentes de celles prévues) pour les
arguments concernés.
Dans certains cas, notamment lorsque les valeurs des arguments sont transmises
à la fonction par un mécanisme de pile, les valeurs d’arguments a priori
correctes peuvent se trouver, elles aussi, corrompues. Ceci est parfois consécutif
à un « décalage » dans ladite pile, lié à la prise en compte, pour des arguments
précédents de la même fonction, d’un nombre d’octets différents de celui prévu :
void f(char, float) ;
int n ; float x ;
…..
f(n, x) ; /* on transmet à f un int (de taille > à un char) et un float */
…..
void f(char c, float y)
{ ….. /* ici, la valeur de c sera incorrecte, */
….. /* mais il se peut que celle de y le soit également */
}
Qui plus est, si certaines des valeurs corrompues reçues en arguments sont des
adresses, on peut amener une fonction à écraser des informations de façon
intempestive. Bien entendu, on court alors le risque de « planter » le programme,
soit directement (motif binaire d’un flottant non normalisé, adresse invalide…),
soit indirectement, suite aux conséquences des anomalies précédentes.
Les risques évoqués plaident largement en faveur de l’emploi systématique de
fichiers en-tête pour ses propres fonctions, comme l’indique la section 4.9.
En C++
En C++, le problème évoqué ici ne se pose plus, compte tenu de l’existence d’un mécanisme de
surdéfinition des fonctions qui permet de définir, de façon unique, une fonction, à la fois par son nom
et par le type de ses arguments. Une erreur de prototype conduit alors soit à l’appel d’une autre
fonction ayant les types d’arguments escomptés, si elle existe, soit à une erreur lors de l’édition de
liens, liée à l’absence de la fonction en question.
4.9 Les fichiers en-tête standards
Il existe un certain nombre de fichiers en-tête (généralement d’extension h)
correspondant chacun à un ensemble de fonctions de la bibliothèque standard.
On y trouve notamment les prototypes de ces différentes fonctions. Il est bien
entendu fortement conseillé de les incorporer par #include. Ceci permettra de
forcer d’éventuelles conversions d’arguments auxquelles on risque de ne pas
penser (notamment pour les conversions de float en double) ou à déceler des
appels irréalistes car faisant appel à des conversions non autorisées.
Malheureusement, l’incorporation de ces fichiers en-tête n’est pas imposée par la
norme et, dans certains environnements, son oubli ne se traduit par aucun
diagnostic de compilation. Par exemple, si vous écrivez :
float x, y ;
…..
y = sqrt (x) ;
sans que sqrt ait fait l’objet d’une déclaration (directe ou par incorporation d’un
fichier en-tête), le compilateur ne détectera aucune erreur puisque les
déclarations de fonctions ne sont pas obligatoires en C.
En fait, ces instructions produiront simplement des résultats faux. En effet, la
fonction sqrt s’attend à recevoir un argument de type double (ce qui sera le cas ici,
compte tenu de la promotion numérique float → double) et elle fournit un résultat
de type double. Comme le compilateur ne connaît pas le type de la valeur de
retour, il la considère par défaut comme étant de type int et il met donc en place
une conversion du résultat de sqrt de int en float. On se trouve alors en présence
des conséquences habituelles d’une mauvaise interprétation de type.
Bien entendu, vous pouvez introduire la déclaration appropriée, si vous la
connaissez :
double sqrt (double) ;
Mais le plus raisonnable est d’aller la chercher dans le fichier en-tête prévu à cet
effet par :
#include <math.h>
En C99/C11 et C++
L’incorporation des fichiers en-tête n’est toujours pas obligatoire en C99 ou C11, pas plus qu’en C++.
Néanmoins, les déclarations de fonction le deviennent, de sorte que, dans le cas des fonctions
standards, un oubli de fichier en-tête provoque systématiquement une erreur de compilation, à moins
qu’on ait donné explicitement le prototype de la fonction correspondante, auquel cas il s’agit d’une
action volontaire et non plus d’une étourderie.
4.10 Nom de type correspondant à une fonction
Contrairement à ce qui se passe pour les pointeurs ou les tableaux, on n’a jamais
besoin directement du nom de type correspondant à une fonction. En revanche,
on peut avoir besoin du nom de type correspondant à un pointeur sur une
fonction :
• pour former le nom de l’opérateur de cast correspondant ;
• comme nom de type d’un argument, dans la déclaration d’une fonction.
Le nom de type correspondant à un tel pointeur s’obtient classiquement en
éliminant l’identificateur d’une déclaration d’une variable de ce type. Bien
entendu, comme il existe deux formes de déclaration, il existe deux formes de
nom de type, l’une où l’on précise le type des arguments de la fonction (dans ce
cas, on élimine aussi leurs identificateurs), l’autre où on ne le précise pas. Nous
en rencontrerons des exemples aux sections 11.5 et 11.7.
5. Le mécanisme de transmission d’arguments
Les concepteurs du C ont prévu que les arguments d’une fonction soient toujours
transmis par valeur, ce qui signifie que la fonction appelée reçoit toujours une
copie de la valeur de l’expression correspondant à l’argument effectif. Nous
allons voir que ce mode de transmission est satisfaisant dans certains cas, voire
sécurisant. En revanche, il semble interdire à une fonction de modifier la valeur
d’un objet reçu en argument. Nous allons voir comment y parvenir en
transmettant à la fonction, non plus l’objet lui-même, mais son adresse par le
biais d’un pointeur.
Le cas des tableaux est particulier, dans la mesure où une référence à un tableau
est convertie en un pointeur sur son premier élément. Il n’est donc jamais
possible de transmettre les valeurs d’un tableau. Ce cas sera étudié aux sections
6 et 7, la section 7 étant consacrée au cas encore plus particulier des tableaux à
plusieurs indices.
Enfin, la section 11 étudiera les pointeurs sur des fonctions. Nous verrons
notamment comment les utiliser en argument et l’intérêt que cela peut présenter.
5.1 Cas où la transmission par valeur est satisfaisante
5.1.1 Exemples introductifs
Si l’on considère une fonction f de prototype :
int f (float) ;
Si n est de type int et x de type float, on est généralement satisfait qu’un appel tel
que :
f(x+2)
soit accepté et ce parce que, effectivement, il y aura tout d’abord évaluation de
l’expression x+2, puis recopie de la valeur obtenue dans la fonction appelée f.
De même, on est généralement satisfait de pouvoir appeler f de cette façon :
f (x)
en étant certain que, quelles que soient les instructions de f, la valeur de x ne sera
pas modifiée.
Voici un petit exemple d’école mettant en évidence cette transmission par valeur
d’arguments de type scalaire :
Lorsque la transmission par valeur est satisfaisante
#include <stdio.h>
int main()
{ int n=5 ;
void aff_double (int) ;
printf ("n avant appel de aff_double : %d\n", n) ;
aff_double (n) ;
printf ("n apres appel de aff_double : %d\n", n) ;
}
void aff_double (int p)
{ printf ("valeur de p a l\'entree dans aff_double : %d\n", p) ;
p *= 2 ;
printf ("valeur de p avant sortie de aff_double : %d\n", p) ;
}
n avant appel de aff_double : 5
valeur de p a l'entree dans aff_double : 5
valeur de p avant sortie de aff_double : 10
n apres appel de aff_double : 5
5.1.2 D’une manière générale
La transmission par valeur est satisfaisante et même sécurisante dès lors que l’on
n’a pas besoin que la fonction appelée modifie la valeur d’objets transmis en
argument. Elle peut ainsi modifier à loisir la valeur de l’argument muet
correspondant, sans que cela n’ait d’incidence sur un quelconque objet de la
fonction appelante.
Cette sécurité ne s’applique pas aux tableaux qui ne peuvent pas être transmis
par valeur. En revanche, elle s’applique aux structures : dans ce dernier cas, on
ne perdra pas de vue que cette transmission par valeur entraîne la recopie de
l’ensemble de la structure concernée, ce qui peut s’avérer parfois pénalisant en
place mémoire (de classe automatique) et en temps. Néanmoins, il est toujours
possible de prévoir de transmettre non plus la structure elle-même, mais
simplement son adresse : on aboutit alors à une économie en temps et en
mémoire, au détriment de la sécurité. Un tel choix n’existe pas pour les tableaux.
5.2 Cas où la transmission par valeur n’est plus
satisfaisante
5.2.1 Un exemple de problème
Supposez que l’on souhaite écrire une fonction effectuant l’échange des valeurs
de deux variables entières qu’on lui transmet en arguments. A priori, la
transmission par valeur ne permet pas de parvenir à un tel résultat, comme le
montre l’exemple suivant :
Conséquences de la transmission par valeur des arguments
#include <stdio.h>
int main()
{ void echange (int, int) ;
int n=10, p=20 ;
printf ("avant appel : %d %d\n", n, p) ;
echange (n, p) ;
printf ("apres appel : %d %d", n, p) ; avant appel : 10 20
} debut echange : 10 20
void echange (int a, int b) fin echange : 20 10
{ int c ; apres appel : 10 20
printf ("debut echange : %d %d\n", a, b) ;
c = a ;
a = b ;
b = c ;
printf ("fin echange : %d %d\n", a, b) ;
}
La fonction echange reçoit deux valeurs correspondant à ses deux arguments
muets a et b. Elle effectue un échange de ces deux valeurs. Mais lorsque l’on est
revenu dans le programme principal, aucune trace de cet échange ne subsiste sur
les arguments effectifs n et p.
En effet, lors de l’appel de la fonction echange, il y a eu transmission de la valeur
des expressions n et p. On peut dire que ces valeurs ont été recopiées
« localement » dans la fonction echange, dans des emplacements nommés a et b.
C’est effectivement sur ces copies qu’a travaillé la fonction echange, de sorte que
les valeurs des variables n et p n’ont pas été modifiées. C’est ce qui explique le
résultat constaté.
5.2.2 Éléments de solution
D’une manière générale, la transmission par valeur pose problème dès que l’on
souhaite qu’une fonction modifie la valeur d’un objet apparaissant en argument
(sauf s’il s’agit d’un tableau). Bien entendu, une telle limitation paraîtrait
naturelle pour une fonction dans un langage qui distinguerait fonctions et
procédures. Mais ce n’est pas le cas du C, dans lequel la fonction est la seule
sorte de module existant.
Pour contourner la difficulté, il existe plusieurs possibilités :
• utiliser un pointeur sur l’objet à modifier ;
• se servir de la valeur de retour de la fonction ;
• utiliser des variables globales.
Utiliser un pointeur sur l’objet à modifier
Il s’agit de la démarche la plus usuelle et la plus naturelle. Elle consiste à
transmettre à la fonction non plus la valeur de l’objet concerné, mais son adresse
par le biais d’une variable (ou d’une expression) de type pointeur. Il y a toujours
transmission par valeur mais la valeur en question est l’adresse de l’objet à
modifier. Nous en verrons des exemples à la section 5.3.
Se servir de la valeur de retour de la fonction
On peut, dans certains cas, se débrouiller pour exploiter la valeur de retour de la
fonction. Par exemple, pour réaliser une fonction calculant une valeur aléatoire
dans une variable entière n, on ne peut pas procéder ainsi :
f(n) ; /* avec f de prototype : void f(int) */
En revanche, on peut éventuellement procéder ainsi :
n = f() ; /* avec f de prototype : int f(void) */
On notera bien qu’il ne s’agit que d’un artifice, car il n’y a pas d’action directe
de f sur n, mais bel et bien renvoi d’un résultat (temporaire) qui est ensuite
recopié dans n. De plus, cette démarche ne peut être utilisée que pour une seule
valeur7, alors que la démarche précédente peut être indifféremment appliquée à
autant d’objets qu’on le souhaite, par le biais d’autant d’arguments de type
pointeur.
Utiliser des variables globales
Les variables globales sont, par essence, accessibles à toute fonction du
programme. Il est donc facile (voire trop facile) à toute fonction d’en modifier la
valeur. En revanche, les variables globales n’autorisent plus le paramétrage
offert par la notion d’argument, à moins de procéder à des recopies
supplémentaires à partir de ou vers ces variables globales. D’une manière
générale, l’expérience montre qu’un programmeur n’a aucun problème pour
recourir aux variables globales mais qu’en revanche, il éprouve ensuite bien du
mal à s’en passer. L’utilisation des variables globales est étudiée en détail à la
section 8, leurs avantages et leurs inconvénients sont discutés au chapitre 19.
5.3 Comment simuler une transmission par adresse
avec des pointeurs
Nous commencerons par montrer comment régler le problème évoqué section
5.2.1, avant de donner des indications plus générales.
5.3.1 Exemple introductif
La fonction echange de la section 5.2.1 ne faisait pas le travail escompté :
échanger les valeurs des deux variables transmises en argument. Voici comment
y parvenir en utilisant des pointeurs sur les variables dont on souhaite échanger
les valeurs :
Exemple de simulation d’une transmission par adresse
#include <stdio.h>
int main()
{
void echange (int *, int *) ; /* prototype réduit */
int n=10, p=20 ;
printf ("avant appel : %d %d\n", n, p) ;
echange (&n, &p) ; avant appel : 10 20
printf ("apres appel : %d %d", n, p) ; debut echange : 10 20
} fin echange : 20 10
void echange (int *ada, int *adb) apres appel : 20 10
{
int c ;
printf ("debut echange : %d %d\n", *ada, *adb) ;
c = *ada ;
*ada = *adb ;
*adb = c ;
printf ("fin echange : %d %d\n", *ada, *adb) ;
}
Les arguments effectifs de l’appel de la fonction echange sont, cette fois, les
adresses des variables n et p (et non plus leurs valeurs). Notez bien que la
transmission se fait toujours par valeur, à savoir que l’on transmet à la fonction
echange les valeurs des expressions &n et &p. Les arguments muets de la fonction
echange sont maintenant deux variables pointeurs destinées à recevoir ces
adresses.
Remarque
Il n’aurait pas fallu se contenter d’échanger simplement les valeurs de ces arguments en écrivant (par
analogie avec la fonction echange du chapitre précédent) :
int * c ;
c = ada ;
ada = adb ;
adb = c ;
Cela n’aurait conduit qu’à échanger (localement) les valeurs de ces deux adresses, alors qu’il fallait
échanger les valeurs situées à ces adresses.
5.3.2 D’une manière générale
On peut toujours transmettre en arguments d’une fonction les adresses d’objets
qu’on souhaite voir modifier par la fonction. On notera cependant qu’il est alors
nécessaire :
• de prévoir des arguments muets du type pointeur sur l’objet en question et non
plus du type de l’objet en question ;
• de faire appel, dans la définition de la fonction, à l’opérateur de
déréférenciation (*) pour accéder aux valeurs de ces objets ou pour les
modifier8.
Cette démarche pourra également être utilisée pour des arguments de taille
importante telles que certaines structures, même lorsqu’on ne cherche pas à en
modifier la valeur, en particulier si l’on souhaite éviter des pertes de temps et
d’emplacement mémoire. Bien entendu, on perdra du même coup la sécurité
correspondante, dans la mesure où rien n’interdit alors à la fonction appelée de
modifier la structure concernée.
En C++
Les possibilités décrites ici à propos du C restent utilisables en C++. Néanmoins, ce dernier dispose de
ce qu’on nomme une transmission par référence. Celle-ci permet de mettre en œuvre une véritable
transmission par adresse, sans avoir à se préoccuper d’en programmer soi-même le mécanisme comme
on le fait ici. À titre indicatif, en utilisant cette transmission par référence, la fonction echange
précédente pourra s’écrire ainsi en C++ :
void echange (int & a, int & b)
{ int c ;
c = a ;
a = b ;
b = c ;
}
et son appel se présentera tout simplement sous la forme f(n, p).
6. Cas des tableaux transmis en arguments
Le cas des tableaux transmis en arguments reste très particulier en C, compte
tenu de la forte corrélation qui existe entre les notions de tableau et de pointeur.
Nous en examinons ici les conséquences à la fois sur la manière d’écrire la
définition d’une fonction ou son en-tête et sur les différentes façons dont on peut
l’appeler. Nous présenterons ensuite les contraintes qui en découlent. Le cas des
tableaux de tableaux (ou tableaux à plusieurs indices) est, bien entendu,
concerné par cette section. Toutefois des indications supplémentaires seront
données section 7 au sujet des tableaux à plusieurs indices.
Tableau 8.5 : cas des tableaux transmis en arguments
Si t1 est un tableau de n éléments : Voir
section
– f(t1) est équivalent à f(&t1[0]) ; 6.1.1
– on peut utiliser un appel de la forme
Appel f(&t1[j]) si l’on souhaite travailler sous-
avec un « sous-tableau ». tableau
présenté à
la section
6.1.4
Trois formes possibles (n expression Voir
En-tête constante entière) : section
f (int t[n]) f (int t[]) f( int * t)
6.1.2
Quelle que soit celle des trois en-têtes Voir
précédents : sections
6.1.3 et
– t[i] est équivalent à *(t+i) et coïncide 6.1.4
avec t1[i] pour l’appel f(t1), ou avec
Définition de la
t1[j+i] pour l’appel f(&t1[j]) ;
fonction
– t est une lvalue (sauf si les éléments de
t sont eux-mêmes des tableaux) ;
– sizeof (t) donne toujours la taille
d’une variable de type pointeur.
Si la dimension n’est pas fixe et qu’elle Voir
Dimension est indispensable à la fonction, il faut la section 6.3
variable transmettre sous forme d’un argument
supplémentaire, par exemple :
f(int t[], int n)
– utiliser l’appel f(t1) plutôt que Voir
f(&t1[0]) dès lors que f travaille sur
section 6.4
plusieurs éléments du tableau ;
Style de – utiliser le qualifieur const dans l’en-
programmation tête si la fonction ne modifie pas le
tableau ;
– dans la définition, n’utiliser le
formalisme pointeur que si le temps
d’exécution est primordial.
6.1 Règles générales
Résumons ce qui a été exposé à la section 3.2 du chapitre 7 :
1. Une référence à un tableau, c’est-à-dire une expression désignant un objet qui
est un tableau, est convertie par le compilateur en un pointeur9 sur son
premier élément, excepté lorsqu’il s’agit de l’opérande de sizeof ou de
l’opérateur &.
2. L’opérateur [] est défini de façon que, si exp1 et exp2 désignent des expressions,
l’une de type pointeur, l’autre de type entier, les deux expressions suivantes
soient équivalentes :
exp1 [exp2] * (exp1 + exp2)
Ces deux règles s’appliquent donc au cas particulier des tableaux transmis en
arguments d’une fonction, à la fois pour les arguments effectifs, pour les
arguments muets et dans le corps de la fonction. Voyons cela plus en détail.
6.1.1 Tableaux en arguments effectifs
Si l’on suppose que f est une fonction et que t1 a été déclaré sous la forme d’un
tableau, d’après la règle 1, l’appel :
f (t1)
est totalement équivalent à :
f (&t1[0])
On peut théoriquement utiliser indifféremment l’un ou l’autre de ces appels, bien
que le premier suggère davantage le fait que f travaille sur un tableau. Le
second, en effet, pourrait laisser croire, à tort, que la fonction f ne peut modifier
qu’une seule valeur, ici, celle de t1[0].
6.1.2 Tableaux en arguments muets
Compte tenu de la règle 2, une fonction ne peut jamais véritablement recevoir un
tableau en argument, mais simplement un pointeur sur un élément du type des
éléments du tableau. Dans ces conditions, l’en-tête de la fonction f précédente
peut être :
void f (int *t)
Cependant, il reste possible d’utiliser une notation évoquant davantage un
tableau en écrivant cet en-tête de la façon suivante :
void f (int t[10])
La dimension indiquée (ici, 10) n’a aucune signification pour le compilateur.
Elle peut, tout au plus, servir au lecteur éventuel du programme source. On ne
changera strictement rien au déroulement de la fonction en utilisant une autre
valeur – 12, 3 ou 1000 – voire en omettant complètement cette dimension :
void f (int t[])
Il est tout naturellement conseillé d’indiquer la (vraie) dimension du tableau
lorsque la fonction est supposée toujours travailler sur des tableaux de cette
taille. Dans le cas contraire, il est préférable de n’indiquer aucune dimension
plutôt qu’une valeur qui se trouve être souvent fausse.
Remarque
Avec un argument muet de type pointeur sur un int, on peut utiliser le qualifieur const de quatre
façons différentes :
void f (int *ad) /* ad et *(ad+i) sont tous deux modifiables */
void f (const int *ad) /* ad est modifiable, *(ad+i) est constant */
void f (int * const ad) /* ad est constant, *(ad+i) est modifiable */
void f (const int * const ad) /* ad et *(ad+i) sont tous deux constants */
Si l’on utilise un argument muet de type tableau d’entier (avec ou sans sa dimension), on ne peut plus
choisir qu’entre deux possibilités int t[] et const int t[]. En outre, un certain flou existe dans la
norme en ce qui concerne la deuxième possibilité. Certaines implémentations considèrent que const
s’applique, comme on s’y attend, aux éléments du tableau. D’autres, en revanche, l’appliquent aussi à
son adresse :
void f (int t[]) /* t et t[i] sont tous deux modifiables */
void f (const int t[]) /* t est constant dans certaiens implémentations */
/* t[i] (ou *(t+i) est toujours constant */
6.1.3 Utilisation d’un argument tableau dans la définition d’une
fonction
D’après la règle 2, on voit que quelle que soit la forme de l’en-tête (forme
tableau ou forme pointeur), on pourra, dans le corps de la fonction, désigner un
élément du tableau transmis en argument, en utilisant indifféremment le
formalisme tableau comme dans t[i] ou le formalisme pointeur comme dans *
(t+i).
Dans les deux cas, on notera bien que l’utilisation du formalisme tableau permet,
dans la fonction, de travailler sur le tableau d’une manière identique à celle
qu’on utiliserait dans la fonction appelante, et ce bien que la seule information
transmise à la fonction appelante soit un simple pointeur. Généralement, c’est
cette forme qu’on utilise en raison de sa plus grande lisibilité :
int main()
{ /* dans la fonction appelante (ici, main) */
int t1[8] ;
…..
t1 [i] = ….. /* modifie le ième élément du tableau t1 */
…..
f (t1) ;
…..
}
void f (int t[]) /* ou void f(int * t) */
{ …..
t[i] = ….. /* modifie l'élément i du tableau d'adresse reçue en argument */
…..
}
Voici un schéma récapitulant la situation après l’appel de f :
dans la fonction dans la fonction appelee f
appelante
_
|_| <----------------------|
|_| |___|
|_| t
t1[3] ---> |_| <--- t[3]
|_|
t1[5] ---> |_| <--- t[5]
|_|
|_|
t1
Par ailleurs, on notera bien que, alors que dans main, l’expression sizeof(t1)
fournirait la taille du tableau en octets, dans f, l’expression sizeof(t) fournira la
taille du pointeur de type int * correspondant à l’argument muet et ce, quelle que
soit la manière dont l’en-tête de f a été écrite.
6.1.4 Différences entre tableau en argument effectif et en
argument muet
Malgré de nombreuses analogies au niveau du formalisme, il existe bon nombre
de différences entre un tableau transmis en argument effectif et un tableau
apparaissant en argument muet.
Dimension du tableau concerné
Si l’on considère l’exemple de la section 6.1.3, on constate que le tableau t1
utilisé comme argument effectif a une dimension bien connue dans la fonction
appelante. Il n’en va plus de même dans la fonction appelée, laquelle, de
surcroît, peut même être compilée séparément de la fonction appelante. On peut
dire également que la réservation de l’emplacement mémoire correspondant à t1
est effectivement décidée lorsque l’on compile la fonction appelante. En
revanche, la présence d’un argument de type tableau dans la fonction appelée
n’entraîne aucune réservation de mémoire.
Cette différence pourra avoir de l’importance si le programmeur souhaite mettre
en place un contrôle de débordement de tableau au sein de la fonction, puisqu’il
devra alors disposer de sa dimension. Nous en reparlerons dans la section 6.3,
consacrée aux tableaux de dimension variable.
Remarque
Si l’on admet qu’en C, il existe un type tableau caractérisé par le type et le nombre de ses éléments, on
voit clairement que la transmission d’un argument de type tableau amène à une dégradation du type en
question : dans la fonction appelée ne subsiste plus que le type des éléments et plus du tout leur
nombre. Il s’agit là d’une lacune du C par rapport à bon nombre d’autres langages.
L’argument muet de type tableau est (généralement) une lvalue
L’argument muet correspondant à un tableau est, comme tout argument muet,
une lvalue, sauf s’il a été explicitement déclaré dans l’en-tête de la fonction avec
le qualifieur const. Ainsi, dans l’exemple précédent, l’argument effectif t1 n’est
pas une lvalue, tandis que l’argument muet t en est une10. Il serait éventuellement
possible, au sein de f, de changer la valeur de t. On risque alors d’aboutir à une
situation plutôt déconcertante. Par exemple, après l’instruction :
t += 2 ;
on aboutirait à une situation telle que celle-ci :
dans la fonction dans la fonction appelee f
appelante (apres execution de t+= 2 ; )
_
|_|
|_|
|_| <----------------------|
t1[3] ---> |_| <--- t[1] |___|
|_| t
t1[5] ---> |_| <--- t[3]
|_|
|_|
t1
Autrement dit, l’élément de rang i de t ne coïnciderait plus avec l’élément de
rang i de t1.
Sous-tableau
Rien n’oblige à transmettre à une fonction telle que f une adresse qui soit
véritablement l’adresse de début d’un tableau. Un appel tel que :
f (&t1[2])
est tout à fait licite. Si l’on suppose que la valeur de l’argument t n’est pas
modifiée dans f, on aboutit alors exactement à la même situation que celle qui
est schématisée précédemment. On parle souvent de sous-tableau dans ce cas,
dans la mesure où cette technique est généralement utilisée pour amener une
fonction à travailler sur une partie des éléments d’un tableau. En général, la
fonction aura alors besoin qu’on lui transmettre le nombre exact d’élément à
traiter. Nous en verrons des exemples section 6.3.
6.1.5 Cas particulier des tableaux de tableaux
Tout ce qui a été dit ici s’applique quel que soit le type des éléments du tableau
concerné et donc, en particulier, quand ces éléments sont eux-mêmes des
tableaux. C’est d’ailleurs la seule façon d’obtenir en C ce que l’on nomme dans
les autres langages tableaux à plusieurs indices ou à plusieurs dimensions. Dans
ce cas, si les règles examinées ici restent valables pour le premier niveau de
tableau, il n’en va plus entièrement de même pour les tableaux qui en constituent
les éléments, en particulier en ce qui concerne l’information de dimension. Cette
situation sera étudiée en détail section 7.
6.2 Exemples d’applications
6.2.1 Fonction utilisant les valeurs d’un tableau
La fonction suivante affiche les valeurs des 10 éléments d’un tableau reçu en
argument :
void affiche (int t[10])
{ int i ;
printf ("valeurs : ") ;
for (i=0 ; i<10 ; i++)
printf ("%d ", t[i]) ; /* ou encore *(t+i) au lieu de t[i] */
printf ("\n") ;
}
Son en-tête peut s’écrire indifféremment :
void affiche (int t[]) ;
void affiche (int * t) ;
void affiche (int t[3]) ; /* déconseillé : risque de tromper le lecteur */
Voici quelques exemples d’appels possibles :
int t1[10], tab[25] ;
…..
affiche (t1) ; /* affiche les 10 valeurs du tableau t1 */
affiche (tab) ; /* affiche les 10 premières valeurs du tableau tab */
affiche (&tab[5]) ; /* affiche les valeurs des éléments tab[5] à tab[14] */
affiche (&t1[4]) ; /* affiche les valeurs des éléments t1[4] à t1[9] puis, */
/* d'après la norme, comportement indéterminé */
/* en pratique, on obtient ensuite 4 valeurs imprévisibles */
Remarques
1. Quel que soit l’en-tête de affiche, l’argument muet t est une lvalue. La fonction peut donc en
modifier la valeur. Par exemple, la fonction affiche précédente pourrait s’écrire ainsi :
void affiche (int t[10]) /* void affiche (int * t) serait préférable */
{ int i ;
printf ("valeurs : ") ;
for (i=0 ; i<10 ; i++)
printf ("%d ", *t++) ;
printf ("\n") ;
}
2. Si affiche ne modifie pas la valeur de t, on peut songer à utiliser le qualifieur const dans son en-
tête, à condition qu’il utilise un argument muet de type pointeur :
void affiche (const int *t) /* t est constant */
Si, comme c’est le cas ici, les valeurs du tableau ne sont pas modifiées, on peut même utiliser l’en-
tête suivant :
void affiche (const int * const t) /* t et t[i] (ou *(t+i)) sont constants */
En revanche, comme il a été dit dans la remarque de la section 6.1.2, l’utilisation d’un argument de
type tableau conduirait à une ambiguïté :
void affiche (const int t[]) /* t[i) (ou *(t+i)) est toujours constant */
/* t est constant dans certaines implémentations */
6.2.2 Fonction modifiant les éléments d’un tableau
La fonction suivante place des zéros dans les dix éléments d’un tableau d’entiers
reçu en argument :
void raz (int t[10])
{ int i ;
for (i=0 ; i<10 ; i++)
t[i] = 0 ; /* ou : *(t+i) = 0; */
}
Les remarques faites à propos de l’exemple précédent restent valables : l’en-tête
de raz peut s’écrire indifféremment :
void raz (int t[]) ;
void raz (int * t) ;
void raz (int t[3]) ; /* déconseillé */
Voici quelques exemples d’appels possibles :
int t1[10], tab[25] ;
…..
raz (t1) ; /* place la valeur 0 dans les 10 éléments du tableau t1 */
raz (tab) ; /* place la valeur 0 dans les 10 premiers éléments du tableau tab */
raz (&tab[5]) ; /* place la valeur 0 dans les éléments tab[5] à tab[14] */
raz (&t1[4]) ; /* place la valeur 0 dans les éléments t1[4] à t1[9] puis, */
/* d'après la norme comportement indéterminé. */
/* En pratique, on écrasera des emplacements situés après le */
/* dernier élément de t1 */
Remarques
1. Si l’on tient compte du fait que raz ne modifie pas la valeur de l’argument t, on peut écrire son en-
tête ainsi :
void raz (int * const t)
En revanche, l’en-tête suivant ne conviendra jamais :
void raz (const int t[])
En effet, dans toutes les implémentations, il interdit la modification des valeurs du tableau. En
outre, dans certaines implémentations (voir la remarque de la section 6.1.2), il n’interdit pas la
modification de la valeur de t.
2. Les deux exemples précédents faisaient appel à un tableau transmis en argument. Ils étaient
cependant particuliers puisque le premier se contentait d’en utiliser les valeurs, tandis que le
second, d’attribuer des valeurs. Comme on s’en doute, compte tenu de la manière dont C traite les
tableaux transmis en argument, une fonction peut à la fois utiliser les valeurs existantes et les
modifier. Voici un exemple de fonction qui, dans un tableau de 10 entiers, remplace la valeur de
chaque élément de rang pair par la valeur de l’élément précédent :
void transfo (int t[10])
{ int i ;
for (i=1 ; i<10 ; i+=2)
t[i] = t[i-1] ;
}
Bien entendu, toutes les remarques faites dans les exemples précédents, à propos de l’en-tête et des
formalismes tableau et pointeur restent valables ici.
6.3 Pour qu’une fonction dispose de la dimension d’un
tableau
Pour traduire correctement une fonction recevant un tableau en argument, le
compilateur n’a pas besoin d’en connaître le nombre d’éléments (voir section
6.1). En contrepartie de cette liberté, un inconvénient majeur apparaît : la
fonction concernée n’a pas connaissance de ce nombre d’éléments. Or, il est
fréquent qu’une telle information soit nécessaire. Deux situations différentes
peuvent se présenter :
• si ce nombre d’éléments est toujours le même et s’il est connu lors de l’écriture
de la fonction, il n’y a pas de problème particulier. À titre d’exemple, cette
situation peut se rencontrer dans une fonction qui manipulerait des vecteurs de
l’espace usuel (à 3 dimensions) ou des points d’un plan ;
• si, en revanche, ce nombre d’éléments est susceptible d’évoluer d’un appel à un
autre, ou s’il n’est pas connu lors de l’écriture de la fonction, il faut prévoir de
le transmettre à la fonction sous forme d’un argument supplémentaire. Bien
entendu, lors de l’appel effectif, il faudra fournir la « bonne valeur » sinon l’on
encourra les risques habituels liés à l’emploi d’un indice incorrect.
Remarque
Il est impossible, en C, d’indiquer une dimension variable dans un tableau correspondant à un
argument muet, comme dans :
void fct (int n, int t[n]) /* t[n] interdit ici, même si n est transmis */
/* en argument */
Cela sera toutefois possible en C99, mais C11 rendra cette possibilité facultative (voir annexe B
consacrée aux normes C99 et C11).
Exemples
1 - Fonction mettant à zéro un tableau d’entiers de taille quelconque
void raz (int t[], int nb) /* ou : raz (int *t, int nb) */
{ int i ;
for (i=0 ; i<nb ; i++)
t[i] = 0 ;
}
Voici quelques exemples d’appel de cette fonction :
int t1[6], t2[15] ;
…..
raz (t1, 6) ; /* place la valeur 0 dans les 6 éléments de t1 */
raz (t2, 15) ; /* place la valeur 0 dans les 15 éléments de t2 */
raz (t2, 6) ; /* place la valeur 0 dans les 6 premiers éléments de t2 */
raz (&t2[4], 7) ; /* place la valeur 0 dans les éléments t2[4] a t2[10] */
raz (&t1[4], 5) ; /* place la valeur 0 dans les éléments t1[4] a t1[5] puis, */
/* d'après la norme, le comportement du programme est */
/* indéterminé ; en pratique, on écrasera des emplacements */
/* situés apres le dernier élément de t1 */
2 - Fonction calculant la somme des éléments d’un tableau d’entiers de taille
quelconque
int som (int t[],int nb)
{ int s = 0, i ;
for (i=0 ; i<nb ; i++)
s += t[i] ;
return (s) ;
}
Voici quelques exemples d’appels de cette fonction :
int t1[30], t2[15], t3[10] ;
int s1, s2, s3, s4, s5 ;
…..
s1 = som(t1, 30) ; /* s1 contiendra la somme des 30 éléments de
t1 */
s2 = som(t2, 15) + som(t3, 10) ; /* s2 contiendra la somme des 15
éléments */
/* de t2 et des 10 éléments de
t3 */
s3 = som (t1, 10) ; /* s3 contiendra la somme des 10 premiers éléments de
t1 */
s4 = som (&t1[3], 5) ; /* s4 contiendra la somme des éléments t1[3] a
t1[7] */
s5 = som (&t3[7], 5) ; /* calcule la somme des éléments t3[7] à t3[9]
puis, */
/* d'après la norme le comportement du programme
est */
/* indéterminé. En pratique, le résultat sera imprévisible.
*/
Remarque
Ce qui a été dit ici s’applique quel que soit le type des éléments du tableau transmis en argument.
Cependant, lorsque ses éléments sont eux-mêmes des tableaux, autrement dit lorsque le tableau
concerné est ce que l’on nomme un tableau à deux indices :
• si la dimension de ces éléments (tableaux) est fixe et connue, aucun problème particulier ne se pose ;
• si, en revanche, cette dimension est, elle-aussi, variable, les choses se compliquent et la démarche
présentée ici, à savoir, transmission de la (deuxième) dimension en argument ne fonctionne plus de
façon satisfaisante. Nous y reviendrons section 7 où nous proposons divers remèdes.
6.4 Quelques conseils de style à propos des tableaux en
argument
Choix de la forme de l’appel
Si t est un tableau, malgré l’équivalence des deux appels :
f(t)
f(&t[0])
il est d’usage d’employer :
• la première formulation lorsque la fonction f est censée travailler sur
l’ensemble du tableau ou, au moins, sur plusieurs de ses valeurs (que celles-ci
soient ou non modifiées) ;
• la deuxième formulation lorsque la fonction f travaille sur des scalaires,
autrement dit, si elle fait intervenir :
– soit la valeur du pointeur &t[0], lui-même, sans que les éléments de t ne
soient concernés ; ce cas est, au demeurant, assez peu fréquent ;
– soit la valeur pointée, c’est-à-dire t[0], laquelle peut éventuellement être
modifiée.
Cela revient à dire que la deuxième formulation est recommandée lorsque la
fonction f peut aussi se présenter sous la forme suivante, dans laquelle n désigne
une variable :
f(&n)
Néanmoins, lorsqu’on souhaitera faire porter une telle fonction sur une partie
d’un tableau, il sera nécessaire de recourir à la deuxième forme d’appel, comme
nous l’avons fait à plusieurs reprises dans certains de nos précédents exemples :
raz (&t2[4], 7) ; /* place la valeur 0 dans les éléments t2[4] à t2[10] */
s4 = som (&t1[3], 5) ; /* s4 contiendra la somme des éléments t1[3] à t1[7] */
Utilisation du qualifieur const
Si une fonction recevant un tableau en argument n’en modifie pas les valeurs, il
est conseillé de se protéger d’éventuelles étourderies (de programmation dans
l’écriture de la fonction uniquement) en utilisant const comme dans :
void f (const int t[])
void f (const int *t)
On notera que la valeur de t est modifiable dans le second cas. Elle l’est parfois
pour le premier dans certaines implémentations, compte tenu de l’ambiguïté
évoquée section 6.1.2. Ce point est peu important, dans la mesure où, de toute
façon, une modification de la valeur de t n’a aucune répercussion dans la
fonction appelante.
Formalisme tableau ou formalisme pointeur
Comme l’indique la section 6.1, on peut théoriquement utiliser indifféremment
le formalisme tableau ou le formalisme pointeur. A priori, le formalisme tableau
est conseillé pour sa plus grande lisibilité.
Il n’en reste pas moins que, lorsque les temps d’exécution seront cruciaux, le
recours au formalisme pointeur pourra se justifier. En effet, considérons par
exemple ces instructions, dans lesquelles on traite les valeurs d’un tableau t
suivant leur ordre naturel :
int i ;
…..
for (i=0 ; i<10 ; i++)
t[i] = …..
Chaque accès à t[i] peut amener le compilateur11 à effectuer un calcul d’adresse
du genre : incrémentation de l’adresse de t de i*sizeof (int) octets.
En revanche, en procédant ainsi :
for (p=t ; p<t+10 ; p++)
*p = …..
chaque accès au tableau nécessite seulement une incrémentation de pointeur et
ce, quelle que soit la qualité de l’optimisation de la compilation.
7. Cas particulier des tableaux de tableaux transmis
en arguments
Comme le signale la section 5 du chapitre 6, C ne dispose pas véritablement de
tableaux à plusieurs indices. Il permet simplement de composer à plusieurs
reprises la notion de tableau pour arriver à des tableaux de tableaux… Nous
allons examiner ici les problèmes que cela pose à une fonction qui doit travailler
sur de tels tableaux et nous proposerons quelques solutions. Le tableau 8.6
récapitule les différents points qui sont ensuite examinés en détail.
Tableau 8.6 : tableau à plusieurs indices transmis en arguments
– pour accéder convenablement dans la Voir
fonction aux éléments du tableau, les section
Conséquences dimensions doivent être des expressions 7.1
des règles constantes, hormis la première qui peut être
générales variable et, éventuellement, transmise en
argument ;
– le formalisme pointeur est lourd à utiliser.
– contrairement à ce qui se passe pour les Voir
tableaux à un indice, il n’est plus possible section
d’utiliser le formalisme tableau dans ce 7.2
cas ;
Dimensions – on peut utiliser des artifices :
variables – traiter le tableau comme un tableau à un
seul indice ;
– utiliser un tableau de pointeurs sur les
lignes (dans le cas des tableaux à deux
indices).
7.1 Application des règles générales
Les règles générales rappelées section 6.1 restent valables mais ne s’appliquent,
en quelque sorte, qu’au premier niveau de tableau. Supposons, par exemple, que
l’on souhaite transmettre en argument le tableau déclaré par :
int t [10] [15] ;
Lors d’un appel tel que f(t), t sera, conformément aux règles usuelles, convertit
en un pointeur sur le premier de ses 10 éléments, c’est-à-dire en un pointeur sur
des tableaux de 15 entiers, donc du type :
int (*) [15] /* pointeur sur des tableaux de 15 int */
On pourrait également appeler f par f(&t[0]). En revanche, l’appel f(&t[0][0])
serait incorrect (du moins en présence de prototype de f) puisqu’il correspondrait
à un type int * et non plus int (*)[15] et que la conversion imposée alors par le
prototype ne serait pas légale12.
En ce qui concerne l’en-tête de f, il peut être déclaré de cette manière :
… f (int t[10][15])
dans l’hypothèse où les valeurs 10 et 15 sont connues lors de l’écriture de la
fonction. En tenant compte de ce qui a été dit section 6.1.2, les en-têtes suivants
conviennent également, la valeur 10 n’étant pas nécessaire :
… f(int (*t)[15]) /* attention aux parenthèses autour de *t */
… f(int t[][15])
Dans tous les cas, le compilateur sera en mesure de mettre en place, dans la
définition de la fonction, les instructions permettant d’accéder à un élément t[i]
[j], en fonction des valeurs de i et de j.
En revanche, il n’est pas possible d’utiliser l’en-tête suivant qui sera rejeté à la
compilation :
… f(int t[][]) /* incorrect : la seconde dimension doit être connue */
En effet, le compilateur a besoin de connaître la taille des éléments de t, c’est-à-
dire ici des tableaux de 15 entiers, pour effectuer convenablement son calcul
d’adresse de t[i][j], voire pour évaluer une expression de la forme t+k, laquelle
nécessite la connaissance du type exact de t.
À titre d’exemple, si on considère un tableau à deux indices comme formé de
lignes et de colonnes13, on peut dire que pour accéder à un élément quelconque
de ce tableau, il est nécessaire d’en connaître la taille des lignes, mais pas
nécessairement celle des colonnes.
Il est facile de généraliser tout cela à des tableaux à plus de deux indices et de
tirer la règle suivante :
Pour qu’une fonction puisse accéder convenablement à un tableau à plusieurs indices reçu en
argument, il est nécessaire que toutes les dimensions du tableau, à l’exception de la première, soient
connues et fixées dans l’en-tête de la fonction, sous forme d’expressions constantes.
Dans ces conditions, ce qui a été dit section 6.3 à propos de la transmission en
argument de la dimension d’un tableau ne pourra s’appliquer qu’à la première
dimension du tableau, et en aucun cas aux autres. La section 7.2 présentera
quelques palliatifs à cette lacune.
Remarques
1. Lorsqu’un tableau apparaît comme champ d’une structure ou d’une union reçues en argument, sa
dimension doit aussi être connue du compilateur. En général, cependant, cette contrainte apparaît
alors relativement naturelle, la dimension étant imposée par la déclaration de type de la structure ou
de l’union correspondante. C’est donc bien dans le cadre des tableaux à plusieurs indices que ce
problème dit souvent de « dimensions variables » se présente avec le plus d’acuité. Bien entendu,
dans le cas de tableaux à plus de deux indices, seule la dimension du tableau principal (c’est-à-dire
la borne du premier indice) pourra être ignorée du compilateur.
2. Malgré l’équivalence existant entre le formalisme pointeur et le formalisme tableau, il est rare qu’on
utilise, par exemple pour des tableaux à deux indices, une notation telle que * (*(t+i) + j),
pourtant parfaitement équivalente à t[i][j].
Exemple 1 : avec un tableau à deux indices
La fonction suivante place la valeur 1 dans chacun des éléments d’un tableau de
dimensions 10 et 15 :
Exemple de transmission en argument d’un tableau à deux indices de dimensions
fixes
void raun (int t[10][15]) /* ou : void raun (int t[] [15]) */
/* ou : void raun (int (*t)[15]) */
/* mais pas : void raun (int t[][]) */
{ int i, j ;
for (i=0 ; i<10 ; i++)
for (j=0 ; j<15 ; j++)
t[i][j] = 1 ; /* équivalent à *(*(t+i)+j) = 1 ; */
}
Comme la connaissance de la deuxième dimension n’est pas indispensable au
compilateur, on voit qu’il est facile de prévoir une fonction qui place la valeur 1
dans un tableau de dimensions n et 15, la valeur de n pouvant être quelconque,
pour peu qu’elle soit transmise en argument :
Exemple de transmission en argument d’un tableau à deux indices, la première
dimension étant variable
void raun (int t[][15], int n) /* ou : void raun (int (*t)[15], int n) */
/* mais pas : void raun (int t[][], int n) */
/* void raun (int t[10][15], int n) est correct */
/* mais déconseillé, la première dimension */
/* n'étant plus nécessairement 10 */
{ int i, j ;
for (i=0 ; i<n ; i++)
for (j=0 ; j<15 ; j++)
t[i][j] = 1 ;
}
En revanche, on ne peut procéder de même pour un tableau de dimensions 10 et
n, et encore moins pour un tableau de dimensions n et p.
Remarque
Ici, on se contente de placer la même valeur dans un certain nombre (15*n) d’éléments consécutifs, à
partir d’une adresse donnée. Si l’on souhaite minimiser le temps pris par l’exécution de la fonction, il
est possible de parcourir tous ces éléments à l’aide d’un pointeur de type int *, en procédant comme
si l’on avait affaire à un tableau à un seul indice. Cette démarche sera présentée section 7.2.1 dans le
cadre, plus général, des tableaux de dimensions variables.
Exemple 2 : avec un tableau à trois indices
Les exemples précédents se généralisent facilement :
Exemple de transmission en argument d’un tableau à trois indices de dimensions
fixes
void raun (int t[10][15][4]) /* ou : void raun (int t[][15][4]) */
/* ou : void raun (int (*t)[15][4]) */
/* mais pas : void raun (int t[][][4]) */
/* ni : void raun (int t[][][]) */
{ int i, j, k ;
for (i=0 ; i<10 ; i++)
for (j=0 ; j<15 ; j++)
for (k=0 ; k<4 ; k++)
t[i][j][k] = 1 ;
}
Exemple de transmission en argument d’un tableau à trois indices à première
dimension variable
void raun (int t[][15][4], int n)
/* ou : void raun (int (*t)[15][4], int n) */
/* mais pas : void raun (int t[][][4], int n) */
/* ni : void raun (int t[][][], int n) */
/* void raun (int t[10][15][4], int n) est correct */
/* mais déconseillé, la première dimension n'étant */
/* plus nécessairement 10 */
{ int i, j, k ;
for (i=0 ; i<n ; i++)
for (j=0 ; j<15 ; j++)
for (k=0 ; k<4 ; k++)
t[i][j][k] = 1 ;
}
Remarques
1. Là encore, il n’est plus possible de procéder de la même manière pour un tableau de dimensions 10,
n et p, et encore moins pour un tableau de dimensions n, p et q.
2. Ici encore, comme dans l’exemple précédent, on se contente de placer la même valeur dans un
certain nombre (n*15*4) d’éléments consécutifs, à partir d’une adresse donnée. Il est alors
possible, pour minimiser le temps nécessaire à l’exécution de la fonction, de parcourir tous ces
éléments à l’aide d’un pointeur de type int *, en procédant comme si l’on avait affaire à un tableau
à un seul indice. Cette démarche sera présentée section 7.2.1 dans le cadre, plus général, des
tableaux de dimensions variables.
7.2 Artifices facilitant la manipulation de tableaux de
dimensions variables
La section 7.1 a montré comment une fonction peut travailler sur un tableau à
plusieurs indices dont la première dimension n’est pas nécessairement connue et,
donc, éventuellement, variable d’un appel à un autre. Cela ne signifie pas pour
autant qu’il est impossible d’écrire une fonction travaillant sur un tableau dont
d’autres dimensions que la première sont variables. Mais il est alors nécessaire
de faire appel à certains artifices. Nous en examinerons deux :
• considérer le tableau à plusieurs indices comme un tableau à un seul indice, en
programmant soi-même les calculs de pointeurs correspondant à des valeurs
données des indices ;
• créer dynamiquement (de façon quelque peu artificielle) des tableaux de
pointeurs sur les lignes d’un tableau à deux indices, ce qui permettra de
retrouver le formalisme tableau.
Par souci de simplicité, nous nous limiterons au cas des tableaux à deux indices.
7.2.1 Traiter un tableau à deux indices comme un tableau à un
indice
Compte tenu de la manière dont les tableaux sont organisés en mémoire, on peut
toujours considérer un tableau à deux indices de dimension n et p comme un
tableau à un indice de dimension n*p. Deux problèmes se posent alors :
• transmettre l’adresse du tableau de la fonction appelante à la fonction appelée ;
• retrouver dans le tableau à un indice la position d’un élément caractérisé dans
le tableau initial par la valeur de deux indices.
Transmission de l’adresse du tableau
Supposons, pour fixer les idées, qu’on souhaite pouvoir appliquer une fonction f
à des tableaux d’entiers tels que :
int t1[10][12] ;
int t2[25][3] ;
On pourrait songer, tout naturellement, à appeler la fonction de cette manière :
f (t1, 10, 12) ;
f (t2, 25, 3) ;
Or dans ce cas, t1 serait de type int (*)[12], tandis que t2 serait de type int (*)[3].
Dans ces conditions, il n’est pas possible de prévoir le type exact du premier
argument muet de f. En fait, il faut utiliser l’en-tête suivant :
void f (int *adr, int n, int p)
Encore faut-il tenir compte du type exact des pointeurs au moment de l’appel. En
effet, les appels précédents deviennent illégaux14, dans la mesure où ils
impliquent des conversions de int(*)[12] ou de int(*)[3] en int * qui sont
interdites par affectation. Il est donc nécessaire de recourir à des conversions
explicites par cast, en écrivant :
f ((int *) t1, 10, 12) ;
f ((int *) t2, 25, 3) ;
On notera que de telles conversions ne sont jamais dégradantes, dans la mesure
où les éventuelles contraintes d’alignement n’interviennent pas dans ce cas.
Accès à un élément du tableau dans la fonction
Compte tenu de l’organisation d’un tableau en mémoire, on voit que, dans notre
fonction f :
• adr pointe sur le premier élément de la première ligne du tableau ;
• adr + p pointe sur le premier élément de la seconde ligne du tableau ;
• adr + i*p pointe sur le premier élément de la ligne de rang i du tableau ;
• adr + i*p + j pointe sur l’élément de rang j de la ligne i du tableau.
Ainsi, là où l’on écrivait t1 [i][j] dans la fonction appelante, on écrira * (adr +
i*p + j) dans la fonction f.
Bien entendu, selon la manière dont le tableau doit être traité dans la fonction, on
pourra parfois faire intervenir quelques raccourcis. En voici trois exemples :
1. Si tous les éléments du tableau doivent être parcourus, de façon classique,
dans une double boucle for, on pourra incrémenter directement adr pour lui
faire parcourir les n*p éléments du tableau :
void f (int *ad, int n, int p)
{ for (i=0 ; i<n ; i++)
for (j=0 ; j<p ; j++)
{ /* ici, on traite l'élément *adr, lequel correspond à l'élément */
/* de rang j de la ligne i du tableau reçu en argument */
adr++ ;
}
}
Qui plus est, si les valeurs de i et de j n’interviennent pas directement, on
pourra même se contenter d’incrémenter adr dans une simple boucle
parcourue n*p fois :
void f (int *ad, int n, int p)
{ for (i=0 ; i<n*p ; i++)
{ /* ici, on traite l'élément *adr */
adr++ ;
}
}
ou encore :
void f (int *adr, int n, int p)
{ for ( ; adr<adr+n*p ; adr++)
/* ici, on traite l'élément *adr */
}
2. Pour traiter une seule ligne du tableau, on pourra se contenter d’incrémenter
adr de 1 dans une simple boucle. Nous en verrons un exemple dans le produit
de matrices présenté à la section 7.2.2.
3. Pour traiter une seule colonne du tableau, on pourra se contenter
d’incrémenter adr de p dans une simple boucle. Là encore, nous en verrons un
exemple dans le produit de matrices présenté section 7.2.2.
Exemple 1 : diagonale d’un tableau carré
Supposons qu’on souhaite écrire une fonction qui place la valeur 1 dans chacun
des éléments d’un tableau carré, excepté pour les éléments de sa diagonale
principale, lesquels reçoivent la valeur 0.
En appliquant la démarche générale présentée ci-dessus et en tenant compte de
ce que, le tableau ayant ses deux dimensions égales, une seule information de
taille suffit ici, on aboutit à :
void diag (int *adr, int n)
{ int i, j ;
for (i=0 ; i<n ; i++)
for (j=0 ; j<n ; j++)
{ if (i==j) *(adr + i*n + j) = 0 ;
else *(adr + i*n + j) = 1 ;
}
}
Si l’on souhaite optimiser le temps d’exécution, on pourra tenir compte de la
particularité du problème en procédant ainsi :
void diag (int *adeb, int n)
{ int * ad ; /* pointeur courant sur un élément quelconque du tableau */
for (ad=adeb ; ad < adeb + n*n ; ad++)
*ad = 1 ;
for (ad=adeb ; ad < adeb + n*n ; ad += n+1)
*ad = 0 ; /* puis des 0 dans les éléments de la diagonale */
/* (ils sont disposés tous les n+1 éléments) */
}
Voici quelques exemples d’appels valables quelle que soit la manière dont on
écrit la fonction :
void diag (int *, int) ; /* prototype de diag */
…..
int t1[3][3] ;
int t2[12][12] ;
…..
diag ((int *)t1, 3) ; /* ne pas oublier le cast en int * */
diag ((int *)t2, 12) ; /* ne pas oublier le cast en int * */
Exemple 2 : produit de matrices
On se propose d’écrire une fonction permettant d’effectuer le produit de deux
matrices de dimensions quelconques. La fonction, nommée prod_mat, recevra en
argument :
les adresses des trois matrices concernées, les deux premières étant utilisées pour
leur valeur, la troisième étant à remplir par la fonction ;
• les valeurs de n, p et q.
• Ici encore, au lieu d’appliquer systématiquement la méthode présentée à la
section 7.2.1, nous tenons compte de la particularité du problème (parcours de
lignes, de colonnes) pour simplifier certaines expressions.
void prod_mat ( double *a, double *b, double *c, /* les trois matrices */
int n, int p, int q) /* a(n,p) b(p,q) c(n,q) */
{
int i, j, k ;
double s ;
double *aik, *bkj, *cij ; /* pointeurs courants dans chacun des trois */
/* tableaux correspondant aux trois matrices */
cij = c ;
for (i=0 ; i<n ; i++)
for (j=0 ; j<q ; j++)
{ aik = a + i*p ; /* adresse du premier élément de la ligne i de a */
bkj = b + j ; /* adresse du premier élément de la colonne j de b */
s = 0 ;
for (k=0 ; k<p ; k++)
{ s += *aik * *bkj ;
aik++ ; /* passe à l'élément suivant de la même ligne de a */
bkj += q ; /* passe à l'élément suivant de la même col. de b */
}
*(cij++) = s ; /* remplit l'élément ligne i colonne j de c */
/* puis passe à l'élément suivant de c */
}
}
Voici un exemple d’appel :
#define N 5
#define P 12
#define Q 4
…..
void prod_mat(double *, double *, double *, int, int, int) ;
double a[N][P], b[P][Q], c[N][Q] ;
…..
prod_mat ((double *)a, (double *)b, (double *)c, N, P, Q) ;
7.2.2 Retrouver artificiellement le formalisme tableau
Au sein d’une fonction recevant en argument un tableau à plusieurs indices dont
les dimensions sont susceptibles de varier d’un appel à l’autre, il est possible de
retrouver le formalisme tableau à plusieurs indices en créant un tableau de
pointeurs contenant l’adresse de début de chaque ligne du tableau.
Pour fixer les idées, supposons que l’on ait affaire à une fonction d’en-tête :
void fct (int *adr, int n, int p)
dans laquelle adr est l’adresse du premier élément du tableau, n et p ses deux
dimensions.
On va créer un tableau de pointeurs sur les n lignes. Généralement, comme n peut
varier d’un appel à l’autre, on allouera dynamiquement un tel tableau, par
exemple, par :
int **ad ;
…..
ad = malloc (n * sizeof (int *)) ;
Ce tableau de pointeurs sera ensuite rempli de la manière suivante :
for (i=0 ; i<n ; i++)
ad[i] = adr + i*p ;
Dans ces conditions, la notation ad [i] [j] correspondra bien (compte tenu de
l’associativité de gauche à droite de l’opérateur []) à :
(ad [i]) [j]
c’est-à-dire à :
*(ad [i] + j)
soit, compte tenu de la façon dont on a rempli le tableau d’adresse adr :
* (adr + i*p +j)
Autrement dit, cette notation ad [i] [j] désigne bien l’élément de rang i, j du
tableau d’adresse adr reçue en argument.
Adaptons cette démarche à nos deux précédents exemples.
Exemple 1 : diagonale d’un tableau carré
#include <stdlib.h>
void diag (int *adr, int n)
{ int i, j ;
int * * ad ;
ad = malloc (n* sizeof (int *)) ;
for (i=0 ; i<n ; i++)
ad[i] = adr + i*n ; /* ici, la deuxième dimension est aussi n */
for (i=0 ; i<n ; i++)
for (j=0 ; j<n ; j++)
{ if (i==j) ad[i][j] = 0 ;
else ad[i][j] = 1 ;
}
free (ad) ;
}
Les appels de diag sont identiques à ceux que nous avons utilisés avec la
démarche précédente section 7.2.1.
Exemple 2 : produit de matrices
void prod_mat ( double *ada, double *adb, double *adc, /* les trois matrices */
int n, int p, int q) /* leurs dimensions */
{
int i, j, k ;
double **a, **b, **c ;
double s ;
/* préparation des tableaux de pointeurs sur les débuts */
/* de lignes de chacune des trois matrices */
a = malloc (n* sizeof (double *)) ;
b = malloc (p* sizeof (double *)) ;
c = malloc (n* sizeof (double *)) ;
for (i=0 ; i<n ; i++)
a[i] = ada + i*p ; /* la deuxième dimension de a est p */
for (i=0 ; i<p ; i++)
b[i] = adb + i*q ; /* la deuxième dimension de b est q */
for (i=0 ; i<n ; i++)
c[i] = adc + i*q ; /* la deuxième dimension de c est q */
/* calcul du produit de matrices de façon usuelle */
for (i=0 ; i<n ; i++)
for (j=0 ; j<q ; j++)
{ s = 0 ;
for (k=0 ; k<p ; k++)
s += a[i][k] * b[k][j] ;
c[i][j] = s ;
}
free (a) ; free (b) ; free (c) ;
}
Les appels de prog sont identiques à ceux qui sont utilisés avec la démarche
précédente à la section 7.2.1.
Remarque
Cette seconde méthode peut s’appliquer également à un tableau de dimensions connues, en vue
d’optimiser certains calculs. Par exemple, si t est un argument muet ainsi défini dans l’en-tête d’une
fonction :
…f (int t[10][5])
on peut quand même créer un tableau de pointeur p sur chaque début de ligne et accéder aux éléments
de t par p[i][j] plutôt que par t[i][j]. En effet, on peut alors montrer que les calculs d’adresses
correspondants sont plus rapides (certains calculs ont été effectués une fois pour toutes dans le tableau
p). En contrepartie, il a fallu passer du temps à créer le tableau, de sorte que la méthode n’a d’intérêt
que si les accès aux éléments du tableau sont assez nombreux.
8. Les variables globales
Comme nous l’avons déjà évoqué aux sections 1.3 et 5.2.2, il est possible en C
de définir des variables globales qui sont en théorie accessibles à toutes les
fonctions, que ces dernières soient ou non définies dans le même fichier source.
Il existe cependant un mécanisme permettant d’interdire l’usage d’une variable
globale en dehors du fichier source où elle a été définie. Nous commencerons
par examiner trois exemples représentatifs de l’utilisation des variables
globales :
• accès à une variable globale déclarée dans le même fichier source ;
• accès à une variable globale déclarée dans un autre fichier source – cela nous
amènera à introduire le mot-clé extern ;
• restriction de l’accès à une variable globale au fichier source où elle a été
déclarée – cela nous amènera à introduire le mot-clé static.
Puis, nous étudierons en détail les différents points résumés dans le tableau 8.7 :
Tableau 8.7 : les variables globales
– il est nécessaire de distinguer – exemples section
définition et redéclaration 8.1
– la norme offre beaucoup de – étude détaillée
Déclaration possibilités, par le biais de la section 8.2 et
notion de définition potentielle démarche
conseillée
section 8.2.3
Limitée au fichier source suivant – étude détaillée
sa (première) déclaration section 8.3
Portée – tableau
(compilateur) comparatif pour
toutes les sortes
de variables à la
section 10
Externe par défaut, c’est-à-dire – étude section 8.4
variable accessible aux autres
fichiers source, sauf si on utilise – tableau
Lien static
comparatif pour
toutes les sortes
de variables
section 10
Statique – exemple section
8.1
– discussion
Classe section 8.5
d’allocation – tableau
comparatif pour
toutes les sortes
de variables
section 10
– par défaut à une valeur nulle étude section 8.6
Initialisation – explicitement avec des
expressions constantes
Risques → plus les programmes Voir chapitre 19
Problèmes sont conséquents, plus les
d’utilisation variables globales sont
déconseillées
8.1 Exemples introductifs d’utilisation de variables
globales
8.1.1 Utilisation de variables globales définies dans le même
fichier source
Exemple d’utilisation de variables globales dans le fichier où elles sont
déclarées
int a, b ; /* a et b sont globales */
int main()
{ float poly (float, float) ;
float x1 = 2.5 ;
float y ;
float x2 = 4.25 ;
a = 5.12 ; b = 2.3 ; /* ici, on modifie les valeurs de a et b */
y = poly (x1, x2) ;
}
/* définition de la fonction poly */
float poly (float x, float y)
{ /* on utilise ici, sans les modifier, */
return (a*x*x + b*y*y) ; /* les variables globales a et b */
}
Les variables a et b ont été déclarées en dehors de toute fonction. Elles sont donc
connues de toutes les fonctions dont la définition apparaît ensuite dans le même
fichier source, c’est-à-dire ici main et poly. Dans la fonction main, on affecte des
valeurs à a et b, valeurs qui seront utilisées dans la fonction poly, sans qu’elles
aient été transmises en arguments.
On notera bien que, contrairement à ce qui se produirait si a et b avaient été
transmises en argument à la fonction poly :
• rien n’empêche à la fonction poly de modifier les valeurs de a et b ;
• n’importe quelle autre fonction que poly pourrait utiliser et surtout modifier les
valeurs de a et b ;
• si l’on souhaite faire varier les valeurs de a ou de b, on ne disposera plus de la
notion de paramètre ; par exemple, on devra procéder ainsi :
a = 8.2 ; b = 1.2 ;
z = poly (x3, x4) ;
8.1.2 Utilisation de variables globales définies dans un autre
fichier source
Reprenons l’exemple précédent, en supposant simplement qu’il fasse l’objet de
deux fichiers source, se présentant ainsi :
Exemple incorrect d’utilisation de variables globales déclarées dans un autre
fichier source
Fichier 1
int a, b ; /* a et b sont globales */
int main()
{ float poly (float, float) ;
float x1 = 2.5 ;
float y ;
float x2 = 4.25 ;
a = 5.12 ; b = 2.3 ; /* ici, on modifie les valeurs de a et b */
y = poly (x1, x2) ;
}
Fichier 2
/* definition de la fonction poly */
float poly (float x, float y)
{ /* on utilise ici, sans les modifier, */
return (a*x*x + b*y*y) ; /* les variables globales a et b */
}
Si l’on compile tel quel le deuxième fichier, on obtient un diagnostic de
compilation lié au fait que le compilateur ne connaît pas les identificateurs a et b.
A ce niveau, il ne faut surtout pas se contenter de dupliquer dans le deuxième
fichier la déclaration effectuée dans le premier :
int a, b ;
En effet, cette démarche conduirait à allouer des emplacements différents pour
les variables a et b du premier fichier et pour celles du second fichier. Ce n’est
pas le but recherché (nous verrons à la section 8.4 que cela conduirait
généralement à un diagnostic de l’éditeur de liens). Il est en fait nécessaire, dans
l’un des deux fichiers, de prévenir le compilateur que l’allocation des
emplacements est prise en compte par ailleurs. Pour ce faire, on utilise, en lieu et
place de la classe d’allocation, le mot-clé extern dans l’une des deux déclarations
globales :
extern int a, b ;
En résumé, nos deux fichiers source se présentent ainsi (on pourrait bien sûr ici
inverser les deux déclarations des variables globales a et b) :
Exemple correct d’utilisation de variables globales déclarées dans un autre
fichier source
Fichier 1
int a, b ; /* a et b sont globales */
int main()
{ float poly (float, float) ;
float x1 = 2.5 ;
float y ;
float x2 = 4.25 ;
a = 5.12 ; b = 2.3 ; /* ici, on modifie les valeurs de a et b */
y = poly (x1, x2) ;
}
Fichier 2
extern int a, b ; /* a et b sont globales, mais leur emplacement */
/* est réservé dans un autre fichier source */
/* définition de la fonction poly */
float poly (float x, float y)
{ /* on utilise ici, sans les modifier, */
return (a*x*x + b*y*y) ; /* les variables globales a et b */
}
8.1.3 Variable globale cachée dans un fichier source
L’exemple précédent a montré qu’une variable globale déclarée dans un fichier
source est utilisable dans un autre fichier source, moyennant une déclaration
appropriée (extern). Il est cependant possible de « cacher » une variable dans un
fichier source, c’est-à-dire de la rendre inaccessible à un autre fichier source. Il
suffit d’utiliser comme classe d’allocation le mot-clé static, comme dans cet
exemple :
static int a ;
int main()
{
…..
}
fct()
{
…..
}
Sans la déclaration static, a serait une variable globale « ordinaire ». Avec static,
en revanche, il devient impossible de faire référence à a depuis un autre fichier
source, même en utilisant extern. Qui plus est, si une autre variable globale
nommée a apparaît dans un autre fichier source, aucun problème particulier ne se
posera, dans la mesure où elle n’interférera nullement avec la variable globale a
du premier fichier source.
Remarque
Dans le fichier source dans lequel figure la déclaration static int a, il n’est plus posible d’accéder à
une éventuelle variable globale a, définie dans un autre fichier source.
8.2 Les déclarations des variables globales
La section 8.1 a proposé des exemples correspondants aux trois situations
possibles :
• variable globale utilisée dans le fichier source où elle a été déclarée ;
• variable globale utilisée dans un fichier source différent, ce qui implique une
déclaration supplémentaire, recourant au mot-clé extern ;
• variable globale cachée dans un fichier source.
Cependant, en ce qui concerne les déclarations des variables globales dans
chacune de ces trois situations, nous vous avons proposé celles qui
correspondent à la forme généralement préconisée. Malheureusement, la norme
ANSI est relativement libérale sur ce sujet car elle a surtout cherché à préserver
l’existant, en faisant en sorte de rester compatible avec la plupart des
comportements des anciennes implémentations. Ici, dans un souci
d’exhaustivité, nous examinons les différentes possibilités offertes par la norme.
Leur connaissance ne devrait, en fait, intervenir pour faciliter la mise au point de
programmes dont vous n’avez pas l’entière maîtrise (ce qui se produit
couramment lorsque vous cherchez à réutiliser des codes existants). En
revanche, en ce qui concerne le développement de nouveaux programmes, nous
vous proposerons ensuite un schéma simple, que nous avons d’ailleurs appliqué
dans nos précédents exemples.
Le tableau 8.8 récapitule les différents points, qui seront exposés ensuite en
détail.
Tableau 8.8 : déclaration des variables globales
– définition : elle entraîne la réservation Voir section
d’un emplacement mémoire ; une 8.2.1
seule par programme source
Définition et – redéclaration : fait référence à une
redéclaration variable définie ailleurs (autre fichier
source ou le même) ; on peut en avoir
plusieurs dans un même fichier
source, tant que la variable n’est pas
définie
– déclaration qui, suivant le contexte, Voir sections
Notion de deviendra une définition ou une 8.2.1 et 8.2.2
définition redéclaration
potentielle – on peut en avoir plusieurs dans un
même fichier source
1 - si initialisation → définition – règles
2 - pas d’initialisation + extern → décrites
redéclaration d’une variable définie par section
ailleurs (dans le même fichier source ou 8.2.2
Règles dans un autre)
prévues par 3 - pas d’initialisation et pas de extern – à connaître
la norme
ANSI → définition potentielle qui deviendra surtout pour
une définition si aucune autre définition interpréter
n’apparaît ensuite dans le même fichier des
source programmes
existants
– définition → pas de extern avec ou Voir section
sans initialisation 8.2.3
Schémas – redéclaration → toujours extern,
conseillés jamais d’initialisation
– jamais plusieurs redéclarations dans
un même fichier source
8.2.1 Définition et redéclaration de variable globale
Comme le montre l’exemple de la section 8.1, une déclaration de variable
globale peut, suivant le cas :
• entraîner la réservation d’un emplacement mémoire ; on dit alors que cette
déclaration réalise une « définition » de la variable ;
• faire référence à une variable déjà définie dans un autre fichier source et, donc,
ne pas entraîner d’allocation mémoire ; on dit parfois qu’une telle déclaration
est une « déclaration de référence », c’est-à-dire qu’elle fait référence à une
variable définie par une autre déclaration ; on parle aussi de « redéclaration ».
Comme on peut s’y attendre, une même variable globale ne doit être définie
qu’une seule fois au sein d’un même programme source. Autrement dit,
lorsqu’un programme est formé de la réunion de plusieurs fichiers source, un
seul d’entre eux devra en contenir la définition. On notera que le cas des
variables globales cachées dans un fichier source est une sorte d’exception, dans
la mesure où le même identificateur peut être défini dans un autre fichier source
mais, dans ce cas, il ne représente plus la même variable !
En revanche, la norme autorise la présence de plusieurs redéclarations d’une
même variable globale au sein d’un même fichier source. En pratique, cette
possibilité s’avère plutôt inutile et source de confusions.
Par ailleurs, et contre toute attente, la norme n’a pas imposé deux sortes de
déclarations permettant de distinguer clairement les définitions des
redéclarations, ce qui signifie que le choix dépendra du contexte. Qui plus est, la
norme introduit la notion de « définition potentielle ». Il s’agit d’une déclaration
qui, en fonction des éventuelles autres déclarations figurant dans le même fichier
source, pourra être considérée soit comme une définition (dans le cas où on n’en
trouve pas d’autre dans le même fichier source), soit comme une redéclaration
(anticipée, alors, puisque la définition n’aura été trouvée qu’après !). La section
suivante va préciser exactement les critères de décision prévus par la norme.
8.2.2 Les règles prévues par la norme
Voyons maintenant précisément les règles qui permettent de décider si une
déclaration est une définition ou une redéclaration.
1 - Une déclaration comportant une initialisation constitue toujours une définition.
Ainsi :
int n = 3 ;
constitue une définition de la variable n. Il en va exactement de même, malgré la
présence du mot extern, pour :
extern int n = 3 ;
On notera bien qu’une conséquence indirecte de cette règle est que la présence
du mot extern ne traduit pas nécessairement une redéclaration. Nous verrons
cependant, à la section 8.2.3, quel schéma de déclaration adopter pour que extern
corresponde toujours à une redéclaration, comme c’était le cas dans nos
exemples de la section 8.1.
2 - Une déclaration avec extern, sans initialisation, constitue toujours une redéclaration (donc jamais
une définition) d’une variable pouvant, éventuellement, être définie dans le même fichier source ou
dans un autre.
Comme on s’y attend, la déclaration :
extern int a ;
fait référence à une variable a définie par ailleurs. Lorsque cette déclaration est la
seule déclaration globale concernant la variable a, dans le fichier source
concerné, aucun problème particulier ne se pose puisque, comme on s’y attend,
la variable correspondante est définie dans un autre fichier source. En revanche,
la norme n’interdit nullement qu’une redéclaration se réfère à une variable
définie au sein du même fichier, soit avant, soit après. En voici des exemples :
int a = 2 ; /* définition de la variable globale a */
…..
extern int a ; /* redéclaration de a : ici, référence à */
….. /* la variable déjà définie */
extern int a ; /* autre redéclaration de a */
extern int a ; /* redéclaration de a : référence à une variable définie */
/* " ailleurs ", soit dans ce fichier, soit dans un autre */
…..
int a = 2 ; /* définition de la variable globale a */
….
extern int a ; /* autre redéclaration de a se référant, ici, à une variable */
/* définie précédemment dans ce même fichier */
static int n = 2 ; /* définition de n */
…..
extern int n ; /* redéclaration de n se référant, ici, à une variable définie */
/* précédemment dans ce même fichier */
extern int n ; /* redéclaration de n : référence à une variable définie */
/* " ailleurs ", soit dans ce fichier (ce sera le cas ici) */
/* soit dans un autre */
….. /* ici, une référence à n portera bien sur la variable n de */
/* ce fichier source, telle qu'elle est définie ci-dessous */
static int n = 2 ; /* définition de n */
D’une manière générale, l’emploi de ces possibilités ne peut que contribuer à
obscurcir les programmes et nous la déconseillons vivement.
Remarque
En théorie, il est possible de redéclarer une variable globale à un niveau local :
int main()
{ extern float x ; /* la variable x est définie " ailleurs ", à un niveau global */
/* dans ce fichier (avant ou après) ou dans un autre */
…..
}
Si x n’est pas définie dans le même fichier source, elle n’est accessible que dans la fonction main du
présent fichier source (à moins, bien sûr, qu’on ne la redéclare dans d’autres fonctions). Bien sûr, si x a
déjà été définie, de manière globale, auparavant dans le même fichier source, cette déclaration
n’apporte rien de plus (si ce n’est, éventuellement, pour permettre un futur découpage du fichier
source en plusieurs autres fichiers…).
3 - Une déclaration sans initilisation et sans le mot-clé extern (donc, éventuellement, avec le mot-clé
static) constitue une définition potentielle de cette variable. Plusieurs définitions potentielles d’une
même variable peuvent apparaître. Une définition potentielle deviendra une définition si, à la fin du
fichier source, aucune définition de cette variable n’a été trouvée. Dans le cas contraire, il s’agira
d’une redéclaration.
Ainsi, cet exemple est correct :
int n ; /* définition potentielle de n */
…..
int n ; /* autre définition potentielle de n */
…..
/* pas d'autre déclaration de n --> n est définie dans ce fichier comme si */
/* on avait écrit : int n = 0; */
En revanche, une définition potentielle ne peut pas apparaître pour une variable
déjà définie, ce qui est logique. Cet exemple est incorrect :
int n = 12 ; /* définition de n */
/* n est déjà définie, on ne peut plus la redéclarer */
…..
int n ; /* définition potentielle d'une variable déjà définie -- > erreur */
La notion de redéclaration et celle de déclaration potentielle peuvent cohabiter
de façon curieuse, comme dans les deux exemples suivants :
extern int n ; /* référence à une variable n définie ailleurs */
…..
int n ; /* définition potentielle de n */
…..
int n = 2 ; /* définition de n */
extern int n ; /* référence à une variable n définie ailleurs */
…..
int n ; /* définition potentielle de n */
…..
/* pas d'autre déclaration de n --> n est définie dans ce fichier comme si */
/* on avait écrit : int n = 0; */
Remarque
Les possibilités de redéclaration s’appliquent à une variable globale constante, comme dans :
extern const int n ; /* référence à une globale constante, définie ailleurs */
La définition de n (avec initialisation) pourra se trouver, tout naturellement, dans un autre fichier
source. Mais elle pourra aussi se trouver le même fichier source, comme dans cet exemple :
extern const int n ;
…..
const int n=3 ; /* OK - on peut aussi écrire : extern const int n=3; */
De même, les possibilités de définition potentielle s’appliquent à une variable globale constante. En
voici un exemple :
const int n ; /* OK (en C seulement, pas en C++) */
…..
const int n=3 ;
En C++
En C++, la signification du qualifieur const a été légèrement modifiée de façon que les variables
concernées soient utilisables dans des expressions constantes. En conséquence, la notion de définition
potentielle ne pourra plus s’appliquer à une variable globale constante : le dernier des exemples
précédents sera incorrect en C++.
8.2.3 Schéma naturel préconisé pour les déclarations des variables
globales
Comme on vient de le voir section 8.2, la norme offre beaucoup (trop) de liberté
dans la façon de déclarer les variables globales. La complexité des règles
correspondantes peut alors conduire à des instructions difficiles à interpréter par
le lecteur du programme. En fait, nous allons voir maintenant comment se
limiter à quelques règles simples conduisant à des déclarations relativement
naturelles, telles que celles que nous avons utilisées dans les exemples de la
section 8.1.
Commençons par remarquer que les affirmations suivantes découlent des règles
étudiées précédemment :
• une déclaration comportant une initialisation est toujours une définition ;
• une déclaration sans initialisation comportant extern est toujours une
redéclaration ;
• si une variable globale n’est déclarée qu’une seule fois dans un fichier source,
sans extern et sans initialisation, il s’agit d’une définition.
Dans ces conditions, on voit qu’on peut adopter le schéma suivant pour déclarer
toutes ses variables globales :
Schéma naturel de déclaration des variables globales
Dans les définitions : on n’utilise jamais extern et l’on s’interdit les déclarations multiples au sein du
même fichier (ce qui n’est guère pénalisant). En revanche, on peut ou non placer une initialisation.
Dans les redéclarations : on utilise systématiquement extern et on s’abstient naturellement de toute
initialisation.
Ce schéma est naturel puisque la définition d’une variable globale prend le
même aspect que celle d’une variable locale, tandis que extern sert précisément à
dire que la variable globale est définie ailleurs.
Remarques
1. Une variante possible du schéma précédent consiste à imposer systématiquement l’initialisation des
variables globales lors de leur définition. Il ne s’agit pas d’une contrainte bien importante, d’autant
plus qu’elle peut mettre à l’abri d’une lacune de certains compilateurs qui ont une fâcheuse
tendance à « oublier » l’initialisation par défaut à zéro.
2. On rencontre parfois, notamment dans les fichiers en-tête de certains logiciels, une variante du
schéma naturel qui consiste à utiliser systématiquement le mot extern pour toute variable globale.
La distinction entre définition et déclaration se fait en imposant l’initialisation pour toutes les
définitions. Cette variante n’est en fait rien d’autre que la variante précédente, dans laquelle on
introduit toujours le mot extern.
8.3 Portée des variables globales
Les déclarations de variables globales sont des cas particuliers de déclarations
d’identificateurs globaux et leur portée, c’est-à-dire la partie du fichier source où
elles sont connues du compilateur est la partie du fichier source suivant leur
déclaration. Toutefois, comme nous l’avons vu section 8.2, une même variable
globale peut parfois faire l’objet de plusieurs déclarations au sein du même
fichier source. Dans ce cas, sa portée commence tout naturellement à la première
déclaration, qu’il s’agisse d’une redéclaration ou d’une définition. Voici
quelques exemples :
….. /* ici, n n'est pas connue du compilateur */
int n ; /* déclaration potentielle de n qui devient connue du compilateur */
…..
int n = 3 ; /* bien qu'elle ne soit définie qu'ici */
….. /* ici, a n'est pas connue du compilateur */
extern int a ; /* à partir d'ici, a est connue du compilateur */
….. /* qu'elle soit ou non définie dans ce fichier */
int a = 2 ; /* définition de a */
Rappelons que la notion de portée concerne uniquement le compilateur. Or une
variable globale définie dans un fichier source peut être utilisée depuis un autre
fichier source. Dans ce cas, la compilation étant séparée, la notion de portée ne
s’applique plus. On verra, dans la section suivante, qu’il faut faire intervenir la
notion de lien, laquelle concerne l’éditeur de liens.
Remarque
Comme il a été dit dans la remarque de la section 8.2.2, il est possible de redéclarer une variable
globale à un niveau local. La portée de cette déclaration est alors limitée au bloc correspondant :
void f1 (…..)
{ extern int n ; /* n est une variable globale, définie ailleurs */
….. /* ici, n est accessible */
}
void f2 (…..)
{ ….. /* ici, n n'est pas accessible, sauf si elle a été */
/* définie auparavant, dans le même fichier source */
}
8.4 Variables globales et édition de liens
Bien que la norme ne précise pas qui du compilateur et de l’éditeur de liens est,
en dernier ressort, responsable de l’allocation mémoire correspondant à une
variable globale, il nous semble bon de montrer ici comment les variables
globales sont gérées dans la plupart des implémentations. Cela nous permettra de
concrétiser la notion de lien présentée de façon abstraite par la norme.
Supposons que l’on ait compilé séparément les deux fichiers source suivants :
Fichier 1
int x ; /* définition d'une variable globale nommée xv */
int main ()
{ int a ; /* déclaration d'une variable locale nommée a */
…..
}
Fichier 2
extern int x ; /* redéclaration d'une variable globale nommée x */
void fct ()
{ …..
}
Dans le cas du premier fichier, la déclaration de x étant une définition, on trouve,
dans le module objet résultant de la compilation, des informations permettant
d’associer l’identificateur x à une adresse. Certes, il se peut que cette adresse ne
soit pas encore connue (en général, elle sera définie par l’éditeur de liens) mais il
n’empêche que l’identificateur x subsiste bien dans le module objet. Il n’en va
pas de même pour une variable locale telle que a. On notera bien qu’un
mécanisme similaire existe pour les identificateurs de fonctions définies dans un
fichier source, identificateurs qui doivent bien subsister dans le module objet
afin que l’éditeur de liens soit en mesure d’incorporer le code correspondant et
de résoudre les appels. La norme traduit cela en disant que x est à lien externe.
Cette notion de lien concerne généralement l’éditeur de liens.
Dans le cas du second fichier source, on trouvera, dans le module objet
correspondant, à destination de l’éditeur de liens, une indication concernant le
fait que l’identificateur x correspond à une information provenant de l’extérieur
et que son adresse n’est donc pas connue pour l’instant. Le rôle de l’éditeur de
liens sera de retrouver, dans le premier module objet, l’adresse effective15 de x et
de la reporter dans le deuxième module objet.
En revanche, les choses sont différentes lorsqu’une variable globale est cachée
dans un fichier source, comme dans :
static int a ; /* définition d'une variable globale cachée dans le fichier */
int main()
{…..
}
Dans ce cas, dans le module objet correspondant, il n’est nullement nécessaire
que subsiste une trace quelconque de l’identificateur correspondant, ici a. Certes,
il se peut que l’allocation de l’emplacement mémoire reste à faire par l’éditeur
de liens, mais ceci se fera sans qu’il ait besoin de connaître le véritable nom de
la variable (un mécanisme analogue existe pour l’allocation automatique des
emplacements des variables locales à un bloc ou à une fonction, alors même que
leurs identificateurs ont disparu en fin de compilation).
La norme traduit cela en disant que a est à lien interne, sous-entendu interne au
fichier source. Ici, cependant, si l’on ne cherche pas à savoir qui, du compilateur
et de l’éditeur de liens, est responsable de l’allocation mémoire, cette notion fait
double emploi avec celle de portée. En pratique, on peut généralement ignorer
cette notion de lien interne. D’ailleurs, dans les tableaux comparatifs de la
section 10, nous parlerons d’accès à une variable, ce qui correspondra à la partie
du programme source où cette variable est accessible, moyennant une éventuelle
redéclaration.
Remarque
Le mécanisme de gestion des variables globales montre que s’il est possible, par mégarde, de définir
des variables globales (non cachées) de même nom dans deux fichiers source différents et de les
compiler sans erreur, il sera, en général, impossible d’effectuer correctement l’édition de liens des
modules objet correspondants. En effet, dans ce cas, l’éditeur de liens se trouvera en présence de deux
adresses différentes pour un même symbole et il ne saura pas laquelle choisir. En toute rigueur, la
norme n’impose cependant pas la détection de cette situation qui, en pratique, conduit à un diagnostic
d’erreur lors de l’édition de liens.
8.5 Les variables globales sont de classe statique
Par sa nature même, une variable globale est de classe d’allocation statique, ce
qui signifie que son emplacement est alloué une fois pour toutes avant le début
de l’exécution du programme et qu’il subsiste jusqu’à la fin de l’exécution.
Une telle variable est dite « rémanente », ce qui signifie qu’elle conserve sa
dernière valeur jusqu’à une nouvelle éventuelle modification. Ceci est vrai que
cette modification soit provoquée par des instructions du programme ou d’une
autre manière (dans le cas des variables volatiles).
On notera bien que cela concerne toutes les variables globales, qu’elles soient
cachées ou non dans un fichier source. La différence entre ces deux sortes de
variables globales n’affecte nullement leur classe d’allocation, mais seulement
leur lien.
Remarque
Souvent, le mot-clé utilisé au niveau de la classe de mémorisation d’une déclaration correspond à la
classe d’allocation de la variable. Ce n’est toutefois pas une règle générale. Notamment, on peut
constater que, si une variable globale déclarée avec static est effectivement de classe statique, il n’est
pas nécessaire que static soit présent pour qu’une variable globale soit de classe statique.
8.6 Initialisation des variables globales
Une variable globale peut être initialisée au moment de sa définition, mais cela
n’est pas obligatoire. En cas d’absence d’initialisation explicite, il est prévu une
initialisation par défaut que nous examinerons avant d’étudier les possibilités
d’initialisation explicites. Signalons que la section 10 propose un tableau
récapitulatif sur tout ce qui concerne l’initialisation des différentes variables.
8.6.1 Initialisation par défaut des variables globales à une valeur
nulle
En l’absence d’initialisation explicite, une variable globale, est, comme toute
variable de classe statique, initialisée à une valeur nulle. Une telle valeur ne doit
pas être systématiquement assimilée à une mise à zéro de tous les octets de la
variable, comme le montre le tableau 8.9 :
Tableau 8.9 : initialisation par défaut des variables de classe statique
Type Valeur d’initialisation
Entiers Entier nul → tous les octets sont à zéro, quelle que soit
(caractères l’implémentation concernée.
compris)
Flottants Flottant nul → en général, tous les octets ne sont pas à zéro.
Pointeur Valeur prédéfinie NULL (voir section 5 du chapitre 7), dépend
de l’implémentation.
Agrégats Valeur nulle pour chacun des constituants élémentaires. Le
processus est récursif. Dans le cas des unions, seul le
premier champ est initialisé à une valeur nulle.
Certains (rares) compilateurs peuvent parfois omettre cette initialisation par
défaut. Quoi qu’il en soit, il est généralement conseillé de toujours procéder à
une initialisation explicite des variables.
8.6.2 Initialisation explicite des variables globales
Les variables globales peuvent être initialisées au moment de leur déclaration
comme dans :
int n = 5 ;
float t[4] = { 3.5, 2.8, 1.25, 5 } ;
Comme ces variables sont toujours de classe d’allocation statique, les valeurs
servant éventuellement à les initialiser doivent obligatoirement être connues
avant l’exécution du programme, donc à la compilation. Il est donc logique que
la norme impose à ces valeurs d’être des expressions constantes. Cette règle
concerne aussi bien les variables de type scalaire (numérique ou pointeur) que
les agrégats ; dans ces derniers cas, l’initialiseur fait intervenir une ou plusieurs
paires d’accolades16 avec, au bout du compte, à l’intérieur, des valeurs de type
scalaire qui doivent donc, elles aussi être des expressions constantes.
Cependant, la norme fait preuve de quelque tolérance dans la mesure où les
expressions constantes en question peuvent être d’un type différent de celui
attendu, pour peu que ce dernier soit acceptable par affectation :
int n = 5.4 ; /* OK mais n sera initialisé avec la valeur 5 */
float x = 3 ; /* OK */
int t[4] = { 3.5, 2.8, 1.25, 5 } ; /* comme avec : { 3, 2, 1, 5 } */
Les expressions constantes scalaires sont décrites en détail à la section 14 du
chapitre 4 et classées en expressions constantes entières, numériques et adresses.
Ici, il s’agit des expressions constantes :
• numériques pour tous les types de base, puisque tout type numérique peut être
affecté à un élément d’un type numérique quelconque ;
• pointeur pour les variables de ce type. On notera que, dans ce dernier cas, il
n’existe que peu de conversions légales. Si nécessaire, l’opérateur de cast peut
être utilisé.
Le tableau 8.10 récapitule ces différentes possibilités d’initialisation et il indique
à quel endroit de l’ouvrage est décrite la syntaxe de l’initialiseur correspondant,
sachant que celle-ci ne dépend pas de la classe d’allocation (seules les
contraintes pesant sur les expressions d’initialisation en dépendant). Ce tableau
est valable non seulement pour les variables globales, mais pour toutes les
variables de classe statique, donc en particulier pour les variables locales de
classe statique.
Tableau 8.10 : initialisation des variables de classe statique
Type Expression utilisable
Expression constante d’un type acceptable par affectation à
Scalaire
la variable.
Initialiseur de la forme { … } présenté section 6 du chapitre
11, dont les éléments terminaux sont des expressions
Structure
constantes d’un type acceptable par affectation aux
différents champs concernés.
Initialiseur de la forme { … } présenté section 6 du chapitre
11, contenant :
Union – soit une valeur d’un type acceptable par affectation au
premier champ si celui-ci est scalaire ;
– soit un initialiseur convenable pour le premier champ si
celui-ci n’est pas scalaire.
Initialiseur de la forme { … } présenté à la section 6 du
chapitre 6, dont les éléments terminaux sont des expressions
Tableau
constantes d’un type acceptable par affectation aux
différents éléments.
Remarque
Les variables déclarées avec le qualifieur const ne peuvent pas apparaître en C dans une expression
constante. Ainsi, à un niveau global, il n’est pas possible d’écrire :
const int n = 5 ; /* déclaration supposée faite à un niveau
global */
int p = n ; /* interdit si p est globale : n n'est pas une expression
constante */
Les mêmes problèmes vont apparaître a fortiori avec des structures :
struct point { int x, y ; } ;
const struct point pc = {5, 3} ; /* supposée à un niveau global */
struct point p1 = pc ; /* interdit ; pc n'est pas une expression constante */
Bien entendu, dans les deux cas, on pourra recourir à la directive #define :
#define N 5
#define pc {5, 3}
int p = N ; /* ici N est remplacé par 5 avant compilation */
struct point p1 = pc /* ici pc est remplacé par {5, 3} avant compilation */
9. Les variables locales
Toute variable déclarée à l’intérieur d’un bloc est dite « locale » à ce bloc.
Lorsque le bloc en question correspond à la définition d’une fonction, on dit que
la variable est locale à la fonction. En toute rigueur, il faut exclure de cette
définition d’une variable locale, toute variable globale redéclarée par extern dans
un bloc ou une fonction (voir la remarque de la section 8.2.2). En revanche, les
arguments muets de la définition d’une fonction peuvent être assimilés aux
variables locales.
Tableau 8.11 : les variables locales à un bloc ou à une fonction
La partie du bloc suivant sa déclaration. Voir
Portée
section 9.1
– automatique par défaut (en général, Voir
mécanisme de pile) : la classe de section 9.2
Classe mémorisation auto est superflue, mais
d’allocation permise ;
– peut être statique (static) ou registre
(register).
– pas d’initialisation par défaut ; Voir
section 9.2
– initialisation explicite possible, mais
Initialisation avec des restrictions sur les expressions
utilisées ;
– effectuée à chaque entrée dans la
fonction, sauf pour la classe statique.
9.1 La portée des variables locales
9.1.1 Règles générales
La portée d’une variable locale s’étend depuis sa déclaration jusqu’à la fin du
bloc où elle est déclarée :
{ ….. /* ici, n n'est pas connue */
int n ;
….. /* ici, n est connue, y compris dans d'éventuelles déclarations */
/* telles que int p = 2 * n; */
}
Une variable locale masque obligatoirement une variable de même nom située
dans une portée plus englobante. Dans le cas de variables locales à un bloc, elles
peuvent donc masquer des variables locales à un bloc englobant ou à la fonction
contenant ce bloc. Dans le cas de variables locales à une fonction, elles ne
peuvent masquer que des variables globales. Voici un exemple :
int n = 1 ; /* définition d'une variable globale n */
extern float x ; /* redéclaration d'une variable globale x */
int main()
{ int p ; /* déclaration d'une variable locale */
int x ; /* déclaration d'une variable locale */
….. /* ici n se réfère à la variable 310 locale à main */
{ long p ;
double n ;
….. /* ici p et n se réfèrent aux variables locales au bloc */
/* tandis que x se réfère toujours à la locale à main */
}
….. /* ici p se réfère à la variable locale à la fonction */
/* tandis que n se réfère à la variable globale */
/* et que x se réfère à la variable locale à main */
}
9.1.2 Cas des arguments muets
Les arguments muets d’une fonction sont, sur ce plan, considérés comme des
variables locales au bloc correspondant à la définition de la fonction. Une
variable locale à une fonction ne peut donc pas porter le même nom qu’un
argument car il s’agirait alors d’une redéclaration, laquelle est interdite dans le
cas de variables locales. En revanche, une variable locale à un bloc peut masquer
un argument et un argument peut masquer une variable globale. Voyez cet
exemple :
int n = 1 ; /* définition d'une variable globale n */
void fct (int n, float y)
{ ….. /* ici n se réfère à l'argument de fct : une déclaration */
/* d'une variable locale n est interdite à ce niveau */
{ double n ; /* ici, n se réfère à la variable locale au bloc */
/* qui masque alors l'argument muet n de fct */
}
}
9.1.3 Cas particuliers
En toute rigueur, la portée d’une variable débute à la fin de sa déclaration, pas
avant17, ce qui paraît en général naturel. Cette particularité peut conduire à des
situations assez peu ordinaires qui, bien que théoriquement légales, sont
fortement déconseillées. En voici un premier exemple :
float x=3 ; /* définition d'une variable globale initialisée à 3 */
int main()
{ float y = 2*x ; /* la variable locale y est initialisée en utilisant */
/* la valeur de la globale x, encore accessible ici */
int x=1 ; /* ici, on déclare une variable locale x */
….. /* qui, à partir d'ici masque la variable globale x */
printf ("%d %f", x, y) ; /* affiche bien 1 et 6.000000 */
…..
}
L’exemple suivant est encore plus surprenant et encore plus déconseillé, d’autant
qu’il ne fonctionne pas dans toutes les implémentations (certains compilateurs
ayant en fait du mal à suivre la norme à la lettre, dans un tel cas) :
long n=3
int main()
{ int n = 2*n ; /* la portée de la locale n ne débute qu'à la fin de
sa */
/* déclaration : le n de l'initialiseur est la variable
globale */
printf ("%d", n) ; /* doit afficher 6, si la norme est
respectée ! */
}
9.2 Classe d’allocation et initialisation des variables
locales
La classe d’allocation d’une variable locale définit la manière dont est géré et
éventuellement initialisé l’emplacement mémoire correspondant. Suivant la
manière dont elle est déclarée, il peut s’agir d’une classe d’allocation
automatique ou statique. En outre, il existe des possibilités restreintes
d’utilisation d’une classe dite « registre ».
9.2.1 Par défaut, les variables locales sont de classe automatique
Cela signifie que l’emplacement correspondant est attribué au moment de
l’entrée dans le bloc (ou la fonction) et qu’il est supprimé lors de la sortie de ce
bloc (ou de cette fonction). Une variable de classe automatique n’est donc pas
rémanente puisqu’on n’est pas sûr d’y retrouver, lors d’une nouvelle entrée dans
le bloc ou la fonction, la valeur qu’elle possédait à la précédente sortie de ce bloc
ou de cette fonction.
On notera que, même si la norme n’impose pas de mécanisme particulier pour la
gestion des variables automatiques, la plupart des implémentations utilisent un
mécanisme de pile LIFO18. Ce point peut s’avérer important en cas de recherche
d’erreurs de programmation. D’autre part, certaines implémentations limitent la
taille de cette pile à une valeur nettement inférieure à la taille mémoire
disponible, ce qui peut créer d’importantes contraintes de programmation. Il ne
faut pas oublier, en effet, que les variables déclarées dans main sont des variables
locales comme les autres, même si leur durée de vie est plus importante. Dans
certains cas, la seule possibilité de s’affranchir de ces contraintes consiste à
recourir aux variables globales puisque celles-ci ne sont pas allouées sur la pile !
9.2.2 Initialisation des variables automatiques
Une variable de classe automatique ne reçoit aucune valeur initiale par défaut.
Elle peut être initialisée lors de sa déclaration. Dans ce cas, l’initialisation est
effectuée à chaque entrée dans le bloc ou la fonction. Contrairement à ce qui
produisait pour les variables globales, il ne semble alors plus nécessaire que
l’expression utilisée pour l’initialisation soit constante, mais seulement
calculable au moment de l’entrée dans le bloc ou la fonction. C’est bien ce que la
norme a prévu pour les variables scalaires (numériques ou pointeur) et dans ce
cas, comme dans le cas des variables globales, l’expression en question peut être
d’un type différent de celui de la variable, pour peu qu’il soit acceptable par
affectation :
int g_nb ; /* définition d'une variable globale */
float g_abs = 5.25 ; /* définition d'une variable globale */
int main()
{ int p = g_nb + 5 ; /* initialisation de la variable locale p avec une */
/* expression de même type */
int n = p + g_abs ; /* initialisation de la variable locale entière n */
/* avec une expression de type float (dont la valeur */
/* sera convertie en int) */
…..
}
void fct(int r)
{ int q = g_nb + 2 * r ; /* initialisation de la variable locale q avec une */
/* expression de même type */
…..
}
Pour les agrégats, il existe une syntaxe particulière d’initialiseur (décrite en
détail dans les chapitres correspondants), utilisant des accolades19, qui permet
d’en fournir, de manière récursive, les éléments terminaux. Dans ce cas, la
norme impose, de manière quelque peu inattendue, qu’il s’agisse d’expressions
constantes d’un type acceptable par affectation. On retrouve alors les mêmes
restrictions que pour les variables globales :
struct point { int x, y ; } ; /* définition d'un type structure point */
int g_nb ; /* définition d'une variable globale */
void fct(int r, int p)
{ struct point p1 = { r, r } ; /* interdit */
struct point p2 = {g_nb, 5 } ; /* interdit */
int t [4] = {2, r, r+p, r-p} ; /* interdit */
}
Néanmoins, il est possible d’initialiser une structure ou une union locale, comme
une variable locale scalaire, avec une expression d’un type compatible par
affectation, c’est-à-dire, en fait, du même type structure ou union :
#define ABS 2
struct point { int x, y ; } ; /* définition d'un type structure point */
struct point fp (void) ; /* fonction renvoyant un résultat de type point */
void fct(struct point p, struct point * adp)
{ struct point p1 = p ; /* initialisation de la variable locale p1 avec une */
/* structure (variable) de même type */
struct point p2 = *adp ; /* initialisation de la variable locale p2 avec une */
/* expression structure (variable) de même type */
struct point p3 = fp() ; /* initialisation de la variable locale p3 avec une */
/* expression structure (variable) de même type */
struct point org = { ABS+2, 5 } ; /* initialisation d'une structure locale */
/* avec des expressions constantes */
}
Le tableau 8.12 récapitule ces différentes possibilités d’initialisation et il indique
à quel endroit de l’ouvrage est décrite la syntaxe de l’initialiseur correspondant,
sachant que celle-ci ne dépend pas de la classe d’allocation (seules les
contraintes pesant sur les expressions d’initialisation en dépendent). Ce tableau
est prévu non seulement pour les variables de classe automatique, mais pour les
variables de classe registre.
Tableau 8.12 : initialisation des variables locales automatiques (ou registre)
Type Expression utilisable
Expression quelconque d’un type acceptable par affectation à
Scalaire
la variable.
– expression quelconque du même type ou ;
– initialiseur de la forme { … } présenté à la section 6 du
Structure chapitre 11, dont les éléments terminaux sont des
expressions constantes d’un type acceptable par affectation
aux différents champs.
– expression quelconque de même type ou ;
– initialiseur de la forme { … } présenté à la section 6 du
Union chapitre 11, contenant soit une valeur d’un type acceptable
par affectation au premier champ si celui-ci est scalaire, soit
un initialiseur convenable pour le premier champ si celui-ci
n’est pas scalaire.
Initialiseur de la forme { … } présenté à la section 6 du chapitre
Tableau 6, dont les éléments terminaux sont des expressions
constantes d’un type acceptable par affectation.
9.2.3 Variable locale de classe statique
Il est possible de demander qu’une variable locale à une fonction ou à un bloc
soit de classe d’allocation statique. Dans ce cas, elle dispose d’un emplacement
permanent et sa valeur se conserve d’un appel au suivant. Il suffit pour cela
d’utiliser dans sa déclaration le mot-clé static en lieu et place de la classe de
mémorisation.
Comme on peut s’y attendre, une telle variable joue un rôle analogue à une
variable globale. En particulier :
• les variables locales statiques sont, par défaut, initialisées à la valeur nulle (voir
section 8.6.1) ;
• les variables locales statiques peuvent être initialisées explicitement, mais
obligatoirement à l’aide d’expressions constantes, exactement comme les
variables globales (voir section 8.6.2)
La seule différence entre la variable locale statique et la variable globale réside
dans sa portée, laquelle se trouve limitée au bloc ou à la fonction concernée.
Exemples
1 - Comptage des appels d’une fonction
La section 3.3 du chapitre 5 donne un exemple d’utilisation de variable statique
pour compter le nombre de passages dans un bloc. Voici le même exemple
transposé au comptage du nombre de passages dans une fonction :
Exemple d’utilisation de variable locale statique pour compter les appels d’une
fonction
#include <stdio.h>
int main()
{
void fct(void) ;
int n ; appel numero : 1
for ( n=1 ; n<=5 ; n++) appel numero : 2
fct() ; appel numero : 3
} appel numero : 4
void fct(void) appel numero : 5
{
static int i ;
i++ ;
printf ("appel numero : %d\n", i) ;
}
La variable locale i a été déclarée de classe statique. On constate bien que sa
valeur progresse de un à chaque appel. De plus, on note qu’au premier appel sa
valeur est nulle.
2 - Fonction ayant un comportement particulier au premier appel
Voici un autre exemple dans lequel on utilise une variable locale statique pour
déceler le premier appel d’une fonction, en vue, par exemple, d’effectuer une
préparation particulière (initialisations, ouverture de fichier…) :
void fct (…)
{ static int pemier_appel = 1 ;
…..
if (premier_appel)
{ /* traitement particulier au premier appel */
premier_appel = 0 ;
}
/* traitement usuel */
}
Remarques
1. On ne confondra pas une variable locale de classe statique avec une variable globale de classe
statique. En effet, malgré l’emploi du même mot-clé, la portée de la première reste limitée au bloc
ou à la fonction où elle a été déclarée. En particulier, si dans le premier de nos exemples, nous
définissions une variable globale i, celle-ci n’aurait aucun lien avec notre variable statique i dans
fct (qui, d’ailleurs, la masquerait).
2. On notera bien la différence entre ces deux situations :
{ int n = 3 ; { static int n = 3 ;
….. …..
} }
Dans le premier cas, la variable n n’occupe de la place en mémoire automatique (en général sur la
pile) que pendant les appels de la fonction. Il faut un peu de temps pour l’initialiser à chaque entrée
dans le bloc. Dans le second cas, la variable n occupe de la place en mémoire statique, donc en
dehors de la pile, de façon permanente. En revanche, on ne perd plus de temps à l’initialiser à
chaque entrée dans la fonction.
9.2.4 Les variables locales de classe registre
Il existe une classe un peu particulière, la classe registre. Toute variable entrant a
priori dans la classe automatique peut être déclarée avec register comme mot-clé
correspondant à la classe d’allocation. Cela concerne donc les variables locales
n’ayant pas reçu l’attribut static, ainsi que les arguments muets. Voici quelques
exemples :
int main()
{ int a ; /* une variable locale de classe automatique */
register int n ; /* une variable locale de classe registre */
…..
{ int i ; /* une variable locale au bloc, de classe automatique */
register float x ; /* une variable locale au bloc, de classe registre */
…..
}
}
void f(float x, register int n) /* x est un argument muet de classe auto */
{ ….. } /* n est un argument muet de classe registre */
Dans ce cas, on demande au compilateur d’utiliser, dans la mesure du possible,
un registre de la machine pour y ranger la variable en question. En général,
l’objectif est d’aboutir à un gain de temps en plaçant dans un registre une
variable fortement sollicitée.
On notera bien que, par sa nature même :
Une variable registre ne possède pas d’adresse : on ne peut plus lui appliquer l’opérateur &.
En pratique, de nombreux éléments rendent l’emploi d’une telle possibilité fort
aléatoire :
• Tout d’abord, il ne s’agit que d’une demande effectuée au compilateur. Rien ne
vous assure qu’il pourra la satisfaire. En outre, elle peut être satisfaite sur une
machine et pas sur une autre. Il ne faut pas oublier, en effet, que le nombre de
registres varie d’une machine à une autre, que sur certaines machines, il existe
des registres spécialisés, c’est-à-dire affectés à un rôle précis (indexation,
indirection, adresse de pile…).
• Même si la demande aboutit, on n’est pas pour autant assuré de gagner du
temps. En effet, rien n’empêche un compilateur un peu maladroit de satisfaire
à tout prix la demande, en utilisant un registre déjà sollicité, par exemple pour
un compteur de boucle…
• On ne dispose d’aucun moyen direct permettant de savoir à coup sûr si la
demande a été satisfaite.
Remarque
Comme l’indiquent la section 2.5.2 du chapitre 6 et la section 2.2.2 du chapitre 11, la classe registre
est généralement déconseillée pour les agrégats, à moins qu’ils ne soient de très petite taille (quelques
caractères).
9.2.5 Le mot-clé auto
Le mot-clé auto peut être utilisé pour indiquer une classe d’allocation
automatique. Cela revient à dire qu’on peut toujours l’ajouter à une déclaration
de variable locale usuelle, c’est-à-dire ne comportant pas l’un des mots-clés
static ou register. Comme on s’en doute, il n’apporte alors aucune information
supplémentaire.
int main()
{ auto int p ; /* totalement équivalent à int p; */
…..
}
En théorie, on peut également appliquer ce mot-clé aux arguments muets, mais
certains compilateurs ont tendance à le refuser dans ce cas.
10. Tableau récapitulatif : portée, accès et classe
d’allocation des variables
Voici un récapitulatif indiquant, pour les différents genres de variables que l’on
peut rencontrer, quels sont la portée, la classe d’allocation et l’accès
correspondants. Notez que la notion d’accès correspond à la partie du
programme source dans laquelle la variable est accessible, indépendamment de
la manière dont il peut être éventuellement nécessaire de la déclarer ou de la
redéclarer. Cette notion regroupe donc en fait les notions de portée et de lien,
cette dernière n’intervenant, de toute façon, que pour les variables globales. De
même, la distinction entre définition et déclaration (deuxième colonne du
tableau) n’a aucune raison d’être pour les variables locales.
Tableau 8.13 : genre, portée et classe d’allocation des variables
11. Pointeurs sur des fonctions
Le langage C permet de définir des variables pointeur destinées à recevoir une
adresse de fonction. Cela présente un intérêt manifeste dans deux situations :
• Pour paramétrer l’appel d’une fonction, c’est-à-dire pour permettre à une
instruction donnée d’appeler une fonction choisie au moment de l’exécution de
cette instruction. Cela signifie que la fonction effectivement appelée peut
varier d’une fois à l’[Link] parvenir à un tel résultat, on déclarera une
variable d’un type pointeur approprié.
• Pour transmettre une fonction en argument d’une autre fonction et donc, là
aussi, paramétrer le travail de la fonction appelée.
Nous allons étudier en détail les différentes manières de déclarer des pointeurs
sur des fonctions et les diverses façons de les utiliser, d’abord dans une
affectation, ensuite pour l’appel de la fonction concernée. Cela nous permettra de
fournir deux exemples correspondant aux deux situations évoquées. Nous
examinerons ensuite succinctement les possibilités d’intérêt très limité que sont
les comparaisons et les conversions.
Le tableau 8.14 récapitule les différents points qui seront ensuite examinés en
détail.
Tableau 8.14 : les pointeurs sur des fonctions
– paramétrer l’appel d’une fonction ; Voir
sections
Usage – transmettre une fonction en argument 11.4 et
d’une autre fonction. 11.5
Comme pour les déclarations de fonctions, Voir
deux formes possibles : section
11.1
Déclaration – complète : type arguments et valeur de
retour (conseillée) ;
– partielle : type valeur de retour seulement
(déconseillée).
On peut lui affecter : Voir
section
Affectation à – la valeur d’une autre variable ;
une – l’adresse d’une fonction. 11.2
variable de Les types doivent être compatibles, au sens
type pointeur de la redéclaration des fonctions (étudiée
section 4.4).
– si adf est un pointeur sur une fonction, Voir
l’appel peut se faire indifféremment par section
Appel d’une 11.3
(*adf) (…) ou adf (…) ;
fonction par le
biais d’un – les arguments sont convertis selon les
pointeur règles relatives aux appels usuels de
fonctions (suivant la façon dont le pointeur
est déclaré).
Possibles par == ou != pour des types Voir
Comparaisons compatibles au sens de la redéclaration des section
fonctions (étudiée section 4.4). 11.6
– possibles par cast ; Voir
section
Conversions – toutes les conversions sont légales → 11.7
risques d’erreurs retardées, lors de l’appel
ultérieur.
11.1 Déclaration d’une variable pointeur sur une
fonction
Rappelons qu’en C, une déclaration de type d’une variable est toujours formée
par l’association d’un déclarateur à un spécificateur de type. En composant de
façon appropriée un déclarateur de pointeur et un déclarateur de fonction, il est
possible de déclarer un pointeur sur une fonction. De même qu’il existe deux
façons de déclarer une fonction (déclaration complète sous forme d’un prototype
ou déclaration partielle sans le type des arguments), il existe deux façons de
déclarer un pointeur sur une fonction. Bien que la première soit vivement
conseillée, nous examinerons les deux à partir d’un même exemple.
Rappelons que pour tenir compte de la complexité et des dépendances mutuelles
intervenant dans les déclarations, nous avons prévu un chapitre séparé (chapitre
16) qui fait le point sur leur syntaxe, leur interprétation et la manière de les
rédiger.
11.1.1 Déclaration complète
Avec cette déclaration :
int (*adf) (double, int) ; /* adf pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
on spécifie que :
• (*adf) (double, int) est un int ;
• (*adf) est une fonction recevant en argument un double et un int et renvoyant un
int (interprétation d’un déclarateur de fonction) ;
• *adf est une fonction recevant en argument un double et un int et renvoyant un
int (suppression des parenthèses) ;
• adf est un pointeur sur une fonction recevant en argument un double et un int et
renvoyant un int (interprétation d’un déclarateur de pointeur).
On notera que les parenthèses autour de *adf sont indispensables. En effet, cette
déclaration :
int *adf2 (double, int) ;
s’interpréterait ainsi :
• *adf2 (double, int) est un int ;
• adf2 (double, int) est un pointeur sur un int (à ce niveau, on a apparemment le
choix entre l’interprétation d’un déclarateur de pointeur et celle d’un
déclarateur de fonction ; dans ce cas, il existe une règle donnant la priorité au
premier) ;
• adf2 est une fonction recevant en argument un double et un int et renvoyant un
pointeur sur un int.
Remarque
Il est possible, dans de telles déclarations, de placer des noms d’arguments comme on peut le faire
dans les prototypes :
int (*adf) (double x, int n) ;
11.1.2 Déclaration partielle
En théorie, la norme ANSI accepte que le déclarateur de fonction ne comporte
pas la liste des types des arguments, ce qui nous conduit à une autre déclaration
du pointeur adf précédent :
int (*adf) () ; /* adf pointe sur une fonction renvoyant un int */
Cette forme est cependant fortement déconseillée, dans la mesure où elle pourra
conduire à appeler, par le biais du pointeur adf, une fonction ayant des arguments
de type quelconque, pour peu qu’elle renvoie bien un int. Les risques encourus
sont alors les mêmes que ceux qui se manifestent lorsqu’on appelle une fonction
avec des arguments effectifs dont le nombre ou les types ne correspondent pas
avec ceux des arguments muets. Ces risques sont étudiés section 4.8.
11.2 Affectation de valeurs à une variable pointeur sur
une fonction
Il faut savoir que :
• Un identificateur de fonction, employé seul, est converti par le compilateur en
un pointeur sur cette fonction. Son type est défini par la valeur de retour de la
fonction, et éventuellement le type des arguments lorsque ceux-ci sont connus.
• Une variable de type pointeur sur une fonction peut se voir affecter une valeur
d’un type correspondant. Cette notion de correspondance de type prend ici
différents aspects suivant la connaissance qu’a ou n’a pas le compilateur des
types des arguments.
Examinons tout d’abord la situation, au demeurant fortement conseillée, où à la
fois les fonctions et les pointeurs sont déclarés de façon complète.
11.2.1 Cas où toutes les déclarations sont complètes
Soit ces déclarations :
int f1 (double, int) ; /* déclaration complète */
int f2 (float) ; /* déclaration complète */
double f3 (void) ; /* déclaration complète */
…..
int (*adf1) (double, int) ; /* adf1 pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
int (*adf2) (double, int) ; /* adf2 pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
float (*adf3) (double) ; /* adf3 pointe sur une fonction à un argument */
/* de type double et renvoyant un float */
Ces affectations sont correctes :
adf1 = f1 ;
adf1 = adf2 ;
Celles-ci, en revanche, seront rejetées20 :
adf1 = f2 ; /* incorrect : les types de adf1 et de f2 ne correspondent pas : */
/* les types des valeurs de retour sont les mêmes, */
/* mais pas ceux des arguments */
adf1 = f3 ; /* incorrect : les types de adf1 et de f3 ne correspondent pas : */
/* les types des valeurs de retour sont différents, */
/* ainsi que ceux des arguments */
adf1 = adf3 /* incorrect : les types de adf1 et de adf3 ne correspondent pas */
Les règles de compatibilité de types sont ici les mêmes que celles intervenant
dans les redéclarations de fonctions (voir section 4.4). Rappelons que certaines
implémentations s’avèrent quelque peu laxistes sur ce plan.
11.2.2 Cas où certaines déclarations sont incomplètes
Lorsque tout ou partie des déclarations sont incomplètes, de nombreuses
affectations deviennent acceptables, tout en comportant des risques potentiels
lors de l’appel des fonctions pointées. D’une manière générale, le compilateur ne
peut effectuer de contrôle que sur les informations dont il dispose. Dès lors que
l’un des deux membres d’une affectation n’est pas déclaré sous forme complète,
le contrôle ne peut plus porter que sur le type de la valeur de retour.
Considérons ces déclarations :
int f1 (double, int) ; /* déclaration complète */
int f2 (float) ; /* déclaration complète */
double f3 (void) ; /* déclaration complète */
int f4 () ; /* déclaration partielle */
void f5 () ; /* déclaration partielle */
…..
int (*adf1) (double, int) ; /* adf1 pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
int (*adf2) () ; /* adf2 pointe sur une fonction dont le type des */
/* arguments n'est pas précisé et renvoyant un int */
int (*adf3) () ; /* adf3 pointe sur une fonction dont le type des */
/* arguments n'est pas précisé et renvoyant un int */
Ces affectations sont théoriquement correctes21 :
adf1 = f1 ; /* correct et sans problème (y compris en C++) */
adf1 = f4 ; /* correct, mais la fonction pointée devra être appelée avec */
/* des arguments numériques qui seront convertis en double et int */
/* rien ne prouve que f4 attend un double et un int */
adf2 = f2 ; /* correct, mais rien n'interdira d'appeler la fonction pointée */
/* par adf2, avec des arguments de type quelconque */
adf2 = f4 ; /* même remarque que précédemment */
adf1 = adf2 ; /* correct, mais rien ne prouve que la fonction pointée par adf2 */
/* attend des arguments du type correspondant à adf1 */
adf1 = adf3 ; /* correct, mais rien ne prouve que la fonction pointée par adf3 */
/* attend des arguments du type correspondant à adf1 */
En revanche, ces affectations seront rejetées22 par le compilateur :
adf1 = f5 ; /* incorrect : les types des valeurs de retour de adf1 et de f3 */
/* ne correspondent pas */
adf2 = f3 ; /* idem */
adf2 = f5 ; /* idem */
On voit que beaucoup d’affectations comportent des risques d’erreurs à
retardement : lors de l’appel de la fonction pointée, on pourra fort bien
transmettre des arguments ne correspondant pas à ce qu’attend la fonction
pointée. On retrouvera les mêmes risques que ceux évoqués section 4.8.
Remarque
L’opérateur & appliqué à un identificateur de fonction fournit directement son adresse. Autrement dit,
&f1 peut être utilisé à la place de l’identificateur f1 lorsque ce dernier est employé seul. Par exemple,
ces deux affectations sont équivalentes :
adf = f1 ;
adf = &f1 ;
En revanche, cet opérateur ne peut pas être utilisé dans un appel de fonction :
&f1 (5.25, 3) ; /* incorrect */
En C++
Comme on peut s’y attendre, les prototypes étant obligatoires en C++, il en va de même pour les types
des arguments dans la déclaration d’un pointeur sur une fonction. De toutes les affectations
précédentes correctes en C, seule la première (adf1 = f1) sera acceptée en C++.
11.3 Appel d’une fonction par le biais d’un pointeur
Comme nous l’avons indiqué en introduction, l’intérêt de la définition d’un
pointeur sur une fonction réside dans la possibilité d’appeler la fonction pointée.
Voyons précisément :
• quelles sont les deux formes permises par le C pour appeler la fonction
pointée ;
• la manière dont la déclaration du pointeur est exploitée par le compilateur, ce
qui mettra une fois de plus en évidence l’intérêt de la déclaration complète.
11.3.1 Appel d’une fonction pointée
Quelle que soit la façon dont le pointeur adf a été déclaré :
int (*adf) (double, int) ; /* adf pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
int (*adf) () ; /* adf pointe sur une fonction renvoyant un int */
il existe deux façons d’appeler la fonction pointée par adf (bien sûr, si l’on a
affaire à la deuxième déclaration, on suppose que la fonction pointée par adf
attend bien deux arguments de type double et int) :
(*adf) (5.35, 4) ; /* appel de la fonction pointée par adf */
adf (5.35, 4) ; /* appel de la fonction pointée par adf */
Le premier appel s’interprète logiquement, dans la mesure où *adf représente
bien la fonction pointée par adf. On retrouve le même formalisme que dans un
appel direct tel que :
fct1 (5.35, 4) ;
La deuxième forme a été introduite par la norme, à titre de simplification.
On prendra garde au fait qu’une instruction telle que la suivante est acceptée par
le compilateur :
adf ; /* correct, mais n'appelle pas la fonction pointée par adf */
Néanmoins, elle entraîne simplement l’évaluation de l’expression adr (de type
pointeur), laquelle, au demeurant, est tout évaluée. En aucun cas, elle ne
provoque d’appel de fonction (même si la fonction pointée n’a pas d’arguments,
il faudra écrire adf ()).
Les parenthèses sont indispensables dans la première forme d’appel. La
notation :
*adf (5.35, 4) ;
serait interprétée comme l’objet pointé par adf (5.35, 4) ce qui n’aurait de
signification que dans l’un des deux cas suivants :
• la fonction pointée par adf renvoie un pointeur ;
• adf est un identificateur de fonction (et non plus un pointeur) renvoyant un
pointeur.
Remarques
1. Selon la norme, dans un appel direct tel que fct1 (5.35, 4), l’identificateur fct1 est converti en
un pointeur sur la fonction fct1, de sorte que cet appel direct se confond, au bout du compte, avec
la deuxième forme d’appel par pointeur (avec cette légère différence qu’il s’agit alors d’un pointeur
constant et non d’une variable pointeur).
2. D’après la norme, le fait de mentionner le nom d’une fonction sans l’assortir d’une liste (même
vide) d’arguments devient légal et… ne fait rien :
void f(int) ;
…..
f ; /* légal mais ne fait rien */
En effet, f est converti ici en un pointeur sur f et l’expression f est donc légale. L’instruction se
contente donc d’évaluer cette instruction expression, sans rien faire d’autre. On aboutit aux mêmes
conclusions que précédemment avec l’expression [Link] plus est, de manière analogue à ce qui se
passe pour les tableaux, la norme prévoit de convertir en un pointeur non seulement l’identificateur
d’une fonction, mais toute expression faisant référence à une fonction. Ainsi, dans :
adf = f1 ;
…..
(*adf) ; /* correct, mais ne fait rien */
l’expression *adf désignant une fonction est, à son tour, convertie en adf…..
11.3.2 Rôle de la déclaration du pointeur dans l’appel de la
fonction pointée
Considérons ces deux déclarations :
int (*adf1) (double, int) ; /* adf1 pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
int (*adf2) () ; /* adf2 pointe sur une fonction renvoyant un int */
Le compilateur les utilisera pour traduire un appel de la fonction pointée par adf,
de la même manière qu’il utilise un prototype de fonction, à savoir : vérification
éventuelle de certains appels et mise en place d’éventuelles conversions.
Comme on peut s’y attendre, seule la première déclaration permettra de détecter
certains appels incorrects, par exemple :
int n ;
(*adf1) (5, 3, n) ; /* rejeté car le nombre d'arguments est incorrect */
(*adf1) (&n, 5) ; /* rejeté car &n, de type int * n'est pas convertible */
/* en double */
(*adf2) (5, 3, n) ; /* OK en compilation, problèmes probables à l'exécution */
(*adf2) (&n, 5) ; /* OK en compilation, problèmes probables à l'exécution */
En ce qui concerne les conversions des arguments effectifs, on aura affaire
naturellement :
• soit à une conversion dans le type attendu dans le cas de la première forme de
déclaration (adf1) ;
• soit aux promotions numériques prévues pour les arguments de type inconnu
dans le cas de la deuxième forme (adf2).
En voici quelques exemples :
long q ; float x ; short p ; char c ;
(*adf1) (x, 5) ; /* avant l'appel, x sera converti en double */
(*adf1) (4, q) ; /* avant l'appel, 4 sera converti en double et q en int */
(*adf2) (p, c, x) ; /* avant l'appel, p et c seront convertis en int */
/* et x en double */
(*adf2) (&p, c) ; /* avant l'appel, seul le deuxième argument sera */
/* converti en int */
11.4 Exemple de paramétrage d’appel de fonctions
Supposons qu’on ait besoin, au sein d’un programme, d’enchaîner dans un ordre
donné différentes actions choisies parmi des actions bien définies. Si ces actions
peuvent se programmer sous forme de fonctions, l’emploi des pointeurs sur des
fonctions peut permettre de simplifier le programme, du moins lorsque le
nombre d’actions est relativement important.
À titre d’exemple, voici deux programmes réalisant la même tâche : enchaîner
différentes actions, repérées par un nombre entier compris ici entre 1 et 3, et
définies par un tableau d’entiers nommé seq_act. Le premier utilise une sélection
classique par switch tandis que le second utilise un tableau de pointeurs sur les
différentes fonctions.
Enchaînement d’appels de fonctions en utilisant une sélection par switch
#include <stdio.h>
int main()
{ int i ;
void f1 (void) ;
void f2 (void) ;
void f3 (void) ;
int seq_act [] = {2, 1, 2, 2, 3, 1} ; /* séquence des actions à enchaîner */
for (i=0 ; i<sizeof(seq_act)/sizeof(seq_act[1]) ; i++)
switch(seq_act[i])
{ case 1 : f1() ; break ;
case 2 : f2() ; break ;
case 3 : f3() ; break ;
}
}
void f1(void) { printf ("action 1\n") ; }
void f2(void) { printf ("action 2\n") ; }
void f3(void) { printf ("action 3\n") ; }
action 2
action 1
action 2
action 2
action 3
action 1
Enchaînement d’appels de fonctions en utilisant des pointeurs sur des fonctions
#include <stdio.h>
int main()
{ int i ;
void f1 (void) ;
void f2 (void) ;
void f3 (void) ;
void (*fct[]) (void) = {f1, f2, f3} ;
int seq_act [] = {2, 1, 2, 2, 3, 1} ; /* séquence des actions à enchaîner */
for (i=0 ; i<sizeof(seq_act)/sizeof(seq_act[1]) ; i++)
fct[seq_act[i]-1]() ; /* attention, fct[seq_act[i]-1] ne ferait rien */
}
void f1(void)
{ printf ("action 1\n") ;
}
void f2(void)
{ printf ("action 2\n") ;
}
void f3(void)
{ printf ("action 3\n") ;
}
action 2
action 1
action 2
action 2
action 3
action 1
Remarques
1. Dans nos exemples, l’ordre d’enchaînement des différentes actions est fixé à la compilation. Rien
n’empêcherait, cependant, qu’il ne soit déterminé qu’à l’exécution. Bien entendu, il se peut alors
que la taille du tableau seq_act ne soit pas connue à la compilation, ce qui imposerait alors de le
créer de façon dynamique.
2. Dans notre deuxième exemple, à la place d’un tableau d’entiers seq_act, on pourrait définir
directement un tableau de pointeurs sur les fonctions correspondantes :
void (*fct[]) (void) = {f2, f1, f2, f2, f3, f1} ;
…..
for (i=0 ;i<sizeof(fct)/sizeof(fct[1]) ; i++)
fct[i]() ;
11.5 Transmission de fonction en argument
Supposons que nous souhaitions écrire une fonction permettant de calculer
l’intégrale d’une fonction quelconque suivant une méthode numérique donnée.
Une telle fonction, nommée ici integ, posséderait alors un en-tête de ce genre :
float integ ( float(*f)(float), ….. )
Le premier argument muet correspond ici à l’adresse de la fonction dont on
cherche à calculer l’intégrale. Sa déclaration peut s’interpréter ainsi :
• (*f)(float) est de type float ;
• (*f) est donc une fonction recevant un argument de type float et fournissant un
résultat de type float ;
• f est donc un pointeur sur une fonction recevant un argument de type float et
fournissant un résultat de type float.
Au sein de la définition de la fonction integ, il sera possible d’appeler la fonction
dont on aura ainsi reçu l’adresse de l’une des deux façons suivantes,
parfaitement équivalentes (voir section 11.3.1) :
(*f)(x)
f(x)
L’utilisation de la fonction integ ne présente pas de difficultés particulières. Elle
pourrait se présenter ainsi :
int main()
{ float integ ( float(*)(float), ….. ) /* déclaration de integ */
float fct1(float) ;
float fct2(float) ;
…..
res1 = integ (fct1, …..) ;
…..
res2 = integ (fct2, …..) ;
…..
}
Notez, dans la déclaration de integ, la présence de float(*)(float) qui correspond à
un nom de type « pointeur sur une fonction recevant un float » et renvoyant un
float. Il s’obtient simplement en éliminant l’identificateur de la fonction et les
noms des arguments de son en-tête.
11.6 Comparaisons de pointeurs sur des fonctions
Les pointeurs sur des fonctions ne peuvent pas être comparés avec les opérateurs
<, <=, > ou >=. Une telle comparaison n’aurait guère de signification ; tout au plus
aurait-elle pu être basée sur les adresses auxquelles sont implantées les fonctions
en mémoire, ce qui aurait été, en définitive, d’un intérêt mineur23.
En revanche, on peut tester l’égalité (==) ou l’inégalité de deux pointeurs sur des
fonctions. Il n’est alors pas nécessaire que les pointeurs soient du même type,
mais simplement d’un type compatible par affectation (voir section 11.2). Quoi
qu’il en soit et comme on peut s’y attendre, à partir du moment où une
comparaison est acceptée en compilation, on ne peut aboutir à l’égalité que si les
pointeurs pointent sur la même fonction.
int (*adf1) (double, int) ; /* adf1 pointe sur une fonction à deux arguments */
/* de type double et int et renvoyant un int */
int (*adf2) () ; /* adf2 pointe sur une fonction renvoyant un int */
int (*adf3) (float, int) ;
int f(double, int) ;
adf1 = f ; adf2 = f ; adf3 = f ;
if (adf1 == adf2 ) … /* comparaison légale et vraie */
if (adf1 == &f) … /* (ou adf1 == f) comparaison légale et vraie */
if (adf3 == &f) … /* (ou adf3 == f) comparaison légale et vraie */
11.7 Conversions par cast de pointeurs sur des
fonctions
En ce concerne l’opérateur de cast appliqué aux pointeurs sur des fonctions,
toutes les conversions sont légales, quelle que soit la manière dont les pointeurs
ont été déclarés.
Par exemple, avec ces déclarations :
int (*adf1) (int, double) ; /* adf1 pointe sur une fonction recevant un int */
/* et un double et renvoyant un int */
int (*adf2) (float) ; /* adf2 pointe sur une fonction recevant un float */
/* et renvoyant un int */
float (*adf3) () ; /* adf3 pointe sur une fonction renvoyant un float */
ces instructions sont légales, même si elles ne sont guère logiques :
adf1 = (int (*) (int, double)) adf3 ;
adf3 = (float (*) ()) adf2 ;
En revanche, les affectations suivantes seraient rejetées24, non en raison de
l’opérateur de cast, mais pour des questions d’affectations incompatibles (voir
section 11.2) :
adf1 = (int (*) (float)) adf3 ;
adf3 = (int (*) ()) adf2 ;
Comme on peut s’y attendre, la norme précise que le comportement du
programme n’est pas défini si l’on cherche ensuite à appeler la fonction pointée
en lui transmettant des arguments d’un type différent de celui attendu.
D’une manière générale, ce genre de conversions est d’un intérêt très limité. Il
peut servir, à la rigueur, à manipuler une adresse de fonction dont le type n’est
pas connu à un certain moment, alors que le « bon type » sera restitué
ultérieurement. Pour gérer convenablement une telle situation, la norme a prévu
qu’on retrouve bien l’adresse initiale après un cycle de conversions de la forme :
pointeur sur une fonction de type T1 → pointeur sur une fonction de type T2
→ pointeur sur une fonction de type T1
Cela signifie que la conversion d’un pointeur sur une fonction, quelle qu’elle
soit, ne modifie pas l’adresse correspondante.
1. En fait, la plupart des langages n’interdisent pas formellement à une fonction de réaliser une action,
même si ce n’est pas là sa vocation.
2. Toutefois, en C++, la notion de référence permettra de transmettre un résultat par adresse.
3. La fonction main peut cependant posséder un en-tête plus complet, dans le cas où l’on y introduit ce que
l’on nomme les arguments de la ligne de commande. Cet aspect est examiné au chapitre 21.
4. Certains compilateurs, cependant, se contentent dans ce cas d’un message d’avertissement qui n’interdit
pas l’exécution du programme.
5. Ces conversions ne concernent que les arguments fournis sous forme de variables. En effet, s’il s’agit
d’expressions, leur évaluation a déjà fait intervenir des conversions implicites…
6. Il ne faut pas oublier que la fonction reçoit une copie de la valeur de l’argument effectif et que l’argument
effectif correspondant ne peut donc en aucun cas être modifié par la fonction, même s’il s’agit d’un objet
et non d’une expression.
7. Quoique, en toute rigueur, on puisse, là encore artificiellement, regrouper plusieurs valeurs au sein d’une
unique structure !
8. En toute rigueur, dans le cas des adresses de structures, l’opérateur -> évite le recours à *, mais il s’agit
que d’une équivalence de notation.
9. Ce pointeur est constant sauf dans le cas des arguments muets que nous examinons plus loin. On notera
que le fait qu’il soit constant dans le cas des arguments effectifs n’a, en définitive, aucune incidence,
puisque c’est une copie de sa valeur qui sera transmise à la fonction appelée.
10. Et ceci que l’on ait déclaré l’argument correspondant sous la forme int t[] ou int * t. Bien entendu,
avec int * const t, t ne serait plus une lvalue. En revanche, compte tenu de l’ambiguïté mentionnée
section 6.1.2, avec const int t[], t ne serait une lvalue que dans certaines implémentations.
11. Ce n’est pas une certitude, dans la mesure où des techniques appropriées d’optimisation de compilation
peuvent simplifier le code obtenu.
12. Certains compilateurs se contentent d’un message d’avertissement dans ce cas, probablement pour
entériner une pratique assez courante.
13. Au sens que nous donnons au terme de ligne et de colonne comme le précise la section 5.3 du chapitre
6.
14. Certaines implémentations les acceptent néanmoins ou se contentent d’un message d’avertissement.
15. En toute rigueur, un programme exécutable peut, la plupart du temps, être placé à n’importe quel
emplacement de la mémoire. Dans ces conditions, les adresses définies par l’éditeur de liens sont
souvent des adresses relatives qui sont transformées en adresses absolues, par le biais d’un mécanisme
d’adressage relatif approprié (par exemple, utilisation d’une adresse de base, complétée par un
déplacement) ou par l’intervention d’un programme particulier (chargeur) d’adaptation des adresses
avant le lancement de l’exécution.
16. Avec cependant une exception pour les tableaux de caractères qu’on peut initialiser avec des chaînes
constantes.
17. Sauf dans le cas des structures, des unions ou des énumérations où la portée débute dès le début de leur
déclaration.
18. Pour Last In, First Out : dernier entré, premier sorti.
19. Avec cependant une exception pour les tableaux de caractères qu’on peut initialiser avec des chaînes
constantes.
20. Curieusement, certains compilateurs peuvent cependant se contenter d’un message d’avertissement qui
n’interdit pas l’exécution du programme.
21. Certains compilateurs peuvent cependant fournir un message d’avertissement pour les conversions
considérées comme dangereuses, c’est-à-dire ici toutes sauf la première. Ces conversions seront
d’ailleurs illégales en C++.
22. Curieusement, certains compilateurs se contentent d’un message d’avertissement dans ce cas, ce qui
n’interdit pas l’exécution du programme.
23. Toutefois, certains compilateurs se contentent d’un message d’avertissement qui n’interdit pas
l’exécution du programme. Dans ce cas, une incertitude subsiste en ce qui concerne l’éventuelle
coïncidence entre ordre des pointeurs et ordre des adresses.
24. Curieusement, certains compilateurs se contentent d’un message d’avertissement dans ce cas, ce qui
n’interdit pas l’exécution du programme.
9
Les entrées-sorties standards
Traditionnellement, dans un langage de programmation, on distingue deux
catégories d’opérations d’entrées-sorties : les entrées-sorties de communication
avec l’utilisateur, nommées souvent « entrées-sorties standards » et les entrées-
sorties d’archivage permanent d’informations au sein d’un fichier.
Ce chapitre est consacré à la première catégorie, laquelle recouvre les fonctions
printf, scanf, puts, gets (ou gets_s en C11), putchar et getchar.
Dans un premier temps, nous commencerons par examiner les caractéristiques
générales de toutes les entrées-sorties standards. Notamment, nous rappellerons
qu’elles peuvent prendre un aspect conversationnel ou différé et nous verrons
qu’elles apparaissent comme un cas particulier d’entrées-sorties formatées
appliquées à des fichiers de type texte.
L’étude de la fonction printf fera l’objet des sections 2, 3 et 4. La section 2 sera
consacrée à une présentation générale des différents arguments de cette
fonction : rôle du format, conversions éventuelles, signification de la valeur de
retour. La section 3 fera une synthèse des différentes possibilités de formatage de
cette fonction. Enfin, la section 4 proposera une description analytique
exhaustive de tous les codes de format. La section 5, quant à elle, étudiera la
fonction putchar qui se présente comme un raccourci de printf, dans le cas où l’on
souhaite n’écrire qu’un seul caractère.
L’étude de la fonction scanf fera l’objet des sections 6, 7 et 8 : présentation
générale, synthèse des principales possibilités, description exhaustive de tous les
codes de format. La section 9 étudiera la fonction getchar qui se présente comme
un raccourci de scanf, dans le cas où l’on ne souhaite lire qu’un seul caractère.
On notera que les fonctions d’entrées-sorties standards relatives aux chaînes que
sont puts et gets sont étudiées au chapitre 10. De même, la généralisation des
fonctions d’entrées-sorties standards aux fichiers est tout naturellement étudiée
au chapitre 13.
1. Caractéristiques générales des entrées-sorties
standards
Récapitulées dans le tableau 9.1, ces caractéristiques sont décrites en détail dans
les sections indiquées.
Tableau 9.1 : les caractéristiques générales des entrées-sorties standards
– conversationnel : typiquement utilisation Voir
Mode du clavier ou de l’écran (avec, section
d’interaction éventuellement, possibilités de redirection 1.1
avec des entrées-sorties) ;
l’utilisateur – différé : informations lues et/ou écrites
dans un fichier dit de type texte.
Formatage des Les informations échangées sont des suites Voir
informations de caractères, d’où la nécessité de section
échangées conversions nommées « formatage ». 1.2
Généralisation À chaque fonction d’entrée-sortie standard, Voir
aux fichiers de correspond une fonction s’appliquant à un section
type texte fichier de type texte. 1.3
1.1 Mode d’interaction avec l’utilisateur
Le plus souvent, les entrées-sorties standards ont lieu dans ce que l’on appelle le
« mode conversationnel ». L’utilisateur communique avec le programme par
l’intermédiaire d’un écran et d’un clavier. Toutefois, certains environnements
utilisent ce qu’on nomme un « travail en mode différé » qui se caractérise ainsi :
• les informations d’entrée, au lieu d’être saisies au clavier au moment de
l’exécution du programme, ont été préalablement enregistrées dans un fichier à
l’aide d’un éditeur de texte ;
• les informations de sortie, au lieu d’être affichées à l’écran, sont enregistrées
dans un fichier ou transmises à une imprimante.
Certains environnements autorisent les deux modes d’interaction, le même
programme pouvant être utilisé, tantôt avec le clavier et l’écran, tantôt avec un
fichier d’entrée et un fichier de sortie Même dans les environnements
n’autorisant que le mode conversationnel, il est souvent possible de procéder à
ce que l’on nomme une redirection de l’entrée et/ou de la sortie. Lorsque l’entrée
et la sortie sont redirigées, on retrouve alors l’équivalent d’un mode différé. On
notera cependant qu’il n’est pas toujours raisonnable d’exploiter en différé un
programme conçu pour fonctionner en mode conversationnel. Par exemple, un
message du genre : valeur incorrecte, donnez-en une autre ne sera probablement
guère justifié dans le cas d’un mode différé, qu’il s’agisse d’une véritable lecture
dans un fichier ou d’une redirection de l’entrée. La tentative de lecture qui s’en
suivrait induirait en effet probablement un comportement incorrect du
programme. La démarche la plus raisonnable serait alors de prévoir un
programme susceptible de s’adapter à son mode d’utilisation. Sur ce plan, on
notera que rien n’est prévu par la norme pour permettre à un programme de
savoir dans lequel de ces deux modes il fonctionne à un instant donné.
Pour tenir compte des différents modes d’interaction avec l’utilisateur, on parle
d’entrée standard et de sortie standard pour désigner les périphériques qui seront
utilisés pendant l’exécution d’un programme.
1.2 Formatage des informations échangées
La communication avec les unités standards se fonde toujours sur la
transmission de caractères. Cela est naturel, dans la mesure où sur un écran ou
une imprimante, on doit bien, au bout du compte, afficher des caractères. Il en va
de même pour le clavier où l’utilisateur ne peut que frapper sur une touche ou
une combinaison de touches représentant un caractère.
Dans ces conditions, la plupart des échanges d’informations impliquent des
conversions appropriées : d’un type quelconque en une suite de caractères pour
les informations de sortie, d’une suite de caractères en un type quelconque pour
les informations d’entrée.
Pour illustrer ce besoin de conversion, considérons une variable n, de type int,
contenant la valeur 301. Si l’on suppose que les entiers sont codés sur 16 bits, le
contenu de n se présentera ainsi :
0000000100101101
Pour afficher « en clair » cette valeur à l’écran, il est nécessaire :
• de convertir le contenu de n, de la base 2 dans la base 10, ce qui donnera une
série de chiffres (ici 3, 0 et 1) ;
• d’associer à chacun de ces chiffres le code correspondant du type char.
Par exemple, dans une implémentation utilisant le code ASCII, en supposant
qu’on souhaite afficher la valeur de n sur 5 emplacements, on sera amené à
transmettre à l’écran une suite de 5 octets, correspondant respectivement aux
codes des caractères : espace, espace, 3, 0 et 1.
Bien entendu, la même remarque s’appliquerait à des flottants, des pointeurs…
Dans le cas des chaînes, cependant, aucun transcodage ne sera nécessaire et l’on
pourra se contenter de transmettre tel quel chacun des octets correspondant aux
caractères de la chaîne, à l’exception de son zéro de fin.
Comme on s’y attend, la lecture au clavier impliquera des transformations
inverses.
D’une manière générale, on traduit tout cela en disant que les entrées-sorties sont
formatées. En C, c’est d’ailleurs précisément la chaîne de caractères utilisée
comme « format » qui dicte la manière dont doivent avoir lieu les conversions
correspondant à ce formatage. On notera bien qu’il existe à la fois des opérations
de formatage en entrée et des opérations de formatage en sortie, mettant en
œuvre des conversions opposées. De plus, les formatages en sortie permettent
l’introduction de « libellés », ce qui n’a pas de signification pour une opération
d’entrée.
1.3 Généralisation aux fichiers de type texte
1.3.1 Les entrées-sorties standard peuvent s’appliquer à des
fichiers
Même avec les instructions d’entrée standards, on peut être amené à lire dans un
fichier ; c’est le cas lorsque l’implémentation fonctionne en mode différé ou
lorsque l’on redirige l’entrée. Le fichier ainsi utilisé en entrée peut alors avoir été
créé de différentes façons, par exemple :
• par un éditeur de texte ;
• comme sortie d’un autre programme ;
• par des fonctions de création de fichier telles que fprintf.
On dit fréquemment qu’un tel fichier constitue un fichier de type texte (ou
formaté) et l’on pourrait penser qu’il se définit comme étant formé d’une suite
de caractères. Toutefois, on verra au chapitre 13 qu’en C, tout fichier est
constitué d’une suite d’octets, donc de caractères, de sorte qu’une telle définition
est insuffisante puisqu’elle peut s’appliquer à n’importe quel fichier.
En fait, l’important, plus que la nature du fichier, est de savoir dans quel cas on
est certain d’y relire les caractères qu’on y a préalablement introduits. Cet aspect
sera examiné en détail au chapitre 13, où l’on trouvera une définition rigoureuse
d’un fichier texte. Ici, nous nous contenterons de dire que certaines
implémentations peuvent imposer des contraintes aux caractères figurant dans un
tel fichier. On est cependant certain de ne rencontrer aucun problème tant que
l’on se limite aux caractères imprimables, aux tabulations horizontales et aux
fins de ligne (à condition qu’elles ne soient pas précédées d’espaces) et que la
longueur des lignes ne dépasse pas 254. On notera cependant que ces conditions
ne sont pas nécessairement respectées lorsqu’on crée un fichier par les
instructions de sortie standards : on peut très bien créer des lignes trop longues,
introduire des caractères non autorisés ou des espaces de fin de ligne… Par
ailleurs, même un fichier créé par un éditeur de texte peut ne pas convenir : on
peut, là encore, y trouver des caractères non imprimables ou, tout simplement
des espaces de fin de ligne. Certains éditeurs vont même jusqu’à transformer des
tabulations en une suite d’espace ou l’inverse.
1.3.2 Généralisation des entrées-sorties standards
On vient de voir que les fonctions d’entrées-sorties standards peuvent
s’appliquer à des fichiers de type texte. Mais les choses sont plus générales que
cela puisque : à toute fonction étudiée ici pour les entrées-sorties standards,
correspond une fonction équivalente s’appliquant à un fichier de type texte.
Par exemple, à printf correspondra fprintf, à scanf correspondra fscanf… En
particulier, dans le chapitre 13 consacré aux fichiers, on verra que :
• la sortie standard est considérée comme un fichier texte particulier, repéré dans
le programme par le nom stdout1 ; un appel de printf peut être remplacé par un
appel équivalent de fprintf avec stdout comme premier argument ;
• l’entrée standard est considérée comme un fichier texte particulier, repéré dans
le programme par le nom stdin ; un appel de scanf peut être remplacé par un
appel équivalent de fscanf avec stdin comme premier argument.
En définitive, tout ce qui sera dit ici à propos des fonctions relatives aux entrées-
sorties standards se généralisera systématiquement aux fichiers de type texte.
2. Présentation générale de printf
Cette section étudie les notions générales qui interviennent dans l’utilisation de
la fonction printf : notion de format, de code de format, rôle des différents
arguments et types autorisés, valeur de retour, risques d’erreurs de
programmation. On notera bien que :
• la description des différents codes de format, ainsi que l’usage qu’on peut en
faire, font l’objet des sections suivantes ;
• les notions étudiées ici se transposent à toutes les fonctions de la famille printf,
à savoir : fprintf, sprintf, vprintf, vfprintf et vsprintf. Nous ne le répéterons pas
systématiquement, hormis dans les tableaux récapitulatifs.
2.1 Notions de format d’entrée, de code de format et
de code de conversion
La fonction printf envoie sur l’unité standard de sortie une suite de caractères
obtenue en formatant des informations issues de différentes variables ou
expressions. Le premier des arguments, nommé précisément format, est une suite
de caractères qui sert au formatage des informations, tandis que les arguments
suivants fournissent les informations à formater. Le format destiné à printf
contient en fait deux sortes d’éléments :
• des caractères à transmettre tels quels à la sortie standard (on les nomme
souvent des libellés) ;
• ce que l’on nomme des « codes de format », à raison d’un code par
information ; ils se reconnaissent à ce qu’ils commencent par le caractère %. Ils
sont composés, en plus de ce caractère %, d’un ou plusieurs caractères, parmi
lesquels on trouve notamment un « code de conversion » ; ce dernier précise la
nature de la conversion à effectuer, par exemple d pour une conversion en une
suite de caractères représentant un entier sous sa forme décimale usuelle, f
pour une conversion en une suite de caractères représentant un nombre
décimal en notation flottante… D’autres éléments peuvent être également
spécifiés comme le « gabarit » ou la « précision ».
Par exemple, dans l’instruction :
printf ("nombre = %d valeur : %10.5f", n, x*y) ;
le format est la chaîne :
"nombre = %d valeur : %10.5f"
Il comporte deux codes de format : %d (code de conversion d) et %10.5f (code de
conversion f).
Les arguments suivants indiquent les informations à convertir, ici n et x*y.
L’exécution de cette instruction affiche :
• le libellé : nombre = ;
• la valeur de l’expression n, suivant le premier code de format : %d ;
• le libellé : valeur : ;
• la valeur de l’expression x*y, suivant le code de format %10.5f.
2.2 L’appel de printf
2.2.1 Syntaxe
Comme printf est une fonction à arguments variables, son prototype n’apporte
aucune information sur le type de ses arguments, exception faite pour le premier
qui correspond au format. C’est pourquoi nous fournissons ici en parallèle ce
que nous nommons – par abus de langage puisqu’il ne s’agit plus d’une
instruction – la « syntaxe » de l’appel de printf (les crochets signifient que leur
contenu est facultatif) :
La fonction printf
printf ( format [,liste_d_expressions] )
int printf (const char * format, …) (stdio.h)
format
Pointeur sur une chaîne – Le contenu détaillé du
de caractères format est étudié aux
correspondant au format. sections 3 et 4.
Il peut donc s’agir
indifféremment :
– d’une variable, voire
d’une expression de
type char * ;
– d’une constante chaîne
(traduite, par le
compilateur, en un
pointeur constant de
type char *).
liste_d_expressions
Suite d’expressions – les expressions seront
séparées par des virgules soumises aux
d’un type en accord avec conversions usuelles
le code de format étudiées à la section
correspondant 2.2.3 ;
– le cas de désaccord
entre format et
liste_d_expressions est
étudié à la section 2.3.
Valeur de retour Nombre de caractères Étude détaillée à la
écrits sur la sortie section 2.2.4
standard
La liste d’expressions peut être absente, pour peu que le format ne contienne
aucun code de format. C’est précisément ainsi que l’on peut afficher un simple
libellé comme dans :
printf ("bonjour") ;
2.2.2 Types des informations à afficher
La fonction printf a été prévue pour afficher uniquement des valeurs de type
scalaire, c’est-à-dire finalement caractère2, numérique ou pointeur. Cela exclut
notamment les valeurs de type structure. Par ailleurs, on notera bien qu’un nom
de tableau figurant en argument sera, comme à l’accoutumée, converti en un
pointeur sur son premier élément ; ce qui signifie qu’il n’existe aucun moyen de
transmettre à printf l’ensemble des valeurs d’un tableau.
Bien entendu, si l’on ne peut pas transmettre à printf la valeur d’une structure ou
d’un tableau, il n’en reste pas moins possible d’afficher le contenu d’une
structure ou d’un tableau. Par exemple, on pourra prévoir autant d’arguments
que de valeurs à afficher. Dans le cas des tableaux, on pourra aussi répéter
plusieurs fois une même instruction d’affichage.
Si l’on appelle printf avec un argument d’un type non scalaire, aucun diagnostic
ne sera fourni par le compilateur qui n’a aucune connaissance des types attendus
par printf. En revanche, lors de l’exécution, on aboutira aux conséquences
usuelles de non concordance de type entre les arguments effectifs d’appel d’une
fonction et les arguments muets correspondants (voir section 4.8 du chapitre 8).
2.2.3 Conversions éventuelles des informations
, le type des arguments de printf, exception faite du premier, n’est pas
A priori
imposé. Le compilateur met en place les conversions éventuelles prévues dans
un tel cas et qui ont été exposées à la section 3.5 du chapitre 4 :
• promotions numériques systématiques :
– char, signed char et unsigned char en int ;
– short en int ;
– unsigned short en int ou unsigned int ;
• conversions systématiques de float en double.
On voit donc que, quelle que soit la façon de programmer, printf ne pourra
jamais recevoir d’argument de type char, short ou float. C’est la raison pour
laquelle il n’existe pas véritablement de code de format pour ces types3 : par
exemple, %c correspond, en fait, à une valeur de type int et non char ; de même, %f
correspond à une valeur de type double et non float. Bien entendu, cela n’interdit
nullement de demander l’affichage de telles valeurs ; simplement, elles auront
été converties avant d’être transmises à printf.
2.2.4 La valeur de retour de printf
La fonction printf fournit en retour le nombre de caractères qu’elle a écrit,
lorsque l’opération s’est bien déroulée. Par exemple, avec :
p = printf ("%3d", n) ;
si n vaut 25, on affichera ce nombre précédé d’un espace et p vaudra 3. Si n vaut
-4583, on affichera ce nombre sans espace avant et p vaudra 5.
En revanche, la fonction printf fournit une valeur négative en cas d’erreur (panne
du matériel, manque de place sur une unité lorsque la sortie a été redirigée vers
un fichier), lorsque ces anomalies n’ont pas déjà été prises en charge par le
système d’exploitation.
Exemple
La valeur de retour de printf peut être exploitée pour contrôler une « mise en
page ». On peut par exemple effectuer plusieurs affichages sur une même ligne,
en imposant les sauts de lignes au moment opportun, comme le fait cette
construction :
int pos_lig = 0 ;
…..
pos_lig += printf( ….. ) ;
if (pos_lig > 60) { printf ("\n") ;
pos_lig = 0 ;
}
Toutefois, cela ne fonctionne convenablement que si chaque appel de printf
n’affiche pas plus de 20 caractères (pour des lignes de 80 caractères), puisque
l’on ne peut connaître le nombre de caractères affichés qu’après l’appel de printf.
En général, plutôt que d’afficher immédiatement les informations, on créera
donc la chaîne correspondante en mémoire, avant de l’afficher. Pour cela, on fera
appel à la fonction sprintf qui travaille comme printf, avec cette différence que
les caractères obtenus sont stockés à une adresse donnée, au lieu d’être transmis
à la sortie standard. Voici un exemple de canevas possible, en supposant qu’un
appel de printf ne créera pas plus de LG caractères :
#define LG 80
…..
int pos_lig = 0 ;
int nb_car ;
char ch[LG+1] ;
…..
nb_car = sprintf (ch, …..) ; /* on prépare les infos à afficher dans ch */
pos_lig += nb_car ;
if (pos_lig >= LG) { printf ("\n") ;
pos_lig = nb_car ;
}
printf ("%s", ch) ; /* et on les affiche ici */
2.3 Les risques d’erreurs dans la rédaction du format
Différentes erreurs de programmation risquent d’apparaître dans l’utilisation de
printf :
• au niveau de chaque code de format qui peut être invalide ou en désaccord avec
le type de l’information correspondante ;
• au niveau du nombre des codes de format.
En aucun cas, ces erreurs ne peuvent être détectées au moment de la
compilation. En revanche, au moment de l’exécution, printf, qui n’est rien
d’autre qu’une fonction à arguments variables, se fonde sur le contenu du format
pour décider de la nature et du nombre des informations qu’elle doit
effectivement récupérer. Nous examinons ici ce qui risque de se produire dans
ces différentes situations.
Tableau 9.2 : les risques d’erreurs de format avec les fonctions de la famille4
printf
2.3.1 Code de format invalide
Si le caractère % est suivi d’un ou plusieurs caractères ne correspondant pas à un
code de format, la norme précise que le comportement du programme est
indéterminé. C’est le cas, par exemple, avec %5k, %k ou %8.3.f.
En pratique, on obtient assez souvent l’affichage de ces caractères (y compris le
%), comme s’il s’agissait de caractères n’appartenant pas à un code de format
(libellés).
Par ailleurs, un code de format peut être rendu invalide par l’utilisation d’un
drapeau, d’un modificateur ou d’une précision n’ayant pas de signification pour
un certain code de conversion. Ce serait par exemple le cas avec %he ou ²%5p. En
pratique, l’information superflue est souvent ignorée.
On notera bien que l’introduction de %d5 au lieu de %5d ne constitue pas à
proprement parler un code de format invalide mais simplement une
« étourderie », dans la mesure où elle est interprétée comme le code de format %d
suivi simplement du caractère 5, qui se trouve alors affiché à la suite du nombre
entier.
2.3.2 Code de format en désaccord avec le type de l’expression
En cas de désaccord entre le code de format et le type effectif de l’information
correspondante, la norme reste prudente en prévoyant simplement que le
comportement du programme est indéterminé.
En pratique, lorsque le code de format, bien qu’erroné, correspond à une
information de même taille que celle relative au type de l’expression, les
conséquences de l’erreur se limitent généralement à une mauvaise interprétation
de l’expression. On obtient donc l’affichage d’une valeur différente de celle
attendue, mais les affichages suivants ne sont pas perturbés. C’est ce qui se
passe, par exemple, lorsque l’on écrit une valeur de type int en %u ou une valeur
de type unsigned int en %d. C’est également ce qui se produit si l’on écrit en %f une
information de type int lorsque l’implémentation représente les types int et float
sur le même nombre d’octets. Dans ce dernier cas, on peut parfois, dans certains
environnements, aboutir à une erreur d’exécution liée à ce que le motif binaire
concerné ne correspond pas à une valeur flottante correctement normalisée.
En revanche, lorsque le code format correspond à une information de taille
différente de celle relative au type de l’expression, les conséquences sont
généralement plus désastreuses, du moins si d’autres valeurs doivent être
affichées à la suite. En effet, tout se passe alors comme si, dans la suite d’octets
(correspondant aux différentes valeurs à afficher) reçue par printf, le « repérage »
des emplacements des valeurs suivantes se trouvait soumis à un décalage5. Les
affichages suivants sont alors, eux aussi, perturbés.
2.3.3 Nombre incorrect de codes de format
Comme printf, fonction à arguments variables, se fonde sur le format pour
déterminer le nombre et le type des valeurs qu’elle doit récupérer à la suite, on
peut affirmer que :
printf cherche toujours à satisfaire le contenu du format.
Il faut alors distinguer deux situations suivant qu’on a fourni trop ou trop peu de
codes de format.
Dans le cas où l’on fournit trop peu de codes de format, les dernières expressions
de la liste ne seront pas affichées. C’est le cas dans cette instruction où seule la
valeur de n sera affichée, la valeur de p ne l’étant pas :
printf ("%d", n, p) ; /* affiche seulement la valeur de n suivant le code %d */
En revanche, si l’on fournit trop de codes de format, la norme prévoit que le
comportement du programme est indéterminé. En pratique, les conséquences
seront effectivement assez désastreuses, puisque printf cherchera généralement à
afficher… n’importe quoi. C’est le cas dans cette instruction où deux valeurs
seront affichées, la seconde étant relativement6 aléatoire :
printf ("%f %e ", x) ; /* affiche la valeur de x suivant le code %f */
/* et une autre valeur (aleatoire) suivant le code %e */
Dans un tel cas, on peut même, dans certains environnements, aboutir à une
erreur d’exécution liée au fait que le motif binaire concerné ne correspond pas à
une valeur flottante correctement normalisée.
Remarque
Les arguments de printf, comme ceux de n’importe quelle fonction, sont évalués lors de l’appel,
indépendamment de l’usage (ou du non-usage) qui en sera fait dans la fonction elle-même. Par
exemple, avec :
printf ("%d", i, n++) ;
n sera incrémentée de un bien que sa valeur ne soit pas exploitée par printf.
3. Les principales possibilités de formatage de printf
Les différents codes de format de printf sont décrits en détail à la section 4 qui
sert de référence. Ici, nous vous proposons une synthèse des principales
possibilités qui vous sont offertes. Récapitulées dans le tableau 9.3, elles sont
décrites en détail dans les sections indiquées.
Tableau 9.3 : possibilités de formatage des fonctions de la famille printf
Action
Démarche à utiliser Voir
souhaitée
– imposer un gabarit minimal à l’aide du Section
paramètre de gabarit ; 3.1
Section
Agir sur le – imposer un gabarit variable avec la valeur de 3.4
gabarit gabarit égale à « * » ; Section
– imposer un gabarit maximal aux chaînes, à 3.9
l’aide du paramètre de précision.
– imposer le nombre de chiffres après le point Section
décimal, avec le paramètre de précision, pour 3.2
les codes e, E et f ;
Section
Agir sur la – imposer un nombre de chiffres variables après 3.4
précision le point décimal, en utilisant une valeur du
paramètre de précision égale à « * » ; Section
– imposer une vraie précision mathématique, en 3.5
recourant aux codes g ou G.
Justifier à Utiliser le drapeau « - » pour imposer une Section
gauche justification à gauche 3.3
Forcer un Utiliser le drapeau « + » Section
signe + 3.6
Forcer un Utiliser le drapeau « espace » Section
espace 3.7
devant un
nombre
Afficher Utiliser le modificateur h Section
un unsigned 3.10
short, de
façon
portable
3.1 Le gabarit d’affichage
Chaque code de format peut comporter une indication dite de gabarit qui précise
un nombre minimal de caractères à afficher. En l’absence d’une telle indication,
printf utilise un gabarit par défaut.
3.1.1 Le gabarit par défaut
Le gabarit par défaut est présenté en détail à la section 4.3. On peut dire, pour
résumer, qu’il consiste à utiliser exactement le nombre d’emplacements
nécessaires pour afficher l’information concernée, sachant que les informations
flottantes utilisent toujours 6 chiffres après le point décimal, du moins tant
qu’aucune précision n’est spécifiée.
Voici quelques exemples où, après une instruction printf, nous donnons plusieurs
valeurs possibles à afficher et le résultat obtenu sur la sortie standard :
printf ("%d", n) ; /* entier avec gabarit par défaut */
n = 20 20
n = 3 3
n = 2358 2358
n = -5200 -5200
printf ("%f", x) ; /* notation décimale, gabarit par défaut (6 chiffres après point)
*/
x = 1.2345 1.234500
x = 12.3456789 12.345679 /* notez l'arrondi à 6 chiffres */
x = 0.000012345 0.000012 /* notez l'arrondi à 6 chiffres */
x = 1e-10 0.000000 /* notez l'arrondi à 6 chiffres */
x = 1.23456e15 1234560029294592.000000 /* notez l'erreur de représentation */
/* de la valeur 1.23456e15 dans le type float 7 */
On voit que le code f n’a d’intérêt que pour des valeurs ni trop petites ni trop
grandes. En effet, les valeurs trop grandes occupent beaucoup de place, tandis
que les valeurs trop petites apparaissent comme nulles ou avec peu de chiffres
significatifs.
printf ("%e", x) ; /* notation exponentielle, gabarit par défaut (6 chiffres après
point) */
x = 1.2345 1.234500e+00
x = 123.45 1.234500e+02
x = 123.456789E8 1.234568e+10 /* notez l'arrondi à 6 chiffres */
x = -123.456789E8 -1.234568e+10 /* notez l'arrondi à 6 chiffres */
Ici, nos exposants sont affichés avec deux chiffres ; mais ce nombre peut varier
suivant l’implémentation.
D’une manière générale, avec le gabarit par défaut, l’affichage d’une
information occupe un emplacement dont la taille peut dépendre de sa valeur, ce
qui ne permet pas d’afficher des informations alignées en colonne. Il existe
cependant deux exceptions :
• les caractères ;
• les flottants affichés avec le code e, pour peu qu’ils soient de même signe. En
effet, dans une implémentation donnée, tous les nombres positifs occupent le
même gabarit. Il en va de même des négatifs dont, cependant, le gabarit est
supérieur de 1 à celui des positifs (compte tenu du signe -).
3.1.2 Le paramètre de gabarit
Le langage C permet d’agir sur le gabarit d’affichage en lui imposant une valeur
minimale. En revanche, il ne permet pas d’imposer une valeur maximale,
exception faite des chaînes. Cette attitude privilégie l’exactitude de l’information
affichée sur sa présentation.
On définit un gabarit minimal en utilisant le paramètre dit de gabarit, placé après
le caractère % et avant le caractère de conversion, cela pour tous les types de
données scalaires acceptées par printf (caractères, chaînes, nombres, pointeurs).
Si l’information peut s’écrire avec moins de caractères, printf la fera précéder
d’un nombre suffisant d’espaces. En revanche, si l’information ne peut s’afficher
convenablement dans le gabarit imparti, printf continuera d’utiliser le nombre de
caractères nécessaires. Moyennant certaines précautions, on pourra ainsi obtenir
des affichages alignés en colonne.
Exemples
Voici, à la suite d’une instruction printf, à la fois des valeurs possibles des
expressions à afficher et le résultat obtenu sur la sortie standard. Notez que le
symbole représente un espace.
printf ("%3d", n) ; /* entier avec 3 caractères minimum */
n = 20 20
n = 3 3
n = 2 2358
n = -5200 -5200
On pourra obtenir des alignements en colonne, pour peu que l’on soit certain de
limiter les valeurs à afficher à une valeur maximale ou que l’on soit prêt à
utiliser un gabarit suffisant pour les plus grandes valeurs possibles du type
concerné. Par exemple, dans la plupart des implémentations, le code %12d
permettra d’afficher n’importe quel entier précédé d’au moins un espace.
printf ("%10f", x) ; /* notation décimale - gabarit mini 10 */
/* (toujours 6 chiffres après point) */
x = 1.2345 1.234500
x = 12.345 12.345000
x = 1.2345E5 123450.000000
x = 0.000012345 0.000012 /* notez l'arrondi à 6 chiffres */
x = 1e-10 0.000000 /* notez l'arrondi à 6 chiffres */
x = 1.23456e15 1234560029294592.000000 /* notez l'erreur de représentation */
/* de la valeur 1.23456e15 dans le type float 8
*/
printf ("%13e", x) ; /* notation exponentielle - gabarit par défaut */
/* (6 chiffres après point)*/
– Dans une implémentation où les exposants sont représentés avec 2 chiffres :
x = 1.2345 1.234500e+00
x = 123.45 1.234500e+02
x = 123.456789E8 1.234568e+10 /* notez l'arrondi à 6 chiffres */
x = -123.456789E8 -1.234568e+10 /* notez l'arrondi à 6 chiffres */
x = 0.000012345 1.234500e-05
x = 1e-10 1.000000e-10
x = 1.23456e15 1.234560e+15
– Dans une implémentation où les exposants sont représentés avec 3 chiffres :
x = 1.2345 1.234500e+000
x = 123.45 1.234500e+002
x = 123.456789E8 1.234568e+010
x = -123.456789E8 -1.234568e+010
x = 0.000012345 1.234500e-005
x = 1e-10 1.000000e-010
x = 1.23456e15 1.234560e+015
Ici, on n’obtient jamais d’espace avant le nombre et les nombres négatifs
s’affichent sur un gabarit de 14 caractères.
Comme aucune implémentation n’utilise d’exposants de plus de quatre chiffres,
on voit qu’avec %16e, on pourra afficher n’importe quelle valeur sur un gabarit
fixe, avec au moins un espace avant et, ici, 6 chiffres après le point décimal de la
mantisse.
3.2 Précision des informations flottantes
Par défaut, les codes e, E ou f affichent 6 chiffres après le point décimal, que l’on
utilise le gabarit par défaut ou qu’on en fixe un par le paramètre de gabarit. On
peut imposer une valeur différente en utilisant le paramètre dit de « précision »
dans le code de format. Sa signification dépend en partie des codes de
conversion.
3.2.1 Cas des codes e et E
Avec les codes de format e ou E, le paramètre de précision indique le nombre de
chiffres à afficher après le point décimal de la mantisse. Il correspond à ce que
l’on nomme généralement en mathématiques le « nombre de chiffres
significatifs », cependant diminué d’une unité puisque la mantisse, comprise
entre 1 et 10, apporte un chiffre significatif supplémentaire. Ce paramètre de
précision peut s’utiliser avec ou sans le paramètre de gabarit.
Voici quelques exemples :
printf ("%.4e", x) ; /* notation exponentielle, gabarit par défaut, précision 4 */
x = 1.234567 1.2346e+00
x = 123.456789E8 1.2346e+10
printf ("%.5e", x) ; /* notation exponentielle, gabarit par défaut, précision 5
*/
x = 1.234567 1.23457e+00
x = 123.456789E8 1.23457e+10
printf ("%13.4e", x) ; /* notation exponentielle, gabarit 13, précision 4 */
x = 1.23456 1.2346e+00
x = 123.456789E8 1.2346e+10
D’une manière générale, on voit que pour afficher p chiffres significatifs et un
espace avant, il est nécessaire d’utiliser un gabarit de p+7 si, dans
l’implémentation concernée, les exposants sont affichés avec 2 chiffres, de p+8
s’ils le sont avec trois chiffres… Comme, en pratique, on ne trouve pas
d’exposants de plus de 4 chiffres, on voit que le gabarit p+9 convient dans tous les
cas puisqu’il ménage au moins un séparateur entre plusieurs nombres successifs.
Par exemple, avec le code de format %16.6e, on affichera convenablement
n’importe quelle valeur avec 7 chiffres significatifs.
3.2.2 Cas du code f
Avec le code de format f¸ le paramètre de précision indique simplement le
nombre de chiffres souhaités après le point décimal. Le nombre de chiffres
significatifs peut, par rapport à cette précision, être :
• plus élevé pour les nombres de valeur absolue supérieure à 1 ;
• moins élevé pour les nombres de valeur absolue inférieure à 0,1 ;
• identique pour les nombres dont la valeur absolue est comprise entre 0,1 et 1.
Contrairement à ce qui se produit avec les codes e ou E, il n’y a donc plus guère
de lien entre le paramètre de précision et le nombre de chiffres significatifs.
Là encore, ce paramètre peut s’utiliser avec ou sans le paramètre de gabarit.
Voici quelques exemples :
printf ("%10.3f", x) ; /* notation décimale, gabarit mini 10 et 3 chiffres après point
*/
x = 1.2345 1.235
x = 1.2345E3 1234.500
x = 1.2345E7 12345000.000
printf ("%.3f", x) ; /* notation décimale, gabarit par défaut et 3 chiffres après point
*/
x = 1.2345 1.235
x = 1.2345E3 1234.500
x = 1.2345E7 12345000.000
Remarques
1. Ce paramètre de précision possède une signification pour la plupart des codes de format ; mais elle
n’est pas toujours très intuitive et est souvent éloignée de la notion de précision mathématique.
Notamment, comme on le verra aux sections 3.9 et 4.4 :
– pour les entiers, il représente un nombre minimal de chiffres à afficher, quitte à compléter par des
zéros ;
– pour les chaînes, il représente un nombre maximal de caractères à afficher, quitte à tronquer la
chaîne.
2. Le code de format g, décrit à la section 3.5, présentera l’avantage sur les codes e et f de permettre
de maîtriser la précision des nombres flottants. En revanche, il ne permettra plus de choisir la forme
d’affichage (flottante ou exponentielle), dans la mesure où ce choix se fera automatiquement.
3.3 Justification des informations
Lorsqu’une information doit être affichée sur un nombre d’emplacements
supérieur à ce qui est nécessaire, elle est justifiée (cadrée) à droite. Cela signifie
qu’elle est précédée d’un nombre suffisant d’espaces, de manière à occuper le
gabarit voulu. Bien entendu, lorsque le gabarit est trop petit ou juste suffisant
pour ce qu’on doit y afficher, aucun espace supplémentaire n’est introduit. Cette
justification à droite s’applique quel que soit le type de l’information concernée.
Elle est généralement satisfaisante pour les nombres. En revanche, elle l’est
moins pour les caractères et surtout pour les chaînes.
On peut facilement demander une justification à gauche à l’intérieur du gabarit
en utilisant le drapeau « - ». On parle de « drapeau » dans un code de format
pour désigner un caractère facultatif choisi parmi une liste, qu’on introduit
immédiatement après le caractère %.
Voici un exemple de programme illustrant cette possibilité dans le cas de chaînes
et d’entiers :
Utilisation du drapeau «-» pour justifier à gauche
#include <stdio.h>
int main()
{ char * ch = "quantite" ;
int n = 20 ;
printf (":%12s:%5d:\n", ch, n) ; /* justification à droite */
printf (":%-12s:%-5d:\n", ch, n) ; /* justification à gauche */
}
: quantite: 20:
:quantite :20 :
3.4 Gabarit ou précision variable
Comme le format n’est interprété qu’au moment de l’exécution par printf, les
paramètres représentant le gabarit et la précision ne peuvent pas apparaître sous
forme d’expressions. Il est cependant possible à ce niveau d’utiliser des
paramètres dont la valeur peut varier d’un appel à l’autre, en utilisant à leur
place le caractère *. Ce dernier indique à printf que la valeur effective du
paramètre de gabarit ou de précision est fournie dans la liste d’expressions.
Voici par exemple comment demander d’attribuer à la valeur de la précision (*)
la valeur de n et d’afficher en %8.*f la valeur de x (la valeur de n, consommée pour
la valeur du paramètre, n’étant pas affichée) :
printf ("%8.*f", n, x);
n = 1 x = 1.2345 1.2
n = 3 x = 1.2345 1.234
Exemple
Dans le programme suivant, nous jouons simultanément sur les indications de
gabarit et de précision d’un affichage flottant :
Gabarit et précision variables
#include <stdio.h>
int main()
{ int n, p ;
float x ;
x = 1.23456 ;
for ( n=6 ; n<= 8 ; n++ )
for ( p=0 ; p<=5 ; p++ )
printf (" %d.%d : %*.*f\n", n, p, n, p, x) ;
}
6.0 : 1
6.1 : 1.2
6.2 : 1.23
6.3 : 1.235
6.4 : 1.2346
6.5 : 1.23456
7.0 : 1
7.1 : 1.2
7.2 : 1.23
7.3 : 1.235
7.4 : 1.2346
7.5 : 1.23456
8.0 : 1
8.1 : 1.2
8.2 : 1.23
8.3 : 1.235
8.4 : 1.2346
8.5 : 1.23456
Remarques
1. Ici, nous avons également affiché les valeurs effectives de n et de p (en %d), ce qui explique que ces
variables apparaissent deux fois dans la liste.
2. On peut utiliser cette possibilité de paramètre variable pour pallier l’impossibilité d’utiliser une
constante définie par #define dans un format. Par exemple, avec :
#define LG 10
l’instruction suivante ne convient pas :
printf ("quantite : %LGd\n", n) ; /* ne convient pas */
car les caractères LG ne sont pas remplacés par le préprocesseur à l’intérieur de la chaîne constante
représentant le format. On aboutit à un code de format invalide qui, dans la plupart des
implémentations conduirait simplement à afficher :
quantite : %LGd
En revanche, en procédant ainsi :
printf ("quantite : %*d\n", LG, n) ;
on obtient bien le résultat escompté (valeur de n affichée sur 10 emplacements).
Une autre démarche consisterait à créer le format au moment de l’exécution, de cette manière :
char format[20] ;
…..
sprintf (format, "quantite : %%%dd\n", LG) ;
printf (format, n) ;
3.5 Le code de format g
3.5.1 Pour imposer un nombre de chiffres significatifs aux
flottants
Avec le code de format e (ou E), on maîtrise la précision mais l’affichage n’est
pas toujours plaisant. Avec le code f, l’affichage est plaisant pour des valeurs ni
trop grandes ni trop petites, mais on ne maîtrise pas le nombre de chiffres
significatifs.
Avec le code de format g, on réunit les avantages des deux codes précédents.
D’une part, la précision mentionnée correspond toujours exactement au nombre
de chiffres significatifs. D’autre part, seuls les nombres dont la valeur absolue
n’est ni trop petite, ni trop grande, sont affichés avec une notation flottante,
analogue à celle obtenue avec le code f. Les autres nombres sont affichés avec
une notation exponentielle, analogue à celle obtenue avec le code e.
Plus précisément, sont considérées comme trop grandes les valeurs dont
l’exposant est supérieur ou égal à la précision indiquée. Cela revient à dire que
l’affichage en notation flottante conduirait à une précision inférieure à celle
escomptée. Par exemple, si l’on a choisi une précision de 6 :
• la valeur 12.3456789 s’affichera en notation flottante sous la forme 12.3457 (6
chiffres significatifs) ;
• en revanche, la valeur 1234.56789e12 s’affichera en notation exponentielle
sous la forme 1.23457e15 (toujours 6 chiffres significatifs).
De même, sont considérées comme trop petites, les valeurs inférieures9 à 10-4.
Par exemple, si l’on a choisi une précision de 6 :
• la valeur 0.01234567 (ou 1.234567e-3) s’affichera en notation flottante sous la
forme 0.0123457 (6 chiffres significatifs) ;
• en revanche, la valeur 0.0000001234567 (ou 1.234567e-7) s’affichera en
notation exponentielle sous la forme 1.23457e-07 (toujours 6 chiffres
significatifs).
Exemple
Le programme suivant affiche selon des codes de format différents (%g, %10g, %.5g
et %10.5g) la même liste de valeurs que nous avons introduite ici dans le tableau x :
Utilisation du code de format g
#include <stdio.h>
#define VALEUR 1.234567
int main()
{ float x [] = {VALEUR * 1e9, VALEUR * 1e4, VALEUR, -VALEUR,
VALEUR * 1e-2, VALEUR * 1e-6 } ;
int i ;
printf ("En %%g\n") ;
for (i=0 ; i<sizeof(x)/sizeof(x[1]) ; i++)
printf (":%g", x[i]) ;
printf (":\n") ;
printf ("En %%10g\n") ;
for (i=0 ; i<sizeof(x)/sizeof(x[1]) ; i++)
printf (":%10g", x[i]) ;
printf (":\n") ;
printf ("En %%.5g\n") ;
for (i=0 ; i<sizeof(x)/sizeof(x[1]) ; i++)
printf (":%.5g", x[i]) ;
printf (":\n") ;
printf ("En %%10.5g\n") ;
for (i=0 ; i<sizeof(x)/sizeof(x[1]) ; i++)
printf (":%10.5g", x[i]) ;
printf (":\n") ;
}
En %g
:1.23457e+09:12345.7:1.23457:-1.23457:0.0123457:1.23457e-06:
En %10g
:1.23457e+09: 12345.7: 1.23457: -1.23457: 0.0123457:1.23457e-06:
En %.5g
:1.2346e+09:12346:1.2346:-1.2346:0.012346:1.2346e-06:
En %10.5g
:1.2346e+09: 12346: 1.2346: -1.2346: 0.012346:1.2346e-06:
3.5.2 Pour supprimer les zéros superflus
Assez curieusement, la norme a prévu que, par défaut, le code de format g
supprime les zéros superflus, ce qui n’est pas le cas avec les codes e ou f. Par
exemple, si l’on utilise le gabarit par défaut et une précision de 6, au lieu
d’afficher :
0.250000
on affichera :
0.25
De même, avec un gabarit de 8 et une précision de 6, on affichera (◊ représentant
un espace) :
◊◊◊◊ 0.25
Le même phénomène se constaterait avec une précision de 4 ou de 3.
Cette règle s’applique quelle que soit la vraie valeur de l’expression concernée.
Par exemple, avec :
x = 0.1 ;
la valeur figurant dans x sera généralement une valeur approchée de 0,1, par
défaut ou par excès selon les implémentations. Supposons que l’erreur de
représentation soit de l’ordre de 10-7. Si on cherche à afficher x avec le code g et
une précision de 6 chiffres, on obtiendra :
0.1
En revanche, si on cherche à l’afficher avec une précision de 8 chiffres, on
obtiendra quelque chose ressemblant à l’une de ces deux présentations :
0.10000027 /* valeur 0.1 représentée par excès */
0.99999978 /* valeur 0.1 représentée par défaut */
Exemple
Voici les résultats qu’on pourrait obtenir avec le programme précédent en
définissant VALEUR égale à 0.1 (au lieu de 1.234567) et en ajoutant un affichage
supplémentaire avec le code de format %.9g :
Suppression des zéros superflus avec le code g
En %g
:1e+[Link].1:-0.1:0.001:1e-07:
En %10g
: 1e+08: 1000: 0.1: -0.1: 0.001: 1e-07:
En %.5g
:1e+[Link].1:-0.1:0.001:1e-07:
En %10.5g
: 1e+08: 1000: 0.1: -0.1: 0.001: 1e-07:
En %.9g
:10000[Link].100000001:-0.100000001:0.00100000005:1.00000001e-07:
3.5.3 Le drapeau # évite la suppression des zéros superflus
On verra à la section 4.2 qu’il est possible d’introduire dans un code format,
immédiatement après %, ce qu’on nomme un drapeau. Il s’agit d’un caractère qui
peut influer sur la manière dont le code de format est interprété. Parmi ces
drapeaux, on trouve le caractère #, qui joue en fait un rôle très différent suivant
les codes concernés. Dans le cas du code g, il permet précisément d’éviter la
suppression des zéros superflus, particulière à ce code.
Exemple
Voici les résultats qu’on pourrait obtenir avec le programme précédent en
introduisant systématiquement le drapeau # dans tous les codes de format :
Utilisation du drapeau # associé au code de format g
En %#g
:1.00000e+08:1000.00:0.100000:-0.100000:0.00100000:1.00000e-07:
En %#10g
:1.00000e+08: 1000.00: 0.100000: -0.100000:0.00100000:1.00000e-07:
En %#.5g
:1.0000e+08:1000.0:0.10000:-0.10000:0.0010000:1.0000e-07:
En %#10.5g
:1.0000e+08: 1000.0: 0.10000: -0.10000: 0.0010000:1.0000e-07:
En %#.9g
:100000000.:1000.00000:0.100000001:-0.100000001:0.00100000005:1.00000001e-07:
3.6 Le drapeau + force la présence d’un signe « plus »
Par défaut, le signe - s’affiche toujours, tandis que le signe + ne s’affiche jamais,
qu’on ait imposé ou non un gabarit. Qui plus est, lorsqu’aucun gabarit n’est
imposé, à valeur absolue égale, les nombres négatifs occupent un emplacement
de plus que les positifs, comme le rappellent ces exemples :
printf (":%d:", n) ;
n = 123 :123:
n = -123 :-123:
printf (":%e:", x) ; /* notation exponentielle - gabarit par défaut */
/* (6 chiffres après point) */
x = 1.2345 :1.234500e+00:
x = -1.2345 :-1.234500e+00:
On peut demander que le signe + s’affiche au même titre que le signe - en
utilisant le drapeau +. Rappelons qu’un drapeau est un caractère particulier qu’on
introduit à la suite de % et qui peut influer sur la manière dont le code de format
est interprété. Voici ce que deviennent les deux exemples précédents lorsqu’on
utilise le drapeau + :
printf (":%+d:", n) ;
n = 123 :+123:
n = -123 :-123:
printf (":%+e:", x) ; /* notation exponentielle - gabarit par défaut */
/* (6 chiffres après point) */
x = 1.2345 :+1.234500e+00:
x = -1.2345 :-1.234500e+00:
D’une manière générale, on notera que, en utilisant le drapeau + :
• à valeur absolue égale, les nombres positifs et les nombres négatifs occupent
toujours le même nombre d’emplacements ;
• avec le code de conversion e, sans gabarit, les affichages occupent le même
nombre d’emplacements, quelle que soit la valeur du nombre concerné.
3.7 Le drapeau espace force la présence d’un espace
Ce drapeau joue un rôle analogue au drapeau +, avec cette seule différence qu’il
permet d’imposer la présence d’un espace en début de tout nombre positif
(espace qui prend, en quelque sorte, la place du signe +). En voici deux
exemples :
printf (":% d:", n) ; /* attention désigne un espace entre % et d */
n = 123 : 123:
n = -123 :-123:
printf (":% e:", x) ; /* attention désigne un espace entre % et d */
x = 1.2345 : 1.234500e+00:
x = -123.45 :-1.234500e+00:
3.8 Le drapeau 0 permet d’afficher des zéros de
remplissage
Par défaut, lorsqu’un gabarit est indiqué dans un code de format numérique, on
peut voir apparaître des espaces à gauche, comme dans cet exemple :
printf (":%4d:", n) ;
n = 12 : 12:
n = -2 -2:
On peut demander que le gabarit soit totalement occupé par des chiffres en
ajoutant, si nécessaire des chiffres 0. Pour ce faire, il suffit d’utiliser le drapeau
0. Nous commencerons par examiner le rôle de ce drapeau avec des valeurs
numériques quelconques, avant de voir comment l’utiliser conjointement avec
une indication de précision dans le cas des entiers.
3.8.1 Cas général
printf (":%04d:", n) ;
n = 12 :0012:
n = -2 :-002:
n = 123 :0123:
n = 1234 :1234:
n = 12345 :12345:
printf (":012f:", x) ;
x = 1.2345 ; :00001.234500:
x = -1.2345 ; :-0001.234500:
printf (":%012.4f:", x) ;
x = 1.2345 ; :0000001.2345:
x = -1.2345 ; :-000001.2345:
printf (":%015e:", x) ;
x = 1.2345 ; :0001.234500e+00:
x = -1.2345 ; :-001.234500e+00:
printf (":%015.4e:", x) ;
x = 1.2345 ; :-00001.2345e+00:
x = -1.2345 ; :000001.2345e+00:
On notera bien que le caractère de remplissage qu’est le zéro prend
effectivement la place des espaces dans le cas où aucun signe n’est présent. En
revanche, il s’insère tout naturellement entre le signe et les premiers chiffres
dans le cas contraire.
Remarque
Le drapeau 0 est ignoré lorsqu’il est utilisé conjointement avec le drapeau - de justification à gauche.
En revanche, il peut tout naturellement être utilisé conjointement avec le drapeau + imposant la
présence d’un signe +, comme dans les exemples suivants :
printf (":%+04d:", n) ;
n = 12 :+012:
printf (":%+015.4e:", x) ;
x = 1.2345 :+00001.2345e+00:
3.8.2 Cas particulier des codes relatifs à des entiers
La méthode précédente ne laisse jamais subsister un seul espace dans le gabarit.
Dans le cas des entiers, on peut limiter le remplissage à un nombre de caractères
inférieur au gabarit. Pour ce faire, on utilise l’indication de précision qui
représente, dans ce cas, un nombre minimal de chiffres à afficher, quitte à
compléter l’affichage par des zéros pour atteindre ce minimum. En voici deux
exemples :
printf (":%8.4d:%08d:", n, n) ; /* on affiche n en 8.4d puis en 08d */
n = 12 : 0012:00000012: /* représente un espace */
n = -56 : -0056:-0000056: /* représente un espace */
printf (":%8.4x:%08x:", n, n) ;
n = 12 : 000c:0000000c: /* représente un espace */
Remarques
1. On notera ici le rôle relativement artificiel du paramètre dit de précision, qui n’a plus aucun lien
avec la notion de précision numérique.
2. Le drapeau 0 utilisé conjointement avec un paramètre de précision dans un code relatif à un entier
est tout simplement ignoré. Ainsi, %08.4d est-il équivalent à %8.4d.
3.9 Le paramètre de précision permet de limiter
l’affichage des chaînes
Si on affiche des chaînes en utilisant le code de conversion s, il est possible de
limiter le nombre de caractères affichés. Pour ce faire, on utilise, ici encore, de
façon relativement artificielle, le paramètre de précision dont la signification est
à nouveau sans rapport avec celle de précision numérique.
Exemple
Utilisation du paramètre de précision pour limiter l’affichage d’une chaine
#include <stdio.h>
int main()
{ char * ch = "bonjour monsieur" ;
printf ("En %%s :%s:\n", ch) ;
printf ("En %%20s :%20s:\n", ch) ;
printf ("En %%20.10s :%20.10s:\n", ch) ;
printf ("En %%10.20s :%10.20s:\n", ch) ;
printf ("En %%.10s :%.10s:\n", ch) ;
printf ("En %%.20s :%.20s:\n", ch) ;
printf ("En %%5.10s :%5.10s:\n", ch) ;
}
En %s :bonjour monsieur:
En %20s : bonjour monsieur:
En %20.10s : bonjour mo:
En %10.20s :bonjour monsieur:
En %.10s :bonjour mo:
En %.20s :bonjour monsieur:
En %5.10s :bonjour mo:
On notera qu’un code de format de la forme %[Link] où n et p désignent deux
constantes, dans lequel la valeur de n est inférieure ou égale à celle de p, devient
complètement équivalent à %.ps. On peut le constater dans l’exemple précédent
pour %5.10s et %.10s.
3.10 Cas particulier du type unsigned short int : le
modificateur h
Rappelons que le type unsigned short subit une promotion numérique :
• en int si ce type est suffisant dans l’implémentation concernée pour représenter
n’importe quelle valeur du type unsigned short ;
• en unsigned int dans le cas contraire.
Cela signifie que, compte tenu des promotions numériques auxquelles sont
soumis les arguments de printf, une expression de type unsigned short devrait,
suivant l’implémentation, être affichée avec un code de conversion
correspondant soit à int, soit à unsigned int. Dans ces conditions, il deviendrait
difficile d’assurer la portabilité des programmes. C’est la raison pour laquelle la
norme a prévu le modificateur h. Il permet d’utiliser des codes de conversion de
la forme hu, ho, hx ou hX pour un argument effectif de type unsigned short, alors que,
au bout du compte, printf ne peut jamais recevoir de valeur de type unsigned short.
Dans les implémentations (quasiment universelles) utilisant la notation en
complément à deux, on peut montrer que le modificateur h n’est pas
indispensable pour le type unsigned short.
En outre, ce modificateur h est également utilisable avec le type short. Mais
comme celui-ci est en fait soumis à une promotion numérique en int, il revient
au même d’utiliser les codes de conversion prévus pour le type int (d ou i) ou les
mêmes codes avec le modificateur h (hd ou hi).
4. Description des codes de format des fonctions de la
famille printf
Bien que ce chapitre se limite à l’étude des entrées-sorties standards, nous avons
regroupé dans cette section ce qui concerne la fonction printf, mais aussi toutes
les fonctions qu’on peut qualifier comme appartenant à la même famille. Il s’agit
de fonctions qui font exactement le même usage des codes de format, à savoir :
fprintf, sprintf, vprintf, vfprintf et vsprintf. Nous utiliserons le terme « afficher »,
lequel correspond effectivement au travail effectué par printf ou vprintf. En toute
rigueur, pour les autres fonctions, il s’agit de l’introduction de caractères dans un
emplacement mémoire ou dans un fichier.
Par ailleurs, vous trouverez à la section 8.7 un tableau récapitulant les
différences existant entre les codes de format en entrée et en sortie.
4.1 Structure générale d’un code de format
Chaque code de format a la structure suivante :
% [drapeaux] [gabarit] [précision] [h|l|L] conversion
Les crochets ([ et ]) signifient que ce qu’ils renferment est facultatif. Les
sections suivantes décrivent de façon très complète la nature et le rôle de ces
différents paramètres. Pour en favoriser l’utilisation, chaque description est
exhaustive, de sorte que certains points peuvent se trouver mentionnés en
différents endroits. De plus, à des fins pratiques, nous vous proposons à la
section 4.7 un tableau récapitulatif indiquant les codes de conversion et les
éventuels modificateurs utilisables avec les différents types d’informations
possibles.
4.2 Le paramètre drapeaux
Le paramètre drapeaux est formé de 0, 1 ou plusieurs des caractères indiqués dans
le tableau 9.4, dans un ordre quelconque10.
Tableau 9.4 : le paramètre drapeaux
-
Justification à gauche (par défaut, la justification se fait à
droite, y compris pour les chaînes). Ce drapeau ne joue
aucun rôle lorsque le paramètre de gabarit est absent.
+
Signe toujours présent, pour les valeurs positives même
lorsqu’il s’agit du signe + (par défaut, le signe + n’est pas
affiché). Ce drapeau ne concerne que les codes de
conversion dits « signés », c’est-à-dire : d, i, e, E, f, g et G.
(espace)
^
Impression d’un espace au lieu du signe + (lorsque celui-ci
n’a pas à être imprimé). Plus précisément, si le premier
caractère à afficher n’est pas un signe ou s’il n’y a rien à
imprimer, un espace sera ajouté à gauche (indépendamment
de la présence ou de l’absence du drapeau -). Si le drapeau +
est présent, le drapeau espace est ignoré.
#
Forme dite « alternée ». Elle n’affecte que les types o, x, X, e,
E, f, g et G comme suit :
o
Fait précéder le résultat du chiffre 0.
x (ou X) Fait précéder de 0x (ou 0X) toute valeur non
nulle.
, ou f
e E Le point décimal est toujours présent, même
si aucun chiffre n’apparaît à sa suite. Par
défaut, ce point n’apparaît que lorsqu’il est
suivi d’au moins un chiffre. En particulier,
lorsque l’on impose une précision 0, le seul
moyen d’obtenir quand même l’affichage du
point décimal est d’utiliser le drapeau #.
g ou G Même effet que pour e ou E, mais les zéros
de droite ne seront pas supprimés.
0
Utilisation du caractère 0 comme caractère de remplissage,
au lieu de l’espace (les éventuelles indications de signe et de
base restant placées avant ce ou ces zéros), dans le cas où la
justification se fait à droite, c’est-à-dire lorsque le drapeau -
n’est pas utilisé. Cette possibilité ne concerne que les codes
de conversion e, E, f, g, G (dans tous les cas), ainsi que les
codes d, i, o, u, x et X, dans le cas où aucune précision n’est
indiquée (en fait, le drapeau 0 devient superflu dans ce
dernier cas).
En cas de présence de drapeau non prévu pour un code de conversion donné, la
norme indique que le comportement du programme est indéterminé. En pratique,
il arrive souvent que le drapeau excédentaire soit ignoré ou que le code de
format soit affiché tel quel.
4.3 Le paramètre de gabarit
Lorsqu’il est présent, le paramètre de gabarit définit un gabarit minimal, c’est-à-
dire le nombre minimal de caractères qui seront affichés. Ceux-ci pourront
éventuellement être complétés par des espaces à gauche (drapeau - absent) ou à
droite (drapeau - présent) ou par des 0 (drapeau 0 présent, aucune précision
mentionnée, codes concernés o, x, X, e, E, f, g et G). Ce paramètre de gabarit peut
être fourni sous l’une des deux formes suivantes :
Tableau 9.5 : le paramètre de gabarit
n
Constante entière positive écrite en notation décimale (0 peut
théoriquement apparaître à cet endroit mais il s’agit alors du drapeau
0).
*
La valeur effective du gabarit est fournie dans la liste d’expressions,
ce qui signifie que la présence du caractère * entraînera la
« consommation » d’une valeur.
Ce paramètre de gabarit n’a pas de signification avec les codes de conversion n
et %. La norme prévoit que le comportement du programme est indéterminé si on
en fournit un par erreur. En pratique, on obtient le même comportement qu’avec
un code de format invalide.
En l’absence de ce paramètre, printf utilise un gabarit par défaut qui est présenté
dans le tableau suivant. On notera bien que ce dernier peut dépendre, d’une
manière assez complexe, à la fois de la valeur à afficher, du code de conversion
utilisé et du paramètre de précision.
Tableau 9.6 : le gabarit par défaut
Code de
Gabarit par défaut Remarques
conversion
c
1 emplacement
, , , ,
d i o u x Le nombre d’emplacements – le signe + n’est pas
ou X nécessaires, sans espace affiché, sauf si on le
avant ni après demande avec le drapeau
+ ;
– on peut demander de faire
précéder les nombres
positifs d’un espace avec
le drapeau « espace ».
f
– un nombre de décimales – le signe + n’est pas
fixé par la précision si affiché, sauf si on le
elle figure, 6 sinon ; demande avec le drapeau
– auparavant, le nombre + ;
d’emplacements – le point décimal n’est
nécessaires avant (y affiché que s’il est suivi
compris pour le point d’autres chiffres ;
décimal s’il est présent). – on peut demander de faire
précéder les nombres
positifs d’un espace avec
le drapeau « espace ».
,
e E
– sous la forme [Link]±nn – le signe + de la mantisse
(code e) ou [Link]±nn n’est pas affiché, sauf si
(code E) pour les nombres on le demande avec le
positifs ; drapeau + ;
– sous la forme -[Link]±nn – on peut demander de faire
(code e) ou -[Link]±nn précéder les nombres
(code E) pour les nombres positifs d’un espace avec
négatifs ; le drapeau « espace » ;
– u représente un chiffre – par défaut, tous les
différent de 0 ; positifs occupent le même
– les valeurs des décimales gabarit dans une
(d) sont obtenues par implémentation donnée ;
arrondi au plus proche ; il en va de même pour les
leur nombre est fixé par la négatifs dont le gabarit
précision si elle figure, est supérieur de une unité
sinon il est de 6 ; à celui des positifs.
– le nombre de chiffres (n)
de l’exposant (au moins
2) peut varier avec
l’implémentation.
g ou G On obtient toujours un Voir détails précédents pour
nombre de chiffres chacun des codes f, e et E
significatifs égal à la
précision p si elle figure, à 6
sinon ; autrement dit :
– si le nombre est affiché
suivant les règles relatives
au code f, on a un gabarit
total de p+1 (7 par défaut)
si une décimale est
présente, p sinon ;
– si le nombre est affiché
suivant les règles relatives
au code e (pour g) ou E
(pour G), le nombre de
décimales est égal à p-1 (5
par défaut).
s (chaînes) Le nombre d’emplacements
nécessaires, sans espaces
avant, ni après.
p
Dépend de
(pointeurs) l’implémentation.
4.4 Le paramètre de précision
Le paramètre de précision n’a de signification que pour un certain nombre de
caractères de conversion correspondant à des valeurs de type numérique ou de
type chaîne (avec une signification radicalement différente dans ce cas). Il peut
prendre l’une des trois formes indiquées. En l’absence d’une telle indication, on
dispose d’une précision par défaut qui dépend du caractère de conversion. Si
l’on emploie un paramètre de précision en dehors des cas prévus, la norme
précise que le comportement du programme est indéterminé. En pratique, le
paramètre superflu est souvent ignoré.
Tableau 9.7 : le paramètre de précision
Forme du
Caractère
paramètre
de Signification
de
conversion
précision
( =
.n n
, , , ,
d i o u x Au moins n chiffres seront imprimés. Si le
constante ou X nombre comporte moins de n chiffres,
décimale l’affichage sera complété à gauche par des
entière zéros. Notez que cela n’est pas contradictoire
positive ou avec le paramètre de gabarit, si celui-ci est
nulle supérieur à n. En effet, dans ce cas, le nombre
pourra être précédé à la fois d’espaces et de
zéros.
, ou f
e E On obtiendra exactement n chiffres après le
point décimal, avec arrondi du dernier.
g ou G On obtiendra au maximum n chiffres
significatifs. Il s’agit d’une valeur maximale
car les zéros superflus de droite peuvent être
supprimés (dès lors que le drapeau # n’est pas
présent). La précision .0 correspond à 1
chiffre significatif.
s
Au maximum n caractères de la chaîne seront
affichés. Si la chaîne comporte d’avantage de
caractères, les derniers ne seront pas affichés,
même si, par ailleurs, on a donné un gabarit
suffisamment grand (qui sera alors complété
par des espaces).
autre Comportement du programme théoriquement
indéterminé. En pratique, la valeur du
paramètre de précision est ignorée.
.
l’un des Même comportement qu’avec la précision .0
précédents
.*
l’un des La valeur effective de n est fournie dans la
précédents liste d’expressions (ce qui signifie que la
présence du caractère * entraînera la
« consommation » d’une valeur de la liste
d’arguments).
Tableau 9.8 : la précision par défaut
Caractère de conversion Précision par défaut
, , , , ou X
d i o u x 1
, , , ou G
e E f g 6
4.5 Le paramètre modificateur h/l/L
Ce paramètre, facultatif, est l’un des trois caractères h, l ou L. Il n’a de
signification que pour les codes de conversion cités dans le tableau 9.9. Pour tout
autre code de conversion, le comportement du programme est théoriquement
indéfini. En pratique, le caractère modificateur est ignoré.
Tableau 9.9 : le paramètre modificateur h/l/L
Caractère
Valeur du
de Signification
paramètre
conversion
h
,
d i L’expression correspondante était, avant
conversion, d’un type short int. En fait,
compte tenu des règles de conversion
implicite, printf ne reçoit jamais de valeur de
type short int mais un int. L’usage de h dans
ce cas est donc inutile mais il reste autorisé
par la norme.
, , ,
u o x X L’expression correspondante était, avant
conversion, d’un type unsigned short. En fait,
compte tenu des règles de conversion
implicite, printf ne reçoit jamais de valeur de
type unsigned short mais seulement, suivant
l’implémentation, une valeur de type unsigned
int ou int. L’usage de h, dans ce cas, s’avère
pratique puisqu’il évite, par exemple, d’avoir
à recourir tantôt à %u, tantôt à %hu suivant
l’implémentation. On notera cependant que
dans les implémentations (quasi universelles)
utilisant la notation en complément à deux et
un bit de signe à gauche, la représentation
binaire d’un entier positif étant la même en
unsigned int ou en int, l’oubli du modificateur
h n’entraîne aucune anomalie.
n
L’expression correspondante est d’un type
short int * ou unsigned short int *.
l ,
d i L’expression correspondante est de type long
int.
, , ou X
o u x L’expression correspondante est de type
unsigned long int.
n
L’expression correspondante est d’un type
long int * ou unsigned long int *.
L
, , , ou
e E f g L’expression correspondante est de type long
G
double.
4.6 Les codes de conversion
Le code de conversion est un caractère qui précise à la fois :
• le type de l’expression reçue par la fonction. Ce type peut également dépendre
d’un éventuel modificateur h/l/L. Par ailleurs, on ne perdra pas de vue que
l’argument effectif correspondant aura pu être soumis à une conversion ;
• la façon de présenter sa valeur.
Le tableau 9.10 indique le rôle de chaque code de conversion, en tenant compte
des éventuels modificateurs. Il indique le type de la valeur effectivement reçue
par la fonction, c’est-à-dire après une éventuelle conversion. Un autre tableau,
proposé à la section 4.7, permet de retrouver les codes utilisables pour un
argument effectif de type donné et donc d’éviter d’avoir à s’interroger sur les
conversions susceptibles d’être mises en jeu.
Tableau 9.10 : les codes de conversion des fonctions de la famille11 printf
Code de Type valeur
Rôle
conversion reçue
,
d i - signed int en Affichage en base 10 avec éventuellement
l’absence de un signe (le signe - est toujours affiché,
modificateur ; tandis que le signe + ne l’est que si le
- long int avec drapeau + est présent). La précision (si elle
le ne figure pas, elle est de 1 par défaut)
modificateur indique le nombre minimal de chiffres à
l. afficher. Ces derniers seront
éventuellement précédés de zéros, à
concurrence de la précision voulue. La
valeur 0 affichée avec une précision 0
conduit à l’affichage d’aucun caractère. Le
code i, introduit par la norme ANSI est
entièrement équivalent au code d.
, , ou X
o u x - unsigned int Affichage :
en l’absence
de – en base 8 (octal) pour le code o ;
modificateur ; – en base 10 (décimal) pour le code u ;
- unsigned long – en base 16 (hexadécimal), avec les
avec le caractères :
modificateur
l ; 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e et
- résultant de f pour le code x
la promotion 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E
numérique et F pour le code X
d’un unsigned La précision (si elle ne figure pas, elle est
short avec le de 1 par défaut) indique le nombre
modificateur minimal de caractères à afficher. Ces
h. derniers seront éventuellement précédés
de zéros, à concurrence de la précision
voulue. La valeur 0 affichée avec une
précision 0 conduit à l’affichage d’aucun
caractère.
f
- double en Affichage en base 10 avec éventuellement
l’absence de un signe (le signe - est toujours affiché,
modificateur tandis que le signe + ne l’est que si le
(l’argument drapeau + est présent). La précision (si elle
effectif peut ne figure pas, elle est de 6 par défaut)
être de type indique le nombre de chiffres suivant le
float) ; point décimal. Avec la précision 0, le point
- long double décimal n’est pas affiché, sauf si le
avec le drapeau # est présent. Un point décimal est
modificateur toujours précédé d’au moins un chiffre
L. (éventuellement le chiffre 0).
,
e E - double en Affichage en notation exponentielle (avec
l’absence de la lettre e pour le code e et la lettre E pour
modificateur le code E). La mantisse est comprise entre
(l’argument 1 (inclus) et 10 (exclu) sauf si la valeur à
effectif peut afficher est nulle, auquel cas la mantisse
être de type est égale à 0. La précision (si elle ne figure
float) ; pas, elle est de 6 par défaut) indique le
- long double nombre de chiffres après le point décimal
avec le de la mantisse. Avec la précision 0, le
modificateur point décimal n’est pas affiché, sauf si le
L. drapeau # est présent. Le nombre de
chiffres de l’exposant peut dépendre de
l’implémentation, ainsi que du type (par
exemple, il peut différer entre e et Le), mais
il ne peut jamais être inférieur à deux. La
valeur nulle est toujours affichée avec un
exposant nul.
,
g G - double en Affichage suivant les règles relatives au
l’absence de code f ou au code e (pour g et E pour G)
modificateur suivant la valeur concernée : la notation
(l’argument exponentielle est utilisée si l’exposant
effectif peut correspondant est inférieur à -4 ou
être de type supérieur ou égal à la précision (si elle ne
float) ; figure pas, elle est de 6 par défaut). La
- long double précision indique ici le nombre de chiffres
avec le significatifs (sa signification diffère donc
modificateur de une unité par rapport aux codes e ou E).
L. Les zéros de fin de la partie décimale de la
mantisse sont supprimés, sauf si le
drapeau # est présent. Le point décimal
n’est affiché que s’il est suivi d’au moins
un chiffre (à moins que le drapeau # ne soit
présent, auquel cas le point décimal est
toujours affiché).
c int
La valeur reçue est convertie en unsigned
char et le caractère correspondant est
affiché. On notera bien que, compte tenu
des règles de conversion de char (signé ou
non) en int, puis de int en unsigned char, on
est toujours assuré que, quelle que soit la
valeur de l’argument effectif d’un type char
signé ou non, le motif binaire du caractère
d’origine est préservé.
s char *
signed char *
La valeur reçue est considérée comme un
ou unsigned pointeur sur une chaîne de caractères. Les
char * caractères de cette chaîne (jusqu’au zéro
de fin non compris) sont affichés. Si une
précision figure avec ce code, elle indique
un nombre maximal de caractères à
prélever dans la chaîne (si ce nombre est
inférieur à la taille de la chaîne, il n’est
alors plus nécessaire que la chaîne
comporte un zéro de fin).
p void *
Affichage sous une forme dépendant de
l’implémentation
%
aucun Affichage du caractère %, sans faire appel à
argument de aucune expression de la liste. Attention, le
la liste n’est code de format complet est bien alors %%.
consommé ici
n
Place, à l’adresse désignée par
l’expression de la liste (qui doit être du
type pointeur sur un int), le nombre de
caractères écrits jusqu’ici. Attention, ce
code n’affiche aucun caractère.
4.7 Les codes utilisables avec un type donné
Le tableau suivant permet de retrouver rapidement les différents codes de
conversion et modificateurs qu’il est possible d’utiliser pour afficher une
expression d’un type donné. Il tient compte des conversions susceptibles
d’intervenir, de sorte qu’il évite d’avoir à s’interroger à ce sujet. Par exemple, on
voit que pour afficher une expression de type float, on peut utiliser l’un des
codes f, e, E, g ou G. On n’a pas nécessairement besoin de savoir qu’une
conversion de float en double sera mise en place dans ce cas.
Bien entendu, ce tableau devra être complété par le tableau précédent pour
obtenir plus d’informations sur le rôle exact de chaque code et sur la
présentation obtenue.
Tableau 9.11 : les codes de format utilisables pour un type donné
Type de
Code de
l’expression
conversion et Commentaires
figurant dans
modificateur
l’appel
char c
signed char – dans les trois cas, l’argument
unsigned char
effectif est converti en int ;
– d est utilisable si on souhaite
obtenir le code du caractère.
signed short int
d ou i hd est équivalent à d et hi est
équivalent à i (voir section 3.10).
unsigned short int
, , ou hX
hu ho hx Ici u, o, x ou X ne sont pas toujours
utilisables (voir section 3.10).
signed int
d ou i
unsigned int
, , ou X
u o x
signed long int
ld ou li
unsigned long int
, , ou lX
lu lo lx
float
, , , ou G
f e E g L’argument effectif est converti en
double.
double
, , , ou
Lf Le LE Lg
LG
long double
, , , ou
lf le lE lg
lG
void * p
Remarque
Il est toujours possible d’employer un code correspondant à un entier non signé avec un entier signé de
même taille et vice versa. Bien entendu, la valeur affichée dépendra alors de l’implémentation. Cette
possibilité peut s’avérer pratique pour obtenir le motif binaire (en octal ou en hexadécimal)
correspondant à un entier signé.
5. La fonction putchar
Alors que la fonction printf permet d’afficher des informations de type scalaire
quelconque sur la sortie standard, la fonction putchar est destinée à afficher un
seul caractère. On peut dire que, si c est de type char, ces deux instructions jouent
le même rôle :
printf ("%c", c) ;
putchar (c) ;
L’emploi de putchar peut se justifier par une plus grande simplicité et surtout une
plus grande rapidité. En effet, printf nécessite obligatoirement l’analyse du
format (même si, comme ici, il est très simple), ce qui n’est plus le cas avec
putchar.
5.1 Prototype
int putchar (intc) ; (stdio.h)
c
Valeur entière qui sera convertie en unsigned Voir détails
char, avant d’être transmise à la sortie à la section
standard. 5.2
Valeur de Caractère réellement écrit, la valeur EOF en Voir détails
retour cas d’erreur. à la section
5.3
Ce prototype impose quelques explications, à la fois par le type exact de son
argument et par la signification de sa valeur de retour.
5.2 L’argument de putchar est de type int
La fonction putchar reçoit en argument non pas une valeur de type char, comme on
pourrait s’y attendre, mais une valeur de type int. Cette curiosité semble se
justifier par le souhait d’harmoniser putchar avec getchar qui doit pouvoir fournir
un int et non un char.
Quoi qu’il en soit, la norme prévoit que la valeur de type int reçue en argument
sera convertie en unsigned char, avant d’être transmise à la sortie standard. Dans
ces conditions, si, comme c’est généralement le cas, on appelle cette fonction
avec un argument de type caractère, on fera apparaître l’une des deux séries de
conversions suivantes :
→ int → unsigned char
unsigned char
signed char → int → unsigned char
Celles-ci sont examinées en détail à la section 9.6 du chapitre 4. En théorie,
seule la première conserve le motif binaire mais, en pratique, la seconde le fait
également dans toutes les implémentations.
Par ailleurs, on peut théoriquement transmettre une valeur entière quelconque à
cette fonction ; toutefois, si cette dernière dépasse la capacité du type unsigned
char, on fera intervenir une conversion dégradante. Dans les implémentations
utilisant la représentation en complément à deux, cela reviendra à ne conserver
que l’octet le moins significatif. Ainsi, avec :
int i ;
…..
for (i=0 ; i<1024 ; i++) /* on écrit 1 024 caractères */
putchar (i) ;
on obtiendra quatre fois la même suite de caractères, comme si l’on avait
procédé ainsi :
int i, j ;
…..
for (j=0 ; j<4 ; j++) /* on écrit 4 fois */
for (i=0 ; i<256 ; i++) /* les mêmes 256 caractères */
putchar (i) ;
5.3 La valeur de retour de putchar
La fonction putchar fournit la valeur du caractère réellement écrit lorsque
l’opération s’est bien déroulée. On notera bien que cette valeur de retour peut
être différente de celle transmise en argument, lorsque cette dernière n’est pas
représentable dans le type unsigned char. Par exemple, avec :
int n, res ;
res = putchar (n) ;
la valeur obtenue dans res sera la même que si l’on avait écrit :
res = (unsigned char) n ;
La fonction putchar fournit la valeur EOF en cas d’erreur (panne du matériel,
manque de place sur une unité lorsque la sortie a été redirigée vers un fichier…),
lorsque ces anomalies n’ont pas déjà été prises en charge par le système
d’exploitation.
6. Présentation générale de scanf
Cette section étudie les notions générales qui interviennent dans l’utilisation de
la fonction scanf : format, code de format, rôle des différents arguments et types
autorisés, valeur de retour, risques d’erreur de programmation. On notera bien
que :
• la description des différents codes de format, ainsi que l’usage qu’on peut en
faire, font l’objet des sections suivantes ;
• la plupart des notions étudiées ici se généralisent aux autres fonctions de la
famille scanf, à savoir fscanf, sscanf ; lorsque ce ne sera pas le cas, nous le
préciserons.
6.1 Format de sortie, code de format et code de
conversion
La fonction scanf lit une suite de caractères sur l’unité standard d’entrée et
effectue une opération de formatage en se fondant sur son premier argument,
nommé précisément format. Les arguments suivants indiquent les emplacements
où seront rangées les informations binaires ainsi obtenues.
Comme dans le cas de printf, le format destiné à scanf contiendra ce que l’on
nomme des codes de format, à raison d’un code par information à lire. Ces codes
se reconnaissent à ce qu’ils commencent par le caractère %. Ils sont composés, en
plus de ce %, d’un ou plusieurs caractères parmi lesquels on trouve un « code de
conversion ». Ce dernier indique la nature de la conversion à effectuer, par
exemple d pour une conversion d’un nombre entier en un int, f pour une
conversion d’un nombre décimal quelconque en un float… D’autres éléments
peuvent également être spécifiés comme le « gabarit » ou la « précision ».
Il est très important de noter que, malgré certaines similitudes, scanf diffère de
printf sur un certain nombre de points :
• tout d’abord, pour printf, les arguments à partir du deuxième pouvaient être
d’un type scalaire quelconque, y compris pointeur ; pour scanf, il ne peut s’agir
que de pointeurs ;
• la signification exacte des codes de format peut varier sensiblement d’une
fonction à l’autre, car notamment les arguments numériques de printf étaient
soumis à des promotions numériques tandis que ceux de scanf ne sont soumis à
aucune conversion ;
• bien que l’on puisse, dans le format de scanf, trouver d’autres caractères que des
codes de format, ceux-ci, exception faite de l’espace, ne joueront pas le même
rôle qu’avec printf ; en effet, comme nous le verrons à la section 7.4, il s’agira
de caractères dont on impose obligatoirement la présence en entrée.
6.2 L’appel de scanf
6.2.1 Syntaxe
Comme scanf est une fonction à arguments variables, son prototype n’apporte
aucune information sur le type de ses arguments, exception faite pour le premier
qui correspond au format. C’est pourquoi nous fournissons ici en parallèle ce
que nous nommons – par abus de langage puisqu’il ne s’agit plus d’une
instruction – la « syntaxe » de l’appel de scanf (les crochets signifient que leur
contenu est facultatif) :
La fonction scanf
scanf ( format [,liste_d_adresses] )
int scanf (const char * format, …) ; (stdio.h)
format
Valeur de type char * ; Il peut donc Le contenu détaillé
s’agir indifféremment : du format est
étudié aux sections
– d’une variable, voire d’une 7 et 8.
expression de ce type ;
– d’une constante chaîne (traduite,
par le compilateur, en un
pointeur constant de type char
*).
liste_d_adresses
Suite d’expressions (séparées par Le cas de
des virgules) de type pointeur sur désaccord entre
une lvalue d’un type en accord format et
avec le code de format liste_d_adresses est
correspondant. étudié à la section
6.3.
Valeur de Nombre de valeurs lues étude détaillée à la
retour correctement et affectées à des section 6.6.
éléments de la liste
(éventuellement 0) ou EOF en cas
d’erreur ou de fin de fichier.
La liste d’adresses peut être théoriquement absente, ce qui est peu fréquent
puisqu’elle peut uniquement servir à obliger l’utilisateur à frapper certains
caractères imposés par le format (voir section 7.5)
6.2.2 Types des informations à lire
La fonction scanf a été prévue pour lire uniquement des informations de type
scalaire, (caractère, numérique ou pointeur) dont on lui fournit les adresses
auxquelles on souhaite les placer. Elle ne peut pas lire de valeurs de type
structure. Par ailleurs, si on lui fournit un nom de tableau, tout se passe comme
on si on lui fournissait l’adresse de son premier élément. Mais si l’on ne peut pas
lire les différentes valeurs d’une structure ou d’un tableau en communiquant une
seule adresse à scanf, il reste toujours possible de lire individuellement chacun
des éléments ou chacun des champs. Pour ce faire, on peut toujours fournir à
scanf autant d’adresses qu’il y a de valeurs à lire. Dans le cas d’un tableau, on
peut également répéter la même instruction de lecture pour chacun de ses
éléments.
Contrairement à ce qui se produit pour printf, aucune conversion n’a lieu pour
les arguments effectifs de scanf puisqu’il s’agit de pointeurs (bien sûr, si par
erreur on transmet à scanf un char au lieu d’un char *, il y aura conversion en
int…). Par conséquent, avec scanf, on devra bien distinguer, par exemple, entre la
lecture d’un short et celle d’un int alors qu’avec un argument de type short ou int,
printf recevait toujours un int. On notera qu’une conversion, par exemple de char
* en int *, au niveau des arguments de scanf serait de toute façon absurde
puisqu’elle conduirait à introduire une information de type int à une adresse de
char.
Par ailleurs, scanf étant une fonction à arguments variables, le compilateur ne
dispose d’aucune information sur le type des arguments attendus. Si l’on appelle
scanf avec des pointeurs sur des objets non scalaires, voire en lui transmettant
autre chose que des pointeurs, aucun diagnostic ne sera effectué à la compilation.
En revanche, lors de l’exécution, on aboutira aux conséquences usuelles de non-
concordance entre arguments muets et arguments effectifs examinées à la section
4.8 du chapitre 8. La situation pourra devenir grave puisqu’on pourra être amené
à utiliser un motif binaire quelconque comme une adresse. Les conséquences,
liées à l’introduction d’informations à des adresses aléatoires seront finalement
les mêmes qu’en cas d’appel de scanf avec un trop grand nombre de codes de
format.
6.3 Les risques d’erreurs dans la rédaction du format
Différentes erreurs de programmation risquent d’apparaître dans l’utilisation de
scanf :
• au niveau de chaque code de format qui peut être invalide ou en désaccord avec
le type de l’information correspondante ;
• au niveau du nombre des codes de format.
En aucun cas, ces erreurs ne peuvent être détectées au moment de la
compilation. En revanche, au moment de l’exécution, scanf se base sur le contenu
du format pour décider de la nature et du nombre des informations qu’elle va
placer en mémoire aux adresses indiquées par les arguments suivants.
Comme avec printf, différentes erreurs de rédaction du format risquent de se
manifester lors de l’exécution. En voici un tableau récapitulatif, accompagné du
comportement qu’on peut attendre du programme dans un tel cas. La description
détaillée de ces différentes erreurs est faite à la suite.
Tableau 9.12 : les risques d’erreurs de format avec les fonctions de la famille
scanf
6.3.1 Code de format invalide
Si le caractère % est suivi d’un ou plusieurs caractères ne correspondant pas à un
code de format, la norme précise que le comportement du programme est
indéterminé. C’est le cas, par exemple, avec %5k, %k ou %8.3.f.
En pratique, les choses se dérouleront comme si l’on avait introduit dans le
format des caractères différents d’un code de format. Ils seront alors traités
comme des caractères imposés (voir section 7.4), ce qui, en général, conduira à
des résultats peu satisfaisants.
En outre, un code de format peut être rendu invalide par l’emploi incorrect d’un
modificateur tel que h, l ou L. Là encore, la norme précise que le comportement
du programme est indéterminé. En pratique, le modificateur incorrect est souvent
ignoré.
On notera bien que l’introduction de %d5 au lieu de %5d ne constitue pas à
proprement parler une erreur de programmation mais simplement une
« étourderie », dans la mesure où ceci est interprété comme le code de format %d
suivi simplement du caractère 5 qui se trouve alors traité comme un caractère
imposé.
6.3.2 Code de format en désaccord avec le type de l’expression
Rappelons que scanf, fonction à arguments variables, se fonde sur les codes de
format pour décider de la nature des informations qu’elle doit fabriquer à partir
des données.
Lorsque le code de format, bien qu’erroné, correspond à une information de
même taille (c’est-à-dire occupant la même place en mémoire) que celle de la
lvalue pointée par l’adresse correspondante de la liste, les conséquences de
l’erreur se limitent à l’introduction d’une mauvaise valeur en mémoire. C’est ce
qui se passe, par exemple, lorsque l’on lit une valeur de type int en %u ou une
valeur de type unsigned int en %d. C’est également le cas si l’on lit en %d une
information de type float lorsque l’implémentation représente les types int et
float sur le même nombre d’octets. Dans ce dernier cas, on peut parfois obtenir,
dans certaines implémentations, une erreur d’exécution liée à ce que le motif
binaire ainsi introduit ne correspond pas à une valeur flottante correctement
normalisée.
Les choses seront à peu près comparables si le code de format correspond à une
information de taille inférieure à celle de la lvalue pointée par l’adresse
correspondante de la liste.
En revanche, lorsque le code de format correspond à une information de taille
supérieure à celle de la lvalue pointée par l’adresse correspondante de la liste, il y
aura écrasement d’un emplacement en mémoire consécutif à cette lvalue. Les
conséquences en sont variées et difficilement prévisibles…
6.3.3 Nombre incorrect de codes de format
Comme scanf, fonction à arguments variables, se fonde sur le format pour
déterminer le nombre et le type des valeurs qu’elle doit introduire en mémoire,
on peut affirmer que :
scanf cherche toujours à satisfaire le contenu du format.
Comme avec printf, il faut distinguer deux situations, suivant que l’on a fournit
trop ou trop peu de codes de format.
Si des adresses de la liste n’ont pas de code format, leur contenu ne sera pas
modifié. C’est le cas dans cette instruction où seule la valeur de n sera modifiée,
la valeur de p restant inchangée :
scanf ("%d", &n, &p) ; /* lit seulement la valeur de n suivant le code %d */
Si, en revanche, vous fournissez trop de codes de format, la norme prévoit un
comportement indéterminé du programme. En pratique, les conséquences seront
généralement assez désastreuses puisque scanf cherchera à introduire des
informations à des emplacements d’adresses aléatoires. Lorsque ces adresses se
situeront à l’intérieur des informations manipulées par votre programme, il y
aura écrasement d’emplacements quelconques. Si une adresse est illégale ou en
dehors de votre programme, vous pourrez aboutir à un arrêt de l’exécution (ce
qui est, somme toute, plus satisfaisant). Voici un exemple d’une instruction qui
lit deux valeurs, la seconde étant rangée en un emplacement aléatoire :
scanf ("%d %e ", &n) ; /* lit une valeur suivant le code %d et la range dans n */
/* et lit une autre valeur suivant le code %e et la */
/* range quelque part - ou interrompt l'exécution ! */
Remarque
Avec printf, la seule source d’erreur possible résidait dans les erreurs de programmation. Avec
scanf, on peut se trouver en présence d’erreurs induites par de mauvaises réponses de l’utilisateur.
Celles-ci dépendent en partie du mécanisme d’alimentation du tampon et de la notion de caractère
invalide. Elles seront plus particulièrement examinées à la section 6.7.
6.4 La fonction scanf utilise un tampon
6.4.1 Notions de tampon, de validation et de fin de ligne
Lorsque scanf lit des informations au clavier, l’utilisateur confirme sa frappe en
utilisant une touche particulière dite de validation, de retour ou de fin de ligne.
Tant qu’une telle « validation » n’a pas été opérée, il peut modifier les
informations en cours de frappe, notamment par utilisation de la touche Retour
arrière.
Un tel mécanisme fait donc obligatoirement intervenir un emplacement mémoire
nommé tampon dans lequel sont rangées provisoirement les informations
fournies par l’utilisateur avant qu’elles ne soient effectivement exploitées par
scanf. Ce mécanisme paraît généralement naturel à l’utilisateur. En revanche, il
faut savoir que la validation introduit dans le tampon un « caractère de fin de
ligne ». Dans certains cas, ce caractère sera considéré comme un simple
séparateur d’information et il n’aura alors guère de conséquences. En revanche,
dans d’autres cas, par exemple avec le code de format %c, il pourra apparaître
comme un caractère à part entière.
Remarque
A priori, la norme ne semble pas imposer explicitement l’emploi d’un tampon pour l’unité standard
d’entrée. Plus précisément, elle ne l’impose que pour les unités dites non interactives dont,
manifestement, le clavier ne fait pas partie ! Néanmoins, les possibilités de correction de la ligne en
cours de frappe et d’association d’une fin de ligne à une validation semblent les seules qui soient
raisonnables. Ce sont, en tout cas, les seules que nous ayons rencontrées jusqu’ici.
6.4.2 Mécanisme d’alimentation du tampon
Il n’est pas nécessaire que scanf trouve toute l’information voulue dans un seul
tampon. Si des informations sont attendues alors que tous les caractères du
tampon ont été exploités (y compris la fin de ligne), on demandera simplement à
l’utilisateur de frapper une nouvelle ligne. On notera bien qu’une telle demande
reste « silencieuse », dans la mesure où rien ne prévient l’utilisateur qu’on attend
quelque chose12 de plus.
À l’opposé, si des informations n’ont pas pu être exploitées lors d’une lecture,
elles restent disponibles pour une lecture ultérieure.
Considérons par exemple ces instructions :
scanf ("%d", &n) ;
scanf ("%d", &p) ;
Une réponse naturelle consiste à saisir deux lignes différentes formées chacune
d’un entier, mais deux entiers sur une même ligne conviennent également.
Certes, sur ce simple exemple, un tel mécanisme reste assez satisfaisant. Il n’en
reste pas moins qu’il peut conduire à un manque de synchronisme apparent entre
l’entrée et la sortie, comme le montre l’exemple suivant :
Quand l’écran et le clavier semblent mal synchronisés
#include <stdio.h>
int main()
{ int n, p ;
printf ("donnez une valeur pour n : ") ;
scanf ("%d", &n) ;
printf ("merci pour %d\n", n) ;
printf ("donnez une valeur pour p : ") ;
scanf ("%d", &p) ;
printf ("merci pour %d", p) ;
}
donnez une valeur pour n : 12 25
merci pour 12
donnez une valeur pour p : merci pour 25
La seconde question (donnez une valeur pour p) est apparue à l’écran, mais le
programme n’a pas attendu que l’utilisateur tape sa réponse pour afficher la
suite. Il a bien pris pour p la seconde valeur entrée au préalable, à savoir 25.
6.4.3 Mécanisme d’utilisation des caractères du tampon
D’une manière générale, pour bien décrire le fonctionnement de scanf, il est
nécessaire de faire intervenir un indicateur de position désignant, à un instant
donné, un caractère du tampon, plus précisément le premier caractère non encore
pris en compte. Nous nommerons cet indicateur un « pointeur de tampon ».
Tous les codes de format numérique ont en commun le fait que, avant toute autre
chose, ils provoquent l’avancement du pointeur sur le premier caractère différent
d’espace blanc. En revanche, le code %c prélève directement le caractère désigné
par le pointeur.
Remarque
Toutes les fonctions lisant sur l’entrée standard utilisent le même tampon que scanf. Cela concerne
bien entendu les fonctions gets et getchar mais aussi toutes les fonctions de lecture dans un fichier
texte, lorsqu’on les applique à l’entrée standard. En revanche, lorsque fscanf sera appliquée à un
fichier autre que l’entrée standard, cette notion de tampon se transformera en celle de ligne. Elle
n’aura plus guère de sens pour sscanf, à moins de dire que cette fonction lit dans un tampon qui n’est
jamais réalimenté.
6.5 Notion de caractère invalide et d’arrêt prématuré
6.5.1 Notion de caractère invalide
Lorsque scanf lit une information de type char (code de conversion c) ou une
chaîne de caractères (code de conversion s), tout caractère est acceptable même
si, dans le cas des chaînes, certains caractères sont interprétés comme
délimiteurs.
En revanche, pour les informations de type numérique, seuls certains caractères
sont acceptables. Par exemple, avec le code de format %d, une lettre ne
conviendra pas. On dira qu’on a affaire à un caractère invalide, sous-entendu
invalide par rapport à l’usage qu’on veut en faire. On notera que cette notion de
caractère invalide est très relative puisqu’en effet, pour un code donné, un même
caractère peut être invalide à un certain endroit des données et pas à un autre. Par
exemple, si on lit un flottant, le premier caractère e rencontré dans :
1.25e08exp
sera valide tandis que le second ne le sera pas.
6.5.2 Arrêt prématuré de scanf
D’une manière générale, le comportement de scanf, en cas de rencontre d’un
caractère invalide revient à considérer que ce dernier marque la fin de
l’information correspondant au code de format en cours de traitement. Deux
situations peuvent cependant se présenter :
1. On dispose, avant ce caractère invalide, d’autres caractères, autres que des
espaces blancs, qui permettent de « fabriquer » effectivement une valeur.
Dans ce cas, il ne se passe rien de particulier, si ce n’est que le pointeur de
tampon reste positionné sur ce caractère invalide. Bien entendu, ce dernier
sera pris en compte, soit par le prochain code du même format s’il y en a un,
soit par une prochaine lecture.
2. On ne dispose, avant ce caractère invalide, d’aucun caractère permettant de
fabriquer une valeur. Dans ce cas, scanf n’affecte aucune valeur à la lvalue
concernée qui reste donc inchangée. De plus, scanf interrompt son traitement,
sans tenir compte des éventuels codes de format suivants et, donc, des
éventuelles autres informations à lire. On parle, dans un tel cas, d’arrêt
prématuré de scanf.
Pour savoir si scanf s’est interrompue prématurément, on peut examiner sa valeur
de retour laquelle, comme on le verra à la section 6.6, fournit le nombre de
valeurs lues correctement. On trouvera des exemples de rencontre de caractères
invalides à la section 6.7.
Remarque
Lors de la lecture d’un flottant, un caractère autre qu’un signe ou un chiffre à la suite de la première
lettre e est manifestement incorrect. C’est ce qui se passe, par exemple, dans :
1.25exp
Dans ce cas, c’est bien le caractère x qui est invalide et non le e qui le précède. En ce qui concerne la
valeur obtenue, tout se passera comme si l’exposant était égal à 0 (ou encore, comme si aucune lettre e
n’était présente).
6.6 La valeur de retour de scanf
6.6.1 Généralités
Si la valeur de retour de printf (nombre de caractères écrits) est d’un intérêt
relativement limité, il n’en va plus de même de celle de scanf puisqu’elle permet
de savoir si la lecture des informations s’est bien déroulée. En effet, elle indique
le nombre de valeurs convenablement lues et affectées à des éléments de la
liste13. Par exemple, avec :
compte = scanf ("%d %e", &n, &x) ;
on obtiendra la valeur 2 dans la variable compte dès lors que la lecture se sera
convenablement déroulée.
Cependant, assez curieusement, lorsqu’aucune valeur n’a pu être lue, la valeur
de retour peut être 0 ou EOF, selon qu’il y a eu ou non rencontre d’une fin de
fichier. Une telle situation peut paraître impossible avec scanf. Mais il n’en est
rien, comme nous allons le voir avant de définir exactement ce qu’est la valeur
de retour de scanf.
6.6.2 Avec scanf, on peut rencontrer parfois une fin de fichier
, on pourrait penser que la rencontre d’une fin de fichier ne peut avoir lieu
A priori
qu’avec des fonctions qui lisent effectivement dans un fichier, donc en aucun cas
avec scanf qui lit sur l’entrée standard. En fait :
• l’entrée standard peut, dans beaucoup d’environnements, avoir été redirigée
vers un fichier ;
• dans certains environnements, il est possible d’appuyer sur une combinaison de
touches qui est interprétée par scanf comme une fin de fichier. C’est le cas de
Ctrl+Z dans les environnements DOS ou Windows ou de Ctrl+D dans les
environnements Unix.
Dans tous les cas, la rencontre d’une fin de fichier sur l’entrée standard provoque
l’arrêt de la lecture par scanf. Suivant qu’on a pu lire convenablement ou non
toutes les informations destinées aux différentes lvalue reçues en argument, il y
aura retour normal ou arrêt prématuré. Mais dans les deux cas, toute nouvelle
lecture sur l’entrée standard sera impossible, tout appel ultérieur d’une fonction
de lecture (scanf, mais aussi getchar ou gets) se traduisant par un arrêt prématuré
sans aucune lecture d’information (valeur de retour nulle). Mais l’exécution du
programme se poursuivra quand même, ce qui généralement conduira à un
comportement assez déroutant.
6.6.3 La valeur de retour de scanf
En général, il s’agit donc, comme on l’a dit jusqu’ici, du nombre de valeurs lues
convenablement et affectées à des éléments de la liste, nombre qui peut
éventuellement être nul. Mais il existe une exception à cette règle, à savoir qu’on
obtient la valeur EOF (généralement -1, en tout cas toujours négative) si, avant
qu’une conversion n’ait été tentée14, on a rencontré :
• soit une fin de fichier ;
• soit une erreur matérielle, situation peu fréquente généralement prise en compte
par le système d’exploitation.
Comme on le constate :
• lorsqu’aucune valeur n’a été lue, la valeur de retour peut être 0 ou EOF ; on peut
cependant affirmer qu’elle est inférieure à un ;
• la fin de fichier peut avoir été atteinte sans que la valeur de retour soit EOF ; si
l’on souhaite effectivement tester la rencontre de la fin de fichier, il sera
préférable de faire appel à la fonction feof appliquée à stdin :
if (feof (stdin)) { /* fin de fichier atteinte sur l'entrée standard */ }
else { /* OK */ }
Remarque
La notion de caractère invalide, présentée ici avec scanf, s’appliquera aux autres fonctions de la
même famille que sont fscanf et sscanf. La valeur de retour de fscanf se définira exactement de la
même manière que celle de scanf, avec cependant cette différence que la notion de fin de fichier y
deviendra plus importante puisqu’elle servira souvent de critère d’arrêt d’une boucle de lecture. Dans
le cas de sscanf, la notion de fin de fichier n’existera plus et elle sera remplacée par celle de fin de
chaîne.
6.7 Exemples de rencontre de caractères invalides
Voici un exemple dans lequel nous supposons que n, p et compte sont de type int et
que c est de type char. Nous fournissons des exemples de réponses possibles,
avec en regard les valeurs effectivement introduites dans les variables (le
symbole représente un espace et le symbole une fin de ligne).
6.7.1 Sans arrêt prématuré
compte = scanf ("%d%c", &n, &c);
12a n = 12 c = 'a' compte = 2
Ici, lors du traitement du code de format %d, scanf rencontre les caractères 1, puis
2, puis a. Ce caractère a ne convenant pas à la fabrication d’une valeur entière,
scanf interrompt son exploration et fournit donc la valeur 12 pour n. Le traitement
du code suivant, c’est-à-dire %c, amène scanf à prendre ce caractère courant (a) et
à l’affecter à la variable c. La variable compte reçoit la valeur 2.
compte = scanf ("%d%d", &n, &p) ;
12 45e10 n = 12 p = 45 compte = 2
Le traitement du premier code de format ne pose aucun problème. Dans le
traitement du suivant, scanf saute le caractère espace ( ), puis il rencontre, après
les caractères 4 et 5, le caractère e qui ne convient pas pour la fabrication d’une
valeur entière. L’exploration s’arrête là et fournit donc la valeur 45 pour p. Là
encore, compte reçoit la valeur 2.
6.7.2 Avec arrêt prématuré
compte = scanf ("%d%d %c", &n, &p, &c) ; /*
désigne un espace */
12 25 b n = 12 p = 25 c = 'b' compte = 3
12b n = 12 p inchange c inchange compte = 1
b n inchange p inchange c inchange compte = 0
Dans le premier cas, la lecture s’est déroulée de façon tout à fait normale et il
n’est pas surprenant que la valeur de retour de scanf soit égale à 3.
En revanche, dans le deuxième cas, le caractère b a interrompu le traitement du
premier code de format %d. Dans le traitement du deuxième code de format (%d),
scanf a rencontré d’emblée le caractère b, toujours invalide pour une valeur
numérique. Dans ces conditions, scanf se trouve dans l’incapacité d’attribuer une
valeur à p. En effet, ici, contrairement à ce qui s’est passé pour n, elle ne dispose
d’aucun caractère correct lui permettant de fabriquer une valeur. On est en
présence d’un arrêt prématuré : scanf s’interrompt sans chercher à lire d’autres
valeurs et fournit, en retour, le nombre de valeurs correctement lues jusqu’ici,
c’est-à-dire 1. Les valeurs de p et de c restent inchangées (éventuellement
indéfinies…).
Dans le troisième cas, le même mécanisme d’arrêt prématuré se produit dès le
traitement du premier code de format, et le nombre de valeurs correctement lues
est 0.
6.7.3 Lorsqu’un programme boucle sur un caractère invalide
Le caractère invalide n’est pas consommé par scanf. Dans ces conditions, il arrive
fréquemment qu’il demeure invalide pour la lecture suivante, voire pour les
lectures suivantes. Cela peut conduire à des situations de blocage, comme dans
l’exemple suivant, dans lequel la notation ^C représente, dans l’environnement
utilisé, une interruption du programme par l’utilisateur :
Boucle infinie sur un caractère invalide
#include <stdio.h>
int main()
{ int n ;
do
{ printf ("donnez un nombre : ") ;
scanf ("%d", &n) ;
printf ("voici son carre : %d\n", n*n) ;
}
while (n) ;
}
donnez un nombre : 12
voici son carre : 144
donnez un nombre : &
voici son carre : 144
donnez un nombre : voici son carre : 144
donnez un nombre : voici son carre : 144
donnez un nombre : voici son carre : 144
^C
Dans le cas présent, on pourrait améliorer la situation en examinant la valeur de
retour de scanf et en décidant de passer à la ligne suivante si celle-ci n’est pas
égale à 1, comme dans cet exemple :
Une amélioration du programme précédent
#include <stdio.h>
int main()
{ int n ;
int compte ;
do
{ printf ("donnez un nombre : ") ;
compte = scanf ("%d", &n) ;
if (compte == 1)
printf ("voici son carre : %d\n", n*n) ;
else while (getchar() != ‘\n') ;
}
while (n) ;
}
donnez un nombre : 12
voici son carre : 144
donnez un nombre : &
donnez un nombre : 123
voici son carre : 15129
donnez un nombre : 0
voici son carre : 0
Remarque
Le chapitre 17 fournira une démarche universelle permettant de remédier aux différents problèmes
posés par scanf donc, en particulier, à ce problème de bouclage sur un caractère invalide.
7. Les principales possibilités de scanf
Les règles qui président au fonctionnement de scanf (ou des autres fonctions de la
famille que sont fscanf et sscanf), ainsi que les différents codes de format sont
décrits en détail à la section 8 qui sert de référence. Ici, nous vous proposons
plutôt une synthèse des principales possibilités qui vous sont offertes. Elles sont
récapitulées dans le tableau 9.13 et examinées en détail dans les sections
indiquées.
Tableau 9.13 : les principales possibilités des fonctions de la famille scanf
– les informations numériques sont Voir
présentées de façon libre et séparées par section
un espace blanc ; 7.1.1
– les espaces blancs ne peuvent pas figurer Voir
Présentation dans une chaîne (code s) car ils servent section
des données alors de délimiteurs ; en revanche, ils 7.1.2
sont considérés comme un caractère
(code c) ;
– la fin de ligne peut s’avérer gênante Voir
lorsqu’on lit des caractères. section 7.3
– il n’existe pas de gabarit théorique Voir
maximal par défaut, bien qu’en pratique section
certaines implémentations en imposent 7.2.1
Gabarit des
données un ;
Voir
– on peut imposer un gabarit maximal à section
l’aide du paramètre de gabarit. 7.2.2
Caractère Un caractère du format n’appartenant pas à Voir
imposé dans un code de format doit figurer à l’endroit section 7.4
les données voulu dans les données.
– le « faux gabarit » du code c permet de Voir
Lire une lire un nombre donné de caractères ; section 7.5
suite
– les codes de format de la forme %[…] Voir
de
permettent de lire des caractères
caractères appartenant ou n’appartenant pas à une section 7.6
liste.
7.1 La présentation des informations lues en données
D’une manière générale, scanf est assez souple en ce qui concerne la manière
dont l’utilisateur peut présenter ses informations, aussi bien dans la façon de les
écrire que de les séparer les unes des autres.
7.1.1 L’écriture des valeurs numériques
Les entiers, exprimés en décimal, pourront être introduits avec le nombre de
chiffres voulu. Il n’est pas nécessaire de respecter un gabarit particulier.
Quel que soit le code de conversion utilisé (f, e, E, g, G), les flottants pourront être
exprimés indifféremment sous forme d’une valeur :
• entière, par exemple 45 ou -123 ;
• en notation décimale, par exemple 12.456 ou -0.0012 ;
• en notation exponentielle, par exemple 12e45 ou -15.035E-11.
En fait, avec scanf, l’existence de trois codes différents e, f et g ne se justifie que
dans le but d’assurer une (certaine) compatibilité avec ceux de printf.
7.1.2 Utilisation d’espaces blancs
La fonction scanf permet, sans toutefois l’imposer, de séparer naturellement les
informations d’un ou plusieurs caractères dits espaces blancs. On regroupe sous
ce terme les cinq caractères suivants15 : espace, fin de ligne, tabulation
horizontale, tabulation verticale, fin de page. Les deux premiers sont
manifestement les plus utilisés.
Cette facilité provient de ce que tous les codes de conversion, à l’exception de c,
commencent par avancer le pointeur de tampon sur le premier caractère différent
d’un espace blanc. Dans le cas du code c, ce mécanisme n’est pas mis en œuvre,
afin de permettre de lire convenablement un caractère correspondant à un espace
blanc.
Voici quelques exemples dans lesquels nous supposons que n et p sont de type
int, tandis que c est de type char. Nous fournissons, pour chaque appel de scanf,
des exemples de réponses possibles ( désigne un espace et ø une fin de ligne)
avec, en regard, les valeurs effectivement introduites dans les variables
correspondantes.
scanf ("%d%d", &n, &p) ;
12 25 n = 12 p = 25
12 25 n = 12 p = 25
12
25 n = 12 p = 25
scanf ("%c%d", &c, &n) ;
a25 c = 'a' n = 25
a 25 c = 'a' n = 25
scanf ("%d%c", &n, &c) ;
12 a n = 12 c = ' '
Dans le dernier cas, on obtient bien le caractère espace dans c. Ceci provient de
ce que, après exploitation du code %d, le pointeur de tampon reste sur le premier
caractère non utilisé, en l’occurrence l’espace qui a servi de fin d’information
numérique. Il est cependant possible de demander d’ignorer les espaces blancs
en faisant précéder le code de format %c d’un espace, comme dans cet exemple :
scanf ("%d %c", &n, &c) ; /* attention désigne un espace entre d et % */
12 a n = 12 c = 'a'
12
a n = 12 c = 'a'
L’usage des espaces blancs, pour pratique qu’il soit, n’est pas pour autant
obligatoire. En effet, une information peut être limitée :
• par une indication de gabarit dans le code de format – on en trouvera des
exemples à la section 7.2 ;
• par la rencontre d’un caractère invalide – on en trouvera des exemples à la
section 6.7.
7.2 Limitation du gabarit
7.2.1 Il n’existe pas de gabarit par défaut
Contrairement à ce qui se passe pour printf, la notion de gabarit par défaut
n’existe pas pour scanf. Lorsqu’on ne fournit pas de gabarit explicite, il n’y a
théoriquement pas de limite, ni inférieure, ni supérieure, au nombre de caractères
lus. Seul le code c fait exception, puisque, en l’absence de gabarit explicite, il lit
toujours exactement un caractère. Cependant, dans le cas des codes relatifs aux
valeurs entières, une ambiguïté existe dans la norme. En pratique, bon nombre
d’implémentations limitent le nombre de chiffres pris en compte pour un entier.
Souvent, il s’agira du nombre maximal de chiffres d’un long ou d’un unsigned long.
Remarque
L’ambiguïté évoquée se retrouvera dans le nombre de chiffres pris en compte par les fonctions strtol
et strtoul, comme nous le verrons à la section 10.3.2 du chapitre 10. C’est d’ailleurs à partir du rôle
de ces fonctions que la norme définit le rôle des codes de format relatifs à des entiers.
7.2.2 On peut imposer un nombre maximal de caractères lus
Il n’est pas possible d’imposer un nombre minimal de caractères. En revanche,
on peut imposer un nombre maximal en mentionnant un gabarit (sous forme
d’une constante positive non nulle) à la suite du caractère % et avant le code de
conversion. Les espaces blancs précédant éventuellement le nombre ne sont pas
comptabilisés dans ce gabarit. Voici un exemple dans lequel nous supposons que
n et p sont de type int. Nous fournissons, pour chaque appel de scanf, des
exemples de réponses possibles ( désigne un espace et une fin de ligne) et, en
regard, les valeurs effectivement introduites dans les variables correspondantes.
scanf ("%3d%3d", &n, &p) ;
12 25 n = 12 p = 25
12345 n = 123 p = 45
12345678 n = 123 p = 456
Dans le dernier cas, les caractères 7 et 8 restent dans le tampon et pourront être
exploités par une prochaine lecture.
D’une manière générale, cette possibilité de gabarit maximal s’applique à tous
les codes numériques ainsi qu’au code de conversion s relatif aux chaînes.
Comme on peut s’y attendre, il ne concerne pas le code de conversion c. On
verra toutefois (section 7.5) qu’un nombre peut figurer devant ce code c mais
qu’alors il possède une signification différente.
Il ne faut pas oublier que certaines implémentations limitent le nombre de
caractères pris en compte pour un entier. Dans ces conditions, le gabarit maximal
qu’on cherche à imposer risque d’être inopérant au-delà de cette limite. Par
exemple, dans certaines implémentations %20d ou %30d seront finalement
équivalents à %10d.
7.3 La fin de ligne joue un rôle ambigu : séparateur ou
caractère
La validation d’une ligne entraîne l’introduction d’un caractère de fin de ligne
dans le tampon. Tant qu’on ne lit pas de caractères par le code c, ce n’est guère
compromettant, dans la mesure où les autres codes (y compris s) commencent
par ignorer les espaces blancs : la fin de ligne en question sera donc ignorée et
l’on demandera à l’utilisateur de fournir une nouvelle ligne. En revanche, les
choses seront beaucoup moins satisfaisantes dès qu’on sera amené à lire des
caractères, comme le montre l’exemple suivant :
Lorsque la fin de ligne apparaît comme un caractère inattendu
#include <stdio.h>
int main()
{ int n ;
char c ;
printf ("donnez un nombre : ") ;
scanf ("%d", &n) ;
printf ("merci pour %d\n", n) ;
printf ("donnez un caractere : ") ;
scanf ("%c", &c) ;
printf ("merci pour le caractere de code %d", c) ;
}
donnez un nombre : 12
merci pour 12
donnez un caractere : merci pour le caractere de code 10
On peut résoudre ce problème en s’arrangeant pour « sauter » le caractère de fin
de ligne intempestif :
Lorsqu’on s’arrange pour éliminer le caractère de fin de ligne
#include <stdio.h>
int main()
{ int n ;
char c ;
printf ("donnez un nombre : ") ;
scanf ("%d", &n) ;
printf ("merci pour %d\n", n) ;
while (getchar() != ‘\n') ; /* ou : scanf ("%*[^\n]%*c"); voir section 7.6 */
printf ("donnez un caractere : ") ;
scanf ("%c", &c) ;
printf ("merci pour le caractere de code %d", c) ;
}
donnez un nombre : 12
merci pour 12
donnez un caractere : a
merci pour le caractere de code 97
Remarque
Le chapitre 17 fournira une démarche universelle permettant de remédier aux différents problèmes
posés par scanf et donc, en particulier à ce problème de fin de ligne intempestive.
7.4 Lorsque le format impose certains caractères dans
les données
Le fonctionnement de scanf permet au programmeur d’imposer à l’utilisateur de
taper des caractères précis à certains emplacements des données. Il s’agit là d’un
usage peu répandu mais on peut cependant s’y trouver confronté à la suite d’une
simple erreur de rédaction d’un format : ce sera par exemple le cas si l’on
introduit %d5 en lieu et place de %5d, le caractère 5 du premier cas ne faisant pas
partie du code de format qui se limite à %d.
La présence d’un caractère autre que espace16 en dehors d’un code de format
conduit scanf à s’assurer que ce caractère est identique au caractère courant du
tampon. Si c’est le cas, le pointeur de tampon progresse sur le caractère suivant
et le traitement se poursuit normalement. Dans le cas contraire, le pointeur de
tampon ne progresse pas et il y a arrêt prématuré de scanf.
Voici un exemple dans lequel on suppose que n, p et compte sont de type int (
représente un espace et une fin de ligne) :
compte = scanf ("%dy%d", &n, &p) ;
12y25 n = 12 p = 25 compte = 2
12y 25 n = 12 p = 25 compte = 2
12x25 n = 12 p inchange compte = 1
12 y25 n = 12 p inchange compte = 1
Dans les deux premiers cas, scanf arrête le traitement du premier code %d à la
rencontre du caractère y et affecte ainsi la valeur 12 à n. La présence de y dans le
format l’amène alors à considérer le prochain caractère du tampon et à s’assurer
qu’il s’agit bien d’un y, ce qui est ici le cas. Le déroulement ultérieur est
classique.
En revanche, dans le troisième cas, le caractère prélevé dans le tampon est un x
alors que l’on attend y ; il y a arrêt prématuré de scanf. Il en va de même dans le
quatrième cas, dans la mesure où, là encore, l’espace rencontré est différent du
caractère y attendu.
Remarques
1. Il ne faut pas considérer ces caractères imposés dans le format comme de nouveaux séparateurs
d’information qui joueraient alors un rôle comparable aux espaces blancs. En effet, leur position et
leur nombre, au sein des données, sont totalement imposés alors qu’ils étaient entièrement libres
pour les espaces blancs.
2. Le caractère espace apparaissant dans un format est traité différemment des autres caractères. En
effet, il ne s’agit plus ici d’imposer un seul caractère à un endroit donné mais bel et bien de
permettre la présence de 0, un ou plusieurs espaces blancs.
7.5 Attention au faux gabarit du code C
Considérons cette instruction :
scanf ("%10c", &c) ; /* lit 10 caractères et les range à partir de l'adresse &c */
Curieusement, elle va lire sur l’unité standard 10 caractères et les ranger à partir
de l’adresse &c. Cela signifie que si, par malheur, c a été déclaré comme une
simple variable de type char, le programme ira écraser des informations voisines.
Bien entendu, le processus se déroulera convenablement si c est par exemple un
tableau de caractères :
char c[10] ;
et que l’on procède ainsi :
scanf ("%10c", c) ; /* attention, ici, c est de type char * */
ou, encore, de la façon suivante, quelque peu tendancieuse :
scanf ("%10c", &c[0]) ;
On notera que les caractères sont lus suivant les modalités habituelles relatives
au code c, ce qui signifie que les espaces blancs sont bien traités comme des
caractères et non comme d’éventuels séparateurs. Il devient ainsi possible de lire
des suites de caractères17 contenant des espaces blancs quelconques (donc, en
particulier des espaces et des fins de ligne), pour peu qu’elles soient de longueur
constante. À ce propos, on notera qu’il devra alors s’agir d’une vraie constante et
pas seulement d’une constante définie par #define comme dans :
#define NB 10
char c[NB] ;
…..
En effet, il ne serait alors pas possible d’utiliser NB comme gabarit en employant
le format "%NBc». Certes, on pourrait faire appel à des possibilités de format créé
dynamiquement à l’exécution (on en trouvera un exemple à la section 4.7.2 du
chapitre 10). En fait, il est, dans ce cas, beaucoup plus simple de répéter la
lecture d’un caractère en procédant ainsi :
for (i=0 ; i<NB ; i++) scanf ("%c", &c[i]) ;
D’une manière générale, ce faux gabarit est peu employé si ce n’est, peut-être,
par distraction.
7.6 Les codes de format de la forme %[…]
De nombreux codes de format utilisent les espaces blancs comme séparateurs.
Le code %[…] permet, d’une certaine manière, d’élargir cette notion à d’autres
caractères, avec toutefois quelques nuances.
Considérons :
scanf ("%[0123456789]", adr) ;
Cette instruction lit des caractères, tant qu’ils appartiennent à l’ensemble des 10
caractères indiqués entre les crochets (ici, les dix chiffres de 0 à 9). Tous les
caractères ainsi lus sont rangés consécutivement en mémoire, à partir de
l’adresse adr. C’est précisément là que réside la différence avec la notion de
séparateur, qui n’est jamais recopié en mémoire.
La plupart du temps, on souhaitera limiter les caractères introduits à partir de
cette adresse. Il suffira pour cela d’utiliser le gabarit, comme dans :
scanf ("%20[0123456789]", adr) ; /* lit au maximum 20 chiffres consécutifs */
Si l’on souhaite savoir combien de caractères ont effectivement été lus, on
pourra utiliser le code %n :
scanf ("%[0123456789]%n", adr, &n_car) ; /* place dans n_car le nombre de */
/* caractères lus */
Voici un programme qui fabrique une chaîne avec un maximum de 20 chiffres
consécutifs lus sur l’entrée standard :
#include <stdio.h>
int main()
{ int n_car ;
char ch[21] ;
scanf ("%20[0123456789]%n", ch, &n_car) ;
ch[n_car] = ‘\0' ;
printf ("%d chiffres lus :%s:\n", n_car, ch) ;
}
15478ez4
5 chiffres lus :15478:
Si les informations lues n’ont pas à être conservées, par exemple parce qu’on
souhaite simplement ignorer les caractères correspondants, on pourra utiliser le
code * qui demande de ne pas affecter le résultat de la lecture :
scanf ("%*[0123456789]") ; /* saute tous les chiffres consécutifs */
On notera bien qu’après une telle opération, le pointeur reste positionné sur le
premier caractère n’appartenant pas à l’ensemble mentionné. Ainsi, pour lire le
premier caractère différent d’un chiffre, on pourra procéder ainsi :
scanf ("%*[0123456789]%c", &c) ; /* lit dans c le premier caractère */
/* diffèrent d'un chiffre */
On peut inverser le fonctionnement de ce code de format en procédant non plus
par appartenance, mais par exclusion, en faisant précéder les caractères à exclure
du symbole ^.
Voici quelques exemples :
scanf ("%20[^0123456789]", adr) ; /* lit au maximum 20 caractères consécutifs autres */
/* que des chiffres et les range à l'adresse adr */
scanf ("%*[^0123456789]") ; /* saute les caractères différents d'un chiffre */
scanf ("%*[^0123456789]%c", &c) ; /* c contient le premier chiffre trouvé */
Signalons enfin qu’on peut utiliser ces possibilités pour forcer un changement de
ligne :
scanf ("%*[^\n]%*c") ; /* ne pas oublier *c pour lire la fin de ligne */
Enfin, à titre indicatif, notez que le code suivant ( désigne un espace) joue le
même rôle qu’un simple espace dans le format18 :
%*[ \t\n\v\f] /* saute tous les espaces blancs */
8. Description des codes de format des fonctions de la
famille de scanf
Bien que ce chapitre se limite à l’étude des entrées-sorties standards, nous avons
regroupé ici tout ce qui concerne non seulement la fonction scanf, mais toutes les
fonctions qu’on peut qualifier comme appartenant à la même famille. Il s’agit de
fonctions qui font le même usage des codes de format, à savoir scanf, fscanf et
sscanf. Elles utilisent des règles presque identiques pour l’analyse de
l’information ; les éventuelles différences vont de soi et seront mentionnées au
moment opportun.
Par ailleurs, vous trouverez à la section 8.7, un tableau récapitulant les
différences existant entre les codes de format en entrée et les codes de format en
sortie.
8.1 Récapitulatif des règles utilisées par ces fonctions
8.1.1 Utilisation d’un tampon
Quelle que soit la fonction concernée, l’information est recherchée dans un
tampon. Dans le cas où la lecture a lieu au clavier (par scanf ou fscanf associée à
stdin), ce tampon correspond à une ligne d’information : la frappe de la touche de
validation alimente ce tampon et y introduit un caractère de fin de ligne.
Lorsqu’il n’y a plus d’information disponible dans le tampon, il y a lecture d’une
nouvelle ligne. Si certains caractères du tampon n’ont pas été pris en compte (y
compris la fin de ligne), ceux-ci restent disponibles pour une prochaine lecture.
Toutes les fonctions accédant à l’entrée standard partagent le même tampon.
On notera que, dans le cas de sscanf, l’adresse du tampon est fournie en argument
et sa fin est marquée par un caractère de fin de chaîne. Les notions
d’alimentation du tampon ou de caractères excédentaires n’ont plus de
signification : un nouvel appel de sscanf peut porter sur le même emplacement
(qui sera alors examiné depuis son début) ou sur un autre emplacement (les
éventuels caractères non utilisés par l’appel précédent de sscanf n’intervenant
plus).
La rencontre d’un espace blanc dans le format, en dehors d’un code de format,
provoque l’avancement du pointeur jusqu’à la rencontre d’un caractère qui ne
soit pas un espace blanc. La rencontre dans le format, en dehors d’un code de
format, d’un caractère différent d’un espace blanc (et de %) provoque la prise en
compte du caractère désigné par le pointeur. Si ce dernier correspond au
caractère du format, la fonction poursuit son exploration du format. Dans le cas
contraire, il y a arrêt prématuré de la fonction.
8.1.2 Les espaces blancs
Les caractères dits espaces blancs jouent un rôle particulier pour tous les codes
de format relatifs à des informations numériques (d, i, o, u, x, X, f, e, E, g, G), ainsi
que pour le code s. Tous ces codes :
• commencent par rechercher dans le tampon le premier caractère différent d’un
espace blanc ;
• peuvent interrompre leur analyse à la rencontre d’un espace blanc.
Dans la localisation standard « C », quasi universelle, les espaces blancs sont
constitués des cinq caractères : espace, fin de ligne, tabulation horizontale,
tabulation verticale et fin de page. En théorie, comme il est dit au chapitre 23, la
norme permet de choisir d’autres localisations, dans lesquelles peuvent,
éventuellement, être introduits d’autres espaces blancs. Dans tous les cas, la
condition « c est un espace blanc » se définit par la condition isspace(c)==1.
8.1.3 Règles d’exploration du format
Lors du traitement d’un code de format, l’exploration s’arrête dès que l’une des
trois conditions suivantes est réalisée :
• Rencontre d’un caractère invalide par rapport à l’usage qu’on doit en faire
(point décimal pour un entier, caractère différent d’un chiffre ou d’un signe
pour du numérique…). Si la fonction n’est pas en mesure de fabriquer une
valeur, il y a arrêt prématuré de l’ensemble de la lecture.
• Rencontre d’un espace blanc ou d’une fin de fichier (d’une fin de chaîne dans
le cas de sscanf).
• Le nombre maximal de caractères précisé par le paramètre de gabarit (s’il a été
spécifié) a été atteint. Les espaces blancs éventuellement ignorés par la plupart
des codes de format ne sont pas comptabilisés dans ce gabarit.
Rappelons que, compte tenu du flou existant dans la norme, certaines
implémentations peuvent imposer un nombre maximal de chiffres aux
informations entières. On peut alors considérer que l’atteinte de ce maximum
constitue une quatrième condition d’arrêt.
8.2 Structure générale d’un code de format
Chaque code de format a la structure suivante :
% [*] [gabarit] [h|l|L] conversion
Les crochets ([ et ]) signifient que ce qu’ils renferment est facultatif. Nous
décrivons, dans les sections suivantes, la nature et le rôle de ces différents
paramètres. Pour favoriser son utilisation, chaque description est complète, de
sorte que certains points peuvent se trouver mentionnés en différents endroits.
On notera bien que les expressions figurant en argument effectif, à la suite du
format, sont obligatoirement d’un type pointeur sur une lvalue. Pour ne pas
alourdir inutilement les descriptions suivantes, nous mentionnerons toujours le
type de l’information pointée, plutôt que le type du pointeur lui-même. Par
exemple, nous dirons que le code de format %d correspond à la lecture d’un int,
même si l’expression correspondante de la liste doit être du type int *.
8.3 Les paramètres * et gabarit
Tableau 9.14 : les paramètres * et gabarit
*
La valeur lue n’est affectée à aucun élément de la liste. Elle
n’est pas comptabilisée dans la valeur de retour.
Gabarit – constante entière positive écrite en notation décimale qui
définit un gabarit maximal, à savoir le nombre maximal de
caractères qui seront pris en compte1 ; avec la plupart des
codes de format, on pourra en lire moins s’il y a rencontre
d’un caractère espace blanc, d’un caractère invalide ou
d’une fin de fichier ;
– ce paramètre de gabarit ne peut pas être employé avec les
codes de conversion % ou n ;
– la norme ne précise pas ce qui ce produit dans le cas où on
fournit un gabarit par erreur.
En pratique, on peut retrouver le comportement observé
en cas de format invalide.
Mais, parfois (par exemple avec %5%d), la perturbation peut
empêcher la reconnaissance
du code de format suivant (ici %d).
1. Certaines implémentations pouvant imposer un nombre maximal de chiffres aux informations entières.
Remarque
La signification de * diffère totalement entre les codes de format d’entrée et de sortie : suppression de
l’affectation dans le premier cas, gabarit (ou précision) variable dans le second. La notion de gabarit
variable n’existe pas en entrée ; quant à la précision variable, elle ne peut exister puisque la notion
même de précision n’existe pas.
8.4 Le paramètre modificateur h/l/L
Ce paramètre, facultatif, est l’un des trois caractères h, l ou L. Il n’a de
signification que pour les codes de conversion cités dans le tableau 9.15. Pour
tout autre code, le comportement du programme est théoriquement indéterminé.
En pratique, le caractère modificateur est souvent ignoré.
Tableau 9.15 : le paramètre modificateur h/l/L
Valeur du Caractère de Type de la lvalue
paramètre conversion correspondante
short int
h , ,
d i n
unsigned short int
, , ,
o u x X
long int
l , ,
d i n
unsigned long int
, , ,
o u x X
double
, , , ,
f e E g G
L long double
, , , ,
f e E g G
8.5 Les codes de conversion
Le code de conversion est un caractère qui précise à la fois :
• le type de la lvalue destinée à recevoir une information ; ce type peut également
dépendre d’un éventuel modificateur h/l/L ;
• la façon dont sera exprimée la valeur correspondante.
Le tableau 9.16 indique le rôle de chaque code de conversion, en tenant compte
de modificateurs éventuels. Il se réfère au type de la lvalue qu’on s’attend à
trouver à l’adresse transmise à la fonction (rappelons qu’ici, cette adresse n’est
soumise à aucune conversion). Un tableau inverse, proposé à la section 8.6,
permet de retrouver rapidement les différents codes qu’il est possible d’utiliser
pour un type de lvalue donné.
Tableau 9.16 : les codes de conversion des fonctions de la famille scanf
1. Avec la localisation standard « C », voir remarque à la suite du tableau.
2. On peut dire également que tous ces codes lisent une information exprimée indifféremment sous forme
d’une constante entière, flottante en notation décimale ou flottante en notation décimale.
3. Certaines implémentations acceptent hn pour un short int et ln pour un long int.
1. Avec la localisation standard « C », voir remarque à la suite du tableau.
Remarques
1. La norme définit la forme des suites de caractères acceptés par certains codes de format par
référence à celles acceptées par les fonctions strtol, strtoul et strtod, présentées au chapitre 10.
Elle ne précise pas cependant que les comportements en cas d’erreur doivent être identiques, même
si c’est le cas dans la plupart des implémentations.
En outre, les codes u, x et X (dont le rôle est déduit de celui de strtoul) acceptent un signe moins
qui conduit à prendre l’opposé de la valeur, d’où un dépassement de capacité en arithmétique non
signée. Le résultat reste encore défini par la norme (voir section 2.2.1 du chapitre 4). En particulier,
dans le cas des implémentations utilisant la technique du complément à deux, le motif binaire est le
même que celui qu’on obtiendrait en arithmétique signée (si la valeur absolue du nombre n’est pas
trop grande).
2. Rarement utilisées, les possibilités dites de localisation, présentées au chapitre 23, peuvent influer
sur le comportement des codes de format sur quelques points : définition des espaces blancs, nature
du point décimal, éventuelles formes supplémentaires légales pour les valeurs numériques.
8.6 Les codes utilisables avec un type donné
Le tableau 9.17 permet de retrouver rapidement les différents codes de
conversion et modificateurs qu’il est possible d’utiliser pour lire une information
d’un type donné. Il devra bien sûr être complété par le tableau précédent pour
obtenir plus d’informations sur le rôle exact de chaque code et sur les
présentations attendues pour les données.
Tableau 9.17 : les codes de format utilisables avec un type donné
Code de
Type de la
conversion et Commentaires
lvalue à lire
modificateur
char c
signed char
Contrairement à ce qui se produisait
unsigned char
pour printf, on ne peut pas lire un
caractère par son code numérique.
signed short int
hd ou hi Ici, h est nécessaire.
unsigned short int
, , ou hX
hu ho hx
signed int
d ou i
unsigned int
, , ou X
u o x
signed long int
ld ou li
unsigned long int
, , ou lX
lu lo lx
float
, , , ou G
f e E g
double
, , , ou
lf le lE lg Ici, l est nécessaire.
lG
long double
, , , ou
Lf Le LE Lg ici, L est nécessaire
LG
void * p
Remarque
Il est toujours possible d’employer un code correspondant à un entier non signé avec un entier signé de
même taille et vice versa. Bien entendu, la valeur obtenue dépendra alors de l’implémentation. Cette
possibilité peut s’avérer pratique pour lire directement le motif binaire (en octal ou en hexadécimal)
correspondant à un entier signé.
8.7 Les différences entre les codes de format en entrée
et en sortie
Pour un type d’information donné, il n’y a pas de correspondance absolue entre
les codes de format des fonctions de la famille scanf et ceux des fonctions de la
famille printf. Le tableau 9.18 récapitule quelles sont les différences.
Tableau 9.18 : les différences entres les codes de format en entrée et en
sortie
Sortie (famille de
Paramètre ou code Entrée (famille de scanf) printf)
Paramètre de non oui
précision
Drapeaux (-, +, 0, #) non oui
Paramètre de gabarit maximal (sauf pour gabarit minimal
gabarit le code c)
Paramètre * suppression de l’affectation gabarit ou précision
variable
Modificateur h indispensable superflu, sauf
parfois pour le type
unsigned short
, , , ,
lf le lE lg lG type double type long double
, , , ,
Lf Le LE Lg LG type long double inexistant
Espace blanc saut des espaces blancs affiché tel quel
Caractère présence obligatoire dans affiché tel quel
n’appartenant pas à les données
un code de format
%[…]
oui non
9. La fonction getchar
Alors que la fonction scanf permet de lire des informations d’un type scalaire
quelconque sur l’entrée standard, la fonction getchar est destinée à lire un seul
caractère. Ainsi peut-on dire que, si c est de type char, ces deux instructions
jouent le même rôle :
scanf ("%c", &c) ;
c = getchar() ;
L’emploi de getchar, dans ce cas, peut se justifier par une plus grande simplicité
et surtout une plus grande rapidité. En effet, scanf nécessite obligatoirement
l’analyse du format même lorsque, comme ici, il est très simple, ce qui n’est plus
le cas avec getchar.
9.1 Prototype et valeur de retour
int getchar () (stdio.h)
Valeur de Caractère lu comme un unsigned char Voir discussion
retour et convertit en int, lorsque suivante et
l’opération s’est bien déroulée, EOF précautions à la
en cas de fin de fichier ou d’erreur. section 9.2
A priori , la fonction getchar lit un caractère sur l’entrée standard. Or elle fournit
non pas une valeur de type char, comme on pourrait s’y attendre, mais une valeur
de type int. Cette particularité permet à la fonction de signaler une erreur ou une
fin de fichier en fournissant alors une valeur qui ne puisse pas être confondue
avec un caractère. Rappelons que la fin de fichier sur l’entrée standard peut se
produire lorsque l’entrée a été redirigée, et même avec le clavier dans certains
environnements (voir section 6.6.2).
Plus précisément, cette valeur de retour est :
• le résultat de la conversion en int du caractère c (considéré comme unsigned char)
si un caractère a pu être lu ; on notera bien qu’une telle valeur n’est jamais
négative ;
• la valeur EOF dans l’un des cas suivants :
– rencontre de fin de fichier ;
– erreur matérielle sur le périphérique concerné, cas rare car généralement
pris en compte par le système d’exploitation.
9.2 Précautions
La vocation de la fonction getchar est de lire des caractères. Or elle fournit un
résultat de type int qui est toujours positif en cas de lecture normale. Dans ces
conditions, si on considère ces déclarations :
unsigned char uc ;
signed char sc ;
char c ;
on peut s’interroger sur le résultat des lectures suivantes :
uc = getchar () ; /* motif binaire toujours conservé */
sc = getchar () ; /* motif binaire pas toujours conservé en théorie */
/* mais conservé en pratique */
c = getchar () ; /* l'un des deux cas précédents, suivant l'implémentation */
Les deux premières font intervenir des conversions de int en unsigned char ou de
int en signed char ; la troisième fait intervenir l’une des deux conversions
précédentes, selon l’implémentation. Ces conversions sont étudiées en détail à la
section 9.6 du chapitre 4. En théorie, seule la première conserve le motif binaire
dans tous les cas (car ici la valeur entière est obligatoirement représentable en
unsigned char). En pratique, la seconde, donc aussi la troisième, le font dans toutes
les implémentations.
On notera cependant qu’en plaçant directement le résultat de la lecture dans une
variable de type caractère, il n’est pas facile de tester le cas où la valeur de retour
est égale à EOF. En effet, avec une variable uc de type unsigned char, on ne trouvera
jamais l’égalité :
if (uc == EOF) /* EOF est de type int, uc sera converti en int, ce qui */
/* conduira à une valeur non négative, jamais égale à EOF */
Avec une variable de type signed char, on pourra bien détecter la fin de fichier,
mais on risque aussi de la confondre avec un autre caractère.
En général, si l’on s’intéresse à cette valeur EOF, il est conseillé de procéder
ainsi :
int n ;
n = getchar () ;
…..
if (n == EOF) …
En cas de lecture satisfaisante, si cela est nécessaire, on la reconvertira en
caractère par l’une des affectations :
uc = n ;
sc = n
c = n ;
Exemple
Voici un programme qui recopie l’entrée standard sur la sortie standard. Bien
entendu, il a surtout un intérêt lorsque les deux conditions suivantes sont
vérifiées :
• au moins l’un des deux périphériques standards a été redirigé vers un fichier ;
• la fin de fichier a bien un sens lorsque l’entrée correspond au clavier.
Recopie de l’entrée standard sur la sortie standard
#include <stdio.h>
int main()
{ int ncar ;
while ( (ncar = getchar()) != EOF ) /* arrêt sur fin fichier */
/* ou erreur matérielle (rare) */
putchar (ncar) ; /* il est inutile de convertir ncar en char pour le */
/* reconvertir ensuite en int à l'appel de putchar */
}
On notera que les problèmes de conversions évoqués ci-dessus ne se posent pas
puisqu’aucune variable de type caractère n’apparaît.
1. En réalité, il ne s’agit pas directement d’un nom de fichier, mais d’un nom de flux. On verra qu’un fichier
est traité par un programme en l’associant, lors de son ouverture, à une variable particulière, de type
FILE *, qu’on nomme un « flux ». stdout est donc, en fait, un nom de flux prédéfini.
2. En toute rigueur, le type caractère est un cas particulier de type numérique.
3. Le type short fait exception, dans la mesure où, pour des questions de portabilité, il sera préférable de
prévoir un code de format approprié (%hd) comme nous le verrons à la section 3.10.
4. Nous appelons « famille printf » les fonctions printf, fprintf, sprintf, vprintf, vfprintf et
vsprintf.
5. Pour prévoir exactement le comportement de printf, il est nécessaire de savoir comment
l’implémentation range les différentes informations en mémoire et comment elle gère les transmissions
d’arguments lors des appels de fonctions. En général, elle utilise un mécanisme de pile, comme pour les
variables locales. Il n’est pas rare que la même pile soit utilisée à la fois pour les variables locales et pour
les arguments.
6. Relativement aléatoire dans la mesure où, en fait, cette valeur peut être prévue si on connaît la manière
dont l’implémentation range les différentes informations en mémoire et celle dont elle gère les
transmissions d’arguments lors des appels de fonctions.
7. Avec le type double, on obtiendrait une erreur de représentation plus petite, éventuellement
imperceptible avec la précision indiquée.
8. Avec le type double, on obtiendrait une erreur de représentation plus petite, éventuellement
imperceptible avec la précision indiquée.
9. Attention, cette valeur quelque peu arbitraire est bien imposée par la norme.
10. Certaines implémentations peuvent ne pas respecter la norme sur ce point, de sorte qu’il est plus prudent
d’introduire les drapeaux dans l’ordre où ils sont mentionnés ci-après.
11. Nous appelons « famille scanf » les fonctions scanf, fscanf et sscanf.
12. En toute rigueur, il en va déjà ainsi pour la première ligne lue par scanf, mais en général, dans ce cas, le
programme a prévu, au préalable, l’affichage d’un message invitant l’utilisateur à entrer des
informations.
13. Ce qui signifie qu’une valeur lue par le code de format %* n’est pas comptabilisée, car elle n’est pas
affectée à un élément de la liste.
14. Mais après avoir éventuellement sauté des espaces blancs si le premier code de format le permettait.
15. De l’anglais white spaces. Dans d’autres localisations que la localisation standard, d’autres caractères
peuvent éventuellement être considérés comme des espaces blancs.
16. Attention, il s’agit bien d’un espace et non d’un espace blanc.
17. Ici, nous ne parlons pas de chaînes car le nombre de caractères à lire est imposé et la notion de fin de
chaîne n’intervient pas. Un caractère de fin de ligne serait ici pris en compte comme n’importe quel
autre, sans interrompre la lecture.
18. Du moins dans la localisation standard. En effet, dans les autres localisations, il se peut que d’autres
espaces blancs apparaissent. Ils seraient bien sautés par un espace dans le format, mais pas par le code
proposé ici.
10
Les chaînes
de caractères
La plupart des langages récents (Java, C#, PHP, Python) ou plus anciens (C++,
Basic, Turbo Pascal) disposent d’un type « chaîne de caractères ». Les variables
d’un tel type sont alors destinées à recevoir des suites de caractères qui peuvent
évoluer, à la fois en contenu et en longueur, au fil du déroulement du
programme. Elles peuvent être manipulées d’une manière globale, en ce sens
qu’une simple affectation permet de transférer le contenu d’une variable de ce
type dans une autre variable de même type. Néanmoins, derrière le terme
« chaîne de caractères » se cachent des mises en œuvres très différentes (type
classe ou type de base, gestion par adresse ou par valeur, chaînes modifiables ou
non…).
Dans quelques anciens langages (Fortan 77, Pascal standard), un tel type
n’existait pas du tout et le traitement de telles informations nécessitait de
travailler sur des tableaux de caractères dont la taille était alors obligatoirement
fixe.
En langage C, il n’existe pas de véritable type chaîne, dans la mesure où l’on ne
peut pas y déclarer des variables d’un tel type. En revanche, il existe une
convention de représentation des chaînes qui consiste à placer un caractère de
code nul à la fin d’une succession d’octets représentant chacun des caractères de
la chaîne. Cela signifie qu’une chaîne de n caractères occupe en mémoire un
emplacement de n+1 octets.
Cette convention est utilisée par le compilateur pour représenter les constantes
chaîne (notées entre doubles quotes), ainsi que par un certain nombre de
fonctions réalisant les traitements classiques : concaténation, recopie,
comparaison, extraction de sous-chaîne, conversions…
Dans ce chapitre, nous commencerons par préciser quelles sont les différentes
manières d’introduire une constante chaîne dans un programme et la traduction
qu’en fait le compilateur. Nous donnerons ensuite quelques indications générales
concernant la façon dont on peut manipuler une chaîne en C et les précautions
que cela impose. Puis, nous étudierons en détail les différentes fonctions de la
bibliothèque standard s’appliquant aux chaînes :
• entrées-sorties ;
• copie de chaînes ;
• concaténation de chaînes ;
• comparaison de chaînes ;
• recherche dans une chaîne ;
• conversions de chaînes en nombres et de nombres en chaînes.
Enfin, nous présenterons quelques fonctions permettant de manipuler des suites
d’octets de longueur donnée. Bien qu’elles ne se basent plus sur la présence d’un
caractère de code nul, elles s’apparentent aux fonctions précédentes, compte tenu
de l’équivalence qui existe entre octet et caractère. C’est la principale
justification de leur présence dans ce chapitre.
Notez que le chapitre 22 vous montrera comment le langage C permet de définir
des caractères étendus, c’est-à-dire permettant de représenter plus de caractères
que le type char, ainsi que des chaînes de tels caractères.
1. Règles générales d’écriture des constantes chaîne
1.1 Notation des constantes chaîne
Dans un programme source, on peut introduire des constantes chaîne qu’on
nomme aussi chaînes littérales ou libellés. La plupart du temps, il s’agit d’une
suite de caractères placés entre guillemets comme dans :
"bonjour" /* une constante chaîne, telle qu'on l'écrit dans un programme source */
Cette notation représente la suite de 7 caractères b, o, n, j, o, u et r.
Parmi les caractères figurant entre les guillemets, on peut trouver n’importe quel
caractère appartenant au jeu de caractères source, éventuellement sous sa
notation échappatoire telle que \n, \t, \a, voire sous une notation hexadécimale ou
octale… Il n’existe que deux exceptions :
• Le guillemet (") ne peut pas y apparaître sous sa forme naturelle, ce qui va de
soi, compte tenu de la signification de ce caractère. En revanche, il peut
toujours apparaître sous sa forme échappatoire (\"), ou éventuellement en
notation hexadécimale ou octale (ces deux dernières possibilités n’étant
cependant pas portables, puisque dépendantes du code utilisé).
• Une fin de ligne ne peut pas apparaître sous sa forme naturelle, mais
uniquement sous sa forme échappatoire \n, ou éventuellement en notation
hexadécimale ou octale.
On notera qu’en revanche, l’apostrophe peut apparaître indifféremment sous
n’importe quelle forme.
Exemples
"ceci est un exemple "incorrect"" /* exemple incorrect */
Cet exemple est incorrect dans la mesure où le second guillemet est interprété
comme la fin de la chaîne et les caractères suivants (inc…) provoqueront
probablement une erreur de compilation. En revanche, cet exemple est correct :
"ceci est un exemple \"correct\"" /* exemple correct de chaîne */
Il correspond à la constante chaîne formée des caractères suivants :
ceci est un exemple "correct"
De même :
"bonjour
monsieur"
est incorrect. Il faut absolument écrire :
"bonjour\nmonsieur"
Voici quelques exemples d’école de chaînes contenant des caractères définis par
leur notation hexadécimale :
"bonjour\x0Amonsieur" /* équivalent à "bonjour\nmonsieur" dans les environ- */
/* nements ou la fin de ligne est représentée par un */
/* seul caractère de code hexadécimal 0A (Unix notamment) */
"\x1\x2\x3" /* chaîne formée des caractères de code 1, 2 et 3 */
Remarques
1. Lorsqu’on utilise pour un caractère, l’une des notations octale ou hexadécimale, il faut prendre
garde à ce que les constantes octales possèdent de 1 à 3 chiffres (voir section 2.3.3 du chapitre 4),
tandis qu’aucune limitation ne porte sur les constantes hexadécimales. Cette particularité, qui
n’avait guère d’incidence dans le cas des caractères, devient cruciale dans le cas des chaînes,
comme le montrent ces exemples :
"\1234" /* chaîne de 2 caractères : caractère de code octal 123, suivi de
4 */
"\x1234" /* chaîne d'un seul caractère dont le code dépend de l'implémentation */
2. Toutes les notations suivantes sont équivalentes et représentent le caractère de fin de chaîne :
\0 \00 \000 \x0 \x00
Bien entendu, un tel caractère ne doit pas être introduit dans une constante chaîne. Même si tous les
caractères indiqués ensuite sont bien incorporés par le compilateur, on risque fort d’obtenir une
chaîne plus courte que prévu, à moins de traiter individuellement chacun des caractères (voir la
remarque de la section 2.1)
1.2 Concaténation des constantes chaîne adjacentes
Une fin de ligne ne peut pas apparaître dans une constante chaîne. Nous venons
de voir comment la remplacer par la notation \n. Cependant, lorsqu’une
constante chaîne est trop longue pour tenir sur une ligne de programme, il n’est
plus question de procéder ainsi. En fait, la norme ANSI a prévu la notion de
chaînes adjacentes, c’est-à-dire de chaînes qui se succèdent au sein d’un
programme source, en étant séparées par un ou plusieurs des éléments suivants :
• commentaire ;
• un espace blanc, c’est-à-dire l’un des caractères : espace, tabulation
horizontale, tabulation verticale, saut de page ou fin de ligne.
Deux chaînes adjacentes sont concaténées en une seule par le compilateur.
Ainsi :
"bon" "jour"
est-il équivalent à :
"bonjour"
Il en va de même pour :
"bon" /* commentaire */ "jour"
Mais ceci est d’un intérêt limité. En revanche :
"voici un exemple de texte ne comportant pas de changement de lignes "
"et cependant ecrit sur deux lignes dans le programme source"
ne possède pas d’équivalent, du moins si l’on limite la taille des lignes du
programme source à une valeur raisonnable. Beaucoup d’environnements
permettraient d’écrire la chaîne précédente sur une seule ligne, mais quelques
problèmes se poseraient dans les listes du programme correspondant (lignes
tronquées ou repliées…).
À ce propos, on ne confondra pas une chaîne s’étendant sur plusieurs lignes avec
une chaîne contenant précisément des caractères de fin de ligne.
Remarque
La concaténation des chaînes adjacentes n’a lieu qu’après leur analyse. Autrement dit, la notation :
"\12" "34"
correspondra à une chaîne formée de 3 caractères (caractère de code octal 12, caractère 3 et caractère
4) et non d’un seul caractère comme si l’on avait écrit :
"\1234"
2. Propriétés des constantes chaîne
Le tableau 10.1 récapitule les propriétés des constantes chaîne. Elles sont ensuite
étudiées en détail dans les sections indiquées.
Tableau 10.1 : les propriétés des constantes chaîne
– utilisation d’un caractère de code nul pour Voir
Conventions en marquer la fin ; section
de 2.1
– remplacement, par le compilateur, de la
représentation notation constante chaîne par son adresse
(char *).
Toujours en mémoire statique, quel que soit Voir
Emplacement
l’emplacement où la constante chaîne section
mémoire
apparaît dans le programme. 2.2
Cas des Suivant l’implémentation, peuvent être Voir
chaînes dupliquées ou non. section
identiques 2.3
Souvent acceptée ; pour l’éviter, utiliser Voir
Modification const. section
2.4
Simulation On initialise un tableau de pointeurs de type Voir
d’un tableau char * par des constantes chaîne. section
de constantes 2.5
chaîne
2.1 Conventions de représentation
Comme il a été dit en introduction, la convention de représentation des chaînes
est utilisée par le compilateur pour représenter les constantes chaîne en mémoire.
Autrement dit, la notation :
"bonjour" /* une constante chaîne, telle qu'on l'écrit dans un programme source */
amènera le compilateur à placer en mémoire la suite de caractères b, o, n, j, o, u
et r, suivie d’un caractère de code nul.
De plus, comme le prévoit la norme, une telle notation sera convertie par le
compilateur en un pointeur sur le premier caractère. Il s’agit donc d’un pointeur
de type char *, c’est-à-dire que tout se passe comme si la constante chaîne en
question était un tableau de caractères…
Exemple
Voici un programme illustrant ces deux particularités (caractère 0 en fin de
chaîne et conversion d’une constante chaîne en un pointeur) :
Mise en évidence des conventions de représentation des constantes chaîne
#include <stdio.h>
int main()
{ char *adr ; /* ou, mieux : const char *adr ; (voir section 2.4.2) */
adr = "bonjour" ;
while (*adr)
{ printf ("%c", *adr) ;
adr++ ;
}
}
bonjour
La déclaration :
char * adr ;
réserve simplement l’emplacement pour un pointeur sur un caractère (ou une
suite de caractères). En ce qui concerne la constante "bonjour", le compilateur a
créé en mémoire la suite d’octets correspondants mais, dans l’affectation :
adr = "bonjour" ;
la notation "bonjour" est convertie en l’adresse de début de la chaîne.
Voici un schéma illustrant ce phénomène. La flèche en trait plein correspond à la
situation après l’exécution de l’affectation adr = "bonjour" ; les autres flèches
correspondent à l’évolution de la valeur de adr, au cours de la boucle.
Remarque
Si nous avions introduit dans adr l’adresse de la chaîne suivante :
adr = "bonjour\0monsieur" ;
notre programme aurait simplement affiché bonjour. Néanmoins, si nous avions cherché à examiner
les caractères situés au-delà du premier caractère de code nul, nous aurions pu récupérer les caractères
de monsieur. Cela montre une fois de plus, si besoin était, le caractère parfaitement conventionnel de
la représentation des chaînes en C.
2.2 Emplacement mémoire
Une constante chaîne peut apparaître dans le programme source soit à un niveau
global, soit à un niveau local.
À un niveau global, elle figure obligatoirement dans une initialisation ou dans
une instruction de déclaration, puisqu’il n’existe pas d’instructions exécutables
de niveau global. En voici deux exemples :
char *message = "bonjour" ;
char t[] = "hello" ;
À un niveau local, elle peut figurer dans une déclaration ou dans une instruction
exécutable ; dans ce dernier cas, cependant, il ne peut s’agir que d’un appel de
fonction, comme dans :
strcpy (adr, "salut") ;
Dans les deux cas, on peut affirmer que :
Une constante chaîne est toujours placée en mémoire statique.
Autrement dit, l’emplacement d’une chaîne est alloué une fois pour toutes avant
l’exécution et il subsiste jusqu’à la fin de l’exécution. On peut dire qu’une
constante chaîne est traitée comme une variable de classe d’allocation statique.
Ce choix, relativement arbitraire, n’a rien d’évident. Considérons par exemple :
void fct (….)
{ char * adr = "bonjour" ;
…..
}
La variable adr voit son emplacement alloué à chaque entrée dans fct, tandis que
l’emplacement recevant la chaîne "bonjour" est alloué de façon permanente.
2.3 Cas des chaînes identiques
Lorsque plusieurs constantes chaîne identiques apparaissent dans un même
fichier source, la norme n’impose pas au compilateur de créer une seule chaîne
en mémoire. Certaines implémentations permettent de choisir entre la
duplication ou l’unicité des chaînes identiques. Hormis le gain de mémoire
qu’apporte l’unicité, elle peut entraîner d’autres conséquences plus sournoises
lorsqu’on tente (et surtout que l’on parvient) à modifier une constante chaîne,
comme nous le verrons à la section 2.4.
Bien entendu, la norme n’imposant rien dans le cas de chaînes identiques au sein
d’un même fichier source, il va de soi qu’il en va de même dans le cas de
chaînes identiques dispersées dans plusieurs fichiers source. D’ailleurs, dans ce
cas, leur détection n’est pas aisée et elle devrait faire intervenir l’éditeur de liens.
Aucune implémentation, à notre connaissance, ne permet de fusionner les
chaînes identiques apparaissant dans des fichiers source différents.
2.4 Les risques de modification des constantes chaîne
2.4.1 Présentation des risques
Considérons cette déclaration :
char *adr = "bonjour" ;
Supposons que l’on tente de modifier l’un des caractères de notre constante
chaîne par une banale affectation telle que l’une des deux suivantes :
*adr = ‘x' ; /* bonjour va-t-il se transformer en xonjour ? */
*(adr+3) = ‘z' ; /* bonjour va-t-il se transformer en bonzour ? */
A priori, la norme précise simplement que le comportement du programme est
indéterminé dans un tel cas. En pratique, on ne rencontre que deux situations :
• soit, comme l’autorise la norme, l’implémentation place les constantes chaîne
dans un emplacement protégé contre la modification, auquel cas les
instructions précédentes conduisent à une erreur d’exécution ;
• soit, la modification a lieu, sans autre forme de procès et ceci, d’ailleurs, que le
caractère concerné soit situé à l’intérieur de la chaîne ou à l’extérieur ; lorsque,
de surcroît, le compilateur prévoit de ne générer qu’une seule fois les chaînes
identiques, on peut aboutir à des résultats assez cocasses comme dans cet
exemple où l’on affiche, non pas bonjour comme on s’y attend, mais bonzour :
char *adr = "bonjour" ;
…..
*(adr+3) = ‘z' ;
printf ("bonjour") ;
2.4.2 Comment s’en protéger ?
Pour se protéger des risques évoqués, on peut utiliser la possibilité de définir des
pointeurs sur des objets constants. Ainsi, en déclarant :
const char *adr = "bonjour" ;
les tentatives suivantes de modifications seront rejetées par le compilateur :
*adr = ‘x' ; /* interdit car adr pointe sur un objet constant */
* (adr+2) = ‘x' ; /* interdit car l'expression adr+2 est, comme adr, */
/* de type pointeur sur un objet constant */
adr[i] = ‘x' ; /* même raison commpte tenu du rôle de l'opérateur [] */
D’une manière générale, il est vivement conseillé de placer systématiquement
les adresses des constantes chaîne dans des pointeurs de type const char *. Malgré
tout, même dans ce cas, la modification restera encore possible :
• par un appel tel que scanf ("%s", adr) ;
• en recopiant une telle adresse dans un pointeur de type char * ; ici, toutefois,
cela ne peut plus se faire par mégarde, puisqu’il faudra recourir à une
conversion explicite par l’opérateur de cast.
Signalons que cette démarche peut être généralisée au cas d’une chaîne créée
lors de l’exécution et qu’on souhaite voir rester constante, comme le montre le
programme suivant :
Une façon d’interdire la modification d’une chaîne donnée
const char *adc ;
char *ad ;
…..
/* allocation de l'espace nécessaire */
ad = malloc (…) ; /* on pourrait ranger directement l'adresse dans ad */
/* mais on ne pourrait pas donner une valeur à la chaîne */
/* préparation de la chaine */
strcpy (ad, …) ;
/* on fige la chaîne et on supprime la valeur du pointeur variable */
adc = ad ;
ad = NULL ;
2.5 Simulation d’un tableau de constantes chaîne
Une constante chaîne est convertie par le compilateur en un pointeur sur son
premier caractère. Il est facile de généraliser cela à plusieurs constantes chaînes,
de manière à constituer un tableau de pointeurs. Bien sûr, on pourrait initialiser
individuellement chacun des pointeurs, en procédant par exemple ainsi :
char *jour[7] ; /* tableau de 7 pointeurs de type char * */
jour [0] = "lundi" ;
jour [1] = "mardi " ;
…..
Mais il est possible de profiter de la syntaxe d’initialisation d’un tableau, en
écrivant directement :
char *jour[7] = { "lundi", "mardi", "mercredi", "jeudi",
"vendredi", "samedi", "dimanche" } ;
En effet, dans la liste d’initialisation située entre accolades, chacune des
notations de la forme "lundi" est effectivement traduite en un pointeur constant de
type char *, lequel est tout à fait convenable pour l’initialisation d’un élément du
tableau jour.
En définitive, cette déclaration réalise à la fois la création des 7 constantes
chaîne correspondant aux 7 jours de la semaine et l’initialisation du tableau jour
avec les 7 adresses de ces 7 chaînes. Voici un exemple employant le tableau
jour :
Initialisation d’un tableau de pointeurs sur des chaînes
int main()
{ char * jour[7] = { "lundi", "mardi", "mercredi", "jeudi",
"vendredi", "samedi", "dimanche" } ;
int i ;
printf ("donnez un entier entre 1 et 7 : ") ;
scanf ("%d", &i) ;
printf ("le jour numero %d de la semaine est %s", i, jour[i-1] ) ;
}
donnez un entier entre 1 et 7 : 3
le jour numero 3 de la semaine est mercredi
Voici un schéma illustrant la situation :
Remarque
On a généralement intérêt à déclarer le tableau jour sous la forme :
const char *jour[7] ; /* les chaînes pointées par jour ne sont pas modifiables */
ce qui correspond à des constantes chaîne. Toutefois, dans ce cas, les valeurs des adresses situées dans
le tableau jour restent encore modifiables. Si on souhaite l’interdire également, on peut utiliser la
déclaration suivante :
const char *const jour[7] ; /* ni les valeurs du tableau jour, ni les chaînes */
/* pointées ne sont modifiables */
3. Créer, utiliser ou modifier une chaîne
Contrairement à ce qui se passe dans des langages qui disposent d’un véritable
type chaîne, le langage C manque d’unité en ce qui concerne la manipulation des
chaînes. En particulier, vous pouvez :
• utiliser des fonctions de la bibliothèque standard, lesquelles travaillent
globalement sur la chaîne, en se basant sur son zéro de fin. Cependant, comme
ces fonctions utilisent en fait non pas la valeur d’une chaîne, mais simplement
une adresse de début, il est facile de les faire porter non plus sur une chaîne
entière, mais simplement sur la fin d’une chaîne ;
• manipuler individuellement chaque caractère de la chaîne, comme on le ferait
d’un simple tableau de caractères. Dans ce cas, la détection éventuelle du zéro
de fin est de votre propre responsabilité.
Ce manque d’unité va se retrouver dans la manière d’allouer un emplacement
mémoire pour y manipuler une chaîne. Le tableau 10.2 récapitule les différentes
possibilités qui sont ensuite étudiées en détail dans les sections indiquées.
Signalons que les fonctions de la bibliothèque standard, auxquelles il est
fréquemment fait référence, sont décrites en détail dans les sections 4 à 9.
Tableau 10.2 : créer, modifier ou utiliser une chaîne
– utiliser un tableau de caractères (statique Voir
Disposer d’un section
ou automatique) ;
emplacement 3.1
– allouer dynamiquement un emplacement.
– initialiser un tableau de caractères, lors de Voir
sa déclaration ; section
3.2
– la modifier totalement caractère par
caractère ;
Agir sur le
contenu d’une – la modifier globalement par des fonctions
chaîne standards telles que strcpy, strncpy ;
– modifier un à un certains de ses
caractères ;
– la modifier partiellement à l’aide des
fonctions standards.
– l’utiliser globalement avec les fonctions Voir
standards ; section
3.3
– examiner individuellement certains de ses
Utiliser une caractères ; Voir
chaîne – n’utiliser que la fin avec les fonctions section
existante standards ; 9
– y rechercher, à l’aide de fonctions
standards, l’occurrence d’un caractère ou
d’une sous-chaîne.
3.1 Comment disposer d’un emplacement pour y
ranger une chaîne
Bien entendu, dans un langage qui dispose d’un vrai type chaîne, il suffit de
déclarer une variable d’un tel type pour disposer d’un emplacement approprié à
une chaîne de caractères. Comme ce n’est pas le cas du langage C, il est
nécessaire de venir « nicher » une chaîne dans un emplacement prévu
théoriquement pour une information de nature différente.
La façon la plus naturelle consiste à utiliser un emplacement comportant un
nombre donné de caractères, donc d’octets. Pour ce faire, on dispose de deux
démarches très différentes.
La première consiste à utiliser un « tableau de caractères » existant, c’est-à-dire
ayant fait l’objet d’une déclaration. Cela impose obligatoirement une taille
maximale à la chaîne qu’on pourra y ranger et ce, que le tableau en question soit
de classe d’allocation statique ou automatique (puisque, même dans ce dernier
cas, sa dimension doit être une expression constante). Par exemple :
char ch [21] ; /* permet de ranger une chaîne d'au plus 20 caractères */
/* compte tenu de son zéro de fin */
La seconde démarche consiste à allouer dynamiquement, au moment de
l’exécution, un emplacement à l’aide de fonctions telles que malloc ou calloc :
char * ch = malloc (21) ; /* à l'adresse ch, on pourra ranger une chaîne d'au */
/* plus 20 caractères, compte tenu de son zéro de fin */
Bien entendu, la taille allouée imposera, là encore, une taille maximale à la
chaîne qu’on pourra y ranger mais :
• contrairement à la démarche précédente, la taille de cet emplacement pourra
être définie au moment de l’exécution :
char * ch ;
int n ;
…..
ch = malloc (n + 1) ;
• il est toujours envisageable d’allouer d’autres emplacements ou d’étendre
l’emplacement existant (par realloc) en cas de besoin.
On notera bien que, quelle que soit la façon d’allouer l’emplacement ch, on
pourra l’utiliser avec le même formalisme étant donné que ch désigne dans les
deux cas l’adresse de début de la chaîne (dans le premier cas, ch est converti en
un pointeur…) et que ch[i] désigne le caractère de rang i, compte tenu de
l’équivalence de cette notation avec *(ch+i).
Dans ces conditions, on voit que tout ce qui est dit dans ce chapitre s’applique
indifféremment aux deux situations.
3.2 Comment agir sur le contenu d’une chaîne
On dispose de plusieurs façons d’agir sur la valeur d’une chaîne :
• en l’initialisant lors de la réservation de l’emplacement correspondant ;
• en créant ou en modifiant individuellement chacun de ses caractères ;
• en lui attribuant une valeur d’une manière qu’on qualifiera de globale, c’est-à-
dire en y plaçant la valeur d’une autre chaîne ;
• en modifiant certains de ses caractères ;
• en utilisant, un peu artificiellement, les fonctions standards pour en modifier
une partie.
3.2.1 Initialisation d’une chaîne lors de sa réservation
Comme nous l’avons dit à la section 3.1, vous serez souvent amené en C à placer
des chaînes dans des tableaux de caractères. Dans ce cas, si vous déclarez, par
exemple :
char ch[20] ;
vous ne pourrez pas pour autant transférer une constante chaîne dans ch, en
écrivant une affectation du genre :
ch = "bonjour" ;
En effet, est une constante pointeur qui correspond à l’adresse que le
ch
compilateur a attribuée au tableau ch. Il ne s’agit pas d’une lvalue, il n’est donc
pas question de lui attribuer une autre valeur (ici, il s’agirait de l’adresse
attribuée par le compilateur à la constante chaîne "bonjour").
En revanche, comme l’indique la section 6.2.5 du chapitre 6, C vous autorise à
initialiser votre tableau de caractères à l’aide de la même notation que celle
utilisée pour les constantes chaîne. Ainsi, vous pourrez écrire :
char ch[20] = "bonjour" ;
Cela sera parfaitement équivalent à une initialisation de ch réalisée par une
énumération de caractères (en n’omettant pas le code zéro noté \0) :
char ch[20] = { ‘b','o','n','j','o','u','r','\0' } ;
N’oubliez pas que, dans ce dernier cas, les 12 caractères non initialisés
explicitement seront :
• soit initialisés à zéro, dans le cas d’un tableau de classe statique – on voit que,
dans ce cas, l’omission du caractère ‘\0' ne serait pas grave (ici) ;
• soit « aléatoires », dans le cas d’un tableau de classe automatique – dans ce cas,
l’omission du caractère ‘\0' serait nettement plus gênante.
De plus, comme le langage C autorise l’omission de la dimension d’un tableau
lors de sa déclaration, lorsqu’elle est accompagnée d’une initialisation, il est
possible d’écrire une instruction telle que :
char message[] = "bonjour" ;
Celle-ci réserve un tableau, nommé message, de 8 caractères (compte tenu du zéro
de fin).
Remarque
Considérons ces deux déclarations :
char ch[20] = "bonjour" ;
char * adr = "bonjour" ;
Il existe de grandes différences entre ch et adr :
• ch n’est pas une lvalue tandis que adr en est une ;
• l’emplacement d’adresse ch est de 20 octets, celui d’adresse adr est de 8 octets ;
• l’emplacement d’adresse ch est de classe statique si sa déclaration est globale, de classe automatique
si sa déclaration est locale ; en revanche, l’emplacement d’adresse (actuelle) adr est toujours
statique.
De plus, dans la première déclaration, aucune constante chaîne n’apparaît, et ce malgré la notation
employée («…») laquelle, ici, n’est qu’une facilité d’écriture remplaçant l’initialisation des premiers
caractères du tableau ch. En particulier, toute modification de l’un des éléments de ch par une
instruction telle que :
*(ch + 3) = ‘x' ;
est parfaitement licite : nous n’avons aucune raison ici de vouloir que le contenu du tableau ch reste
constant.
3.2.2 Attribuer une valeur à une chaîne, caractère par caractère
Quelle que soit la manière dont on a alloué un emplacement pour une chaîne et
quel que soit son contenu, on peut lui attribuer une (nouvelle) valeur caractère
par caractère. Il faut alors penser à introduire un dernier caractère de code nul
pour en marquer la fin, sauf si l’on est certain de ne pas avoir à faire appel à une
fonction standard quelconque (ne serait-ce que strlen ou puts). On notera
d’ailleurs que si ce besoin n’existe pas, c’est qu’on manipule en fait non plus une
chaîne, mais tout simplement un tableau de caractères.
Voici un exemple dans lequel nous créons de cette manière une chaîne formée
des 10 caractères correspondant aux chiffres 0 à 9 :
Exemple de création d’une chaîne, caractère par caractère
#include <stdio.h>
#include <string.h> /* pour strlen */
int main()
{ char chiffres [11] ; /* 10 caractères plus le zéro de fin */
int i ;
for (i=0 ; i<10 ; i++)
chiffres [i] = ‘0' + i ; /* car les codes des chiffres sont */
/* toujours consecutifs */
chiffres[10] = ‘\0' ; /* caractère de fin de chaîne */
printf ("chaine obtenue de longueur %d : %s", strlen (chiffres), chiffres) ;
}
chaine obtenue de longueur 10 : 0123456789
3.2.3 Modifier globalement une chaîne avec les fonctions
standards
Comme il n’existe pas de vrai type chaîne en C, il n’est pas possible d’affecter
une chaîne à une autre. Pour y parvenir, il est nécessaire de recourir à des
fonctions standards telles que strcpy ou strncpy, comme dans ces exemples :
char ch1[10] = "bonjour" ;
char ch2[10] ;
…..
strcpy (ch2, ch1) ; /* recopie la chaîne "bonjour" de ch1 dans ch2 */
strcpy (ch2, "hello") ; /* recopie la constante chaîne "hello" dans ch2 */
3.2.4 Modifier partiellement une chaîne, caractère par caractère
Ici, il ne s’agit que d’appliquer ce qu’on a vu à la section 3.2.2, avec cette
différence qu’on ne modifie qu’une partie de la chaîne. Bien entendu, si la
longueur de la chaîne est censée évoluer, il ne faudra pas oublier d’agir en
conséquence sur le zéro de fin. Voici un exemple :
Lorsqu’on oublie de modifier le 0 de fin d’une chaîne
#include <stdio.h>
int main()
{ char ch[10] = "bonjour" ;
puts (ch) ;
ch[3] = ‘d' ; puts (ch) ; /* ici, on a oublié le 0 de fin */
ch[4] = ‘\0' ; puts (ch) ; /* ici, on l'a bien placé */
}
bonjour
bondour
bond
3.2.5 Modifier partiellement une chaîne avec les fonctions
standards
En dehors des fonctions de concaténation, il n’existe théoriquement pas de
fonction standard modifiant partiellement une chaîne. Il existe bien des fonctions
de recherche d’occurrence de caractères ou de sous-chaînes, mais celles-ci se
contentent de fournir une adresse, sans effectuer elles-mêmes de modifications
(avec, toutefois, une légère exception pour strtok).
Mais comme il est possible de transmettre à une fonction une adresse qui
corresponde à un caractère quelconque de la chaîne, et pas obligatoirement au
premier, on voit qu’il est possible de modifier la fin d’une chaîne.
Par exemple, avec :
char ch1[20] = "bonjour" ;
char *ch2 = "monsieur" ;
on peut très bien procéder ainsi :
strcpy (ch1+2, ch2+4) ;
La chaîne ch1 contiendra alors :
bosieur
On notera bien que cette façon de procéder présente les risques suivants :
• fournir comme adresse de début de chaîne une adresse située à l’extérieur de la
chaîne ; par exemple, avec :
strcpy (ch1+10, ch2) ;
• on viendra bien placer des caractères dans le tableau ch1, mais comme ils sont
situés après le zéro de fin de la chaîne actuelle, il ne seront pas nécessairement
visibles…
• aboutir à un débordement de la chaîne, comme dans :
char ch1[] = "bonjour" ;
…..
strcpy (ch1+3, "ne annee") ;
3.3 Comment utiliser une chaîne existante
De façon voisine de ce qu’on a dit à propos de l’attribution d’une valeur à une
chaîne, il existe plusieurs façons d’utiliser la valeur d’une chaîne :
• en examinant individuellement tout ou partie de ses caractères ;
• en l’exploitant d’une manière globale, c’est-à-dire en considérant toute la
chaîne, à l’aide des fonctions standards ;
• en utilisant seulement la fin, à l’aide des fonctions standards.
3.3.1 Utilisation globale d’une chaîne
Comme il n’existe pas de vrai type chaîne en C, l’utilisation globale d’une
chaîne, comme sa création globale, ne peut pas se faire par affectation1, mais
uniquement par le biais des fonctions de la bibliothèque standard. En voici
quelques exemples, les deux premiers ayant déjà été rencontrés :
strcpy (ch2, ch1) ; /* recopie la chaîne ch1 dans ch2 */
strcpy (ch2, "hello") ; /* recopie la constante chaîne "hello" dans ch2 */
strlen (ch) /* longueur de la chaîne ch */
3.3.2 Utilisation d’une chaîne, caractère par caractère
Quelles que soient la méthode utilisée pour réserver l’emplacement d’une chaîne
(tableau de caractères ou allocation dynamique) et la manière dont elle a été
créée (globalement ou caractère par caractère), on peut toujours examiner un
caractère de rang i en faisant appel à l’une des deux notations parfaitement
équivalentes ch[i] ou *(ch+i). Voici un exemple de programme qui affiche
«verticalement» les différents caractères d’une chaîne fournie par l’utilisateur :
Exemple d’accès aux différents caractères d’une chaîne
#include <stdio.h>
#include <string.h> /* pour strlen */
int main()
{ char mot[11] ;
int i ;
printf ("donnez un mot d'au plus 10 caracteres : ") ;
gets (mot) ;
for (i=0 ; i<strlen(mot) ; i++)
printf ("%c\n", mot[i]) ;
}
donnez un mot d'au plus 10 caracteres : hello
h
e
l
l
o
3.3.3 Utilisation d’une partie d’une chaîne
On peut bien sûr accéder à une partie d’une chaîne caractère par caractère, ce qui
constitue un cas particulier de la section 3.3.2. Mais on peut également :
• utiliser des fonctions qui recherchent l’occurrence d’un caractère ou d’une
sous-chaîne dans une chaîne donnée ; on notera que ces fonctions fournissent,
en toute rigueur, non pas une chaîne, mais l’adresse de début de la chaîne
concernée, c’est-à-dire finalement l’adresse d’un caractère de la chaîne
initiale…
• faire porter des fonctions standards sur la fin d’une chaîne, en leur
communiquant non plus l’adresse de début de la chaîne, mais l’adresse d’un
caractère de cette chaîne.
Voici un exemple de programme illustrant la seconde possibilité :
Exemple d’utilisation de la fin d’une chaîne
#include <stdio.h>
#include <string.h>
int main()
{ char ch[] = "bonjour monsieur" ;
puts (ch) ;
puts (&ch[8]) ; /* ou : puts (ch+8) ; */
puts (&ch[11]) ; /* ou : puts (ch+11) ; */
}
bonjour monsieur
monsieur
sieur
On notera bien que cette dernière démarche ne peut s’appliquer qu’à la fin d’une
chaîne, et en aucun cas à une partie quelconque d’une chaîne. En effet, s’il est
possible de choisir l’adresse du premier caractère d’une chaîne, on ne choisit
jamais celle du dernier, lequel est, par convention, celui qui précède le caractère
nul.
4. Entrées-sorties standards de chaînes
4.1 Généralités
En ce qui concerne l’écriture de chaînes sur l’unité standard, on peut utiliser soit
le code de format %s dans printf, soit la fonction puts, spécifique aux chaînes. De
manière similaire, pour la lecture sur l’unité standard, on peut utiliser soit le
code de format %s de scanf, soit la fonction gets, spécifique aux chaînes2.
Cette section est plutôt consacrée aux fonctions puts et gets, dans la mesure où les
fonctions printf et scanf sont étudiées en détail au chapitre 9. Néanmoins, nous
reprenons ici, en le détaillant, ce qui est spécifique au code de format %s, de
manière à comparer ses possibilités avec celles de puts et gets.
Par ailleurs, on n’oubliera pas que toutes les possibilités d’entrée-sortie sur les
unités standards se généralisent à des fichiers de type texte. On dispose à cet
effet de fonctions appropriées, semblables aux précédentes, mais possédant
naturellement un argument supplémentaire précisant le fichier concerné, à savoir
fprintf, fputs, fscanf et fgets. La fonction fgets permet en outre de limiter la
longueur de la chaîne lue. Ces quatre fonctions sont étudiées en détail au
chapitre 13 et elles seront simplement évoquées dans cette section.
4.2 Écriture de chaînes avec puts
La vocation de puts est essentiellement d’afficher une chaîne sur une ligne.
4.2.1 Prototype
La fonction puts
int puts (const char *chaine) (stdio.h)
chaine
Adresse d’une chaîne de caractères
Valeur de Valeur non négative quand le Discussion à la
retour déroulement a été correct, EOF en cas section 4.2.3
d’erreur
4.2.2 Rôle
La fonction puts envoie sur l’unité standard stdout les différents caractères de la
chaîne d’adresse chaîne, le zéro de fin n’étant pas compris ; puis elle transmet un
caractère de fin de ligne (‘\n'). On notera bien que l’affichage de la chaîne n’est
précédé d’aucun caractère, en particulier d’aucune fin de ligne ; cela signifie
qu’avec puts, on peut éventuellement afficher une chaîne à la suite de quelque
chose, mais qu’en revanche, on ne peut jamais rien afficher à sa suite.
Exemples
Avec ces instructions :
char ch1[] = "bonjour" ;
char ch2[] = "monsieur" ;
printf ("essai de puts : ") ;
puts (ch1) ;
puts (ch2) ;
On obtient sur la sortie standard :
essai de puts : bonjour
monsieur
Si la chaîne ch1 avait contenu l’information «bonjour\n" au lieu de "bonjour", on
obtiendrait une ligne vide supplémentaire :
essai de puts : bonjour
monsieur
Remarque
La fonction fputs, décrite au chapitre 13, joue un rôle comparable à puts, avec cette seule différence
qu’au lieu d’envoyer son information sur la sortie standard, elle l’envoie dans un fichier texte
quelconque.
4.2.3 Valeur de retour
La fonction puts fournit en retour une valeur non négative3 lorsque l’opération
s’est bien déroulée. En revanche, elle fournit conventionnellement la valeur EOF,
prédéfinie dans stdio.h (-1 en général), en cas d’erreur, c’est-à-dire ici :
• panne du matériel concerné ; il s’agit d’un cas rare et généralement pris en
compte par le système d’exploitation ;
• disquette absente ou déverrouillée, lorsque la sortie a été redirigée vers un
fichier ; cette situation est, elle aussi, généralement prise en compte par le
système d’exploitation ;
• d’un manque de place sur l’unité lorsque la sortie a été redirigée vers un fichier.
4.3 Écriture de chaînes avec le code de format %s de
printf ou fprintf
La fonction printf est décrite en détail au chapitre 9, la fonction fprintf au
chapitre 13. Ces deux fonctions jouent le même rôle, avec cette seule différence
que, alors que printf envoie son information sur la sortie standard, fprintf
l’envoie dans un fichier texte quelconque. En particulier, elles font le même
usage des codes de format.
En ce qui concerne plus particulièrement les chaînes, le code de format %s se
contente de transmettre au flux concerné les caractères de la chaîne
correspondante, sans son zéro de fin et sans aucun caractère supplémentaire ni
avant, ni après. Il n’en allait pas de même avec puts ou fputs, qui ajoutent un
caractère de fin de ligne après ceux de la chaîne.
On peut dire que ces deux instructions sont équivalentes :
puts (ch) ;
printf ("%s\n", ch) ;
Les fonctions puts et fputs sont généralement d’exécution plus rapide que printf
ou fprintf. En revanche, les possibilités du code de format %s sont plus riches que
celles offertes par puts ou fputs pour différentes raisons.
Tout d’abord, printf ou fprintf permettent l’introduction de libellés. Ainsi :
printf ("voici la reponse attendue : %s\n", ch) ;
remplace :
printf ("voici la reponse attendue : ") ;
puts (ch) ;
On notera que ces instructions :
puts ("voici la reponse attendue : )
puts (ch) ;
ne donneraient pas exactement le même résultat que les précédentes, dans la
mesure où elles produiraient deux lignes et non plus une seule !
Ensuite, un seul appel de printf ou fprintf peut transmettre plusieurs
informations, éventuellement de types différents, comme dans :
char ch[] = "objets" ;
int n = 10 ;
…..
printf ("il y a %d %s", n, ch) ;
Enfin, le code de format %s peut comporter des indications de mise en forme :
• soit par le biais d’un gabarit qui précise le nombre minimal de caractères à
écrire ;
• soit par le biais d’une précision qui, dans ce cas, indique un nombre maximal à
prélever dans la chaîne.
Exemple
Avec ces instructions :
char mot [] = "langage" ;
int i ;
…..
for (i=0 ; i<strlen(mot) ; i++)
printf (":%5.4s:\n", &mot[i]) ;
on obtient :
: lang:
: anga:
: ngag:
: gage:
: age:
: ge:
: e:
4.4 Lecture de chaînes avec gets
La vocation de gets est de lire des chaînes se présentant sur des lignes différentes.
4.4.1 Prototype
char *gets (char *chaine) (stdio.h)
chaine
Adresse à laquelle seront rangés les
caractères lus.
Valeur de Adresse de la chaîne lue quand l’opération Discussion
retour s’est bien déroulée, le pointeur NULL en cas à la section
d’erreur. 4.4.3
4.4.2 Rôle
La fonction gets lit des caractères sur l’unité d’entrée standard stdin, en les
rangeant à l’adresse chaîne et en s’interrompant à la rencontre d’un caractère fin
de ligne (‘\n') ou, éventuellement, d’une fin de fichier ou d’une erreur.
Comme avec le code de format %s dans scanf :
• le caractère de fin de ligne n’est pas introduit en mémoire ;
• un caractère de fin de chaîne est ajouté à la fin des caractères ainsi lus ; cela
signifie que pour lire convenablement une chaîne de n caractères, il faut
disposer de n+1 octets à l’adresse chaîne ;
• le contenu de l’emplacement mémoire d’adresse chaîne reste inchangé si une fin
de fichier est rencontrée sans qu’aucun autre caractère (ne serait-ce ‘\n') n’ait
été lu.
En revanche, le comportement de gets diffère de celui du code de format %s dans
scanf sur les points suivants :
• pour gets, seule la fin de ligne sert de délimiteur alors que pour %s, il existe 5
caractères délimiteurs4 qu’on nomme des espaces blancs ; la chaîne lue par gets
peut donc contenir n’importe quel caractère espace blanc (excepté \n), en
particulier des espaces ;
• ce caractère fin de ligne est consommé par la lecture, c’est-à-dire que le
pointeur ne reste pas placé dessus comme il le ferait avec le code %s dans scanf.
Cela signifie qu’une prochaine lecture, en particulier par gets, accédera bien au
caractère de la ligne suivante.
En cas de rencontre de fin de fichier alors qu’aucun caractère n’a encore été lu,
le contenu de l’emplacement d’adresse chaîne reste inchangé. En revanche, en cas
d’erreur, ce contenu est indéterminé. En pratique, on y trouvera tout ou partie des
caractères lus avant l’erreur.
Remarques
1. Il existe une fonction fgets, décrite au chapitre 13, similaire à gets, qui lit ses informations dans un
fichier texte quelconque au lieu de les lire sur l’entrée standard. Elle présente sur gets l’avantage
de limiter le nombre de caractères lus, de sorte qu’on préférera souvent appliquer fgets à stdin au
lieu d’utiliser gets. Il faudra cependant tenir compte du fait que, contrairement à gets, fgets
introduit une fin de ligne en mémoire, sauf lorsque le nombre maximal de caractères a été atteint.
2. Théoriquement, la norme C11 proscrit l’emploi de gets et demande d’utiliser à la place la fonction
gets_s qui, tout en fonctionnant comme gets, dispose d’un argument supplémentaire permettant de
limiter le nombre de caractères lus en mémoire. Malheureusement, cette nouvelle fonction fait
partie d’un ensemble de fonctions facultatives (voir l’annexe B consacrée aux normes C99 et C11),
de sorte qu’il n’est guère facile de s’appuyer sur cette possibiité pour écrire des codes portables.
4.4.3 Valeur de retour
La fonction gets fournit comme valeur de retour l’adresse de la chaîne lue
lorsque l’opération s’est bien déroulée, le pointeur NULL en cas d’erreur. Cette
dernière situation peut se produire dans les cas suivants :
• rencontre de la fin de fichier alors qu’aucun caractère n’a encore été trouvé. On
notera que la notion de fin de fichier a un sens pour l’unité d’entrée standard,
même lorsque celle-ci n’est pas redirigée vers un fichier. En effet, dans bon
nombre d’environnements, il est possible de « simuler » une fin de fichier
depuis le clavier, à l’aide d’une combinaison de touches particulières. Dans ce
cas, aucune lecture ultérieure ne peut avoir lieu5 ;
• erreur matérielle, cas rare et généralement pris en compte par le système
d’exploitation.
Comme on peut le remarquer, la valeur de retour n’est pas nulle lorsque la fin de
fichier a été rencontrée après un ou plusieurs caractères. En général, il est donc
nécessaire de faire appel également à la fonction feof(stdin), comme on le ferait
avec fgets pour distinguer une fin de fichier normale d’une fin de fichier
anormale (pour plus de détails, voir la section 5.6.5 du chapitre 13). À titre
indicatif, voici les différentes situations possibles :
feof (stdin) Valeur de retour de gets
!= NULL
NON La lecture s’est déroulée normalement.
== NULL
Erreur de lecture (problème matériel rare)
!= NULL
OUI Fin de fichier « anormale » : une chaîne non
terminée par une fin de ligne a été lue.
== NULL
Fin de fichier « normale » (aucune chaîne
n’a été lue).
Exemple
Si l’utilisateur fournit sur l’entrée standard les informations suivantes, supposées
terminées par une fin de fichier (notée simplement ici EOF) :
un premier exemple de "chaine"
un second et dernier exemple
EOF
et qu’on les lit par une succession d’appels de la forme :
gets (ch) ;
on obtiendra successivement dans ch :
• au premier appel : un premier exemple de "chaine" ;
• au second appel : un second et dernier exemple.
La valeur de feof (stdin) sera toujours 0. Ce n’est que si l’on effectue un
troisième appel gets (ch) que feof (stdin) prendra la valeur 1, alors que la valeur
de retour de gets deviendra NULL, le contenu de ch étant inchangé.
En revanche, si l’utilisateur fournit sur l’entrée standard les informations
suivantes, voisines des précédentes, à la deuxième fin de ligne près (la mention
EOF désignant toujours la fin de fichier) :
un premier exemple de "chaine"
un second et dernier exempleEOF
on obtiendra les mêmes informations que précédemment dans ch ; mais après le
second appel de gets, feof(stdin) prendra la valeur 1.
4.5 Lecture de chaînes avec le code de format %s dans
scanf ou fscanf
Rappelons que la fonction scanf lit ses informations sur l’entrée standard stdin,
tandis que fscanf lit ses informations dans un fichier texte quelconque. Ces deux
fonctions offrent des possibilités plus riches que gets ou fgets puisque, dans un
seul appel, on peut lire plusieurs informations de types quelconques. Mais elles
présentent aussi quelques différences qui peuvent apparaître comme des
lacunes :
a) Avec le code %s, scanf et fscanf recherchent toujours le premier caractère
différent d’un espace blanc (espace, fin de ligne, tabulation horizontale,
tabulation verticale, changement de page). On ne peut donc pas lire une chaîne
qui commence par un espace blanc, ce qui était possible avec gets ou fgets (sauf,
toutefois, pour la fin de ligne qui, alors, conduisait à une chaîne vide).
b) Les fonctions scanf et fscanf utilisent également tous ces espaces blancs
comme délimiteurs de fin d’information, alors que gets et fgets se limitaient, tout
au plus, à la fin de ligne. Il n’est donc plus possible, en particulier, de lire une
chaîne contenant un espace, même si ce dernier n’est pas en début de chaîne.
c) Avec le code %s, l’espace blanc servant de délimiteur – quel qu’il soit (y
compris, donc, la fin de ligne) – n’est jamais consommé par la lecture par scanf
ou fscanf ; il reste disponible pour une prochaine lecture. Certes, ce phénomène
n’est guère gênant lorsqu’on lit deux chaînes consécutives par scanf. Par
exemple, si à cette instruction :
scanf ("%s%s", ch1, ch2) ;
l’utilisateur fournit l’une des trois réponses suivantes (la notation
correspondant ici à un espace et à une fin de ligne) :
bonjour monsieur
bonjour monsieur
bonjour
monsieur
on obtiendra toujours dans ch1 la chaîne bonjour et dans ch2 la chaîne monsieur (sans
espaces avant, ni après).
En revanche, si l’on utilise ces instructions :
scanf ("%s", ch1) ; gets (ch2) ;
on obtiendra dans ch2, dans les deux premiers cas l’une des deux chaînes
suivantes :
monsieur
monsieur
et une chaîne vide dans le troisième cas !
Notez bien qu’il n’est pas toujours raisonnable de provoquer systématiquement
le saut des espaces blancs après lecture par %s, en ajoutant un espace (noté ici )
après le code %s :
"%s "
En effet, ce dernier provoque la recherche d’un caractère différent d’un espace
blanc. Cela ne peut se concevoir que si l’on est certain que d’autres informations
seront fournies en même temps que la chaîne à lire. Dans le cas contraire, cela
risque de forcer la lecture d’une nouvelle ligne d’information, alors que
l’utilisateur n’a pas nécessairement de raisons de vouloir en fournir une à ce
niveau.
4.6 Comparaison entre gets et scanf dans les lectures
de chaînes
Le tableau suivant récapitule les points communs et les différences de
comportement qui apparaissent entre la lecture par scanf et la lecture par gets. En
raison du lien étroit qui existe entre scanf, sscanf et fscanf d’une part, gets et fgets
d’autre part, nous y avons tenu compte de ces autres fonctions, bien que leur
description détaillée soit faite dans d’autres chapitres. De même, nous citons
gets_s introduite facultativement par la norme C11.
Tableau 10.3 : comparaison entre gets (ou fgets) et les fonctions de la
famille6 scanf
Avec le code de
Avec gets ou fgets format %s dans
(ou gets_s en C11) scanf, fscanf ou
sscanf
Introduction du ‘\0' oui oui
de fin
Délimiteur de fin de ligne (‘\n') ou fin de espace blanc
l’information fichier (généralement
simulable au clavier)
Délimiteur recopié – non pour gets ou gets_s non
en mémoire (C11) ;
– oui pour fgets si fin de
ligne atteinte.
Délimiteur oui non
consommé
Possibilités de – non pour gets (mais on oui, en agissant sur
limitation de la peut toujours appliquer le gabarit ; a priori,
longueur des fgets à stdin) ;
on doit utiliser une
chaînes lues vraie constante,
– oui pour fgets ; mais on peut aussi
– oui pour gets_s (C11) (si la créer
ligne lue est trop longue, dynamiquement un
on obtient une chaîne format (voir section
vide. 4.7.2).
Possibilité de lire non oui
plusieurs chaînes en
un seul appel
Chaîne à lire non si fin de fichier et aucun si arrêt prématuré
modifiée caractère lu (avec gets_s, on de la lecture, dans
obtient une chaîne vide) le traitement des
codes précédents
4.7 Limitation de la longueur des chaînes lues sur
l’entrée standard
Que l’on utilise gets ou scanf pour lire sur l’entrée standard, il est nécessaire de
fournir l’adresse de la chaîne à lire, alors même qu’on n’en connaît pas
nécessairement la longueur. Dans ces conditions, quelle que soit la manière dont
l’emplacement correspondant a été réservé (tableau de caractères ou allocation
dynamique), le risque existe de voir l’utilisateur fournir une chaîne plus longue
que l’emplacement prévu.
Certaines implémentations limitent la taille des lignes qu’il est possible de
fournir sur l’unité standard, de sorte qu’on peut se protéger d’un tel dépassement
en adaptant en conséquence la taille de l’emplacement utilisé. Il s’agit cependant
d’une solution non portable qui, de plus, ne s’applique plus nécessairement à un
fichier texte quelconque et en particulier au cas où l’entrée standard a été
redirigée.
Par ailleurs, bien que la norme C11 propose une fonction get_s, compatible avec
gets et permettant de limiter le nombre de caractères introduits en mémoire, on
ne peut guère s’appuer sur elle pour réaliser des programmes portables, compte
tenu de son caractère facultatif.
En fait, comme nous allons le voir, il existe des solutions parfaitement
portables :
• l’une, la plus répandue, faisant appel à fgets,
• l’autre utilisant scanf en imposant un gabarit maximal.
4.7.1 Avec fgets
La fonction gets ne permet pas de limiter le nombre de caractères introduits en
mémoire. En revanche, la fonction fgets (voir chapitre 13) le prévoit. Aussi est-il
souvent préférable d’utiliser :
fgets (ch, LG_MAX, stdin) ;
plutôt que :
gets (ch) ;
Il faut simplement prendre quelques précautions algorithmiques pour tenir
compte du fait que, suivant les cas, une fin de ligne peut être ou non introduite
dans ch. De plus, les éventuels caractères excédentaires restent disponibles dans
le tampon pour une prochaine lecture, comme le montre cet exemple simple qui
se contente de lire des chaînes jusqu’à la rencontre d’une chaîne de longueur
nulle :
Limitation de la longueur des chaînes lues, avec conservation des caractères
excédentaires
#include <stdio.h>
#include <string.h>
#define LG_MAX 10
int main()
{
char ch[LG_MAX] ;
do
{ printf ("donnez une chaine : ") ;
fgets (ch, LG_MAX, stdin) ;
printf ("chaine lue : ") ; puts (ch) ;
}
while (strlen(ch) != 1) ; /* 1 et non 0 car au moins fin de ligne */
}
donnez une chaine : salut
chaine lue : salut <--- à cause de la fin de ligne
donnez une chaine : anticonstitutionnellement
chaine lue : anticonst <--- ici, pas de fin de ligne,
donnez une chaine : chaine lue : itutionne <--- ici, non plus
donnez une chaine : chaine lue : llement <--- ici, fin de ligne
donnez une chaine : <--- ici, on a frappé directement "retour"
chaine lue :
Il est cependant possible, le cas échéant, de « sauter » les caractères
excédentaires en procédant comme suit :
Limitation de la longueur des chaînes lues, avec élimination des caractères
excédentaires
#include <stdio.h>
#include <string.h>
#define LG_MAX 10
int main()
{
char ch[LG_MAX] ;
char c ;
int fin_ligne ; /* indicateur de présence/absence de fin de ligne dans ch */
do
{ printf ("donnez une chaine : ") ;
fgets (ch, LG_MAX, stdin) ;
/* traitement du cas où l'utilisateur a fourni une réponse */
/* de plus de LG_MAX caractères (validation non comprise) */
fin_ligne = 1 ;
if ((strlen(ch)==LG_MAX-1) && (ch[LG_MAX-2] != ‘\n'))
{ do c = getchar() ; while (c!= ‘\n') ;
fin_ligne = 0 ;
}
/* affichage de la chaîne lue, en tenant compte de la fin de ligne */
printf ("chaine lue : %s", ch) ;
if (!fin_ligne) printf ("\n") ;
}
while (strlen(ch) != 1) ; /* 1, non 0 car au moins fin de ligne */
}
donnez une chaine : chaine lue : salut
salut
donnez une chaine : anticonstitutionnellement
chaine lue : anticonst
donnez une chaine : bonjour
chaine lue : bonjour
donnez une chaine : <--- ici, on a frappé directement " retour "
chaine lue :
Remarque
Ici, notre but était simplement de montrer comment limiter la taille des chaînes lues au clavier. Par
souci de clarté, nous n’avons pas cherché à gérer convenablement la rencontre éventuelle d’une fin de
fichier. On trouvera un exemple sur ce point à la section 4 du chapitre 17.
4.7.2 Avec le code de format %s
On peut imposer à %s, comme à la plupart des codes de format, un gabarit
maximal, c’est-à-dire un nombre maximal de caractères qui seront lus par scanf,
indépendamment des éventuels espaces blancs. Cela revient à dire qu’on peut
limiter précisément le nombre de caractères qui seront introduits en mémoire, de
façon analogue à ce que l’on fait avec fgets. Par exemple, avec :
scanf ("%10s", ch) ;
on n’introduira pas plus de 11 caractères (en comptant le zéro de fin) dans ch.
Cependant, alors que cette limite pouvait être fournie sous forme d’une variable
(ou d’une expression) dans le cas de fgets, il semble qu’il doive s’agir d’une
constante dans le cas présent, et même d’une « vraie » constante. En effet, ceci
ne conviendrait pas :
#define LG_MAX 10
…..
scanf ("%LG_MAXs", ch) ;
car le préprocesseur n’effectue aucune substitution de symbole à l’intérieur des
constantes chaînes.
En réalité, le premier argument de printf n’est rien d’autre que l’adresse d’une
chaîne, laquelle n’est pas nécessairement constante. Dans ces conditions, rien
n’empêche de disposer d’un gabarit variable, dès lors que l’on crée la chaîne
format au moment de l’exécution. Pour ce faire, on peut faire appel à la fonction
sprintf qui travaille comme printf, avec cette différence qu’au lieu d’envoyer les
informations formatées sur la sortie standard, elle les range en mémoire. En voici
un exemple dans lequel on impose, lors de l’exécution, une longueur maximale à
une chaîne lue par scanf :
Comment utiliser un gabarit variable avec %s
#include <stdio.h>
#define LG_MAX 5 /* longueur maximale d'une chaîne lue */
int main()
{ char ch1 [LG_MAX+1], ch2 [LG_MAX+1] ; /* +1 pour le \0 de fin */
char format [20] ; /* pour le format de lecture */
int i ;
sprintf (format, "%%%ds %%%ds\0", LG_MAX, LG_MAX) ;
/* on obtient dans format : %xs */
/* où x est la valeur de LG_MAX */
printf ("controle format : \"%s\"\n", format) ;
for (i=0 ; i<2 ; i++) /* on lit deux fois deux chaînes */
{ printf ("donnez deux chaines d'au plus %d caracteres :\n", LG_MAX) ;
scanf (format, ch1, ch2) ;
printf ("chaines lues :\nch1 :%s:\nch2 :%s:\n", ch1, ch2) ;
}
}
controle format : "%5s %5s"
donnez deux chaines d'au plus 5 caracteres :
bonjour chere
chaines lues :
ch1 :bonjo:
ch2 :ur:
donnez deux chaines d'au plus 5 caracteres :
alexandrine
chaines lues :
ch1 :chere:
ch2 :alexa:
On notera bien que, comme avec fgets, les éventuels caractères excédentaires
restent dans le tampon mais, cette fois, on ne dispose d’aucun moyen de les
connaître, et encore moins de les « sauter ».
Remarque
La démarche présentée ici peut sembler d’un intérêt limité, dans la mesure où l’usage de scanf est
généralement fortement déconseillé, compte tenu de sa grande susceptibilité aux erreurs de données.
En fait, cette démarche se transposera aisément à la fonction sscanf, souvent utilisée pour fiabiliser
les lectures, comme on le verra au chapitre 17.
5. Généralités concernant les fonctions de
manipulation de chaînes
En dehors des entrées-sorties de chaînes qui ont fait l’objet de la section
précédente, le langage C dispose de nombreuses fonctions standards de
manipulation de chaînes qu’on peut classer en cinq catégories :
• copie de chaînes ;
• concaténation de chaînes ;
• comparaison de chaînes ;
• recherche de caractères ou de sous-chaînes dans une chaîne ;
• conversions de chaînes en nombres.
Les sections 6 à 10 seront consacrées à leur étude détaillée. Ici, nous présentons
quelques principes généraux qui s’appliquent à toutes ces fonctions.
5.1 Ces fonctions travaillent toujours sur des adresses
Tout d’abord, rappelons qu’il n’y a pas de véritable type chaîne en C, mais
simplement une convention de représentation. On ne peut donc jamais
transmettre à une fonction la valeur d’une chaîne, mais seulement son adresse,
c’est-à-dire un pointeur sur son premier caractère. Ainsi, pour comparer deux
chaînes, on transmettra à la fonction concernée (ici, strcmp) deux pointeurs de
type char *.
Mieux, pour recopier une chaîne d’un emplacement à un autre, on fournira à la
fonction voulue (par exemple, strcpy) l’adresse de la chaîne à copier et l’adresse
de l’emplacement où devra se faire la copie. Encore faudra-t-il avoir prévu de
disposer de suffisamment de place à cet endroit ! En fait, toutes les fonctions qui
placent ainsi une information (susceptible d’être d’une longueur quelconque) à
un emplacement d’adresse donnée possèdent deux « variantes » :
• l’une travaillant sans contrôle, et se basant donc toujours sur le zéro de fin ;
• l’autre disposant d’un argument supplémentaire permettant de limiter le
nombre de caractères effectivement copiés à l’adresse concernée.
5.2 Les adresses sont toujours de type char *
Si l’on examine les prototypes des fonctions de manipulation de chaînes, on
s’aperçoit que tous les arguments correspondant à une chaîne de caractères ont
été déclarés de type char * et non pas unsigned char * ou signed char *.
Or le type char * en C peut, suivant l’implémentation, correspondre soit à signed
char *, soit à unsigned char *. Cette imprécision risque de surprendre. En fait, il ne
faut pas oublier que :
• l’attribut de signe d’un type caractère n’a d’incidence que lorsque l’on
s’intéresse à la valeur numérique associée ;
• il n’y a aucun risque à transmettre à une telle fonction un argument effectif qui
soit de type unsigned char * ou signed char *. En effet, les conversions éventuelles
mises en place au vu du prototype ne modifient jamais l’adresse
correspondante7.
5.3 Certains arguments sont déclarés const, d’autres
pas
Dans les prototypes des fonctions de la bibliothèque standard, certains arguments
sont de la forme const char *, tandis que d’autres sont simplement de la forme char
*. Pour justifier cela, rappelons différentes informations disséminées dans
plusieurs chapitres. Supposons que les fonctions f et fc aient pour en-tête :
void fc(const char *adc) ;
void f (char * ad) ;
Voyons ce que cela implique dans la définition de ces fonctions d’une part, dans
leur utilisation d’autre part, avant d’en tirer quelques conclusions.
Dans la définition des fonctions
Il est impossible, dans le corps de la fonction fc, de modifier l’objet pointé par
adc par des affectations de la forme :
*adc = … /* interdit car adc pointe sur un objet constant */
Qui plus est, il est impossible d’utiliser la valeur de adc pour aller modifier des
objets voisins, par des affectations de la forme :
*(adc+i) = … ; /* interdit car l'expression adc+i est, comme adc, */
/* de type pointeur sur un objet constant */
adc[i] = … ; /* même raison compte tenu du rôle de l'opérateur [] */
Certes, la fonction pourrait passer outre volontairement, en utilisant des
conversions explicites par cast des pointeurs concernés. Mais on peut
raisonnablement penser que la présence de const dans le prototype montre que ce
ne sera précisément pas le cas.
En définitive, on voit que la fonction fc est censée ne pas modifier la chaîne
pointée par adc (sauf si elle reçoit la même adresse, par un autre argument, de
forme char *).
Bien entendu, aucune restriction de ce genre n’existe pour la définition de la
fonction f.
Dans l’utilisation des fonctions
Dès lors que son prototype est connu, la fonction f, qui attend un pointeur de
type char *, ne peut être appelée avec un argument effectif de type const char *.
Aucune contrainte de ce genre n’existe, en revanche, pour la fonction fc. Voici
quelques exemples :
char *ch ;
const char *chc = "salut" ;
…..
fc (chc) ; /* correct : chc est du type attendu, const char * */
fc (ch) ; /* correct : ch de type char * est converti en const char * */
f (ch) ; /* correct : ch est du type attendu, char * */
8
f (chc) ; /* incorrect : chc, de type const char * ne peut pas être */
/* converti en char * */
Rappelons que l’idée consiste à dire qu’il est possible de traiter un objet non
constant comme un objet constant puisqu’il n’y a alors aucun risque, tandis qu’il
n’est pas possible de traiter un objet constant comme un objet non constant
puisqu’alors on risque de le modifier.
Conclusion
En définitive, il est conseillé d’utiliser le qualifieur const pour les adresses de
constantes chaîne, qu’il s’agisse de celles générées par le compilateur par une
notation de la forme "bonjour" ou de celles dont on souhaite que la valeur ne
change pas. Ainsi, on ne court pas le risque de voir une fonction de la
bibliothèque standard modifier une chaîne de façon inattendue.
Par exemple, avec :
const char *adc1 = "bonjour" ;
char *ad2 = "salut" ;
l’instruction suivante sera rejetée par le compilateur :
strcpy (adc1, "hello") ; /* erreur de compilation */
En revanche, celle-ci sera acceptée, avec tous les risques qui en découlent lors de
l’exécution :
strcpy (ad2, "hello") ; /* accepté en compilation mais peut parfois conduire */
/* à une erreur d'exécution (voir section 2.4) */
D’une manière générale, nous vous conseillons d’utiliser ces propriétés dans vos
propres fonctions de traitement de chaînes.
5.4 Attention aux valeurs des arguments de limitation
de longueur
Certaines fonctions (strncat, strncpy et strncmp) disposent d’un argument
permettant de limiter le nombre de caractères recopiés (pour strncat et strncpy) ou
pris en compte (pour strncmp). Or cet argument est de type size_t, synonyme défini
par typedef, dans les différents fichiers en-tête en ayant besoin9, sous forme d’un
type entier non signé.
Dans ces conditions, si l’on fournit un argument effectif de type entier, sa valeur
sera, au vu du prototype, convertie en non signé et la valeur d’origine pourra ne
pas être conservée, dans deux cas :
• elle est supérieure à la capacité du type size_t ;
• elle est négative.
Le premier cas ne peut se produire que si le type associé à size_t n’est pas
unsigned long ; la valeur transmise à la fonction sera alors inférieure à celle prévue.
Le second cas peut se produire suite à une erreur de programmation. La
conversion de la valeur d’origine négative en non signé conduira souvent à une
très grande valeur positive, ce qui pourra perturber sérieusement le travail de la
fonction concernée.
D’une manière générale, dès lors que cet argument résulte d’un calcul, on
limitera considérablement les risques évoqués en adoptant la démarche suivante :
size_t lg ;
…..
lg = … /* détermination de la taille nécessaire */
strncpy (ch1, ch2, lg) ;
5.5 La fonction strlen
La fonction strlen fournit la longueur d’une chaîne dont on lui a transmis
l’adresse en argument. Cette longueur correspond tout naturellement au nombre
de caractères trouvés depuis l’adresse indiquée jusqu’au premier caractère de
code nul, ce dernier caractère n’étant pas pris en compte.
Par exemple, l’expression :
strlen ("bonjour")
vaudra 7. De même, avec :
char * adr = "salut" ;
l’expression :
strlen (adr)
vaudra 5.
Voici le prototype exact de strlen :
size_t strlen (const char *chaine) (string.h)
chaine
Adresse de la
chaîne
Valeur de retour Longueur de la Le type size_t correspond à un type
chaîne entier non signé dépendant de
l’implémentation.
À titre indicatif, voici une fonction nommée longueur réalisant la même chose que
la fonction strlen :
size_t longueur (const char *ad)
{ size_t lgr = 0 ;
while (*ad++) lgr++ ;
return lgr ;
}
Remarques
1. Compte tenu de la manière dont C représente les chaînes, la fonction strlen n’a d’autre possibilité
que d’examiner un par un les caractères de la chaîne concernée. Lorsque l’on doit manipuler des
chaînes un peu longues, il peut parfois s’avérer intéressant d’éviter certains calculs de longueur
superflus, en gérant soi-même la longueur de la chaîne…
2. N’importe quelle adresse (pour peu qu’elle soit de type char *) peut être fournie à la fonction
strlen qui, indépendamment de ce qu’on y a réellement placé, va examiner systématiquement tous
les octets qui suivent jusqu’à trouver un caractère de code nul.
3. La norme ne précise pas quel est le comportement de strlen dans le cas où elle ne trouve aucun
caractère de code nul. Même si cela est rare, il n’est pas impossible que la fonction soit conduite à
utiliser des adresses inexistantes ou, pour le moins, situées en dehors de la mémoire allouée au
programme. En fait, on se retrouve dans la même situation que dans un simple débordement de
tableau, avec les mêmes risques potentiels.
4. Comme la valeur de retour de strlen est de type size_t, il faut prendre quelques précautions
lorsque l’on est amené à la transmettre directement à une fonction à arguments variables telle que
printf. Ainsi, si ch est l’adresse d’une chaîne, avec :
printf ("%u", strlen(ch)) ; /* déconseillé */
on obtiendra un affichage erroné dès lors que size_t ne correspond pas au type unsigned int. Il
est préférable de procéder ainsi :
unsigned long taille ;
…..
taille = strlen(ch) ; /* conversion éventuelle du type correspondant à size_t
*/
/* dans le type unsigned long - jamais
dégradante */
printf ("%lu", taille) ;
6. Les fonctions de copie de chaînes
6.1 Généralités
Comme le langage C ne dispose pas d’un vrai type chaîne, donc pas de variables
de type chaîne, il n’est pas possible d’appliquer un opérateur d’affectation à des
chaînes. La seule possibilité qui nous est offerte consiste simplement à recopier à
une adresse donnée les caractères d’une chaîne située à une autre adresse. Pour
ce faire, la bibliothèque standard nous offre deux fonctions nommées strcpy et
strncpy, la première effectuant la recopie complète d’une chaîne, la seconde
disposant d’un paramètre supplémentaire permettant de limiter le nombre de
caractères effectivement recopiés.
6.2 La fonction strcpy
Examinons tout d’abord un exemple d’utilisation classique, avant de présenter
cette fonction d’une manière générale.
6.2.1 Exemple introductif
Exemples usuels d’utilisation de la fonction strcpy
#include <stdio.h>
#include <string.h>
int main()
{ char ch1 [20] ; /* pour une chaîne d'au plus 19 caractères */
char ch2 [15] = "bonjour" ;
const char * mes = "salut" ;
/* ici, ch1 n'est pas encore initialisé */
strcpy (ch1, mes) ; /* recopie la chaîne d'adresse mes dans ch1 */
printf ("ch1 : %s\n", ch1) ;
strcpy (ch1, ch2) ; /* recopie la chaîne d'adresse ch2 dans ch1 */
printf ("ch1 : %s\n", ch1) ;
strcpy (ch2, "hello") ; /* recopie la chaîne constante "hello" dans ch2 */
printf ("ch2 : %s\n", ch2) ;
}
ch1 : salut
ch1 : bonjour
ch2 : hello
Notez bien que ch1 et ch2 désignent deux tableaux de caractères. Seul le second a
été initialisé lors de sa déclaration avec la chaîne "bonjour". Le premier contient
initialement des caractères imprévisibles, de sorte que si l’on tentait d’en afficher
le contenu dès le début du programme par :
printf (ch1 : %s\n) ;
on obtiendrait des caractères quelconques, en nombre imprévisible,
éventuellement supérieur à 19, et peut-être à une erreur d’exécution.
Par ailleurs, on n’oubliera pas que les notations "salut" ou "hello" sont traduites
en un pointeur.
6.2.2 Prototype
char *strcpy (char *but, const char *source) (string.h)
but
Adresse à laquelle sera Voir risques présentés à la
recopiée la chaîne section 6.2.5
source
Adresse de la chaîne à
recopier
Valeur de Adresse but Commentaires à la section
retour 6.2.4
6.2.3 Rôle
Cette fonction recopie à l’adresse but la chaîne située à l’adresse source, avec son
zéro de fin. Il est tout à fait possible que la chaîne située à l’adresse source soit
vide, auquel cas, on obtient une chaîne vide à l’adresse but :
strcpy (ch1, "") ; /* la chaîne d'adresse ch1 est maintenant une chaîne vide */
On notera que les informations situées initialement à l’adresse but n’ont aucune
importance. En revanche, l’emploi de cette fonction présente de nombreux
risques qui sont examinés en détail à la section 6.2.5.
6.2.4 Valeur de retour
La fonction strcpy fournit toujours en retour l’adresse but. Cela reste d’un intérêt
limité ; on peut, par exemple, imbriquer des appels comme dans :
strcpy (ch1, strcpy (ch2, ch3)) ; /* recopie la chaîne d'adresse ch3 */
/* dans ch2 et dans ch1 */
ce qui conduit au même résultat que :
strcpy (ch2, ch3) ;
strcpy (ch1, ch2) ;
La seconde formulation nous paraît préférable, pour au moins deux raisons :
d’une part, elle est plus lisible ; d’autre part, un mauvais emploi de la première
formulation peut conduire à des situations délicates ; considérons, par exemple :
strcpy (strcpy(ch1, ch2), strcpy(ch1, ch3)) ;
Étant donné que l’ordre d’évaluation des arguments d’une fonction n’est pas
imposé par la norme, on ne sait laquelle des deux expressions strcpy(ch1, ch2) et
strcpy(ch1, ch3) sera évaluée en premier et donc, on ne sait laquelle des deux
chaînes d’adresse ch2 ou ch3 sera finalement présente dans ch1.
Quant à la formulation :
strcpy (strcpy (ch1, ch2), ch3) ;
elle est stupide puisque équivalente simplement à :
strcpy (ch1, ch3) ;
6.2.5 Les risques de la fonction strcpy
Elle n’effectue aucun contrôle de longueur
Aucun contrôle de longueur n’est réalisé par cette fonction. Il est nécessaire que
l’emplacement réservé à l’adresse but soit suffisant pour y recevoir la chaîne
située à l’adresse source. Par exemple, avec :
char ch [5] ;
…..
strcpy (ch, "bonjour") ;
on écrase 3 octets situés après la fin du tableau ch.
Il est cependant possible de limiter le nombre de caractères introduits dans la
chaîne d’adresse but, en recourant à la fonction strncpy étudiée ci-après.
Elle ne s’assure pas de la présence d’un zéro de fin de chaîne
Si, par malheur, la chaîne d’adresse source ne dispose pas de zéro de fin, la
recherche de ce caractère se poursuivra au-delà de la fin de la zone concernée.
C’est ce qui pourrait se produire avec :
char ch1 [10] ;
char ch2 [7] = "bonjour" ; /* aucun zéro de fin n'est introduit ici */
…..
strcpy (ch1, ch2) ;
En effet, ici, tableau ch2 ne comporte aucun caractère nul10. Dans ces conditions,
la fonction strcpy va recopier tous les caractères trouvés, à partir de l’adresse ch2,
jusqu’à ce qu’elle rencontre un zéro de fin. Le résultat est quasi imprévisible : il
est fort probable que l’on débordera du tableau ch2 et que l’on écrasera d’autres
variables, peut-être même la chaîne située en ch2, ce qui pourra induire un effet
de chevauchement involontaire dont nous parlerons ci-après.
On notera cependant qu’en limitant le nombre de caractères recopiés en faisant
appel à la fonction strncpy, on pourra s’affranchir de ce problème.
Attention aux chaînes qui se chevauchent
La norme prévoit que si les emplacements d’adresse source et but ont une partie
commune, le comportement du programme est indéterminé. Cela s’explique par
le fait qu’on risque alors de recopier certains caractères de la chaîne source sur
elle-même (éventuellement d’en écraser le zéro de fin, et partant, de retomber
dans le risque évoqué ci-avant). En pratique, les conséquences d’une telle action
dépendent des positions relatives des deux chaînes et de l’ordre dans lequel les
caractères sont effectivement recopiés, ordre qui n’est toutefois pas imposé par
la norme.
On notera que cette situation de chevauchement peut se produire, certes, dans
des cas relativement triviaux tels que :
char ch [] = "bonjour" ;
strcpy (ch+3, ch) ; /* comportement indéterminé */
Mais elle peut aussi se produire hélas, de façon moins évidente, en cas d’oubli de
zéro de fin de chaîne ; dans ce cas, l’existence du chevauchement peut dépendre
de la manière dont l’implémentation range les différentes variables en mémoire.
D’une manière générale, on peut affirmer qu’il n’y a pas chevauchement dès lors
que la condition suivante est vérifiée (indépendamment du fait qu’il est
nécessaire de disposer de la place nécessaire) :
Relation de non-chevauchement pour strcpy
abs (source - but) → strlen (source
Remarque
La fonction memmove, présentée à la section 11.2.2, permet de recopier un certain nombre d’octets d’un
endroit à un autre, en acceptant un éventuel recouvrement. Il est possible d’y faire appel pour recopier
une chaîne, à condition de ne pas omettre d’introduire son zéro de fin.
6.3 La fonction strncpy
Cette fonction joue le même rôle que strcpy, avec cette unique différence qu’elle
dispose d’un argument supplémentaire permettant de limiter le nombre de
caractères effectivement recopiés à l’adresse but. Elle présente cependant une
lacune, à savoir qu’elle n’assure pas dans tous les cas la présence d’un zéro de
fin de chaîne à l’adresse but. Là encore, nous commencerons par un exemple
classique d’utilisation, avant de présenter la fonction strncpy d’une manière
générale.
6.3.1 Exemple d’introduction
Exemples usuels d’utilisation de la fonction strncpy
#include <stdio.h>
#include <string.h>
int main()
{
char ch1[20] = "xxxxxxxxxxxxxxxxxxx" ; /* 19 caractères x */
char ch2[20] ;
printf ("donnez un mot : ") ;
gets (ch2) ;
strncpy (ch1, ch2, 6) ; /* recopie au maximum 6 caractères de ch2 dans ch1 */
printf ("%s", ch1) ;
}
donnez un mot : bon
bon
donnez un mot : bonjour
bonjouxxxxxxxxxxxxx
Nous avons appelé strncpy pour recopier au maximum 6 caractères de ch2 dans
ch1. Le premier exemple d’exécution ne pose aucun problème, dans la mesure où
la longueur de ch2 était inférieure à cette limite. En revanche, dans le second
exemple d’exécution, on voit que, si la copie s’est effectivement limitée à 6
caractères, aucun caractère de fin de chaîne n’a été introduit à la suite dans ch1.
Ici, les conséquences restent relativement limitées, dans la mesure où on trouve
un zéro de fin un peu plus loin. Il n’en serait pas nécessairement allé de même si
la chaîne but n’avait pas été initialisée.
Remarque
Le même phénomène d’absence de fin de chaîne aurait eu lieu si l’utilisateur avait fourni un mot de 6
caractères.
6.3.2 Prototype
char *strncpy (char *but, const char *source, size_t
longueur) (string.h)
but
Adresse à laquelle sera Voir risques présentés à la
recopiée la chaîne section 6.3.5 et démarche
conseillée à la section 6.3.6
source
Adresse de la chaîne à
recopier
longueur
Nombre maximal de – la recopie peut ne pas
caractères recopiés, y introduire de 0 de fin ou
compris l’éventuel 0 de fin en introduire plusieurs ;
voir section 6.3.3
– voir risques liés au type
size_t à la section 5.4
Valeur de Adresse but Même signification que
retour pour strcpy ; voir
éventuellement la section
6.2.4
6.3.3 Rôle
Comme strcpy, la fonction strncpy recopie la chaîne d’adresse source à l’adresse
but, mais elle limite le nombre de caractères recopiés à la valeur longueur.
Plus précisément, si la chaîne d’adresse source possède moins de longueur
caractères, elle se retrouvera intégralement à l’adresse but, suivie d’au moins un
caractère nul. En effet, dans ce cas, strncpy complète les caractères placés à
l’adresse but par des caractères nuls, à concurrence de longueur caractères. Par
exemple :
char * zone [10] ;
…..
strncpy (zone, "abc", 10) ; /* on trouvera, à l'adresse zone : les caractères */
/* a, b et c, suivis de 7 caractères nuls */
Si, en revanche, aucun caractère de fin de chaîne n’a été trouvé dans les longueur
premiers caractères de la chaîne d’adresse source, aucun caractère de fin ne sera
introduit à la suite des longueur caractères recopiés à l’adresse but.
On voit que, contrairement à ce qui se produit avec strcpy, la chaîne créée à
l’adresse but ne dispose pas systématiquement d’un zéro de fin11. Cela impose
généralement de prendre quelques précautions algorithmiques appropriées.
On notera également que la copie d’une chaîne vide, qui introduit au moins un
zéro de fin, ne produit pas le même effet que la copie d’au maximum 0 caractère
qui, quant à elle, n’introduit pas de zéro de fin.
Remarque
On peut montrer que ces deux instructions sont équivalentes :
strcpy (ch1, ch2) ;
strncpy (ch1, ch2, strlen(ch2)+1) ;
6.3.4 Valeur de retour
Comme strcpy, la fonction strncpy fournit toujours en retour l’adresse but. Ce qui a
été dit à la section 6.2.4 à propos de strcpy vaut aussi pour strncpy.
6.3.5 Les risques de la fonction strncpy
La fonction strncpy présente des risques analogues à ceux évoqués à propos de
strcpy :
• elle ne s’assure pas de la présence d’un zéro de fin de chaîne ;
• son comportement est indéterminé en cas de chaînes qui se chevauchent, c’est-
à-dire qui ne vérifient pas la relation suivante :
Relation de non-chevauchement pour strcnpy
abs (source - but) → min (strlen (source), longueur-1)
Toutefois, si la relation de non-chevauchement n’est pas vérifiée, les risques
encourus en pratique sont moindres qu’avec strcpy. On peut montrer qu’ils se
limitent à une modification de la chaîne d’origine.
6.3.6 Comment faire des copies fiables ?
Si strncpy permet de limiter la longueur des chaînes recopiées, elle présente
l’inconvénient de ne pas toujours introduire le zéro de fin de chaîne. Voici
comment procéder pour recopier dans un tableau ch1 de dimension LG1 une chaîne
ch, quelle qu’elle soit, sans déborder de ch1, et en obtenant toujours un zéro de
fin. Nous supposons ici que la condition de non-recouvrement est effectivement
vérifiée et que la valeur de LG1 ne dépasse pas la capacité du type correspondant à
size_t :
Pour recopier de façon fiable tout ou partie d’une chaîne ch dans le tableau ch1
de dimension LG1
char ch1 [LG1] ;
…..
strncpy (ch1, ch, LG1-1) ; /* on copie au maximum LG1-1 caractères dans ch1 */
if (strlen(ch) >= LG1-1) /* si la copie a été interrompue, on ajoute */
ch1 [LG1-1] = ‘\0' ; /* le zéro de fin */
7. Les fonctions de concaténation de chaînes
7.1 Généralités
Il existe deux fonctions de concaténation, c’est-à-dire de « mise à bout » de deux
chaînes, afin de n’en former qu’une seule. A priori, de telles fonctions créent une
nouvelle chaîne, à partir de deux autres. Elles devraient donc recevoir en
arguments trois adresses ! En fait, C a prévu de se limiter à deux adresses, en
convenant arbitrairement que la chaîne résultante s’obtiendrait en ajoutant la
seconde à la fin de la première. Cette première chaîne se trouve donc détruite en
tant que chaîne (en fait, seul son zéro de fin aura effectivement disparu). Dans
certains cas, il pourra être nécessaire de recopier préalablement cette chaîne ou
d’en conserver la longueur…
Les deux fonctions de concaténation strcat et strncat sont très voisines puisque la
seule différence réside dans le fait que la seconde dispose d’un paramètre
supplémentaire permettant de limiter le nombre de caractères concaténés.
7.2 La fonction strcat
7.2.1 Exemple introductif
Exemple usuel d’utilisation de la fonction strcat
#include <stdio.h>
#include <string.h>
int main()
{ char ch1[50] = "bonjour" ;
const char * ch2 = " monsieur" ;
printf ("avant : %s\n", ch1) ;
strcat (ch1, ch2) ;
printf ("apres : %s", ch1) ;
}
avant : bonjour
apres : bonjour monsieur
Notez la différence entre les deux déclarations (avec initialisation) de chacune
des deux chaînes ch1 et ch2. La première permet de réserver un emplacement plus
grand que la chaîne qu’on y place initialement, ce qui nous permet, ici, de
concaténer ultérieurement la chaîne ch2.
7.2.2 Prototype
char *strcat (char *but, const char *source) (string.h)
but
Adresse de la chaîne Voir risques présentés à la
réceptrice section 7.2.5
source
Adresse de la chaîne à
concaténer
Valeur de Adresse but Commentaires à la section
retour 7.2.4
7.2.3 Rôle
Cette fonction recopie la chaîne située à l’adresse source à la fin de la chaîne
d’adresse but, c’est-à-dire à partir de son zéro de fin. Ce dernier se trouve donc
remplacé par le premier caractère de la chaîne d’adresse source.
La chaîne d’adresse source peut être vide, auquel cas la chaîne d’adresse but reste
inchangée, comme on s’y attend. C’est ce qui se passe pour la chaîne d’adresse
ch1 dans :
char *ch1, *ch2="" ;
…..
strcat (ch1, "") ; /* la chaîne d'adresse ch1 reste inchangée */
strcat (ch1, ch2) ; /* la chaîne d'adresse ch1 reste inchangée */
7.2.4 Valeur de retour
La fonction strcat fournit toujours en retour l’adresse but. Cela reste d’un intérêt
limité. Par exemple, on peut imbriquer des appels, comme dans :
strcat (strcat (ch1, ch2), ch3) ; /* la chaîne d'adresse ch2 est concatenée à */
/* celle d'adresse ch1 ; puis, au résultat, */
/* on concatène la chaîne d'adresse ch3 */
qui conduit en fait au même résultat que :
strcat (ch1, ch2) ;
strcat (ch1, ch3) ;
La seconde formulation nous paraît préférable, pour au moins deux raisons :
d’une part, elle est plus lisible ; d’autre part, un mauvais emploi de la première
formulation peut conduire à des situations délicates. Par exemple, avec :
strcat (strcat(ch1, ch2), strcat(ch2, ch3)) ;
on finira par concaténer la chaîne d’adresse ch2 (valeur de strcat(ch2, ch3) à celle
d’adresse ch1 (résultat de strcat(ch1, ch2). Mais, comme l’ordre d’évaluation des
arguments d’une fonction n’est pas imposé par la norme, on ne sait laquelle des
deux expressions strcat(ch1, ch2) et strcat(ch2, ch3) sera évaluée en premier, ce qui
peut conduire à deux résultats différents selon le cas.
7.2.5 Les risques de la fonction strcat
Elle n’effectue aucun contrôle de longueur
Aucun contrôle de longueur n’est réalisé par cette fonction. Il faut que
l’emplacement réservé pour la première chaîne soit suffisant pour y recevoir la
partie à lui concaténer. Si, dans l’exemple de la section 7.2.1, nous avions utilisé
un emplacement plus petit pour ch1 :
char ch1[10] = "bonjour" ;
l’appel strcat(ch1, ch2) serait venu introduire des caractères au-delà de la fin du
tableau ch1.
On notera que, toujours dans ce même exemple, comme ch2 a reçu l’attribut const,
un appel tel que :
strcat (ch2, ch1) ; /* rejeté en compilation */
conduirait à une erreur de compilation12, compte tenu de la conversion qu’il
implique de ch2 de const char * en char *.
Si l’on souhaite limiter le nombre de caractères introduits dans la chaîne
d’adresse but, on pourra recourir à la fonction strncat.
Elle ne s’assure pas de la présence d’un zéro de fin de chaîne
Comme on s’en doute, cette fonction n’a aucune connaissance de la taille
effective des emplacements qui ont pu être réservés aux adresses indiquées. Cela
signifie que, si par malheur, l’une des chaînes ne dispose pas de zéro de fin, la
recherche de ce caractère se poursuivra au-delà de la fin de la zone concernée.
C’est ce qui pourrait se produire avec :
char ch1[7] = "bonjour" ; /* aucun zéro de fin n'est introduit dans ch1 */
const char * ch2 = " monsieur" ;
strcat (ch1, ch2) ;
En effet, ici, le tableau ch1 ne comporte aucun caractère nul13. Dans ces
conditions, la fonction strcat va introduire la chaîne " monsieur" après le premier
zéro trouvé au-delà de la fin du tableau ch1. Le résultat est quasi imprévisible.
Attention aux chaînes qui se chevauchent
La norme prévoit que si les deux chaînes concernées ont des parties communes,
le comportement du programme est indéterminé. Cela s’explique par le fait
qu’on risque alors de recopier certains caractères de l’une des chaînes sur elle-
même. On notera bien que cette condition de chevauchement concerne non
seulement les chaînes initiales, mais également les chaînes après concaténation.
En pratique, les conséquences dépendent des positions relatives des deux chaînes
et de l’ordre dans lequel les caractères sont effectivement recopiés par la
fonction, ordre qui n’est pas imposé par la norme.
Cette situation de chevauchement peut certes se produire dans des cas
relativement triviaux, tels que :
char ch [20] = "bonjour" ;
strcat (ch, ch+3) ; /* comportement indéterminé */
mais aussi, hélas, de façon moins évidente, en cas d’oubli de zéro de fin de
chaîne. Dans ce cas, l’existence du chevauchement peut dépendre de la manière
dont l’implémentation range les différentes variables en mémoire.
D’une manière générale, tant que l’on se contente de manipuler des chaînes
disposant chacune d’un emplacement de taille bien définie et que l’on s’assure
de ne pas déborder de cet emplacement, on n’a pas à se préoccuper de ce risque.
Dans le cas contraire, si l’on souhaite tester l’absence de chevauchement des
deux chaînes d’adresse source et but, il est nécessaire de vérifier que la condition
suivante est vraie (en supposant, naturellement, qu’il y a la place nécessaire en
but) :
Relation de non-chevauchement de deux chaînes pour strcat
( (but - source) → strlen(source) ) || ( (source - but) → strlen(but) +
strlen(source)
Remarque
La fonction memmove, présentée à la section 11.2.2, permet de recopier un certain nombre d’octets d’un
endroit à un autre, en acceptant un éventuel recouvrement. Il est possible de faire appel à elle pour
concaténer une chaîne à une autre, à condition de ne pas oublier le zéro de fin.
7.3 La fonction strncat
Cette fonction joue le même rôle que strcat, avec cette unique différence qu’elle
dispose d’un argument supplémentaire permettant de limiter le nombre de
caractères effectivement concaténés à la chaîne d’adresse but.
7.3.1 Exemple introductif
La fonction strncat
#include <stdio.h>
#include <string.h>
int main()
{ char ch1[50] = "bonjour" ;
char * ch2 = " monsieur" ;
printf ("avant : %s\n", ch1) ;
strncat (ch1, ch2, 6) ;
printf ("apres : %s", ch1) ;
}
avant : bonjour
apres : bonjour monsi
Il s’agit du même exemple que celui de la section 7.2.1, dans lequel nous avons
remplacé l’appel de strcat par celui de strncat, en limitant à 6 le nombre de
caractères concaténés à ch1.
7.3.2 Prototype
char *strncat (char *but, const char *source, size_t
longueur) (string.h)
but
Adresse de la chaîne Voir risques présentés à la
réceptrice section 7.3.5 et démarche
conseillée à la section 7.3.6
source
Adresse de la chaîne à
concaténer
longueur
Nombre maximal de – la concaténation introduit
caractères concaténés, y toujours un 0 de fin (voir
compris l’éventuel 0 de fin section 7.3.3)
– voir risques liés au type
size_t à la section 5.4
Valeur de Adresse but Même signification que
retour pour strcat ; voir
éventuellement à la section
7.2.4
7.3.3 Rôle
Comme strcat, la fonction strncat recopie la chaîne située à l’adresse source à la
fin de la chaîne d’adresse but, c’est-à-dire à partir de son zéro de fin qui se trouve
donc remplacé par le premier caractère de la chaîne d’adresse source ; mais elle
limite le nombre de caractères recopiés à la valeur longueur.
On notera que, même dans le cas où aucun caractère de fin de chaîne n’a été
trouvé dans les longueur premiers caractères de la chaîne d’adresse source, un
caractère de fin de chaîne est ajouté à la chaîne d’adresse but. Si cette démarche
est satisfaisante, il n’en reste pas moins qu’elle diffère de celle adoptée par la
fonction strncpy !
Comme avec strcat, la chaîne d’adresse source peut être vide, auquel cas la
chaîne d’adresse but reste inchangée, comme on s’y attend ; il en va de même
lorsque la valeur de longueur est égale à 0. C’est ce qui se passe pour la chaîne
d’adresse ch1 dans :
char *ch1, *ch2="" ;
…..
strncat (ch1, "", 10) ; /* la chaîne d'adresse ch1 reste inchangée */
strncat (ch1, ch2, 10) ; /* la chaîne d'adresse ch1 reste inchangée */
strncat (ch1, "chose", 0) ; /* la chaîne d'adresse ch1 reste inchangée */
7.3.4 Valeur de retour
Comme strcat, la fonction strncat fournit toujours en retour l’adresse but. Cela
reste d’un intérêt limité. Par exemple, on peut imbriquer des appels (de strcat
et/ou de strncat), comme dans :
strncat (strncat (ch1, ch2, 5), ch3, 10) ;
strcat (strncat (ch1, ch2, 5), ch3) ;
strncat (strcat (ch1, ch2), ch3, 15) ;
Les commentaires de la section 7.2.4 concernant strcat restent valables ici.
7.3.5 Les risques de la fonction strncat
La fonction strncat présente des risques analogues à ceux qui sont évoqués à
propos de strcat :
• elle ne s’assure pas de la présence d’un zéro de fin de chaîne ;
• son comportement est indéterminé en cas de chaînes qui se chevauchent, c’est-
à-dire qui, ici, ne vérifient pas la relation suivante :
Relation de non-chevauchement de deux chaînes pour strncat
( (but - source) → min (strlen(source), longueur-1)
|| ( (source - but) → strlen(but) + min (strlen(source), longueur-1)
Bien entendu, si la condition proposée pour strcat est vraie, celle qui est
proposée pour strncat l’est a fortiori, quelle que soit la valeur de longueur.
De plus, il ne faut pas perdre de vue que le troisième argument de strncat est de
type non signé et qu’il faut éviter d’utiliser un argument effectif qui soit négatif
car il correspondrait alors à une très grande valeur entière (voir section 5.4).
7.3.6 Comment faire des concaténations fiables ?
Si, contrairement à ce que faisait strcpy, la fonction strncat introduit toujours un
zéro de fin, il n’en reste pas moins que l’utilisation de l’argument de limitation
du nombre de caractères recopiés s’avère d’un emploi mal aisé. En effet, en
général, on connaît la taille maximale de la chaîne – par exemple LG_MAX – qu’on
peut ranger à l’adresse but. La valeur correspondante de longueur est alors LG_MAX-
strlen(but). Voici une façon de procéder :
Pour faire des concaténations fiables
#define LG_MAX 50
char ch[LG_MAX+1] ; /* pour un chaîne d'au plus LG_MAX caractères */
…..
/* concaténation de tout ou partie de ch2 à ch, sans dépasser la taille de ch */
strncat (ch, ch2, LG_MAX-strlen(ch)) ;
On notera bien que si l’on n’a pas la certitude que l’expression LG_MAX -
strlen(but) n’est jamais négative, on court le risque d’aboutir, après conversion
dans le type size_t, à une grande valeur (voir section 5.5).
Exemple
Appliquons la démarche précédente à un petit exemple : on concatène une
succession de chaînes fournies par l’utilisateur jusqu’à ce que l’on ait atteint une
taille définie à l’avance (LG_MAX). Dans ce cas, la dernière chaîne lue est tronquée,
de manière à ne pas dépasser cette taille.
#include <stdio.h>
#include <string.h>
#define LG_MAX 50
#define LG_LIGNE 80
int main()
{ char ch [LG_MAX+1] = "" ; /* pour disposer d'un zéro de fin au départ */
char ligne [LG_LIGNE] ;
printf ("donnez une suite de chaines, a raison d\'une par ligne\n") ;
do /* ici, strlen(ch) < LG_MAX */
{ gets (ligne) ;
strncat (ch, ligne, LG_MAX-strlen(ch)) ; /* donc LG_MAX-strlen(ch)>0 */
}
while (strlen(ch)<LG_MAX) ;
printf ("voici vos chaines concatenees :\n%s\n", ch) ;
}
Remarque
En pratique, il faudrait limiter la longueur de la chaîne lue par gets. On pourrait procéder comme
indiqué à la section 4.7.
8. Les fonctions de comparaison de chaînes
8.1 Généralités
Les valeurs de variables de type caractère peuvent être comparées à l’aide des
opérateurs relationnels classiques (<, <=, >, >=, ==, !=). Compte tenu de la nature
numérique du type caractère, cela revient, au bout du compte, à comparer leurs
codes (avec, en toute rigueur, un résultat pouvant dépendre de l’attribut de signe
de la variable en question – voir section 3.3.2 du chapitre 4). L’ordre
alphabétique n’est donc que très partiellement respecté.
En ce qui concerne les chaînes, on ne dispose pas d’opérateurs relationnels. Si
l’on appliquait un tel opérateur à des variables déclarées par exemple par char *
ch1 et char ch2[], on comparerait les valeurs des pointeurs ch1 et ch2 et, en aucun
cas, les chaînes situées à ces adresses. En revanche, il existe deux fonctions
standards strcmp et strncmp qui effectuent une comparaison lexicographique usuelle
de deux chaînes. Rappelons qu’une telle comparaison se fait caractère par
caractère, en commençant par le premier de chaque chaîne. Elle est analogue à
celle qui est utilisée pour ordonner les mots d’un dictionnaire. Bien entendu,
l’ordre des caractères correspond ici à celui de leur code. Mais contrairement à
ce qui pouvait se produire dans les comparaisons de deux valeurs de type char, où
les attributs de signe pouvaient intervenir, les caractères d’une chaîne sont
toujours considérés comme de type unsigned char. Cela reste vrai quel que soit
l’attribut du pointeur correspondant.
Voici, à propos des comparaisons de caractères, quelques rappels qui
interviendront dans les comparaisons de chaînes.
a) Les caractères du jeu standard minimal d’exécution ont toujours un code
positif, même avec l’attribut signé. Cela signifie que ces caractères seront
ordonnés de la même manière dans le cas des variables de type signed char et dans
le cas des chaînes.
b) Bien que cela ne soit pas imposé par la norme, en pratique, les majuscules
sont ordonnées les unes par rapport aux autres ; il en va de même des minuscules
(voir section 3.3.2 du chapitre 4). En revanche, suivant l’implémentation, les
minuscules peuvent apparaître avant ou après les majuscules. Les 10 caractères
représentant les dix chiffres de 0 à 9 sont ordonnés mais leur place par rapport
aux majuscules ou aux minuscules dépend de l’implémentation. Rien ne peut
être affirmé en ce qui concerne les autres caractères, en particulier nos caractères
nationaux (lettres accentuées, ç, etc.). Dans la plupart des implémentations, ils
apparaîtront à l’extérieur des autres lettres, soit avant, soit après. On est
relativement éloigné de l’ordre utilisé dans un dictionnaire usuel !
c) Comme on le verra au chapitre 23, la norme autorise une implémentation à
fournir plusieurs « localisations ». Le choix d’une localisation donnée peut bien
sûr avoir une incidence sur le jeu de caractères d’exécution. Il n’en reste pas
moins que la comparaison de chaînes (par strcmp ou strncmp), comme celle des
caractères (par les opérateurs de comparaison arithmétique), restera basée sur la
valeur du code des caractères. Cependant, la norme a prévu une fonction de
comparaison particulière, nommée strcoll, susceptible d’utiliser un ordre imposé
par la localisation en vigueur, lequel peut alors être différent de celui induit par
les codes des caractères. Cette fonction sera elle aussi étudiée au chapitre 23, de
sorte que toutes les fonctions étudiées dans cette section sont totalement
indépendantes de la localisation.
8.2 La fonction strcmp
int strcmp (const char *chaine1, const char *chaine2) (string.h)
chaine1
Adresse de la première
chaîne
chaine2
Adresse de la seconde
chaîne
Valeur de – négative si la chaîne – attention à l’ordre des
retour d’adresse chaine1 arrive caractères (voir section
avant la chaîne d’adresse 8.1) ;
chaine2 ;
– les chaînes sont
– positive si la chaîne ordonnées suivant l’ordre
d’adresse chaine1 arrive lexicographique, basé sur
après la chaîne d’adresse la valeur des codes des
chaine2 ; caractères (considérés
– zéro si les deux chaînes comme des unsigned char).
sont identiques.
Exemples
strcmp ("bonjour", "monsieur") /* négatif, quelle que soit l'implémentation */
strcmp ("paris2", "paris10") /* positif, quelle que soit l'implémentation */
strcmp ("bonjour", "bonjour") /* nul, quelle que soit l'implémentation */
strcmp ("Paris", "paris") /* positif dans certaines implémentations, */
/* négatif dans d'autres mais jamais nul car */
/* ‘P' et ‘p' sont toujours de codes différents */
strcmp ("ré", "rat") /* positif ou négatif suivant l'implémentation */
/* (si elle accepte les caractères accentués) */
strcmp ("ré", "rue") /* positif ou negatif suivant l'implémentation */
/* en général, même signe que strcmp ("ré", "rat") */
8.3 La fonction strncmp
Elle travaille comme strcmp mais limite la comparaison au nombre maximal de
caractères indiqués par l’entier longueur. Pour simplifier, nous parlerons de chaîne
restreinte d’adresse ad pour désigner une chaîne d’adresse ad limitée à un
maximum de longueur caractères.
int strncmp (const char *chaine1, const char *chaine2, size_t
longueur) (string.h)
chaine1
Adresse de la première chaîne
chaine2
Adresse de la seconde chaîne
longueur
Nombre maximal de caractères Voir risques liés au type
soumis à la comparaison size_t à la section 5.4
Valeur – négative si la chaîne restreinte – attention à l’ordre des
de d’adresse chaine1, arrive avant la caractères ; voir section
retour chaîne restreinte d’adresse 8.1 ;
chaine2 ;
– les chaînes sont
– positive si la chaîne restreinte ordonnées suivant
d’adresse chaine1 arrive après la l’ordre lexicographique,
chaîne restreinte d’adresse basé sur la valeur des
chaine2 ; codes des caractères
– zéro si les deux chaînes (considérés comme des
unsigned char).
restreintes sont identiques.
Exemples
strncmp ("bonjour", "bon", 12) /* positif, quelle que soit l'implémentation */
strncmp ("bonjour", "bon", 4) /* positif, quelle que soit l'implémentation */
strncmp ("bonjour", "bon", 2) /* nul, quelle que soit l'implémentation */
On notera que la fonction strcmp fournirait le même résultat dans le cas des deux
premières comparaisons, puisque la valeur de longueur n’est pas intervenue pour
mettre fin à la comparaison.
Remarque
Comme l’explique la section 5.4, l’utilisation d’une valeur négative pour l’argument longueur peut
conduire à une valeur très grande. Cette remarque n’est cependant pas aussi cruciale ici que pour les
fonctions strncpy et strncat. En effet, les seules conséquences possibles sont un résultat faux, et en
aucun cas, un risque d’écrasement d’emplacement mémoire, comme c’était le cas avec les fonctions
citées.
9. Les fonctions de recherche dans une chaîne
La bibliothèque standard propose des fonctions classiques de recherche dans une
chaîne de la première occurrence :
• d’un caractère donné : strchr et strrchr ;
• d’une autre chaîne (nommée alors sous-chaîne) : strstr ;
• d’un des caractères appartenant à un ensemble de caractères : strpbrk.
Toutes ces fonctions fournissent en résultat l’adresse de l’information cherchée
si elle a pu être localisée, un pointeur nul dans le cas contraire.
En outre, on trouve des fonctions permettant :
• d’extraire ou de supprimer d’une chaîne un préfixe formé de certains
caractères ;
• d’éclater une chaîne en plusieurs parties, en utilisant comme séparateurs des
caractères de son choix : strspn, strcspn et strtok.
9.1 Les fonctions de recherche d’un caractère : strchr
et strrchr
9.1.1 La fonction strchr
Elle permet de rechercher la première occurrence d’un caractère donné dans une
chaîne.
char *strchr (const char *chaine, int c) (string.h)
chaine
Adresse de la chaîne
concernée
c
Caractère recherché, après Voir remarque à propos du
conversion en unsigned char type int à la section 9.1.3
Valeur Adresse du premier caractère c Le caractère de fin de chaîne
de trouvé, s’il existe, pointeur est pris en compte dans la
retour NULL sinon recherche (voir remarque ci-
après)
Exemples
strchr ("bonjour", ‘o') ; /* adresse du premier ‘o' de la chaîne "bonjour" */
strchr ("bonjour", ‘a') ; /* fournit la valeur NULL */
Remarque
Ici, le caractère de fin de chaîne participe à la recherche, de sorte que l’expression :
strchr (ch, ‘\0')
est équivalente à :
ch + strlen (ch)
9.1.2 La fonction strrchr
La fonction strrchr réalise le même traitement que strchr, mais en explorant la
chaîne concernée à partir de la fin. Elle fournit donc la dernière occurrence du
caractère mentionné.
char *strrchr (const char *chaine, int c) (string.h)
chaine
Adresse de la chaîne
concernée
c
Caractère recherché, après Voir remarque à propos du
conversion en unsigned char type int à la section 9.1.3
Valeur de Adresse du dernier Le caractère de fin de
retour caractère c trouvé, s’il chaîne est pris en compte
existe, pointeur NULL sinon dans la recherche (voir
remarque ci-après)
Exemples
strrchr ("bonjour", ‘o') ; /* adresse du dernier ‘o'
de la chaîne "bonjour" */
strrchr ("bonjour", ‘a') ; /* fournit la valeur NULL */
Remarque
Comme avec strchr, le caractère de fin de chaîne participe à la recherche, de sorte que l’expression :
strrchr (ch, ‘\0')
est équivalente à :
ch + strlen (ch)
9.1.3 Attention à la conversion de int en unsigned char
Le deuxième argument transmis aux deux fonctions strchr et strrchr est du type
14
int. En général, on appelle ces fonctions avec des valeurs de type char, signed
char ou unsigned char. Celles-ci seront, au vu du prototype, converties en int, avant
d’être reconverties dans la fonction en unsigned char. Dans ces conditions, comme
il l’indique la section 9.6 du chapitre 4, on peut montrer que, théoriquement, seul
un argument effectif de type unsigned char garantit la conservation du motif
binaire. En pratique, cette conservation a lieu dans tous les cas, ce qui permet de
ne pas se préoccuper de l’attribut de signe du caractère recherché.
Les mêmes remarques s’appliquent lorsque l’on fournit une valeur de type int,
obtenue par la conversion d’une valeur de type caractère.
9.1.4 Quelques situations faisant appel à ces fonctions
On peut chercher à localiser un caractère d’une chaîne avec différents objectifs
qui peuvent se classer en deux catégories :
• on cherche simplement à savoir que ce caractère est présent ; dans ce cas, la
chaîne en question peut éventuellement être une constante (comme c’était le
cas dans nos exemples précédents) ;
• on souhaite modifier la chaîne concernée ; dans ce cas, il est nécessaire que la
chaîne concernée ne soit pas constante.
Voici quelques exemples :
Pour savoir si un caractère donné est présent dans une chaîne
if (ad=strchr(ch, ‘e') ….. /* ad contient l'adresse du premier ‘e' de ch */
else ….. /* ch ne contient aucun caractère ‘e' */
Bien entendu, on peut ici utiliser indifféremment strchr ou strrchr. Toutefois,
strchr sera plus efficace puisqu’il n’a pas besoin de rechercher préalablement le
zéro de fin de chaîne.
Pour tronquer une chaîne après la première occurrence d’un caractère
donné
char * ad ;
ad = strchr (ch, c) ; /* recherche de la première occurrence du caractère c */
if (ad != NULL) *(ad+1) = ‘\0' ; /* la chaîne contenue dans ch s'interrompra la */
/* on fera *ad = ‘\0' si l'on ne souhaite pas */
/* conserver le caractère trouvé */
Pour remplacer toutes les occurrences d’un caractère donné par un autre
caractère
Voici un programme qui lit des chaînes en données et qui remplace tous les
caractères e par des caractères i :
Exemple d’utilisation de strchr pour effectuer des remplacements de caractères
dans une chaîne
#include <stdio.h>
#include <string.h>
int main()
{ char c ='e' ; /* caractère à remplacer */
char c_rep = ‘i' ; /* caractère de remplacement */
char ch[81] ;
int i ;
int nb_car ;
char * ad ;
while (1) /* on traite différentes chaînes jusqu'à chaîne vide */
{ printf ("donnez une chaine (vide pour finir) : ") ;
gets (ch) ;
if (strlen(ch)==0) break ; /* if (!ch[0]) ou même if (!*ch) */
/* seraient plus rapide */
nb_car = 0 ;
ad =ch ; /* on commencera l'exploration au début de ch */
while (ad = strchr (ad, c)) /* ensuite, on repartira au caractère suivant */
/* le caractère remplacé */
{ *ad = c_rep ;
nb_car++ ;
ad++ ; /* pour le cas où c = c_rep */
}
printf ("chaine apres remplacement par %c des %d caracteres %c :\n%s\n",
c_rep, nb_car, c, ch) ;
}
}
donnez une chaine (vide pour finir) : tete
chaine apres remplacement par i des 2 caracteres e :
titi
donnez une chaine (vide pour finir) : programmation
chaine apres remplacement par i des 0 caracteres e :
programmation
donnez une chaine (vide pour finir) : eeeee
chaine apres remplacement par i des 5 caracteres e :
iiiii
donnez une chaine (vide pour finir) :
Remarques
1. Nous avons bien prévu de repartir, après chaque recherche, en ad + 1 (ce qui n’est pas gênant
puisque, dans le pire des cas, on pointe sur le zéro de fin de chaîne, ce qui est tout à fait licite
puisqu’il s’agit simplement d’une chaîne vide) et non en ad, ceci pour tenir compte de l’éventualité
ou c et c_rep seraient identiques. Dans ce cas, en effet, on bouclerait indéfiniment sur le premier
caractère trouvé.
2. Si l’on souhaite optimiser un tel programme en temps d’exécution, il pourra être préférable de
traiter la chaîne caractère par caractère.
9.2 La fonction de recherche d’une sous-chaîne : strstr
La fonction strstr permet de rechercher la première occurrence d’une chaîne
donnée (sous-chaîne) dans une autre.
char *strstr (const char *chaine1, const char *chaine2) (string.h)
chaine1
Adresse de la chaîne concernée
chaine2
Adresse de la sous-chaîne Peut être vide, voir
recherchée ci-après
Valeur de – adresse de la première occurrence
retour complète de la sous-chaîne
cherchée, si elle existe ;
– pointeur NULL sinon.
On notera bien que, fort heureusement, le zéro de fin des deux chaînes ne
participe pas à la recherche. Cela signifie notamment que la chaîne recherchée
n’a pas nécessairement besoin d’être située à la fin de la chaîne examinée.
La norme prévoit conventionnellement qu’une chaîne vide coïncide avec
n’importe quelle partie d’une chaîne, autrement dit que strstr (ch, "") fournit
toujours ch.
Exemples
strstr ("recherche", "ch") ; /*fournit l'adresse du troisième caractère (c) */
/* de la chaîne "recherche" */
strstr ("recherche", "cha") ; /* fournit la valeur NULL */
9.3 La fonction de recherche d’un caractère parmi
plusieurs : strpbrk
La fonction strpbrk permet de rechercher dans une chaîne la première occurrence
d’un des caractères appartenant à un ensemble.
char *strpbrk (const char *chaine1, const char *chaine2) (string.h)
chaine1
Adresse de la chaîne où s’effectuera la recherche
chaine2
Adresse de la chaîne contenant les différents caractères
concernés
Valeur de – adresse de la première occurrence, dans la chaine
retour d’adresse chaine1, de l’un des caractères de chaine2 ;
– pointeur NULL si aucun des caractères de chaine2 n’appartient
à chaine1.
Là encore, fort heureusement, les caractères de fin des chaînes ne participent pas
à la recherche.
Exemples
Si l’on suppose que ch est de type char * :
strpbrk ("bonjour", "ae") /* vaut NULL car aucun des caractères a ou e */
/* ne figure dans la chaîne "bonjour" */
strpbrk ("bonjour", "oun") /* fournit l'adresse du premier o de "bonjour" */
ad = strpbrk (ch, "aeiouy") ; /* si ad est NULL, la chaîne d'adresse ch */
/* ne contient aucune aucune voyelle minuscule, */
/* sinon, ad contient l'adresse de la première */
ad = strpbrk (ch, "0123456789") ; /* si ad est NULL, la chaîne d'adresse ch */
/* ne contient aucun chiffre, sinon ad */
/* contient l'adresse du premier chiffre */
Voici comment, en s’inspirant de l’exemple de la section 9.1.3, on pourrait
remplacer par un espace tous les caractères de ponctuation d’une phrase
contenue dans ch :
Pour remplacer par un espace les ponctuations d’une chaîne
char ponct [] = ".,;:!?" ;
char ch [81] ;
char *ad ;
…..
ad = ch ;
while (ad = strpbrk (ad, ponct)) *ad = ‘ ‘ ;
Remarque
La fonction strcspn, étudiée ci-après, joue un rôle voisin de strpbrk.
9.4 Les fonctions de recherche d’un préfixe
On dispose de fonctions permettant de déterminer, dans une chaîne, un
« segment initial » (ou encore un « préfixe »), c’est-à-dire la plus longue suite de
caractères répondant à une condition donnée. Cette condition s’exprime soit par
une appartenance (strspn), soit par une non-appartenance (strcspn) à un ensemble
de caractères donnés. Contrairement à la plupart des autres fonctions portant sur
des chaînes, ces deux fonctions fournissent comme résultat non pas une adresse,
mais le nombre de caractères vérifiant la condition en question. Ce nombre peut
éventuellement être égal à 0.
9.4.1 La fonction strspn
Elle fournit la longueur du segment initial d’une chaîne formé entièrement de
caractères appartenant à un ensemble donné.
size_t strspn (const char *chaine1, const char *chaine2) (string.h)
chaine1
Adresse de la chaîne où s’effectuera la
recherche.
chaine2
Adresse d’une chaîne contenant les
caractères recherchés.
Valeur de Longueur du segment initial de la chaîne Le
retour d’adresse chaine1 formé entièrement de caractère de
caractères appartenant à la chaîne d’adresse fin de
chaine2. chaîne
n’intervient
pas dans la
recherche.
Exemples
strspn ("2587abc25","0123456789") /* vaut 4 */
strspn (" bonjour monsieur"," ") /* vaut 2 */
strspn ("XZXXYZautre choseXX","XYZ") /* vaut 6 */
9.4.2 La fonction strcspn
Elle fournit la longueur du segment initial d’une chaîne formé entièrement de
caractères n’appartenant pas à un ensemble donné.
size_t strcspn (const char *chaine1, const char *chaine2) (string.h)
chaine1
Adresse de la chaîne où s’effectuera la
recherche.
chaine2
Adresse d’une chaîne contenant les
caractères recherchés.
Valeur de Longueur du segment initial de la chaîne Le
retour d’adresse chaine1 formé entièrement de caractère de
caractères appartenant à la chaîne d’adresse fin de
chaine2. chaîne
n’intervient
pas dans la
recherche.
Exemples
strcspn ("abc25xzt","0123456789") /* vaut 3 */
strcspn ("bonjour monsieur"," ") /* vaut 7 */
strcspn ("choseXZXXYZautre chose","XYZ") /* vaut 5 */
Remarque
Les deux fonctions strpbrk et strcspn diffèrent uniquement par leur valeur de retour, en cas de
recherche infructueuse. Considérons ces déclarations :
size_t lg
char *ad ;
Si l’on suppose que la chaîne d’adresse ch1 contient au moins un des caractères de la chaîne d’adresse
ch2, ces instructions :
ad = strpbrk (ch1, ch2) ;
g = ad - ch1 ;
fournissent le même résultat que l’appel :
lg = strcspn (ch1, ch2) ;
9.5 La fonction d’éclatement d’une chaîne : strtok
9.5.1 Introduction
On peut avoir besoin d’éclater une chaîne en plusieurs sous-chaînes, sachant que
ces dernières sont séparées les unes des autres par un ou plusieurs caractères
bien définis.
Par exemple, dans une chaîne représentant une heure exprimée sous l’une des
formes :
[Link]
12h35m42s
12h 35mn 42s
on peut vouloir extraire les sous-chaînes "12", "35" et "42".
De manière comparable, on peut vouloir extraire les différents mots d’une phrase
sachant qu’ils sont séparés par certains caractères de ponctuation ou par des
espaces. En effet, il suffira d’appliquer strtok autant de fois que voulu à une
même chaîne pour obtenir les adresses des sous-chaînes correspondantes. Celles-
ci seront terminées par un zéro de fin que la fonction aura introduit à la place
d’un caractère délimiteur. Cela signifie donc que la chaîne d’origine est
partiellement modifiée. On notera que, à chaque appel, on fournit la liste des
caractères délimiteurs. Ceux-ci peuvent donc, éventuellement, changer d’un
appel à un autre. En pratique, ce sera rarement le cas.
9.5.2 Prototype
char *strtok (char *chaine, const char *delimiteurs) (string.h)
chaine
Adresse de la chaîne à éclater pour le premier
appel, NULL ensuite (tant qu’on travaille avec la
même chaîne).
delimiteurs
Adresse d’une chaîne contenant les caractères Peut
délimiteurs. différer
d’un appel
à un autre,
pour une
chaîne
donnée.
Valeur de – adresse de la première sous-chaîne de la Voir
retour chaîne délimitée (avant et après) par des canevas
caractères délimiteurs, si elle existe ; d’utilisation
type à la
– pointeur NULL, sinon. section
9.5.3
9.5.3 Canevas d’utilisation type
Pour obtenir les différentes sous-chaînes constituant la chaîne d’adresse chaine,
on effectue tout d’abord un premier appel avec l’adresse chaine, ce qui a comme
conséquences :
• la recherche, à partir de l’adresse chaine, du premier caractère différent des
caractères appartenant à la chaîne delimiteurs ; si un tel caractère n’existe pas, la
fonction renvoie la valeur NULL ;
• si un tel caractère a été trouvé, la fonction recherche alors, à partir de là, le
premier caractère qui corresponde à un des caractères de delimiteurs et, s’il
existe, le remplace par un caractère de fin de chaîne. On notera bien que si
d’autres caractères délimiteurs apparaissent à la suite, ils ne sont pas pris en
compte pour l’instant ; ils seront éventuellement sautés lors d’un prochain
appel (à moins qu’on en ait modifié la liste !).
Après ce premier appel, on obtient donc la première sous-chaîne : son adresse est
fournie par la valeur de retour de la fonction et elle se termine bien maintenant
par un zéro. Notez que si aucun délimiteur n’est présent en début de la chaîne,
l’adresse obtenue à ce niveau n’est rien d’autre que l’adresse de début de la
chaîne.
Tant que la valeur de retour est différente de NULL, on répète le processus, en
fournissant cette fois la valeur NULL en premier argument de strtok. Le second
argument correspond toujours aux caractères délimiteurs mais ils peuvent
différer d’un appel au suivant (pour une chaîne donnée). Chaque fois, la fonction
réalise un travail comparable à celui effectué lors de son premier appel avec
toutefois une différence : son exploration commence non plus au début de la
chaîne, mais à la suite du zéro qu’elle a placé lors de son précédent appel.
En résumé, voici la démarche qui permet d’éclater la chaîne ch en se basant sur
les délimiteurs contenus dans delim, en supposant qu’on utilise les mêmes
délimiteurs pour chacune des sous-chaînes :
Canevas usuel d’utilisation de strtok pour éclater la chaîne ch en sous-chaînes,
en se basant sur les délimiteurs contenus dans la chaîne delim
char *adr ; char *ch ; char *delim ;
…..
adr = strtok (ch, delim); /* adr pointe sur la première sous-chaîne */
….. /* NULL si aucun caractère différent de ceux de delim */
while (adr)
{ adr = strtok (NULL, delim) ; /* adr pointe successivement sur chacune */
….. /* des sous-chaînes suivantes et il vaut */
} /* NULL lorsque le processus est terminé */
Remarques
1. La norme ne précise pas le comportement de strtok si on l’appelle une première fois avec un
premier argument nul. Suivant les implémentations, cela peut aller d’une simple valeur de retour
nulle à un comportement erratique…
2. La possibilité d’appel de strtok avec un premier argument nul n’est qu’une facilité d’emploi dont
on pourrait, en théorie, se passer. En effet, après un appel tel que :
adr = strtok (ch, delim) ;
on peut toujours remplacer
strtok (NULL, delim) ;
par :
strtok (adr+strlen(adr)+1, …) ;
Exemple
Voici un programme qui utilise strtok pour éclater un certain nombre de chaînes,
dont les trois citées en introduction à la section 9.5.1, à savoir :
• "[Link]", en se basant sur le seul séparateur ":" ;
• "12h35m42s" en se basant sur les séparateurs "hms" ; par souci de simplicité, on a
utilisé la même liste de séparateurs pour chaque appel, alors qu’on aurait pu,
éventuellement, se limiter à "h" au premier appel, à "m" au deuxième et à "s" au
troisième ;
• "12h 35mn 42s" en se basant sur les séparateurs "hmns " ; là encore, on a utilisé la
même liste de séparateurs pour chaque appel, alors qu’on aurait pu,
éventuellement, se limiter à "h " au premier appel, à "mn " au deuxième et à "s "
au troisième.
• "pour//faire?des$$choses$un$/peu?/$bizarres" en utilisant des séparateurs différents
d’un appel au suivant.
Exemples d’utilisation de strtok pour découper une chaîne
#include <stdio.h>
#include <string.h>
int main()
{ char ch1[] = "[Link]" ;
char ch2[] = "12h35m42s" ;
char ch3[] = "12h 35mn 42s" ;
char ch4[] = "pour//faire?des$$choses$$un$/peu?/$bizarres" ;
char * adr ;
printf ("decoupe de \"%s\",\n en se servant du seul separateur \":\"\n", ch1) ;
adr = strtok (ch1, ":") ; /* localisation du premier ":" */
while (adr)
{ printf ("%s\n", adr) ;
adr = strtok (NULL, ":") ; /* localisation des ":" suivants */
}
printf ("decoupe de \"%s\",\n en se servant des separateurs \"hms\"\n", ch2) ;
adr = strtok (ch2, "hms") ; /* localisation du premier séparateur*/
while (adr)
{ printf ("%s\n", adr) ;
adr = strtok (NULL, "hms") ; /* localisation du séparateur suivant */
}
printf ("decoupe de \"%s\",\n en se servant des separateurs \"hmns \"\n", ch3) ;
adr = strtok (ch3, "hms ") ; /* localisation du premier séparateur */
while (adr)
{ printf ("%s\n", adr) ;
adr = strtok (NULL, "hmns ") ; /* localisation du séparateur suivant */
}
printf ("decoupe de \"%s\",\n en se servant de separateurs variables\n", ch4) ;
adr = strtok (ch4, "/") ; /* localisation basée sur "/" */
printf ("%s\n", adr) ;
adr = strtok (NULL, "$") ; /* localisation basée sur "$" */
printf ("%s\n", adr) ;
adr = strtok (NULL, "?") ; /* localisation basée sur "?" */
printf ("%s\n", adr) ;
adr = strtok (NULL, "/") ; /* localisation basée sur "/" */
printf ("%s\n", adr) ;
}
decoupe de "[Link]",
en se servant du seul separateur ":"
12
30
25
decoupe de "12h35m42s",
en se servant des separateurs "hms"
12
35
42
decoupe de "12h 35mn 42s",
en se servant des separateurs "hmns "
12
35
42
decoupe de "pour//faire?des$$choses$$un$/peu?/$bizarres",
en se servant de separateurs variables
pour
/faire?des
$choses$$un$/peu
$bizarres
10. Les fonctions de conversion d’une chaîne en un
nombre
10.1 Généralités
La bibliothèque standard propose des fonctions de conversion de tout ou partie
d’une chaîne en un nombre. Par exemple, la chaîne :
" 14.4bonjour"
pourra être convertie :
• en un long, ce qui conduira à la valeur entière 14 (les caractères excédentaires
étant ignorés pour la conversion),
• en un double, ce qui conduira à une valeur approchée de 14,4 (les caractères
excédentaires étant, là aussi, ignorés par la conversion).
Les fonctions universelles
D’une manière générale, les fonctions les plus universelles sont :
• strtol pour la conversion de chaîne en long ;
• strtoul pour la conversion de chaîne en unsigned long ;
• strtod pour la conversion de chaîne en double.
Elles exploiteront au moins les suites de caractères acceptés par les codes de
conversion d, u, e et f des fonctions de la famille scanf. De plus, les fonctions de
conversion en entier strtol et strtoul accepteront des chiffres exprimés non
seulement en base 10, mais éventuellement dans une autre base entre 2 et 36.
Les fonctions particulières
Par ailleurs, il existe des fonctions particulières nommées atoi (conversion en
int), atol (conversion en long) et atof (conversion en float) qui ne sont en fait que
des cas particuliers des précédentes. Elles n’ont été conservées par la norme qu’à
titre de compatibilité avec les anciennes versions de compilateurs qui ne
disposaient pas des fonctions les plus générales.
Remarques
1. La norme ne propose pas de fonctions effectuant les conversions inverses de celles qui sont
évoquées ici, c’est-à-dire de nombres en chaînes. Bon nombre d’implémentations, cependant,
disposent de fonctions itoa (int en chaîne), ltoa (long en chaîne) et ultoa (unsigned long en
chaîne).
2. Ici, nous parlons de ce que l’on pourrait nommer des « conversions unitaires », c’est-à-dire d’une
chaîne en un nombre ou d’un nombre en une chaîne. Mais on peut dire que :
– la fonction sprintf peut réaliser, entre autres, une conversion de plusieurs nombres en une seule
chaîne ; en l’appliquant à un seul nombre, on peut réaliser les conversions correspondant aux
fonctions itoa, ltoa et ultoa évoquées précédemment ;
– la fonction sscanf peut réaliser (entre autres) une conversion d’une chaîne en plusieurs nombres ;
en l’appliquant à un seul nombre, on peut réaliser les conversions correspondant à certaines des
fonctions étudiées ici.
La pseudo variable errno
Il existe une macro nommée errno, définie dans errno.h, qui s’utilise comme une
variable. Sa valeur peut être modifiée par les fonctions évoquées en cas d’erreur.
Pour pouvoir l’exploiter correctement, il ne faut pas oublier de l’initialiser à zéro
avant l’appel de la fonction. Nous en verrons des exemples aux sections 10.2.4 et
10.3.5.
10.2 La fonction de conversion d’une chaîne en un
double : strtod
Le rôle principal de strtod15 est, comme on s’y attend, d’assurer la conversion du
début d’une chaîne en un double. Néanmoins, cette fonction dispose d’un second
argument qui, dès lors qu’il n’est pas nul, permet de savoir quels caractères ont
véritablement été exploités pour la conversion.
10.2.1 Prototype
double strtod (const char *chaine, char **carinv) (stdlib.h)
chaine
Adresse de la chaîne à convertir
carinv
Si non NULL, adresse à laquelle sera rangée Description
l’adresse du premier caractère invalide s’il détaillée à
existe. Si aucune conversion n’a pu se faire, la section
on obtient l’adresse de début de chaîne, 10.2.2 si
espaces blancs compris. S’il n’existe aucun carinv vaut
caractère invalide, on obtiendra l’adresse du NULL et à la
zéro de fin de chaîne. section
10.2.3 si
carinv est
différent de
NULL
Valeur de Résultat de la conversion en double du début
retour de la chaîne (0 si aucun caractère n’a pu être
exploité)
Compte tenu de la relative complexité de cette fonction, nous l’étudierons
progressivement en séparant l’aspect conversion proprement dit de l’aspect
connaissance du premier caractère invalide.
10.2.2 Rôle principal de strtod : créer une valeur de type double
À l’image de ce que feraient les codes de format %e ou %f avec scanf, sscanf ou
fscanf, cette fonction analyse la chaîne d’adresse chaîne, en ignorant les éventuels
espaces blancs de début. Elle prend en compte les mêmes formes de données, à
savoir : un flottant exprimé en notation décimale (le point décimal pouvant être
absent) ou un flottant exprimé en notation exponentielle16. Le premier caractère
invalide (par rapport à ce que l’on attend) ou la fin de la chaîne arrête
l’exploration ; là encore, lorsqu’aucun signe ou chiffre ne figure à la suite de
l’éventuelle lettre e ou E, tout se passe comme si l’exposant avait alors une valeur
nulle.
La fonction fournit en retour le résultat de la conversion, sauf lorsqu’aucun
caractère n’est exploitable, auquel cas elle fournit la valeur zéro.
Voici quelques exemples :
strtod ("12.25", NULL) /* résultat : environ 12,25 (double) */
strtod ("-1.35bonjour", NULL) /* on s'arrête sur le b ;résultat : environ -1,35 */
strtod ("bonjour", NULL) /* on s'arrête sur le b ; aucun caractère */
/* à exploiter ; résultat : 0 */
strtod (" bonjour", NULL) /* on s'arrête sur le b ; aucun caractère */
/* à exploiter ; résultat : 0 */
strtod ("- 1.25", NULL) /* on s'arrête sur l'espace qui suit le - */
/* résultat : 0 */
strtod (" -1.25exp", NULL) /* on s'arrête sur x ; résultat : environ -1,25 */
Comme avec les fonctions de la famille scanf, il est possible que le contenu de la
chaîne conduise à un résultat dépassant la capacité d’un double. Ce sera le cas
dans la plupart des implémentations avec des chaînes telles que "1.25e3000". La
fonction fournit alors l’une des deux valeurs particulières HUGE_VAL ou –HUGE_VAL (la
valeur HUGE_VAL étant définie dans math.h), et donne à la variable errno la valeur
ERANGE, prédéfinie dans math.h et dans errno.h.
De façon comparable, il est possible que le contenu de la chaîne conduise à un
résultat trop petit pour être représentable en double. Ce sera le cas dans la plupart
des implémentations avec des chaînes telles "1.0e-30000". Dans ce cas, la fonction
fournit simplement la valeur 0 et donne à la variable errno la valeur prédéfinie
ERANGE.
Remarques
1. Alors qu’il est possible de détecter le dépassement de capacité en comparant la valeur de retour de
strtod avec HUGE_VAL, il n’existe rien de comparable pour le sous-dépassement de capacité. Ce
dernier ne peut être détecté qu’en examinant errno, ce qui est manifestement moins pratique.
2. La norme définit les formes acceptées par les codes f, e, E, g et G des fonctions de la famille scanf,
par référence à celles acceptées par strtod. Mais elle ne précise pas que les comportements en cas
d’erreur (valeur de errno, valeur de retour égale à HUGE_VAL) doivent être identiques, même si c’est
le cas dans bon nombre d’implémentations.
3. Le choix d’une localisation différente de la localisation standard « C » peut introduire d’autres
caractères reconnus comme espaces blancs, modifier le caractère utilisé comme point décimal ; en
outre, d’autres formes peuvent être acceptées.
10.2.3 Rôle secondaire de strtod : fournir le premier caractère
invalide
Si l’on convertit par strtod la chaîne :
" 14.4bonjour"
l’exploration s’arrêtera au caractère b. On peut disposer de cette information en
fournissant en deuxième argument de strtod l’adresse d’un emplacement auquel
la fonction placera l’adresse du premier caractère invalide rencontré. Par
exemple, avec :
char *ad_carinv ;
…..
strtod (" 12e45bonjour", &ad_carinv) ; /* attention : &ad_carinv et non */
/* ad_carinv */
on obtiendra dans ad_carinv, l’adresse du caractère b.
Si aucun caractère invalide n’est rencontré alors que la fonction a pu fabriquer
une valeur, on obtiendra l’adresse de la fin de la chaîne. On dispose ainsi d’un
moyen simple de savoir si la chaîne contient ou non des caractères excédentaires
(sans qu’on puisse cependant discerner des autres cas celui où ces caractères
excédentaires sont tous des séparateurs…).
Si aucun caractère n’a pu être exploité avant le premier caractère invalide (ce qui
correspondrait à un arrêt prématuré dans le cas de scanf !), on obtient l’adresse de
début de la chaîne, même si elle commence par des espaces blancs (le caractère
pointé n’est alors plus, à proprement parler, un caractère invalide). Par exemple,
avec :
strtod (" bonjour", &ad_carinv) ;
on obtiendra dans ad_carinv, l’adresse du premier espace de la chaîne et non
l’adresse du caractère b.
Comme on obtient l’adresse du premier caractère invalide (et non simplement sa
valeur), il est possible de connaître exactement la partie de la chaîne qui a
effectivement été exploitée par la conversion. On pourrait par exemple la
recopier par strncpy en considérant ad_carinv - chaîne caractères.
10.2.4 Exemple
Voici un exemple de programme qui convertit en double, à l’aide de strtod,
différentes chaînes fournies en données. Pour chaque conversion, on affiche le
premier caractère invalide rencontré s’il en existe un. De plus, on examine la
valeur de errno. À ce propos, on notera que pour éviter tout problème, il est
nécessaire d’initialiser errno à 0 avant tout appel de strtod. Le programme
s’interrompt à la rencontre d’une chaîne vide.
Exemples d’utilisation de strtod
#include <string.h> /* pour strlen */
#include <stdio.h>
#include <stdlib.h>
#include <errno.h> /* pour errno et ERANGE */
#include <math.h>
int main()
{
char ch[81] ;
int i ;
double x ;
char * ad_carinv ; /* pour l'adresse du premier caractère invalide */
while (1)
{ printf ("donnez une chaine (vide pour finir) :") ;
gets (ch) ;
if (strlen(ch) == 0) break ;
errno = 0 ;
x = strtod (ch, &ad_carinv) ;
printf (" --> double : %10e", x) ;
if (*ad_carinv != ‘\0') printf (" - prem car inv : %c", *ad_carinv) ;
if (errno == ERANGE) printf (" - erreur capacite ") ;
if (x == HUGE_VAL) printf (" – HUGE_VAL ") ;
if (x == -HUGE_VAL) printf (" - -HUGE_VAL ") ;
printf ("\n") ;
}
}
donnez une chaine (vide pour finir) : -123.45
--> double : -1.234500e+02
donnez une chaine (vide pour finir) :.1e15bof
--> double : 1.000000e+14 - prem car inv : b
donnez une chaine (vide pour finir) :.e4
--> double : 0.000000e+00 - prem car inv : .
donnez une chaine (vide pour finir) : .e4
--> double : 0.000000e+00 - prem car inv : /* il s'agit d'un espace */
donnez une chaine (vide pour finir) :12e12345pour voir
--> double : 1.797693e+308 - prem car inv : p - erreur capacite - HUGE_VAL
donnez une chaine (vide pour finir) :-12e12345pour voir
--> double : -1.797693e+308 - prem car inv : p - erreur capacite - -HUGE_VAL
donnez une chaine (vide pour finir) :12.34exp3
--> double : 1.234000e+01 - prem car inv : x
donnez une chaine (vide pour finir) :123e-1234
--> double : 0.000000e+00 - erreur capacite
donnez une chaine (vide pour finir) :
10.3 Les fonctions de conversion d’une chaîne en
entier : strtol et strtoul
Le rôle principal de ces fonctions est, comme on s’y attend, d’assurer la
conversion d’une chaîne en un entier de type long pour strtol et de type unsigned
long pour strtoul. Néanmoins, ces deux fonctions disposent d’un second argument
qui, dès lors qu’il n’est pas nul, permet de savoir quels caractères ont été
véritablement exploités pour la conversion.
10.3.1 Prototype
Ces deux fonctions ont des prototypes très voisins puisque leur seule différence
réside dans le type de la valeur de retour.
long strtol (const char *chaine, char **carinv, int
base) (stdlib.h)
unsigned long strtoul (const char *chaine, char **carinv, int
base) (stdlib.h)
chaine
Adresse de la chaîne à convertir
carinv
Si non NULL, adresse à laquelle sera rangée Description
l’adresse du premier caractère invalide s’il détaillée aux
existe. Si aucune conversion n’a pu se faire, section 10.3.2
on obtient l’adresse de début de chaîne, et 10.3.3 si
espaces blancs compris. S’il n’existe aucun carinv nul et à
caractère invalide, on obtient l’adresse du la section
zéro de fin de chaîne. 10.3.4 si carinv
non nul
base
– base, entre 2 et 36 dans laquelle le nombre – étude du cas
est supposé être écrit ; base = 0
– si nul, le nombre est censé être exprimé en à la section
décimal, en octal s’il commence par 0 et en 10.3.2 ;
hexadécimal s’il commence par 0x ou 0X. – étude du cas
base non
nul à la
section
10.3.3.
Valeur Résultat de la conversion en entier (long ou Curieusement,
de unsigned long) du début de la chaîne (0 si aucun strtoul accepte
retour caractère n’a pu être exploité). un signe -
(voir section
10.3.2,
remarque 1)
Compte tenu de la richesse des possibilités de ces deux fonctions, nous les
étudierons progressivement. Nous commencerons par examiner leur rôle dans le
cas de l’utilisation la plus simple : deuxième argument de valeur NULL et troisième
argument égal à 0. Dans ce cas, ce rôle est très proche de celui des codes de
format %ld ou %lu des fonctions de la famille scanf. Nous verrons ensuite que le
troisième argument peut permettre de choisir une base autre que décimale, entre
2 et 36. Enfin, nous verrons comment le second argument permet de connaître,
comme avec strtod, quels caractères ont été effectivement exploités par la
conversion.
10.3.2 Création d’une valeur entière à partir de chiffres décimaux,
octaux ou hexadécimaux
Nous considérons ici un appel de l’une des deux formes :
strtol (chaine, NULL, 0) ;
strtoul (chaine, NULL, 0) ;
À l’image de ce que feraient les codes %ld (pour strtol) ou %lu (pour strtoul), ces
fonctions analysent la chaîne d’adresse chaîne, en ignorant les éventuels espaces
blancs de début. Elles prennent en compte une suite de caractères correspondant
à un entier. Le premier caractère invalide (par rapport à ce que l’on attend) ou la
fin de la chaîne arrêtent l’exploration. Mais contrairement à ce qui produit pour
les codes %ld ou %lu, les notations hexadécimales ou octales sont autorisées. Cela
signifie qu’une suite de caractères commençant par 0x ou 0X est interprétée en
hexadécimal, une suite commençant par 0 – sans que le caractère suivant ne soit
un x ou un X – est interprétée en octal. Toute autre suite de caractères est
interprétée en décimal17.
Bien entendu, la fonction fournit en retour le résultat de la conversion, sauf
lorsqu’aucun caractère n’est exploitable, auquel cas elle fournit la valeur zéro.
Voici quelques exemples avec strtol. Ils se transposent tous à strtoul, y compris
pour les nombres négatifs, comme l’explique la remarque ci-après :
strtol (" -123", NULL, 0) /* résultat : -123 */
strtol ("- 123", NULL, 0) /* on s'arrête sur l'espace qui suit */
/* le signe - résultat : 0 */
strtol ("-123bonjour", NULL, 0) /* on s'arrête sur b : résultat : -123 */
strtol (" 013", NULL, 0) /* notation octale : résultat : 11 (en décimal)*/
strtol (" 0139", NULL, 0) /* notation octale ; on s'arrête sur 9 */
/* résultat : 11 (en décimal) */
strtol ("0x13", NULL, 0) /* notation hexadécimale ; */
/* résultat : 19 (en décimal) */
strtol ("-0x13", NULL, 0) /* notation hexadécimale ; */
/* résultat :-19 (en décimal) */
strtol ("0x1bonjour", NULL, 0) /* notation hexadécimale ; on s'arrête sur o */
/* (et non sur b) ; résultat : 27 (décimal) */
strtol (" hello", NULL, 0) /* aucun car. exploitable ; résultat : 0 */
On notera que, comme avec les fonctions de la famille scanf, il est possible que le
contenu de la chaîne conduise à un résultat non exprimable dans le type prévu.
Ce serait par exemple le cas dans la plupart des implémentations avec des
chaînes telles que "12345678901234567890", aussi bien pour strtol que pour strtoul.
Dans une telle situation :
• s’il s’agit de strtol, cette fonction fournit l’une des valeurs prédéfinies dans
math.h LONG_MAX (plus grand positif) ou LONG_MIN (plus petit négatif) et donne à la
variable errno la valeur prédéfinie ERANGE ;
• s’il s’agit de strtoul, cette fonction fournit la valeur prédéfinie ULONG_MAX et donne
à la variable errno la valeur prédéfinie ERANGE.
Remarques
1. Contre toute attente, la norme autorise la conversion par strtoul de chaînes comportant un signe
moins et elle précise que, dans ce cas, on prend l’opposé du nombre qui suit, ce qui conduit
nécessairement à un dépassement de capacité en arithmétique non signée. Dans ce cas (voir section
2.2.1 du chapitre 4), le résultat reste défini par la norme. En particulier, dans le cas des
implémentations utilisant la technique du complément à deux, le motif binaire est le même que
celui qu’on obtiendrait en arithmétique signée, du moins si la valeur absolue du nombre n’est pas
trop grande. Quoi qu’il en soit, dans un tel cas, le signe moins n’est pas considéré comme un
caractère invalide et la variable errno ne sera pas positionnée à ERANGE !
2. La norme se contente de dire que les caractères effectivement pris en compte doivent correspondre à
une constante entière, sans mentionner s’il existe une limite au nombre de chiffres considérés.
Certaines implémentations imposent effectivement une limite (naturellement supérieure au nombre
maximal de chiffres d’un entier long, dans la base considérée). Cela n’a aucune incidence sur le
résultat fournit par strtol ou strtoul puisqu’on se trouve de toute façon en dépassement de
capacité. Néanmoins, cela interviendra au niveau du premier caractère invalide qui pourra alors être
un chiffre a priori convenable. Nous en verrons un exemple à la section 10.3.5.
3. La norme définit les formes acceptées par les codes d, o, i, u, x et X des fonctions de la famille
scanf par référence à celles acceptées par strtol ou strtoul. Mais elle ne précise pas que les
comportements en cas d’erreur (valeur de errno, valeur de retour égale à LONG_MIN, LONG_MAX ou
ULONG_MAX) doivent être identiques, même si c’est le cas dans bon nombre d’implémentations.
4. Le choix d’une localisation différente de la localisation standard C peut introduire d’autres
caractères reconnus comme espaces blancs ; en outre, d’autres formes peuvent être acceptées.
10.3.3 Création d’une valeur entière à partir de chiffres en base
quelconque
Comme nous venons de le voir dans la précédente section, avec une valeur nulle
du troisième argument, les fonctions strtol et strtoul utilisent une base 10 (pour
peu que la chaîne ne commence pas par 0). Mais ce troisième argument, dès lors
qu’il est non nul, permet de préciser une base de conversion, entre 2 et 36. Bien
entendu, pour une base b ne dépassant pas 10, les caractères valides (en dehors
d’un éventuel signe initial) sont les chiffres de 0 à b-1. Par exemple, en base 4, on
acceptera les chiffres allant de 0 à 3. Pour les bases supérieures à 10, on
généralise la notation hexadécimale, en utilisant simplement les lettres de
l’alphabet (majuscules ou minuscules) : A pour 10, B pour 11, F pour 15, G pour
16… Z pour 35.
Voici quelques exemples avec strtol (ils se transposent à strtoul, y compris pour
les nombres négatifs, comme l’explique la remarque ci-après) :
strtol (" 110", NULL, 2) /* base 2 ; résultat : 6 (en décimal) */
strtol ("12", NULL, 3) /* base 3 ; résultat : 5 (en décimal) */
strtol ("-12", NULL, 3) /* base 3 ; résultat : -5 (en décimal) */
strtol ("124", NULL, 3) /* on s'arrête sur le 4 ; résultat : 5 (en décimal) */
strtol ("AB", NULL, 12) /* base 12 ; résultat : 131 (en décimal) */
strtol ("AB", NULL, 11) /* on s'arrête sur le B ; résultat : 10 (en décimal) */
strtol ("0x2c", NULL, 16) /* 0x en base 16 --> OK ; résultat : 44 (en décimal) */
strtol ("0x2c", NULL, 15) /* on s'arrête sur le x ; résultat : 0 */
strtol ("-0x2c", NULL, 16) /* 0x en base 16 --> OK ; résultat : -44 (en décimal)*/
Remarque
Quelle que soit la base utilisée, un signe peut être présent, et en particulier un signe moins. Contre
toute attente, cette remarque reste valable pour strtoul. Les commentaires effectués en remarque 1 de
la section 10.3.2 (cas d’une base décimale) se transposent ici.
10.3.4 Pour connaître le premier caractère invalide
Si l’on convertit par strtol ou strtoul la chaîne :
" 14bonjour"
l’exploration s’arrêtera au caractère b. Si l’on souhaite disposer de cette
information, on peut fournir en deuxième argument de strtol ou strtoul, l’adresse
d’un emplacement de type pointeur sur des caractères. La fonction y placera
alors l’adresse du premier caractère invalide rencontré. Par exemple, avec :
char *ad_carinv ;
…..
strtol (" 14bonjour", &ad_carinv, 0) ; /* attention : &ad_carinv */
/* et non adcarinv */
on obtiendra dans ad_carinv, l’adresse du caractère b.
Si aucun caractère invalide n’est rencontré, on obtiendra l’adresse de la fin de la
chaîne. Comme avec strtod, on dispose ainsi d’un moyen simple de savoir si la
chaîne contient ou non des caractères excédentaires (sans qu’on puisse
cependant discerner des autres cas celui où ces caractères excédentaires sont tous
des séparateurs…).
Si aucun caractère n’a pu être exploité avant le premier caractère invalide (ce qui
correspondrait à un arrêt prématuré dans le cas de scanf !), on obtient l’adresse de
début de la chaîne, même si elle commence par des espaces blancs (le caractère
pointé n’est alors plus, à proprement parler, un caractère invalide). Par exemple,
avec :
strtol (" bonjour", &adcarinv, 0) ;
on obtiendra dans adcarinv l’adresse du premier espace de la chaîne et non
l’adresse du caractère b.
Comme on obtient l’adresse du premier caractère invalide (et non simplement sa
valeur), il est possible de connaître exactement la partie de la chaîne qui a
effectivement été exploitée par la conversion. On pourrait par exemple la
recopier par strncpy en considérant ad_carinv - chaîne caractères.
Remarques
1. Dans les implémentations qui limitent le nombre de caractères pris en compte (voir section 10.3.2,
remarque 2), le premier caractère invalide pourra alors être un chiffre a priori convenable ! On en
verra des exemples dans le programme proposé à la section 10.3.5.
2. Comme strtoul accepte un signe moins, celui-ci, dès lors qu’il est bien placé, ne sera pas considéré
comme un caractère invalide. Par exemple :
strtoul ("-12", &adcarinv, 0) ; /* on obtiendra 0 dans ad_carinv */
strtoul ("-12.45, &adcarinv, 0) ; /* on obtiendra dans adcarinv, l'adresse du . */
10.3.5 Exemple
Voici un exemple de programme qui convertit en long, selon diverses bases
précisées par le tableau bases, différentes chaînes fournies en données. Pour
chaque conversion, on affiche le premier caractère invalide rencontré s’il en
existe un. De plus, on examine la valeur de errno. À ce propos, on notera que
pour éviter tout problème, il est nécessaire d’initialiser errno à 0 avant tout appel
de strtol. Le programme s’interrompt à la rencontre d’une chaîne vide. Notez
qu’ici l’implémentation limite le nombre de caractères pris en compte, comme
on peut le remarquer dans la sixième donnée (1111111123456789) : 8 chiffres en
base 5, 11 chiffres en base 2.
Exemples d’utilisation de strtol
#include <stdio.h>
#include <stdlib.h>
#include <errno.h> /* pour la variable errno et ERANGE */
#include <limits.h> /* pour LONG_MAX et LONG_MIN */
#include <string.h> /* pour strlen */
int main()
{ char ch[81] ;
long n ;
int i ;
char * ad_carinv ; /* pour l'adresse du premier caractère invalide */
int bases [] = { 0, 2, 5, 10, 16, 25 } ; /* bases utilisées avec strtol */
while (1)
{ printf ("donnez une chaine (vide pour finir) : ") ;
gets (ch) ;
if (strlen(ch) == 0) break ;
for (i=0 ; i<sizeof(bases)/sizeof(bases[0]) ; i++) /* on explore les */
/* différentes bases */
{ errno = 0 ;
n = strtol (ch, &ad_carinv, bases[i]) ;
printf (" --> long base %2d : %10ld", bases[i], n) ;
if (*ad_carinv != ‘\0') printf (" - prem car inv : %c", *ad_carinv) ;
if (errno == ERANGE) printf (" - erreur capacite ") ;
if (n == LONG_MAX) printf (" – LONG_MAX ") ;
if (n == LONG_MIN) printf (" – LONG_MIN ") ;
printf ("\n") ;
}
}
}
donnez une chaine (vide pour finir) : 123
--> long base 0 : 123
--> long base 2 : 1 - prem car inv : 2
--> long base 5 : 38
--> long base 10 : 123
--> long base 16 : 291
--> long base 25 : 678
donnez une chaine (vide pour finir) : 123bonjour
--> long base 0 : 123 - prem car inv : b
--> long base 2 : 1 - prem car inv : 2
--> long base 5 : 38 - prem car inv : b
--> long base 10 : 123 - prem car inv : b
--> long base 16 : 4667 - prem car inv : o
--> long base 25 : 2147483647 - prem car inv : u - erreur capacite - LONG_MAX
donnez une chaine (vide pour finir) : 123zut
--> long base 0 : 123 - prem car inv : z
--> long base 2 : 1 - prem car inv : 2
--> long base 5 : 38 - prem car inv : z
--> long base 10 : 123 - prem car inv : z
--> long base 16 : 291 - prem car inv : z
--> long base 25 : 678 - prem car inv : z
donnez une chaine (vide pour finir) : 0x123
--> long base 0 : 291
--> long base 2 : 0 - prem car inv : x
--> long base 5 : 0 - prem car inv : x
--> long base 10 : 0 - prem car inv : x
--> long base 16 : 291
--> long base 25 : 0 - prem car inv : x
donnez une chaine (vide pour finir) : 0123
--> long base 0 : 83
--> long base 2 : 1 - prem car inv : 2
--> long base 5 : 38
--> long base 10 : 123
--> long base 16 : 291
--> long base 25 : 678
donnez une chaine (vide pour finir) : 1111111123456789
--> long base 0 : 2147483647 - prem car inv : 5 - erreur capacite - LONG_MAX
--> long base 2 : 255 - prem car inv : 2
--> long base 5 : 12207069 - prem car inv : 5
--> long base 10 : 2147483647 - prem car inv : 5 - erreur capacite - LONG_MAX
--> long base 16 : 2147483647 - prem car inv : 3 - erreur capacite - LONG_MAX
--> long base 25 : 2147483647 - prem car inv : 2 - erreur capacite - LONG_MAX
donnez une chaine (vide pour finir) : -123
--> long base 0 : -123
--> long base 2 : -1 - prem car inv : 2
--> long base 5 : -38
--> long base 10 : -123
--> long base 16 : -291
--> long base 25 : -678
donnez une chaine (vide pour finir) :
10.4 Cas particulier des fonctions atof, atoi et atol
Ces trois fonctions atof, atoi et atol n’ont été conservées par la norme qu’à titre
de compatibilité avec les anciens compilateurs qui ne disposaient pas des
fonctions plus générales que sont strtod, strtol et strtoul. Leur rôle est défini par
rapport à ces dernières, sans que la norme n’impose de façon formelle l’identité
des comportements en cas d’erreur.
double atof (const char *chaine) (stdlib.h)
chaine
Chaîne à convertir
Valeur Résultat de la conversion en atof(ch) strtod (ch,
(char**)NULL)
de retour double du début de la chaîne, en
suivant les mêmes règles que (exception faite du
pour strtod comportement en cas
d’erreur)
long atol (const char *chaine) (stdlib.h)
chaine
Chaîne à convertir
Valeur Résultat de la conversion en
atol(ch) strtol (ch,
(char **)NULL, 10)
de retour long du début de la chaîne, en
suivant les mêmes règles que (exception faite du
strtol comportement en cas
d’erreur)
int atoi (const char *chaine) (stdlib.h)
chaine
Chaîne à convertir
Valeur Résultat de la conversion en int
atol(ch) (int) strtol (ch,
(char **)NULL, 10)
de retour du début de la chaîne, en
suivant les mêmes règles que (exception faite du
strtol comportement en cas
d’erreur)
11. Les fonctions de manipulation de suites d’octets
Dans certains cas, on peut avoir besoin de manipuler directement une suite
d’octets de longueur donnée. C’est notamment le cas lorsqu’on souhaite recopier
la valeur d’un objet dont on connaît la taille et l’adresse, sans nécessairement en
connaître le type.
Les fonctions standards examinées jusqu’ici dans ce chapitre sont inadaptées à
ce type de besoin, dans la mesure où elles se fondent sur la convention de
représentation des chaînes de caractères, à savoir la présence d’un caractère de
code nul en marquant la fin. On notera que même les fonctions qui permettent de
limiter le nombre de caractères pris en compte ne conviennent pas non plus, car
on risque d’être perturbé par l’éventuelle présence d’un octet de code nul. C’est
pourquoi il existe quelques fonctions spécialisées ressemblant à certaines des
fonctions déjà étudiées, avec cette différence qu’elles ne tiendront plus compte
d’un éventuel octet de code nul. Nous les étudierons après quelques
considérations d’ordre général.
11.1 Généralités
Comme on peut s’y attendre, ces fonctions recevront en argument une adresse.
Celle-ci sera de type void * et non plus char * comme dans le cas des fonctions de
traitement de chaîne. On peut penser que ce choix d’un pointeur générique
permet précisément de montrer qu’on travaille sur des informations non typées.
En soi, cela ne provoque aucune gêne dans l’utilisation de telles fonctions
puisque la conversion d’un pointeur de type quelconque en void * ne modifie pas
l’adresse correspondante. En revanche, si vous voulez utiliser la même
convention dans vos propres fonctions de manipulation de suites d’octets, il ne
faudra pas oublier qu’un pointeur générique ne peut pas être soumis à des
opérations arithmétiques : vous pourrez contourner la difficulté en recopiant la
valeur reçue dans une variable locale de type char *.
De façon comparable à ce qui se produit pour les fonctions de traitement de
chaînes :
• les adresses des emplacements qu’une fonction est censée ne pas modifier sont
déclarées du type const void *. Les commentaires de la section 5.3 s’appliquent
encore ici ;
• le nombre d’octets est reçu sous la forme d’un argument de type size_t ; on est
donc confronté aux mêmes problèmes que ceux évoqués à la section 5.4.
11.2 Les fonctions de recopie de suites d’octets
Il existe en fait deux fonctions de recopie de suites d’octets, l’une n’autorisant
pas de chevauchement entre la source et le but, l’autre l’autorisant.
11.2.1 La fonction memcpy
void *memcpy (void *but, const void *source, size_t
longueur) (string.h)
but
Adresse à laquelle sera
recopiée la suite d’octets
source
Adresse du premier octet à
recopier
longueur
Nombre d’octets à recopier Voir risques liés au type
size_t à la section 5.4
Valeur de Adresse but Peu d’intérêt en pratique
retour
Cette fonction recopie à l’adresse but la suite de longueur octets débutant à
l’adresse source. De façon comparable à strcpy ou strncpy, elle requiert que les
deux zones concernées ne se chevauchent pas, autrement dit que la condition
suivante soit vérifiée18 :
abs (source-but) >= longueur
Si tel n’est pas le cas, le comportement du programme est en théorie
indéterminé. En pratique, on peut montrer que, comme avec strncpy, les risques
se limitent à la modification intempestive de la zone d’origine.
11.2.2 La fonction memmove
void *memmove (void *but, const void *source, size_t
longueur) (string.h)
but
Adresse à laquelle sera
recopiée la suite d’octets
source
Adresse du premier octet à
recopier
longueur
Nombre d’octets à recopier Voir risques liés au type
size_t à la section 5.4
Valeur de Adresse but Peu d’intérêt en pratique
retour
Cette fonction joue un rôle comparable à memcpy en acceptant que les deux zones
concernées se chevauchent éventuellement. Au bout du compte, tout se passe
comme si la fonction procédait en deux temps :
• recopie de longueur octets de source vers un emplacement temporaire ;
• recopie de longueur octets de cet emplacement temporaire vers but.
11.3 La fonction memcmp de comparaison de deux
suites d’octets
int memcmp (const void *zone1, const void *zone2, size_t
longueur) (string.h)
zone1
Adresse de la première suite d’octets
zone2
Adresse de la seconde suite d’octets
longueur
Nombre d’octets soumis à la Voir risques
comparaison liés au type
size_t à la
section 5.5
Valeur de – négative si la zone d’adresse zone1 – attention à
retour arrive avant la chaîne d’adresse zone2 ; l’ordre des
– positive si la zone d’adresse zone1 arrive octets
après la chaîne d’adresse zone2 ; (caractères) ;
voir section
– zéro si les deux zones sont identiques. 8.1 ;
– les octets de
code nul
participent
pleinement à
la
comparaison.
La fonction memcmp effectue une comparaison lexicographique de deux suites
d’octets de même longueur, en utilisant les mêmes règles que strcmp ou strncmp.
Elle présente tout naturellement la différence de ne pas tenir compte, dans les
zones concernées, de la présence éventuelle d’octets nuls qui, quant à eux,
participent pleinement à la comparaison.
D’une manière générale, on peut montrer que, à arguments identiques, l’égalité
par memcmp implique obligatoirement l’égalité pour strncmp, alors que la réciproque
n’est pas vraie. Par exemple, avec :
char t1[] = {‘x', ‘y', ‘\0', ‘z' } ;
char t2[] = {‘x', ‘y', ‘\0', ‘w' } ;
…..
strncmp (t1, t2, 4) == 0 /* vrai */
memcmp (t1, t2, 4) == 0 /* faux */
11.4 La fonction memset d’initialisation d’une suite
d’octets
La fonction memset permet d’initialiser chacun des octets d’une suite donnée avec
une valeur déterminée.
void *memset (void *zone, int c, size_t longueur) (string.h)
zone
Adresse du premier octet à
initialiser
c
Valeur qui, après Voir discussion ci-après
conversion en unsigned char,
sera utilisée pour initialiser
chaque octet
longueur
Nombre d’octets à Voir risques liés au type
initialiser size_t à la section 5.4
Valeur de Adresse zone Peu d’intérêt en pratique
retour
On constate que memset reçoit comme deuxième argument une valeur de type int
et non de type char, comme on pourrait s’y attendre. C’est le résultat de la
conversion de cette valeur en unsigned char qui sera utilisé pour initialiser les
différents octets. Dans ces conditions, si, comme c’est souvent le cas, on
transmet à cette fonction un argument de type caractère, on fera apparaître l’une
des deux séries de conversions : unsigned char → int → unsigned char ou signed char
→ int → unsigned char. Celles-ci sont examinées en détail à la section 9.6 du
chapitre 4. En théorie, seule la première série conserve le motif binaire. En
pratique, la seconde le fait également dans toutes les implémentations.
Exemple
#define LG 10
char t[LG+1] ;
…..
memset (t, ‘x', LG) ; /* ‘x' est de type int ; il n'y aura pas de conversion */
t[LG] ='\0' ; /* ou memset (t+LG, 0, 1) ; */
printf ("*%s*", t) ; /* affiche : *xxxxxxxxxx* */
11.5 La fonction memchr de recherche d’une valeur
dans une suite d’octets
La fonction memchr permet de rechercher la première occurrence d’une valeur
donnée dans une suite d’octets.
void *memchr (const void *zone, int c, size_t longueur) (string.h)
zone
Adresse du premier octet
concerné
c
Valeur qui, après Voir discussion ci-après
conversion en unsigned char,
sera recherchée dans la
zone
longueur
Nombre d’octets à Voir risques liés au type
considérer size_t à la section 5.4
Valeur de Adresse du premier octet Renvoie NULL si longueur vaut
retour trouvé s’il existe, valeur NULL 0
sinon
On constate que, comme memset, memchr reçoit comme deuxième argument une
valeur de type int et non de type char, comme on pourrait s’y attendre. C’est le
résultat de la conversion de cette valeur en unsigned char qui sera recherché dans
les différents octets. Dans ces conditions, si, comme c’est souvent le cas, on
transmet à cette fonction un argument de type caractère, on fera apparaître l’une
des deux séries de conversions : unsigned char → int → unsigned char ou signed char
→ int → unsigned char. Celles-ci sont examinées en détail à la section 9.6 du
chapitre 4. En théorie, seule la première série conserve le motif binaire. En
pratique, la seconde le fait également dans toutes les implémentations.
Exemple
Voici un petit exemple comparant les rôles de memchr et de strchr :
Comparaison entre memchr et strchr
#include <stdio.h>
#include <string.h>
int main()
{ char *ad ; /* et non void * car on devra faire des calculs sur ad */
char t[11] = "xxxxxxxxxx" ; /* t contient x x x x x x x x x x x */
strcpy(t, "hello") ; /* t contient maintenant : h e l l o \0 x x x x x */
ad = memchr (t, ‘x', 10) ; /* recherche non interrompue par \0 */
if (ad != NULL) printf ("x en position %d de t\n", ad-t+1) ;
ad = strchr (t, ‘x') ; /* recherche interrompue par \0 */
if (ad == NULL) printf ("pas de x dans la chaine t\n") ;
ad = memchr (t, ‘z', 10) ;
if (ad == NULL) printf ("pas de z dans t\n") ;
}
x en position 7 de t
pas de x dans la chaine t
pas de z dans t
1. Du moins pas par affectation entre valeurs de type chaîne. Il est en effet possible d’affecter à un pointeur
de type char * l’adresse d’une autre chaîne… On peut aussi, très artificiellement, exploiter les
possibilités d’affectation entre structures en englobant dans une structure un tableau de caractères
représentant une chaîne.
2. En toute rigueur, on peut aussi utiliser le « faux gabarit » du code c, à condition d’ajouter explicitement le
caractère de fin de chaîne !
3. La norme n’est pas plus précise !
4. Du moins avec la localisation standard « C », car avec d’autres localisations, d’autres caractères peuvent
être considérés comme des espaces blancs.
5. Mais l’exécution n’est pas interrompue pour autant.
6. Nous appelons « famille scanf » les fonctions scanf, fscanf et fscanf.
7. Attention, nous parlons bien de conversions de pointeurs, pas de conversions d’un type caractère dans un
autre type caractère.
8. Avec certaines implémentations, on obtient simplement un message d’avertissement qui n’interdit pas
l’exécution du programme.
9. Il s’agit de stddef.h, stdio.h, stdlib.h et string.h.
10. Certaines implémentations refusent l’initialisation d’un tableau de caractères avec une chaîne de
longueur égale à la dimension du tableau. Dans ce cas, on peut provoquer le phénomène en procédant
ainsi :
char ch1[7] ;
strncpy (ch1, "bonjour", 7) ; /* recopie au plus 7 caractères dans ch1 */
11. Il est assez cocasse de noter que dans certains cas, elle pourra au contraire en posséder plusieurs…
12. Avec certaines implémentations, on obtient simplement un message d’avertissement qui n’interdit pas
l’exécution du programme. Dans ce cas, si les constantes ne sont pas placées dans une zone protégée, on
écrasera les caractères situés au-delà de la chaîne d’adresse ch2.
13. Certaines implémentations refusent l’initialisation d’un tableau de caractères avec une chaîne de
longueur égale à la dimension du tableau. Dans ce cas, on peut provoquer le phénomène en procédant
ainsi :
char ch1[7] ;
strncpy (ch1, "bonjour", 7) ; /* recopie au plus 7 caractères dans ch1 */
14. Toutefois, lorsque l’argument est une constante caractère, il est déjà de type int.
15. Certaines implémentations disposent d’une fonction strtold de conversion d’une chaîne en un long
double.
16. On peut aussi, de façon équivalente, dire qu’on prend en compte une information exprimée
indifféremment sous la forme d’une constante entière ou d’une constante flottante (en notation décimale
ou exponentielle).
17. La fonction strtol accepte les mêmes formes que le code li puisque ce dernier accepte les formes
admises par ld, lo, lx et lX.
18. Ici, il n’y a plus de caractère de fin de chaîne.
11
Les types structure,
union et énumération
Le langage C permet de définir ce que l’on nomme des « structures ». Un objet
de type structure est, comme un tableau, un objet de type agrégé, c’est-à-dire
constitué de la réunion d’un ensemble de valeurs. Mais à la différence du
tableau, ces valeurs ne sont pas nécessairement d’un même type. Par ailleurs, le
repérage d’une valeur à l’intérieur de la structure se fait, non plus par un indice,
mais par un nom de champ, c’est-à-dire en fait un identificateur.
Sur le plan de son utilisation, la structure s’avérera beaucoup moins
indispensable que le tableau. En effet, alors qu’une notation donnée telle que t[i]
peut désigner n’importe quel élément d’un tableau, une notation telle que
[Link] désigne obligatoirement un champ donné de la structure art. On ne
pourra donc pas parcourir les champs d’une structure à l’intérieur d’une boucle
comme on le faisait avec les éléments d’un tableau. Il n’en reste pas moins que
l’emploi de structures permettra d’accroître la clarté des programmes en
rassemblant dans un même objet des informations possédant un lien, par
exemple :
• les différentes informations associées à une personne : nom, prénom, adresse,
numéro de téléphone, date de naissance… ;
• les différentes informations associées à un point d’un plan : nom, abscisse,
ordonnée…
Par ailleurs, le langage C permet également de définir ce qu’il nomme des
unions. Il s’agit d’un moyen relativement artificiel de considérer un même objet
avec différents types. À chaque type se trouve associé, comme dans le cas d’une
structure, un nom de champ. On peut dire que, alors que les différents champs
d’une structure sont juxtaposés, ceux d’une union sont superposés. Comme on
peut s’y attendre, l’union revêt un caractère peu portable et reste destinée à des
applications assez particulières. On peut par exemple l’utiliser pour interpréter
de plusieurs façons un même motif binaire ou encore pour économiser de la
mémoire en faisant occuper à des instants différents un même emplacement par
des informations de types différents.
Malgré la différence de nature manifeste entre les structures et les unions, celles-
ci restent étroitement liées en C, notamment sur le plan de la syntaxe de leurs
déclarations et de leur utilisation : une union se déclare comme une structure en
remplaçant simplement le mot-clé struct par le mot-clé union ; un champ d’une
union (une façon d’en voir le contenu à un moment donné) s’utilise comme celui
d’une structure en faisant appel à l’opérateur «.». Ces considérations justifient
que les types structure et union soient étudiées dans le même chapitre.
Les types structure et union peuvent être considérés comme des types définis par
le programmeur, dans la mesure où ce dernier décide des caractéristiques du
type. La même remarque s’applique aux types énumération. Ces derniers ne sont
en fait que des cas particuliers du type int, destinés à faciliter la définition de
constantes symboliques. Leur présence dans ce chapitre est surtout justifiée par
leur ressemblance syntaxique avec les types structure ou union.
Après deux exemples introductifs de programmes utilisant l’un des structures,
l’autre des unions, nous ferons le point sur la déclaration de ces agrégats. Nous
verrons ensuite comment les variables de ces deux types sont organisées en
mémoire. Puis nous en examinerons les différentes possibilités d’utilisation :
manipulation des champs, affectation, opérateurs & et sizeof, comparaisons de
pointeurs sur les champs, opérateur ->, transmission de structure ou d’union en
argument ou en valeur de retour. Nous proposerons ensuite quelques exemples
d’objets classiques faisant intervenir des structures : structures contenant des
tableaux ou d’autres structures, tableaux de structures, structures contenant des
pointeurs sur des structures de son propre type. Nous montrerons comment
initialiser une structure ou une union au moment de sa déclaration. Nous
étudierons ensuite ce que l’on nomme les « champs de bits », c’est-à-dire des
champs dont la taille s’exprime en bits et non plus en octets. Enfin, nous
terminerons avec les types énumération.
1. Exemples introductifs
1.1 Exemple d’utilisation d’une structure
Voici un exemple de programme présentant intuitivement la notion de structure.
Il lit sur l’entrée standard les informations relatives à cinq points caractérisés
chacun par un nom (un seul caractère) et deux coordonnées entières. Il utilise
une variable de type structure pour y ranger les informations lues à un instant
donné, ainsi qu’un tableau de 5 de ces structures pour y conserver l’ensemble
des informations.
Exemple d’utilisation d’une structure
#include <stdio.h>
#define NP 5
int main()
{
struct point /* définition d'un type structure nomme point */
{ char nom ; /* nom de point */
int x ; /* abscisse */
int y ; /* ordonnée */
} ;
struct point p ; /* p est une variable de type structure point */
struct point courbe[NP] ; /* courbe est un tableau de NP éléments */
/* de type structure point */
int i ;
/* lecture des noms et coordonnées des différents points de la courbe */
printf ("donnez le nom et les coordonnees de %d points\n", NP) ;
for (i=0 ; i<NP ; i++)
{ scanf (" %c%d%d",&[Link], &p.x, &p.y) ; /* notez l'espace avant %c */
courbe[i] = p ; /* recopie de p dans l'élément de rang i de courbe */
}
/* affichage des informations lues */
printf ("voici les differents points fournis\n") ;
for (i=0 ; i<NP ; i++)
printf ("%c : %5d %5d\n", courbe[i].nom, courbe[i].x, courbe[i].y) ;
}
donnez le nom et les coordonnees de 5 points
g 2 5
b 5 9
s 12 7
t -1 -9
z -3 10
voici les differents points fournis
g : 2 5
b : 5 9
s : 12 7
t : -1 -9
z : -3 10
La déclaration :
struct point /* définition d'un type structure nommé point */
{ char nom ; /* nom de point */
int x ; /* abscisse */
int y ; /* ordonnée */
} ;
définit un modèle de structure nommé point, en précisant le nom et le type de
chacun de ses champs. En revanche, elle ne réserve pas de variables
correspondant à cette structure.
Nous utilisons ensuite ce modèle pour déclarer :
• une variable du type structure point nommée p :
struct point p ;
• un tableau de tels objets nommé courbe :
struct point courbe[NP] ;
L’instruction :
scanf (" %c%d%d",&[Link], &p.x, &p.y) ;
permet de lire des informations qu’on affecte à chacun des trois champs de la
variable p. Ceux-ci se notent p.x et p.y ; leurs adresses se notent donc &p.x et &p.y1.
L’affectation suivante :
courbe[i] = p ;
recopie globalement toutes les informations de p dans courbe[i], de la même
manière que si l’on avait procédé ainsi :
courbe[i].nom = [Link] ;
courbe[i].x = p.x ;
courbe[i].y = p.y ;
Notez que nous aurions pu lire directement les informations relatives à courbe[i]
sans passer par l’intermédiaire de p :
scanf (" %c%d%d", &courbe[i].nom, &courbe[i].x, &courbe[i].y) ;
comme nous le faisons d’ailleurs dans l’instruction d’écriture :
printf ("%c : %5d %5d\n", courbe[i].nom, courbe[i].x, courbe[i].y) ;
1.2 Exemple d’utilisation d’une union
Voici un exemple de programme nous permettant, dans certaines
implémentations, d’afficher le motif binaire correspondant à une valeur de type
flottant.
Exemple d’utilisation d’une union (pas nécessairement portable)
#include <stdio.h>
int main()
{
union entflot /* définition d'un type union nommé entflot */
{ long n ; /* comportant ici deux champs nommés n et x */
float x ; /* on suppose que les types long et float sont de même taille */
} ;
union entflot u ;
printf ("donnez un nombre reel : ") ;
scanf ("%f", &u.x) ;
printf ("voici son motif binaire (en hexa) : %lx", u.n) ;
}
donnez un nombre reel : 123.45e15
voici son motif binaire (en hexa) : 5bdb4a8f
La déclaration :
union entflot /* définition d'un type union nommé entflot */
{ long n ; /* comportant ici deux champs nommés n et x */
float x ;
} ;
définit un modèle de type union nommé entflot, en précisant le nom et le type de
chacun de ses champs. En revanche, elle ne réserve pas de variables
correspondant à cette structure.
Nous utilisons ensuite ce modèle pour déclarer une variable de ce type nommée
u :
union entflot u ;
Une telle variable occupera un emplacement de taille suffisante pour y introduire
soit une valeur de type long (champ n), soit une valeur de type float (champ x).
Comme on a supposé que, dans notre implémentation, ces types avaient la même
taille, le contenu de notre variable u peut être considéré :
• tantôt comme un long qu’on désigne alors par u.n ;
• tantôt comme un float qu’on désigne alors par u.x.
C’est ainsi que l’instruction :
scanf ("%f", &u.x) ;
lit une valeur au clavier et la code dans le type float en la rangeant dans l’union
u. En revanche, l’instruction :
printf ("voici son motif binaire (en hexa) : %lx", u.n) ;
considère le contenu de u comme une valeur de type long, valeur qu’elle affiche
en hexadécimal.
Remarque
Dans les implémentations où le type long est de taille supérieure au type float, on obtiendra en fait,
en plus du motif binaire attendu, des informations excédentaires aléatoires. Si, en revanche, le type
long est de taille inférieure au type float, on n’obtiendra qu’une partie du motif binaire souhaité.
2. La déclaration des structures et des unions
Tableau 11.1 : déclaration de structures et d’unions
– définit le nom du type ; la Voir
classe de mémorisation et les récapitulatif
qualifieurs sont sans de toutes
signification à ce niveau ; les
déclarations
– définit le type des différents au chapitre
champs, par des déclarations 16
Définition d’un type usuelles, sans classe de
structure ou union mémorisation et avec
(forme conseillée) d’éventuels qualifieurs (const
n’étant autorisé que pour les
objets pointés) ;
– les champs peuvent être d’un
type objet quelconque (excepté
le type en cours de définition)
ou d’un type champ de bits.
– déclaration usuelle utilisant le Voir section
nom de type structure ou union 2.2
comme spécificateur de type ;
Déclaration de
variables utilisant un – classe de mémorisation : extern,
auto (superflue), static ou
type structure ou
register (peu d’intérêt) ;
union
– qualifieurs : const ou volatile
s’appliquent à tous les champs
de la structure ou de l’union.
Déclaration sans description des Voir section
champs, surtout utile en cas de 2.3
Déclaration partielle dépendance mutuelle entre
structures ou unions
On peut mêler définition de type Voir section
Mixage et déclaration de variables, voire 2.4
définition/déclaration ne pas donner de nom au type
(type anonyme).
– noms de champ : un espace Voir section
différent pour chaque structure 2.5 et 2.6
Espaces de noms ou union ;
– noms de type : un seul espace
pour toute la portée.
2.1 Définition conseillée d’un type structure ou union
2.1.1 Généralités
Il est conseillé de séparer la définition d’un type structure ou union de la
déclaration de variables utilisant ce type, même si la norme autorise qu’on
fusionne les deux instructions. C’est ce que nous avons fait dans les exemples
introductifs de la section 1. Lorsque l’on procède ainsi, la définition d’un type
structure se fait par une déclaration telle que :
struct article { int numero, qte ; /* définition d'un type structure nommé */
float prix ; /* article et composé de trois champs */
} ; /* numero, qte et prix */
Celle d’une union se fait par une déclaration telle que :
union entflot { int n ; /* définition d'un type union nommé entflot */
long p ; /* et composé de trois champs : n, p et x */
float x ;
} ;
D’une manière générale, de telles déclarations précisent :
• le nom donné au type structure ou union, ici article ou entflot ;
• le nom et le type des différents champs à l’aide de déclarations analogues à
celles des variables. Compte tenu de leur complexité, ces déclarations font
l’objet d’une récapitulation au chapitre 16. Ici, nous nous contentons
d’examiner les contraintes ou les particularités propres aux déclarations de
champs.
2.1.2 Contraintes spécifiques à la définition de type structure ou
union
Bien que la définition d’une structure ou d’une union apparaisse,
syntaxiquement parlant, comme une déclaration, elle comporte quelques
restrictions naturelles, du moins lorsqu’on utilise la forme conseillée.
Une définition de type structure ou union ne peut jamais comporter de classe de mémorisation.
En effet, une définition d’un type structure ou union telle que :
struct article { ….. } ;
union entflot { ….. } ;
n’entraîne aucune réservation d’emplacement mémoire pour un objet, de sorte
que la notion de classe d’allocation (statique, automatique ou registre) n’a aucun
sens à ce niveau.
En toute rigueur, la syntaxe des déclarations que propose la norme ANSI
n’interdit pas explicitement l’usage du mot-clé static dans ce cas. En pratique, il
est rejeté ou ignoré par la plupart des compilateurs.
Une définition de type structure ou union ne peut pas comporter de qualifieurs (const ou volatile).
Là encore, comme une définition de structure ou d’union n’entraîne aucune
allocation mémoire, la notion de constance (mot-clé const) ou de volatilité (mot-
clé volatile) n’a pas de signification à ce niveau. Certes, les concepteurs du
langage auraient pu convenir qu’elle s’appliquerait alors à tous les objets qui
seraient déclarés ultérieurement de ce type, mais ce n’est pas le cas2. De toute
façon, on pourra toujours préciser de tels qualifieurs au moment de la déclaration
d’objets du type, comme dans :
const struct article sc ; /* sc est une structure constante de type article */
const union entflot ef ; /* ef est une union constante de type entflot */
En toute rigueur, la syntaxe des déclarations que propose la norme ANSI
n’interdit pas explicitement l’usage de qualifieurs. En pratique, il est rejeté ou
ignoré par la plupart des compilateurs.
2.1.3 Le type des champs d’une structure ou d’une union
Le langage C se révèle aussi souple pour les champs d’une structure ou d’une
union que pour les éléments d’un tableau. La seule restriction est qu’il doit s’agir
d’objets, ce qui exclut les fonctions, mais pas les pointeurs sur des fonctions. En
définitive, une structure ou une union pourront comporter des champs qui
seront :
• de n’importe quel type de base ;
• des tableaux d’éléments de type quelconque – nous en verrons un exemple à la
section 5.1 ;
• des structures – nous verrons à la section 5.2 un exemple de structure
comportant un champ de type structure ; comme on peut s’y attendre, une
structure ne peut pas comporter un champ du type qu’on est en train de
définir :
struct sbizarre { int n ;
struct bizarre sb ; /* interdit */
…..
} ;
• des unions ; là encore, comme on peut s’y attendre, une union ne peut pas
comporter un champ du type qu’on est en train de définir :
union ubizarre { int p ;
union ubizarre ub ; /* interdit */
…..
} ;
• des pointeurs sur des objets d’un type quelconque, y compris s tructure ou
union, voire cette fois, sur des structures ou unions du type qu’on est en train
de définir ; certes, une telle possibilité n’a guère d’intérêt pour les unions. En
revanche, elle permet précisément de créer des structures de données
complexes telles que des listes chaînées, des graphes, des arbres… Nous vous
proposerons un exemple de liste chaînée à la section 8 du chapitre 14 ;
• des pointeurs sur des fonctions.
En outre – et ceci est, cette fois, spécifique aux structures et aux unions –
certains champs pourront être formés de ’champs de bits, c’est-à-dire d’objets
dont la taille n’est plus un multiple de l’octet ; ce cas est étudié à la section 7.
Remarque
Lorsqu’un champ d’une structure ou d’une union est lui-même un tableau, sa dimension doit toujours
être précisée.
2.1.4 Contraintes spécifiques aux déclarations des champs
Les qualifieurs appliqués à des noms de champs
Comme on le verra à la section 2.2.3, on peut toujours imposer les qualifieurs
const ou volatile à une variable de type structure au moment de sa déclaration.
Cependant, dans ce cas, ces qualifieurs s’appliqueront à la structure dans sa
globalité c’est-à-dire à tous ses champs. En fait, il est également possible
d’introduire de tels qualifieurs au niveau de chaque champ, au moment de la
définition du type lui-même, comme dans cet exemple :
struct chose { const int x ;
int y ;
const char c ;
float z ;
}
En général, ces champs constants devront être initialisés au moment de la
déclaration des variables du type struct chose. Il existe deux exceptions :
• le champ comporte, en plus de const, le qualifieur volatile ; dans ce cas
l’initialisation est possible mais non obligatoire ;
• on a affaire à la redéclaration par extern d’une variable de type structure ; dans
ce cas, la valeur du champ a dû être fournie par ailleurs et l’initialisation à ce
niveau est interdite.
Compte tenu de la syntaxe des initialisations des structures (voir section 6), on
voit que pour initialiser une variable du type struct chose précédent, il est
nécessaire de fournir des valeurs non seulement pour les champs constants x et c,
mais aussi pour le champ y :
struct chose exple = { 3, 5.25, ‘a'} ; /* la valeur pour y est obligatoire */
/* même si ce champ n'est pas constant */
/* celle de z (ici absente) est facultative */
Remarques
1. En théorie, les champs des unions sont soumis aux mêmes règles. En pratique, cette possibilité est
peu utilisée.
2. La norme laisse la liberté à une implémentation de placer des objets constants dans un emplacement
protégé contre d’éventuelles modifications. Il est évident que cette possibilité ne peut plus
s’appliquer à une structure dont seulement certains champs seraient constants.
3. On ne confondra pas un champ constant avec un champ formé d’un pointeur sur un objet constant ;
dans ce dernier cas, l’initialisation n’est pas obligatoire.
Nom de champ et classe de mémorisation
Les déclarations des champs ne peuvent pas comporter de classe de mémorisation.
Les raisons de cette interdiction sont évidentes. Il n’est pas possible d’imaginer
une structure ou une union dont les différents champs disposeraient de classes de
mémorisation différentes. En effet, les différents objets correspondant doivent
obligatoirement former un tout en mémoire. Bien entendu, il sera possible de
préciser globalement une classe de mémorisation lors de la déclaration ultérieure
de variables utilisant le type concerné.
2.2 Déclaration de variables utilisant des types
structure ou union
2.2.1 Généralités
À partir du moment où l’on a défini un type structure ou union comme il a été dit
dans la section précédente, on peut déclarer des variables de ce type ou utilisant
ce type à l’aide d’instructions telles que :
static const struct article art1, *ada, t[10] ;
Celle-ci, comme toute déclaration de variable, associe un ou plusieurs
déclarateurs (ici art1, *ada et t[10]) à une partie commune à tous ces déclarateurs
et comportant :
• un spécificateur de type formé du nom du type structure ou union
correspondant (ici struct article) ;
• une éventuelle classe de mémorisation (ici static) ;
• un éventuel qualifieur (ici const).
Cette déclaration déclare en définitive que :
• art1 est une variable de type structure article, constante ;
• ada est un pointeur sur des objets constants de type structure article ;
• t est un tableau constant de 10 éléments de type structure article.
Les déclarations en C peuvent devenir complexes car :
• un même spécificateur de type peut être associé à des déclarateurs de nature
différente ;
• les déclarateurs peuvent se « composer » : il existe des déclarateurs de
tableaux, de pointeurs et de fonctions ;
• la présence d’un déclarateur de type donné ne renseigne pas précisément sur la
nature de l’objet déclaré. Par exemple, un pointeur sur un tableau comportera,
entre autres, un déclarateur de tableau ; ce ne sera pas pour autant un tableau.
Pour tenir compte de cette complexité et de ces dépendances mutuelles, le
chapitre 16 fait le point sur la syntaxe des déclarations, la manière de les
interpréter et la manière de les rédiger. Ici, nous examinerons néanmoins, de
manière moins formelle, les déclarations correspondant aux situations les plus
usuelles.
En C++
En C, le spécificateur de type est bien de la forme struct article et pas simplement, comme on
aurait pu l’espérer, article. En C++ cependant, les deux formes seront utilisables :
struct article art1, art2 ; /* acceptée en C ou en C++ */
article art1, art2 ; /* accepté seulement en C++ */
La même remarque s’applique aux unions.
Notez qu’on peut cependant parvenir à éliminer les mots-clés struct ou union en C en faisant appel à
typedef, par exemple de cette manière :
typedef struct article s_article ; /* s_article est synonyme de struct article */
s_article art1, art2 ; /* équivalent a struct article art1, art2 ; */
2.2.2 La classe de mémorisation
Une déclaration comportant un spécificateur de type structure peut, comme toute
déclaration de type de variable, commencer par un mot-clé nommé classe de
memorisation choisi parmi extern, static, auto ou register. La forme même des
déclarations en C fait que ce mot-clé est associé à tous les déclarateurs d’une
même déclaration, comme dans :
static struct article art1, *ada ;
Par ailleurs, dans les rares cas où ce mot-clé est présent, il sert généralement à
modifier la classe d’allocation de la variable correspondante mais ce n’est pas
toujours le cas. En particulier, l’application du mot-clé static à une variable
globale la « cache » à l’intérieur d’un fichier source, tout en la laissant de classe
statique.
D’une manière générale, le rôle et la signification de ces mots-clés dans les
différents contextes possibles sont étudiés en détail aux sections 8, 9 et 10 du
chapitre 8. Ici, nous nous contentons d’effectuer quelques commentaires à
propos de la classe registre. En théorie, la norme n’interdit pas d’appliquer le
mot-clé register à une structure, comme dans :
register struct article art ; /* art a la classe de mémorisation registre */
Mais ce mot-clé demande alors que les objets en question soient, dans la mesure
du possible, placés dans un registre. Dans une implémentation donnée, cela ne
sera possible que pour des structures de petite taille. Et encore, dans ce cas, il ne
faudra pas oublier qu’une variable ayant la classe de mémorisation register n’a
pas d’adresse précise et que, en théorie, l’opérateur & ne lui est pas applicable.
En pratique, certains compilateurs acceptent quand même l’emploi de & dans ce
cas, quitte à ne pas placer la structure en question dans un registre !
La même remarque s’applique aux unions, avec cette différence qu’en pratique,
ces dernières sont souvent de petite taille.
2.2.3 Les qualifieurs const et volatile
La signification générale des qualifieurs const, volatile, const volatile ou volatile
const a été présentée à la section 6.3 du chapitre 3. Elle se transpose facilement
aux agrégats que sont les structures ou les unions, comme nous allons le voir.
Lorsqu’un tel qualifieur précède un spécificateur de type structure ou union, il
concerne l’ensemble de ses champs. Par exemple, avec :
struct complexe { float x ; float y ; } ;
const struct complexe origine = {2.5, 3.4} ;
origine est une structure comportant deux champs flottants constants.
L’instruction suivante sera rejetée en compilation :
origine.x = 0 ; /* incorrect */
De même, avec :
const struct complexe *adc = … ; /* ne pas confondre avec : */
/* struct complexe * const adc = … */
adc est un pointeur sur une structure constante et l’instruction suivante sera
rejetée en compilation :
(*adc).x = 0 /* ou, comme on verra à la section 4.6 adc->x = 0 */
Les mêmes réflexions s’appliquent à une union.
Notez qu’en général, comme toute variable constante, une structure ou une union
constante devra être initialisée au moment de sa déclaration, puisqu’elle ne
pourra plus être modifiée ensuite. Il existe cependant deux exceptions :
• sa déclaration comporte le qualifieur volatile : elle pourra donc être modifiée
indépendamment du programme ; son initialisation n’est donc pas
indispensable mais elle reste possible ;
• il s’agit de la redéclaration d’une variable globale (par extern) ; l’initialisation a
dû être faite par ailleurs et elle est alors interdite à ce niveau.
Par ailleurs, rappelons qu’il est également possible, au moment de la définition
d’un type structure, d’imposer à certains champs d’être constants (voir section
2.1.4). On dispose donc de deux mécanismes permettant de rendre des champs
constants :
• champ par champ, lors de la définition du type structure ;
• globalement, lors de la déclaration de variables du type.
2.3 Déclaration partielle ou déclaration anticipée
La norme autorise qu’on déclare un nom de structure ou d’union sans sa
description, comme dans :
struct enreg ; /* on précise que enreg est un type structure, */
/* sans en donner la description */
Une telle déclaration est autorisée lorsque le compilateur n’a effectivement pas
besoin de la description du type correspondant. C’est notamment ce qui se
produit si, dans la portée de enreg, on a seulement besoin de pointeurs sur des
objets de type enreg. Bien entendu, dans l’ensemble du programme source, il
existera au moins une description complète de enreg.
Dans la portée de l’identificateur correspondant, il est permis de fournir la
description complète du type, par exemple :
struct enreg ; /* on précise que enreg est un type structure, */
/* sans en donner la description */
…..
struct enreg {char c ; int n ; } ; /* description du type */
On dit souvent dans ce cas que la première déclaration constitue une déclaration
anticipée du type structure enreg.
Il existe une situation où cette déclaration anticipée s’avère indispensable, à
savoir en cas de dépendances mutuelles entre deux structures (ou plus), comme
dans cet exemple :
struct machin ; /* déclaration anticipée de la structure machin */
struct chose { … /* définition (normale) de la structure chose */
/* qui contient un pointeur sur un objet de type machin */
struct machin *adm ;
…
} ;
struct machin { … /* définition (tardive) de la structure machin */
/* qui contient un pointeur sur un objet de type chose */
struct chose *adc ;
…
} ;
Bien entendu, on pourrait inverser l’ordre des définitions des structures machin et
chose ; mais une déclaration anticipée (cette fois de chose) resterait nécessaire.
2.4 Mixage entre définition et déclaration
Nous venons de voir comment C permet de dissocier clairement la définition
d’un type structure ou union et la déclaration de variables utilisant ce type.
Malheureusement, ce langage accepte que ces deux instructions soient
fusionnées. Par exemple, cette déclaration :
struct article { int numero, qte ; /* définition du type structure article */
float prix ; /* et déclaration des variables art1, */
} art1, *ada, t[10] ; /* ada et t */
joue le même rôle que les deux déclarations suivantes :
struct article { int numero, qte ;
float prix ;
} ;
struct article art1, *ada, t[10] ;
Il est également possible de procéder ainsi :
struct article { int numero, qte ; /* définition du type structure article */
float prix , /* et déclaration des variables */
} art1, *ada ; /* art1 et ada */
struct article t[10] ; /* déclaration de t */
On peut même ne pas donner de nom de type à la structure (on parle de type
« anonyme ») en procédant ainsi :
struct { int numero, qte ; /* définition d'un type structure "anonyme" */
float prix , /* et déclaration des variables art1, */
} art1, *ada, t[10] ; /* ada et t */
Bien entendu, dans ce dernier cas, il n’est plus possible d’utiliser ce type
structure anonyme dans d’autres déclarations.
D’une manière générale, nous déconseillons l’exploitation de ces différentes
possibilités qui ne peuvent que contribuer à obscurcir les programmes
correspondants, voire à compliquer leur adaptation ultérieure.
Remarques
1. Lorsque définition de type et déclaration de variables du type sont fusionnées en une seule
instruction, le mot-clé static devient autorisé et il porte alors tout naturellement sur les objets
concernés et non sur le type lui-même.
2. En C11, il est possible, dans certains cas, d’omettre à la fois le nom de structure et le nom de
variable. On parle alors de structures ou d’unions anonymes (voir annexe B consacrée aux normes
C99 et C11).
2.5 L’espace de noms des identificateurs de champs
Contrairement aux noms de variables scalaires ou tableaux, les noms de champ
n’ont de signification que lorsqu’ils sont associés à un objet (expression ou nom
de variable) du type structure (ou union) correspondant. Considérons ces
déclarations :
struct article { int numero, qte ;
float prix ;
} ;
struct article art1, art2 ;
struct fournis { int numero ; /* champ indépendant du champ numéro de article */
char *adresse ;
} ;
struct fournis f1, f2 ;
float numero ; /* variable indépendante des champs numéro de article ou fournis */
Lorsque, dans la portée de ces déclarations, l’identificateur numero sera employé
sans suffixe, il désignera la variable scalaire de type float. En revanche, pour
désigner le champ numero d’un objet de type article ou fournis, il faudra utiliser en
suffixe l’objet correspondant comme dans [Link] ou [Link].
On traduit souvent ce phénomène en disant que :
L’espace de noms des identificateurs de champs d’une structure (ou d’une union) est limité à la
structure (union) concernée.
Remarque
Cette règle ne s’appliquera pas à l’espace de noms des identificateurs définis dans une énumération,
qui suivent les mêmes règles que les identificateurs de variables usuelles (scalaires, tableaux ou
pointeurs).
2.6 L’espace de noms des identificateurs de types
Comme on peut s’y attendre, il n’est pas possible d’utiliser un même
identificateur pour représenter deux types structure différents (à l’intérieur d’une
même portée). Mais assez curieusement, on ne peut pas non plus utiliser un
même identificateur pour représenter par exemple un type structure et un type
union :
struct enreg { ….. } ;
union enreg { ….. } ; /* incorrect si l'on est dans la portée */
/* de l'identificateur enreg précédent v */
Cette fois cependant, on notera bien que cette notion d’espace de noms n’a de
signification qu’au sein d’une portée donnée. Ainsi, un identificateur de type
structure local peut cacher un nom de type structure ou union global. De même,
dans deux fichiers source différents, le même identificateur peut représenter
deux types différents : bien entendu, nous conseillons d’éviter cette situation
dans la mesure du possible, pour d’évidentes questions de clarté.
Ajoutons qu’outre les structures et les unions, il existe une troisième sorte de
définition de type, celle des énumérations ; les identificateurs correspondants
sont traités de la même manière. En définitive :
Dans une portée donnée, il n’existe qu’un seul espace de noms pour tous les identificateurs de types :
structures, unions et énumérations.
En C++
En fait, cette restriction à un seul espace de noms plutôt qu’à trois devient pleinement justifiée en C++
puisqu’alors, la distinction entre structure, union ou énumération ne se fait plus obligatoirement par
l’un des mots-clés struct, union ou enum, au moment de la déclaration.
3. Représentation en mémoire d’une structure ou
d’une union
Nous examinons ici la manière dont un objet de type structure ou union est
représenté en mémoire. Contrairement à ce qu’on pourrait penser, il ne s’agit pas
là d’un simple détail technologique : certaines des propriétés étudiées par la suite
en dépendent.
Tableau 11.2 : représentation en mémoire d’une structure ou d’une union
– contrainte tableau : un tableau d’objets de Voir
type quelconque doit apparaître comme une section
Contraintes 3.1
suite contiguë de tels objets ;
générales
– contraintes d’alignement des champs dans
certaines implémentations.
Contraintes supplémentaires : Voir
section
– champs alloués suivant l’ordre de leurs 3.2
déclarations ;
– adresse structure = adresse premier champ.
Contraintes Conséquences :
structures
– présence éventuelle d’octets de remplissage ;
– jamais d’octet de remplissage en début de
structure ;
– structure toujours implémentée suivant la
contrainte la plus forte de ses champs.
Contraintes supplémentaires : Voir
section
– adresse union = adresse de chacun de ses 3.3
champs
Conséquences :
Contraintes – pas d’octets de remplissage à proprement
unions parler ;
– la taille d’une union est égale à la taille du
plus grand de ses champs ;
– union toujours implémentée suivant la
contrainte la plus forte de ses champs.
Appliqué à une structure ou une union, fournit Voir
Opérateur la taille totale compte tenu des éventuels octets section
sizeof
d’alignement ou de remplissage. 3.4
3.1 Contraintes générales
D’une part, la norme impose qu’un tableau d’objets de type quelconque
apparaisse toujours comme une suite contiguë de ces objets. Cette règle
s’applique donc aux tableaux de structures et aux tableaux d’unions.
D’autre part, pour des questions d’efficacité, la norme autorise une
implémentation à imposer à certains types de données des contraintes sur leurs
adresses. Voici des exemples de situations usuelles :
• alignement des entiers de quatre octets sur des adresses multiples de 4, ce qui,
sur des machines à 32 bits, permet d’accéder en une fois à l’entier
correspondant ;
• alignement d’objets de 8 octets sur des adresses multiples de 8, ce qui, sur des
machines à 64 bits, permet d’accéder en une seule fois à l’objet correspondant.
Bien entendu, cette seconde contrainte, si elle existe, s’appliquera à chacun des
champs des structures et des unions3. Par ailleurs, des contraintes spécifiques
vont apparaître, comme nous allons le voir maintenant.
3.2 Cas des structures
En plus des contraintes générales précédentes, la norme impose aux objets de
type structure les deux contraintes suivantes :
• les champs doivent être alloués selon l’ordre de leurs déclarations ;
• l’adresse d’une structure doit correspondre à celle de son premier champ.
Voyons les conséquences de ces contraintes spécifiques et des contraintes
générales.
Les champs d’une structure doivent être alloués selon l’ordre de leurs
déclarations
Signalons tout d’abord que l’ordre des déclarations est le même dans les deux
déclarations suivantes :
struct essai { int n, p ;
float x, y ;
} ;
struct essai { int n ;
int p ;
float x ;
float y ;
} ;
Comme pour les tableaux, rien n’impose que l’ordre d’implémentation
corresponde aux adresses croissantes plutôt qu’aux adresses décroissantes (voir
section 4.1 du chapitre 6). Simplement, cet ordre doit être compatible avec celui
des pointeurs, ce qui revient à dire qu’avec notre exemple, la relation suivante
sera toujours vraie :
(void *)& n < (void *)& x
Utilisation éventuelle d’octets de remplissage
Pour permettre à une implémentation de satisfaire à d’éventuelles contraintes
d’alignement, la norme autorise la présence dans une structure d’octets
supplémentaires dits « octets de remplissage ». Par exemple, considérons cette
déclaration, dans une implémentation où les int sont alignés sur des adresses
paires :
struct essai { int n ;
char c ;
int p ;
} ;
Un objet de type struct essai sera implémenté suivant ce schéma qui montre la
présence d’un octet de remplissage entre les champs c et p :
Respect de la règle relative aux tableaux
Supposons que l’on ait affaire à une implémentation dans laquelle le type int
occupe 2 octets alignés sur des adresses paires et considérons la définition
suivante de la structure nommée essai :
struct essai { int n ;
char c ;
} ;
En théorie, trois octets suffisent pour représenter des objets de type essai. En fait,
il est nécessaire qu’un tel objet puisse être placé dans un tableau en respectant la
règle de contiguïté entre deux éléments successifs. Si on se limite à 3 octets, on
voit que la contrainte d’alignement des entiers (en vigueur dans
l’implémentation évoquée) n’est plus respectée pour un élément sur deux.
Pour régler ce genre de problème, il est nécessaire de prévoir un octet de
remplissage, soit entre n et c, soit après c. Les objets de type essai occuperont
donc un emplacement de 4 octets.
On notera bien que, fort heureusement, tout objet de type essai sera concerné par
cette règle, qu’il appartienne ou non à un tableau. D’ailleurs, il n’est pas toujours
possible de dire si un objet donné appartient à un tableau : il suffit de penser à un
objet pointé…
L’adresse d’une structure correspond à celle de son premier champ
Pour que cette règle soit satisfaite, on voit qu’il ne peut jamais y avoir d’octets
de remplissage en début d’une structure. Même dans l’exemple suivant, l’octet
de remplissage ne pourra jamais être situé au début, mais toujours entre c et n :
struct essai { char c ;
int n ;
}
Par ailleurs, on peut montrer que, d’une manière générale, une structure est
toujours implémentée en suivant la contrainte d’alignement la plus forte de
chacun de ses membres. Par exemple, si une structure contient des champs de
type double, dans une implémentation où les double sont implémentés à une
adresse multiple de 4, la structure sera implémentée à une adresse multiple de 4.
3.3 Cas des unions
En plus de la contrainte générale relative aux tableaux, la norme précise que,
dans un objet de type union, l’adresse de cet objet correspond à l’adresse de
chacun de ses champs. On peut d’ailleurs considérer cette règle comme une
définition de la notion d’union en C.
Rien n’impose que tous les champs d’une union soient de même taille, de sorte
que l’emplacement mémoire alloué à une union correspond au plus grand de ses
champs. Comme tous les champs d’une union ont même adresse, on voit qu’une
union doit toujours être implémentée en suivant la contrainte d’alignement la
plus forte de chacun de ses champs (il en va de même pour une structure, mais la
raison en est différente). On peut alors montrer que la règle relative aux tableaux
est alors obligatoirement satisfaite.
Dans le cas des unions, la notion d’octet de remplissage entre deux champs n’a
plus de signification puisque tous les champs ont la même adresse. Bien entendu,
cela n’exclut nullement la présence, à la suite d’un champ d’une union, d’octets
non utilisés par ce champ, mais éventuellement utilisé par un autre, ou encore la
présence d’octets de remplissage à l’intérieur d’un champ de type structure.
3.4 L’opérateur sizeof appliqué aux structures ou aux
unions
L’opérateur sizeof s’applique indifféremment à un type structure ou union, ou à
un objet d’un tel type. Il en fournit la taille en octets, compte tenu de l’éventuelle
présence d’octets de remplissage. Il présente l’avantage manifeste d’éviter
d’avoir à calculer soi-même une taille qui dépend non seulement de la taille de
chacun des champs, mais de surcroît de contraintes spécifiques à une
implémentation. En effet, comme on l’a vu à la section 3.2, avec cette structure :
struct essai { int n ;
char c ;
} ;
la relation suivante n’est pas toujours vraie :
sizeof (struct essai) == sizeof (n) + sizeof(c) /* pas toujours vrai */
On notera qu’il reste possible de déterminer le nombre d’éléments d’un tableau
de structures ou d’unions suivant la méthode générale indiquée à la section 3.4
du chapitre 6. Par exemple, avec :
struct essai tab [10] ; /* tableau de 10 éléments de type struct essai */
le nombre d’éléments de tab pourra toujours s’obtenir par :
sizeof (tab) / sizeof (tab[0])
4. Utilisation d’objets de type structure ou union
Cette section fait le point sur les différentes utilisations qu’on peut faire d’une
variable de type structure ou union.
Tableau 11.3 : utilisation d’objets de type structure ou union
Manipulation On peut appliquer à un champ n’importe Voir
individuelle de quelle opération applicable à un objet du section
champs type du champ. 4.1
Possible entre structures ou unions de même Voir
Affectation
type, c’est-à-dire ayant le même nom de section
globale
structure. 4.2
Fournit l’adresse de la structure ou de Voir
Opérateur & l’union correspondante, sous forme d’un section
pointeur constant de type correspondant. 4.3
– comparaison d’ordre (<, <=, >, >=) possible Voir
pour des champs d’un même objet (si section
Comparaison 4.4
cette condition n’est pas vérifiée →
de pointeurs
résultat indéfini) et de même type (sinon,
sur des
erreur de compilation) ;
champs
– comparaison d’égalité (==, !=) toujours
possible pour des champs de même type.
Impossible par == ou != sur l’ensemble de la Voir
Comparaison
structure ou de l’union → procéder champ section
globale
par champ 4.5
Simplification de notation : Voir
Opérateur -> adr -> p (*adr).p section
4.6
Transmission La structure est bien transmise par valeur. Il Voir
en argument reste toujours possible de transmettre un section
ou en retour pointeur. 4.7
4.1 Manipulation individuelle des différents champs
d’une structure ou d’une union
4.1.1 Cas des structures
Comme on peut s’y attendre et de façon analogue à ce qui se passe pour les
tableaux, on peut appliquer individuellement à chaque champ d’un objet de type
structure n’importe quelle opération applicable à un objet ayant le type de ce
champ. La sélection d’un champ particulier se fait en utilisant l’opérateur .. Par
exemple, avec :
struct article { int numero, qte ;
float prix ;
} ;
struct article art1 ;
struct article * ada ;
le champ numero de la structure art1 se note simplement :
[Link]
Le champ numero de la structure pointée par ada se notera :
(*ada).numero /* ou encore, comme on le verra à la section 4.6 : ada->numero
*/
Si l’on souhaite lire sur l’entrée standard des valeurs pour les différents champs
de art1, on pourra procéder ainsi :
scanf ("…..", &[Link], &[Link], &[Link]) ;
On notera que les priorités relatives des opérateurs & et . évitent l’emploi de
parenthèses, lesquelles seraient cependant acceptées, comme dans &([Link]).
En revanche, on notera bien que la notation (&art1).numero n’aurait pas de
signification, elle serait rejetée en compilation.
Voici un autre exemple. Pour incrémenter de un la valeur du champ qte de art1,
on pourra écrire :
[Link]++
Là encore, les priorités relatives des opérateurs ++ et . évitent l’emploi de
parenthèses.
4.1.2 Cas des unions
Les différents champs d’une union se manipulent comme ceux d’une structure.
Par exemple, avec :
union entflot { int n ;
long p ;
float x ;
} ;
union entflot ue ;
union entflot * adu ;
le champ p de ue se notera simplement ue.p ; le champ p de l’union pointée par adu
se notera (*adu).p (ou, comme on le verra à la section 4.6, adu->p).
On ne perdra cependant pas de vue que les champs d’une union sont, par
définition, superposés, alors que ceux d’une structure sont juxtaposés. Dans ces
conditions, une instruction telle que la suivante n’aura généralement aucun
intérêt :
scanf ("…..", &ue.n, &ue.p, &ue.x) ;
4.2 Affectation globale entre structures ou unions de
même type
4.2.1 Cas des structures
Il est possible d’affecter globalement à un objet de type structure la valeur d’un
autre objet de même type. Par exemple, avec :
struct article { int numero, qte ;
float prix ,
} ;
struct article art1, art2, art3, t_art[10], *ada ;
on pourra écrire :
art1 = art2 ; /* recopie tous les champs de art2 dans ceux de art1 */
Cette affectation remplace avantageusement la suite d’affectations :
[Link] = [Link] ;
[Link] = [Link] ;
[Link] = [Link] ;
De même, on pourra écrire :
t_art[i] = art2 ; /* recopie tous les champs de art2 dans ceux */
/* de l'élément de rang i de t_art */
*ada = art1 ; /* recopie tous les champs de art1 dans */
/* l'objet pointé par ada */
Comme l’opérateur d’affectation fournit en résultat la valeur de son opérande de
gauche, on peut même écrire :
art3 = art2 = art1 ; /* recopie tous les champs de art1 dans art2 et dans art3 */
Toutefois, ces affectations globales ne sont permises que si les objets en question
ont été définis avec le même nom de type. Autrement dit, avec :
struct article1 { ….. } ;
struct article2 { ….. } ;
struct article1 artic1 ;
struct article2 artic2 ;
l’affectation suivante sera illégale, même si les définitions des structures article1
et article2 sont identiques (mêmes noms et mêmes types de champs) :
artic1 = artic2 ; /* incorrect */
En toute rigueur, il existe un cas où, bien que le type n’ait pas de nom,
l’affectation reste possible, à savoir celui (déconseillé) où les variables ont été
déclarées avec le même type anonyme, comme dans :
struct { ….. } artic1, artic2 ;
La norme n’impose rien quant à la façon dont une implémentation donnée doit
effectivement réaliser une affectation globale entre structures. En fait, pour
d’évidentes questions d’efficacité, la recopie porte généralement sur l’intégralité
de l’objet, c’est-à-dire éventuellement sur les octets de remplissage. En pratique,
cela n’apporte généralement aucune gêne.
4.2.2 Cas des unions
Les mêmes possibilités s’appliquent théoriquement aux unions. Par exemple,
avec :
union entflot { int n ;
long p ;
float x ;
} ;
union entflot u1, u2 ;
on peut effectivement écrire :
u1 = u2 ;
Ici, comme tous les champs du type entflot ont même adresse, cette affectation
pourrait généralement être remplacée par une seule des affectations suivantes :
u1.n = u2.n ;
u1.p = u2.p ;
u1.x = u2.x ;
Néanmoins, cette affectation globale peut s’avérer intéressante :
• lorsque l’union concernée comporte des champs qui sont des tableaux ;
• lorsqu’on désire affecter une union à une autre, sans avoir à se préoccuper du
type de l’information qu’on y a placée.
4.3 L’opérateur & appliqué aux structures ou aux
unions
L’opérateur & est applicable à un objet de type structure. Il fournit un pointeur
constant dont la valeur correspond à l’adresse de son premier champ. Dans le cas
d’une union, cette valeur correspond à l’adresse de tous ses champs. On notera
cependant que les types correspondants sont différents. Par exemple, avec :
struct article { int numero, qte ;
float prix ,
} ;
struct article art ;
la simple comparaison suivante serait illégale, les deux pointeurs considérés
n’étant pas de même type4 :
&art == &[Link] /* incorrect */
En revanche, celle-ci serait légale :
(void *) &art == (void *) &[Link] /* correct et vrai */
4.4 Comparaison entre pointeurs sur des champs
Comme l’indique la section 8.2 du chapitre 7, les opérateurs == et != s’appliquent
à n’importe quels pointeurs, pour peu qu’ils soient de même type5. Cela reste
valable pour les champs de structures ou d’unions. On notera que la comparaison
de pointeurs sur des champs de types différents reste possible moyennant
l’utilisation de l’opérateur de cast.
En ce qui concerne les comparaisons basées sur une relation d’ordre (<, <=, >, >=),
la norme autorise qu’on les applique à des champs d’une même structure. Il est
cependant nécessaire que les pointeurs soient de même type6, donc que les
champs pointés soient de même type. Dans le cas contraire, on aboutira à une
erreur de compilation. Là encore, la comparaison de pointeurs sur des champs de
type différent reste possible moyennant l’utilisation de l’opérateur de cast. On
notera bien que la condition d’appartenance à une même structure n’est
généralement pas vérifiable à la compilation. Lors de l’exécution, on aboutira
alors simplement à un résultat indéfini (et pas à un comportement indéterminé).
En pratique, tout se passe comme si on comparait les adresses correspondantes,
en tenant simplement compte de ce que l’ordre des pointeurs peut être opposé à
celui des adresses (voir section 3.3 du chapitre 7).
D’une manière générale, ces comparaisons d’ordre présentent peu d’intérêt dans
la mesure où peu de programmes sont sensibles à l’ordre d’implémentation des
différents champs en mémoire.
4.5 Comparaison des structures ou des unions par ==
ou != impossible
Avec :
struct article { int numero, qte ;
float prix ,
} ;
struct article art1, art2 ;
les comparaisons suivantes ne sont pas légales :
art1 == art2 /* illégal */
art1 != art2 /* illégal */
Cette interdiction résulte non pas d’une impossibilité, mais simplement d’un
problème d’efficacité, dans la mesure où il aurait fallu ignorer les éventuels
octets de remplissage des structures concernées.
Il en va de même pour les unions, avec une raison quelque peu différente ; cette
fois, il n’y a plus, à proprement parler, d’octets de remplissage. Mais comme la
taille de l’information figurant dans une union peut varier suivant son type, il
faudrait connaître ce type pour effectuer convenablement la comparaison…
4.6 L’opérateur ->
Introduisons cet opérateur sur un exemple avant de le définir de manière
générale. Considérons ces déclarations :
struct article { int numero, qte ;
float prix ,
} ;
struct article *ada ;
L’expression *ada désigne naturellement l’objet pointé par ada. On peut accéder à
chacun des champs de cet objet en utilisant l’opérateur.. Par exemple, la lvalue
désignant son champ numero serait :
(*ada).numero /* champ numero de l'objet de type article pointé par ada */
On notera que les parenthèses sont ici indispensables, compte tenu des priorités
relatives des opérateurs * et ..
Le langage C a prévu un opérateur supplémentaire noté -> permettant d’alléger
quelque peu l’écriture. En effet, l’expression précédente peut également se
noter :
ada -> numero /* champ numero de l'objet de type article pointé par ada */
D’une manière générale, l’opérateur ->, qui en définitive n’apporte aucune
nouvelle possibilité par rapport à celles offertes par les opérateurs * et ., se
définit ainsi :
L’opérateur ->
pointeur -> nom_de_champ (*pointeur).nom_de_champ
pointeur
Pointeur sur un objet de type structure ou
union
Nom_de_champ
Identificateur d’un champ du type structure
ou union concerné
4.7 Structure ou union transmise en argument ou en
valeur de retour
En langage C, la transmission des arguments se fait toujours par valeur, ce qui
implique une recopie de l’information transmise à la fonction. Il en va donc ainsi
pour les structures et les unions. La norme a même introduit la possibilité pour
une fonction de fournir un résultat qui soit une structure ou une union.
Bien entendu, indépendamment de ces transmissions par valeur, il reste toujours
possible de transmettre à une fonction la valeur d’un pointeur sur une structure
ou d’une union, auquel cas la fonction peut éventuellement modifier la valeur de
l’objet pointé.
Voyons cela plus en détail sur quelques exemples que nous limiterons aux
structures, avant d’apporter quelques commentaires.
4.7.1 Exemple de transmission de la valeur d’une structure
Aucun problème particulier ne se pose. Il s’agit simplement d’appliquer ce qui a
été vu dans les paragraphes précédents. Voici un exemple simple :
Transmission en argument de la valeur d’une structure
#include <stdio.h>
struct enreg { int a ;
float b ;
} ;
int main()
{ struct enreg x ;
void fct (struct enreg) ;
x.a = 1; x.b = 12.5;
printf ("\navant appel fct : %d %e",x.a,x.b);
fct (x) ;
printf ("\nau retour dans main : %d %e", x.a, x.b);
}
void fct (struct enreg s)
{ s.a = 0; s.b=1;
printf ("\ndans fct : %d %e", s.a, s.b);
}
avant appel fct : 1 1.25000e+01
dans fct : 0 1.00000e+00
au retour dans main : 1 1.25000e+01
Naturellement, les valeurs de la structure x sont recopiées localement dans la
fonction fct lors de son appel. Les modifications de s au sein de fct n’ont aucune
incidence sur les valeurs de x. Si l’on souhaite qu’une fonction puisse modifier
les valeurs d’une structure, il faudra lui en fournir l’adresse, comme dans
l’exemple suivant.
4.7.2 Exemple de transmission de l’adresse d’une structure
Cherchons à modifier notre précédent programme pour que la fonction fct
reçoive effectivement l’adresse d’une structure et non plus sa valeur. L’appel de
fct devra donc se présenter sous la forme :
fct (&x) ;
Cela signifie que son en-tête sera de la forme suivante :
void fct (struct enreg *ads) ;
Comme cela a été dit à la section 4.6, on dispose, pour accéder, au sein de la
définition de fct, aux différents champs de la structure d’adresse ads, de deux
notations équivalentes : par exemple (*ads).a ou ads->a pour le champ a. Voici ce
que pourrait devenir notre exemple en employant l’opérateur -> :
Transmission en argument de l’adresse d’une structure
#include <stdio.h>
struct enreg { int a ;
float b ;
} ;
int main()
{
struct enreg x ;
void fct (struct enreg *) ;
x.a = 1; x.b = 12.5;
printf ("\navant appel fct : %d %e",x.a,x.b);
fct (&x) ;
printf ("\nau retour dans main : %d %e", x.a, x.b);
}
void fct (struct enreg * ads)
{ ads->a = 0 ; ads->b = 1;
printf ("\ndans fct : %d %e", ads->a, ads->b);
}
avant appel fct : 1 1.25000e+01
dans fct : 0 1.00000e+00
au retour dans main : 0 1.00000e+00
4.7.3 Exemple de transmission d’une structure en retour d’une
fonction
Comme le signale l’introduction de la section 4.7, la norme a introduit la
possibilité pour une fonction de renvoyer un résultat de type structure. Ce point
est assez particulier, dans la mesure où :
• il peut paraître peu naturel qu’une fonction fournisse un résultat qui ne soit pas
de type scalaire ;
• une fois de plus, la chose est possible pour une structure, alors qu’elle ne l’est
pas pour un tableau.
Voici un exemple d’une fonction qui, à partir des coordonnées d’un point d’un
plan fournit en résultat un point symétrique par rapport à l’origine :
Exemple de transmission de la valeur d’une structure en retour d’une fonction
#include <stdio.h>
struct point { int x, y ; } ;
int main()
{ struct point a ;
struct point b ;
struct point sym (struct point) ; /* prototype de la fonction sym */
a.x = 2 ; a.y = 5 ;
b = sym (a) ;
printf ("coordonnees de b : %d %d", b.x, b.y) ;
}
struct point sym (struct point p)
{ struct point sp ; /* point local a sym */
sp.x = -p.x ;
sp.y = -p.y ;
return sp ;
}
coordonnees de b : -2 -5
Notez bien que sp a dû être créée localement par la fonction.
4.7.4 Commentaires
Tout d’abord, la transmission de la valeur d’une structure en argument ou en
valeur de retour peut s’avérer d’autant plus pénalisante que cette dernière est de
taille importante, aussi bien en temps de recopie qu’en place occupée en
mémoire automatique. Il arrive parfois que pour gagner du temps d’exécution ou
pour pallier des limitations de la pile, on transmette l’adresse d’une structure à
une fonction, bien que cette dernière ne la modifie pas. On notera que, dans ce
cas, on peut se protéger de certaines étourderies dans l’écriture de la fonction en
utilisant le qualifieur const pour la structure pointée, comme dans ce prototype :
void fct (const struct enreg *) ; /* une tentative de modification de enreg */
/* sera en principe rejetée en compilation */
Les mêmes remarques s’appliquent à une union mais avec un peu moins
d’acuité, dans la mesure où une union est souvent de petite taille. Pour qu’il en
soit autrement, il faut qu’au moins l’un de ses champs soit une structure ou un
tableau.
Par ailleurs, une fonction peut toujours fournir en résultat un pointeur sur une
structure ou une union. Toutefois, il ne faudra pas oublier qu’alors la structure ou
l’union en question ne peut plus être locale à la fonction. En effet, elle
n’existerait plus dès l’achèvement de la fonction, alors que le pointeur
continuerait à pointer sur quelque chose d’inexistant…
Rappelons qu’un tableau ne peut être transmis par valeur ni en argument, ni en
valeur de retour. Cependant, on peut parvenir artificiellement à un résultat
semblable en faisant du tableau un champ unique d’une structure (ou d’une
union, ce qui revient alors exactement au même) :
struct tab_art { int t[10] ; } ;
tab_art ta ;
On notera que les éléments d’un tel tableau se noteront de façon peu naturelle,
par exemple ta.t[i]. En outre, la dimension du tableau devra obligatoirement être
constante, de sorte qu’il sera impossible alors à une fonction de travailler par ce
biais sur un tableau de dimension variable.
En C++
Les possibilités de transmission de la valeur d’une structure en valeur de retour s’avéreront
indispensables en C++, dans les situations de surdéfinition d’opérateur.
5. Exemples d’objets utilisant des structures
Les champs d’une structure peuvent être d’un type absolument quelconque :
pointeur (y compris pointeur sur la structure elle-même), tableau, structure (d’un
type différent). De même, un tableau peut être constitué d’éléments qui sont eux-
mêmes des structures. Nous examinons ici les situations les plus répandues :
• structure comportant des champs de type tableau ;
• structure comportant des champs qui sont eux-mêmes de type structure ;
• tableaux de structures ;
• structure comportant des pointeurs sur elle-même.
5.1 Structures comportant des tableaux
Soit les déclarations :
struct personne { char nom[30] ;
char prenom [20] ;
float heures [31] ;
} ;
struct personne employe, courant ;
Elles réservent les emplacements pour deux structures nommées employe et courant.
Ces dernières comportent trois champs :
• nom qui est un tableau de 30 caractères ;
• prenom qui est un tableau de 20 caractères ;
• heures qui est un tableau de 31 flottants.
De telles variables pourraient servir par exemple à conserver, pour un employé
d’une entreprise, le nom, le prénom et le nombre d’heures de travail effectuées à
chacun des jours du mois courant.
La notation :
[Link][4]
désigne le cinquième élément du tableau heures de la structure employe. Il s’agit
d’un élément de type float. Notez que, malgré les priorités identiques des
opérateurs . et [], leur associativité de gauche à droite évite l’emploi de
parenthèses.
De même :
[Link][0]
représente le premier caractère du champ nom de la structure employe.
Par ailleurs,
&[Link][4]
représente l’adresse du cinquième élément du tableau heures de la structure
courant. Notez que la priorité de l’opérateur & étant inférieure à celle des deux
autres, les parenthèses ne sont, là encore, pas nécessaires.
Enfin :
[Link]
représente le champ nom de la structure courant, c’est-à-dire plus précisément
l’adresse de ce tableau.
À titre indicatif, voici un exemple d’initialisation d’une structure de type personne
lors de sa déclaration (les initialisations de structures sont étudiées à la section
6) :
struct personne emp = { "Dupont", "Jules", { 8, 7, 8, 6, 8, 0, 0, 8} }
5.2 Structures comportant d’autres structures
Supposez que nous ayons besoin d’introduire deux dates à l’intérieur des
structures employe et courant définies dans la section précédente : la date
d’embauche et la date d’entrée dans le dernier poste occupé. Si ces dates sont
elles-mêmes des structures comportant trois champs correspondant au jour, au
mois et à l’année, nous pouvons alors procéder aux déclarations suivantes (la
seconde faisant intervenir un type structure préalablement défini) :
struct date
{ int jour ;
int mois ;
int annee ;
} ;
struct personne
{ char nom[30] ;
char prenom[20] ;
float heures [31] ;
struct date date_embauche ;
struct date date_poste ;
} ;
struct personne employe, courant ;
La notation :
employe.date_embauche.annee
représente l’année d’embauche correspondant à la structure employe. Il s’agit
d’une valeur de type int. La notation :
courant.date_embauche
représente la date d’embauche relative à la structure courant. Il s’agit cette fois
d’une structure de type date. Elle pourra éventuellement faire l’objet
d’affectations globales comme dans :
courant.date_embauche = employe.date_poste ;
5.3 Tableaux de structures
Condisérons ces déclarations, analogues à celles utilisées dans le programme
introduisant la notion de structure à la section 1.1 :
struct point { char nom ;
int x ;
int y ;
} ;
struct point courbe [50] ;
La structure point peut servir à représenter un point d’un plan, défini par son nom
(caractère) et ses deux coordonnées. Le tableau courbe, quant à lui, peut servir à
représenter un ensemble de 50 points du type ainsi défini. Si i est un entier, la
notation :
courbe[i].nom
représente le nom du point de rang i du tableau courbe. Il s’agit donc d’une valeur
de type char. Notez bien que la notation :
[Link][i]
n’aurait pas de sens. De même, la notation :
courbe[i].x
désigne la valeur du champ x de l’élément de rang i du tableau courbe.
Par ailleurs,
courbe[4]
représente la structure de type point correspondant au cinquième élément du
tableau courbe.
Enfin, courbe est un identificateur de tableau, et, comme tel, désigne son adresse
de début.
Là encore, voici à titre indicatif un exemple d’initialisation (partielle) de notre
variable courbe, lors de sa déclaration (les initialisations de structures sont
décrites à la section 6) :
struct point courbe[50]= { {‘A', 10, 25}, {‘M', 12, 28}, {‘P', 18,2} }
5.4 Structure comportant des pointeurs sur des
structures de son propre type
D’une manière générale, en langage C, un identificateur ne peut être utilisé
qu’après avoir été déclaré. Cette règle semble interdire la déclaration suivante :
struct element { int num ;
float x ;
float y ;
struct element * suivant ;
} ;
En fait, la portée d’un identificateur de type comme element débute non pas à la
fin de la déclaration, mais dès l’ouverture de l’accolade ouvrante introduisant la
description du type. La déclaration précédente est donc correcte (heureusement)
et, d’ailleurs, équivalente à :
struct element ; /* déclaration anticipée */
struct element { int num ;
float x ;
float y ;
struct element * suivant ;
} ;
On trouvera un exemple d’utilisation d’une telle possibilité à la section 8.3 du
chapitre 14.
6. Initialisation de structures ou d’unions
Comme toute variable, une structure ou une union de classe statique est
initialisée par défaut avec des valeurs nulles. Par ailleurs, quelle que soit sa
classe d’allocation, une structure ou une union peut toujours être initialisée
explicitement au moment de sa déclaration.
Tableau 11.4 : initialisation des structures ou des unions
– classe automatique (ou registre) : Voir section
aucune initialisation → valeurs 6.1
imprévisibles ;
Initialisation
implicite – classe statique → valeurs nulles dans
les éléments terminaux des structures
et dans le premier élément des
unions.
– soit en utilisant une syntaxe – exemples à la
appropriée d’initialiseur de la forme section 6.2.1
{…..} qui fournit les éléments
– syntaxe
terminaux sous forme d’expressions initialiseur à
Initialisation constantes (quelle que soit la classe la section
explicite d’allocation) ; 6.2.2
– soit, dans le cas de la classe
automatique ou registre, avec une
expression quelconque de même
type.
6.1 Initialisation par défaut des structures ou des
unions
Les structures et les unions sont soumises aux règles d’initialisation s’appliquant
à tous les types de variables.
Les structures ou les unions de classe automatique ou registre ne sont pas
initialisées : cela revient à dire que leur valeur est imprévisible. On ne perdra pas
de vue que les structures déclarées dans la fonction main sont de classe
automatique, même si leur allocation et leur initialisation n’ont lieu qu’une seule
fois, à l’entrée dans la fonction main.
Les structures ou les unions de classe statique voient leurs éléments initialisés
avec des valeurs nulles. Une telle valeur nulle ne doit pas être systématiquement
assimilée à une mise à zéro de tous les octets de la variable. En effet, il s’agit
bien :
• d’un entier nul pour les types entiers (caractères inclus) ; dans ce cas précis, il
s’agit bien d’octets à zéro ;
• d’un flottant nul pour les types float, double ou long double ; dans ce cas, sur la
plupart des machines, le motif binaire correspondant n’est pas formé d’octets
tous nuls ;
• d’un pointeur nul (NULL) ; sa représentation binaire exacte dépend de
l’implémentation.
Lorsqu’un champ d’une structure est lui-même une structure, ce sont les champs
de cette dernière qui sont initialisés à zéro. Lorsqu’un champ d’une structure est
un tableau, ce sont les éléments de ce dernier qui sont initialisés à zéro. Le
processus est totalement récursif, de sorte que, dans tous les cas de composition,
aussi compliqués soient-ils, on finit toujours par aboutir à des éléments
« terminaux » de type scalaire (numérique ou pointeur).
Dans le cas des unions, cette initialisation à zéro ne concerne que le premier
champ, ce qui n’est pas nécessairement satisfaisant puisque :
• une valeur nulle pour un champ ne correspond pas nécessairement à une valeur
nulle pour un autre champ ;
• si l’union comporte des champs de taille supérieure à ce premier champ,
certains de ses octets ne seront pas initialisés.
6.2 Initialisation explicite des structures
Comme n’importe quelle variable d’un type de base ou comme un tableau, une
structure peut être initialisée explicitement au moment de sa déclaration. Mais
ces possibilités dépendent en partie de la classe d’allocation.
Plus précisément, comme pour les tableaux, il existe une syntaxe particulière
d’initialiseur de la forme {…..}, qui fournit des valeurs constantes pour les
éléments terminaux et qui est utilisable quelle que soit la classe d’allocation.
Nous commencerons par en examiner quelques exemples avant d’en préciser les
règles générales.
Nous verrons ensuite que les structures de classe automatique ou registre
peuvent également être initialisées par une expression (plus nécessairement
constante) de même type. Une telle possibilité n’existait pas pour les tableaux
(mais il n’existe pas d’expression de type tableau !).
6.2.1 Exemples usuels d’initialisation explicite de structures
Exemple simple
Ayant ainsi défini le type article :
struct article { int numero, qte ;
float prix ;
} ;
On peut déclarer en l’initialisant une variable de type article en procédant ainsi :
struct article art1 = { 100, 285, 295.5 } ;
Cette instruction place respectivement les valeurs 100, 285 et 295.5 dans les
champs numero, qte et prix de la variable art1.
Il est possible de ne mentionner dans les accolades que les valeurs des premiers
champs, comme dans ces exemples :
struct article art2 = { 25 } ; /* numero 25, qte et prix non initialisés */
struct article art3 = { 50, 15} ; /* numero 50, qte 15, prix non initialisé */
Les valeurs manquantes seront, suivant la classe d’allocation du tableau,
initialisées à zéro (classe statique) ou aléatoires (classe automatique).
Exemples d’initialisation d’un tableau de structures
Ayant ainsi défini le type article :
struct article { int numero, qte ;
float prix ;
} ;
il est possible de déclarer en l’initialisant un tableau de trois éléments de type
article, en utilisant indifféremment l’une des deux déclarations suivantes :
struct article t_art[3] = { { 5, 12, 12.95},
{ 9, 25, 36.75},
{ 15, 123, 99.50}
} ;
struct article t_art[3] = { 5, 12, 12.95,
9, 25, 36.75,
15, 123, 99.50
} ;
La première forme englobe entre accolades chacun des initialiseurs des trois
structures éléments du tableau. Nous déconseillons l’emploi de la seconde
forme, qui exploite une possibilité de la norme que nous présenterons de façon
précise à la section 6.2.2.
Les dernières valeurs du tableau et de la structure peuvent être omises, comme
dans ces exemples :
struct article t_art[3] = { { 5, 12}, /* structure t_art[0] : numero=5, qte=12 */
{ 15} /* structure t_art[1] : numero=12 */
} ;
struct article t_art[3] = { 5, 15, 12.95, 123 } ;
/* structure t_art[0] : numero=5, qte=15, prix=12.95 */
/* structure t_art[1] : numero=123 */
6.2.2 Règles d’écriture d’une liste d’initialisation de structure
Voici les règles générales qui président à l’écriture d’une liste d’initialisation de
structure et à l’attribution des différentes valeurs aux différents éléments :
1. La liste d’initialisation d’une structure se place entre accolades ({}). Celles-ci
peuvent théoriquement être omises dans le cas où la structure est un élément
d’un tableau ou elle-même membre d’une structure.
2. Il ne doit pas y avoir plus d’éléments qu’on en attend au niveau
correspondant. Par exemple, la déclaration suivante entraînera une erreur de
compilation :
struct article art = {4, 3, 8.25, 12} ; /* incorrect : trop de valeurs pour art
*/
Il en irait de même pour :
struct article t[3] = { {1, 2}, {3, 4, 5.2, 12}, {6} } ;
/* incorrect : trop de valeurs pour t[1] */
Cette dernière déclaration ne devra pas être confondue avec la suivante, qui
comporte les mêmes valeurs mais qui les répartit différemment :
struct article t[3] = { 1, 2, 3, 4, 5.2, 12, 6 } ;
3. Si l’initialiseur pour une structure qui est elle-même membre d’une autre
structure (ou élément d’un tableau) ne comporte pas d’accolades, on utilise
les valeurs présentes jusqu’à la prochaine accolade ouvrante ou fermante.
Dans ce cas (contrairement à ce qui se passe en présence d’accolades), il ne
doit pas manquer de valeurs. En revanche, s’il reste des valeurs, on les utilise
pour le membre (ou l’élément) suivant. C’est bien cette règle que nous avons
utilisée dans le dernier exemple de la section précédente. Mais elle peut
prendre une allure moins triviale. Voici des exemples corrects7 :
struct article t_art1[3] = { { 5, 12, 12.95 }, 9, 25, 15, 123 } ;
struct article t_art2[3] = { { 5, 12, 12.95 }, {9, 25}, { 15, 123 } } ;
struct article t_art3[3] = { 5, 12, 12.95, {9, 25} , 15, 123 } ;
struct article t_art4[3] = { 5, 12, 12.95, 9, 25, 15, 123 } ;
Et voici d’autres exemples qui, bien que proches des précédents, sont
incorrects :
struct article t_art5[3] = { 5, 12, { 9, 25}, 15 } ;
/* incorrect car l'initialiseur pour t_art[0] */
/* se termine à la rencontre de { et est incomplet */
struct article t_art6[3] = { 5, 12, 12.95 , 12, {9, 25}} ;
/* incorrect à cause de l'initialiseur de t_art6[1] */
D’une manière générale, nous recommandons l’usage systématique des
accolades pour tous les niveaux.
6.2.3 Les valeurs terminales de la liste d’initialisation d’une
structure
Les valeurs terminales utilisées dans la liste d’initialisation d’une structure (qui
sont donc toujours de type scalaire) doivent être des expressions constantes, d’un
type acceptable par affectation avec le type de l’élément à initialiser. Cette
déclaration est correcte :
struct article { int numero, qte ;
float prix ;
} ;
struct article art = { 4.25, 12.8, 5 } ; /* équivalent à { 4, 12, 5 } */
Les expressions constantes scalaires sont décrites en détail à la section 14 du
chapitre 4 et classées en expressions constantes entières, arithmétiques et
pointeur. Ici, il s’agit des expressions constantes :
• numériques pour tous les types de base, puisque tout type numérique peut être
affecté à un élément d’un type numérique quelconque ;
• pointeur pour les éléments de ce type ; on notera que, dans ce dernier cas, il
n’existe que peu de conversions légales ; si nécessaire, l’opérateur de cast peut
être utilisé.
On notera qu’il est possible d’utiliser des symboles définis par #define :
#define QTE 10
#define BASE 5.25
…
struct article art1 = { 12, QTE, 5*BASE } ;
struct article art2 = { 15, 2*QTE, 15*BASE } ;
En revanche, on n’oubliera pas que les variables déclarées avec le qualifieur
ne peuvent pas intervenir en C dans des expressions constantes (elles le
const
pourront en C++). Ainsi, les déclarations précédentes de art1 et art2 ne seraient
pas correctes si, par exemple, QTE avait été définie par :
const int QTE = 10 ;
Remarque
Il est logique que la norme impose aux valeurs terminales d’être connues à la compilation dans le cas
de structures de classe statique ; en revanche, la contrainte n’est pas justifiée dans le cas des structures
automatiques.
6.2.4 Initialisation d’une structure par une expression
Une structure de classe automatique ou registre peut être initialisée par une
expression de même type, c’est-à-dire finalement par une structure de même
type.
En voici un exemple :
struct article {…..} ;
struct article art ;
void fct (struct article s)
{ struct article art1 = s ;
struct article art2 = art1 ;
…..
}
Lors de l’entrée dans la fonction fct, les structures art1 et art2 se verront
initialisées avec les valeurs respectives de s (ici, structure globale) et de art1 (ici,
structure locale).
En revanche, l’initialisation d’une structure de classe statique par une autre
structure de même type n’est pas possible, même en utilisant comme initialiseur
une structure ayant reçu le qualifieur const (ce sera possible en C++).
struct article {…..} ;
const struct article art = {…..} ;
struct article art1 = art ; /* on suppose être à un niveau global */
/* à art1 est de classe statique */
/* l'instruction est incorrecte */
Remarque
L’initialisation d’une structure par une expression n’est possible que si celle-ci n’appartient pas elle-
même à un agrégat.
6.3 L’initialisation explicite d’une union
L’initialisation d’une variable de type union peut se faire au moment de sa
déclaration. Mais, comme l’initialisation implicite, elle ne peut porter que sur le
premier champ de l’union, ce qui en limite souvent l’intérêt. En voici un
exemple :
union entflot { int n ;
long p ;
float x ;
} ;
union entflot ue = { 253 } ; /* comme si on faisait ue.n = 253 ; */
D’une manière générale, l’initialiseur d’une union doit être placé entre accolades
(même s’il s’agit d’une valeur scalaire). Comme pour les structures, les valeurs
terminales doivent toujours être des expressions constantes d’un type acceptable
par affectation. Cette règle s’applique même au cas où le premier champ est
scalaire :
void fct (int p)
{ union entflot ue = {p} ; /* interdit, p n'est pas une expression constante */
…..
}
Enfin, une union de classe automatique peut être initialisée par une autre union
de même type, par exemple :
void f (union entflot u1)
{ union entflot u2 = u1 ; /* correct */
…..
}
7. Les champs de bits
7.1 Introduction
Un membre d’une structure (ou d’une union) peut être ce qu’on nomme un
« champ de bits », c’est-à-dire une suite de bits en nombre quelconque. En
général, une telle possibilité s’avère intéressante lorsqu’une même structure
(union) comporte plusieurs champs de bits consécutifs. Elle permet en effet :
• soit de compacter une information ; par exemple, sur un emplacement de 16
bits, on pourra ranger 4 petits nombres entiers compris entre 0 et 15 ;
• soit, surtout, de forcer une structure de données à correspondre à une
représentation imposée par l’implémentation, par exemple pour analyser un
« mot d’état » fourni par un périphérique spécialisé ou pour lui envoyer un
« mot de commande » en temps réel…
Par essence même, l’emploi des champs de bits n’est pas portable et il n’est donc
pas surprenant que la norme laisse beaucoup de latitude à l’implémentation
quant aux contraintes qui pèsent sur eux.
7.2 Exemples introductifs
Considérons cet exemple de déclaration :
struct etat /* définition d'une structure */
{ unsigned int pret : 1 ; /* formée de 6 champs de bits */
unsigned int ok1 : 1 ;
signed int donnee1 : 5 ;
int : 3 ; /* champ "anonyme" - inutilisé */
unsigned int ok2 : 1 ;
signed int donnee2 : 4 ;
} ;
struct etat se ;
Les indications figurant à la suite des deux-points précisent la longueur du
champ en bits. Lorsqu’aucun nom de champ ne figure devant cette indication de
longueur (le spécificateur de type doit figurer, bien qu’il soit alors inutile), on dit
qu’on a affaire à un « champ anonyme ». Un tel champ sert à indiquer que l’on
« saute » le nombre de bits correspondants, lesquels ne seront bien sûr plus
accessibles.
La variable se ainsi déclarée peut être schématisée comme suit (en toute rigueur,
comme on le verra à la section 7.3.1, l’ordre d’affectation des bits peut être
quelque peu différent dans certaines implémentations) :
Avec ces déclarations, la notation :
se.donnee1
désigne un entier signé pouvant prendre des valeurs comprises entre -16 et +15
(du moins dans une implémentation où les entiers sont codés selon la technique
du complément à deux). Elle pourra apparaître à n’importe quel endroit où C
autorise l’emploi d’une variable de type int. Les instructions suivantes sont
correctes :
se.donnee1 = 11 ;
se.donnee2 = -se.donnee1 + 2 ;
se.ok1 = 0 ;
se.ok2 = 1 ;
[Link] = 1 ;
On remarquera qu’ici, on se trouve plutôt dans la situation où l’on cherche à
manipuler chacun des champs de bits d’une structure. La structure complète
devra généralement être envoyée ou recherchée à l’extérieur du programme.
Cela pourra se faire :
• par le biais d’entrées-sorties binaires avec des fichiers ;
• par le biais d’une union associant une telle structure à un entier qu’on pourra
alors lire ou écrire classiquement sur un périphérique quelconque ; nous en
verrons un exemple à la section 7.4.
7.3 Les champs de bits d’une manière générale
7.3.1 Contraintes relatives à leur implémentation
Les champs de bits constituent une possibilité peu portable, ce qui explique que
la norme laisse une certaine latitude dans la façon de les implémenter.
Les emplacements utilisés pour représenter un ou plusieurs champs de bits
consécutifs se nomment « unités de champ de bits ». Leur taille dépend de
l’implémentation. En général, elle correspond à un mot machine, c’est-à-dire à la
taille du type int.
Quand deux champs de bits se suivent au sein d’une même structure (ce qui est
fréquent en pratique) :
• si l’on dispose de suffisamment de place dans l’unité de champ de bits où a été
implémenté le premier, le deuxième doit lui être adjacent ; c’est précisément
grâce à cette règle qu’on peut espérer compacter des informations ou épouser
la structure fine d’un mot donné ;
• dans le cas contraire, l’implémentation reste libre d’implémenter le deuxième
champ de bits, soit à cheval entre deux unités de champs de bits, soit dans une
nouvelle unité (laissant donc des bits inutilisés dans ce cas).
En revanche, la norme ne précise pas si la description d’un champ de bits se fait
en allant des poids faibles vers les poids forts ou dans le sens inverse. Ce point
dépend donc de l’implémentation et, en pratique, on rencontre les deux
situations (y compris pour différents compilateurs sur une même machine !). En
outre, lorsqu’un champ de bits occupe plusieurs octets, l’ordre dans lequel ces
derniers sont décrits dépend, lui aussi, de l’implémentation. En pratique, ces
remarques ne sont guère pénalisantes, dans la mesure où l’on définit
généralement une structure de champs de bits pour s’adapter à une machine
donnée et que l’on ne vise qu’exceptionnellement la portablité.
On notera bien que si, dans une implémentation donnée, il existe une limite à la
taille d’un champ de bits (celle d’une unité de champ de bits), il n’existe
théoriquement pas de limite à celle d’une structure formée de champs de bits,
pas plus qu’il n’en existe pour une structure quelconque.
7.3.2 Contraintes relatives à leur type
La norme n’autorise pour les champs de bits que des types int, avec ou sans
qualificatif de signe, et éventuellement des qualifieurs (const, volatile) qui jouent
alors le même rôle que pour une variable usuelle.
En revanche, il est très important de noter que, contrairement à ce qui se produit
pour le type int, il existe non pas deux, mais trois types entiers différents pour un
champ de bits. En effet :
• avec signed int, équivalent à signed, le bit de poids fort (dont la place exacte
dépend de l’implémentation) est considéré comme bit de signe. C’est la raison
pour laquelle le champ donnee1 de notre exemple d’introduction pouvait prendre
des valeurs positives ou négatives. Les limites exactes dépendent, comme pour
les entiers ordinaires, de la technique de codage utilisée. Dans le cas quasi
universel de la notation en complément à deux, on obtient bien des valeurs
entre -16 et 15. À ce propos, on notera bien qu’un champ de bit signé de 1 bit,
comme :
signed int pae : 1 ;
ne peut prendre que les valeurs 0 et -1. La valeur 1 ne peut jamais être
obtenue ;
• avec unsigned int, équivalent à unsigned, on considère qu’on a affaire à des
nombres sans signe ;
• quant à int, il correspondra soit à signed int, soit à unsigned int, suivant
l’implémentation considérée8. On retrouve ici une ambiguïté comparable à
celle existant avec le type char.
On notera que le type d’un champ de bits intervient aussi lorsqu’on est amené à
lui affecter une valeur trop grande pour sa taille. Dans ce cas, rappelons que pour
les champs de bits non signés, il y a, comme pour les entiers non signés, perte
des bits les plus significatifs. Pour les champs de bits signés, comme pour les
entiers signés, le résultat dépend théoriquement de l’implémentation. En
pratique, il y a également perte des bits les plus significatifs.
7.3.3 Champs de bits anonymes et forçage d’alignement
Comme nous l’avons fait dans l’exemple d’introduction, on peut définir un
champ de bit sans nom, par une simple indication de taille. Le spécificateur de
type, inutile dans ce cas, doit quand même être précisé. Voici deux exemples
équivalents :
int : 4 /* champ de bits anonyme de taille 4 */
signed : 4 /* champ de bits anonyme de taille 4 */
Cela permet de sauter des bits dans la description d’un emplacement. Bien
entendu, les bits en question ne seront aucunement accessibles ni modifiables.
Un champ de bits anonyme de taille 0 demande que le champ de bits suivant, s’il
y en a un, soit alloué à l’intérieur d’une nouvelle unité de champ de bits, même
s’il y avait suffisamment de place dans l’unité précédente :
struct essai { int n : 4 ;
int p : 3 ; /* les 3 bits de p seront contigus à ceux de n */
int : 0 ; /* on force l'emploi d'une nouvelle unité */
int q : 3 ; /* les 3 bits de q ne seront pas contigus à ceux */
/* de p ; ils seront alloués dans une nouvelle unité */
} ;
7.3.4 Autres contraintes
Comme on peut s’y attendre, à partir du moment où un champ de bits n’est pas
formé d’un nombre entier d’octets, il ne possède pas d’adresse. On ne peut pas
former de tableaux de champs de bits. D’ailleurs, la syntaxe même de leur
déclaration ne le permet pas. Si l’on veut par exemple ranger 4 petits entiers
dans un emplacement de 16 bits, il faudra utiliser 4 champs de bits différents :
struct compact { int v1 : 4 ;
int v2 : 4 ;
int v3 : 4 ;
int v4 : 4 ;
} ;
En principe, la norme demande qu’on précise systématiquement la taille de
chaque champ de bits, comme nous venons de le faire. Certaines
implémentations acceptent cependant que, lorsque plusieurs champs successifs
sont de même taille, on ne l’indique qu’une fois. Par exemple, le type compact
précédent pourra se définir ainsi :
struct compact { int v1, v2, v3, v4 : 4 ; } ; /* hors norme mais accepté */
/* dans certains cas */
7.3.5 Initialisation d’un champ de bits
Comme tout champ d’une structure, un champ de bits peut être initialisé comme
n’importe quel entier. Ainsi, la structure se de notre exemple d’introduction peut
être initialisée de cette manière au moment de sa déclaration :
struct etat se = { 1, 0, 11, 1, 7} ;
À titre de curiosité, comme la valeur utilisée comme initialiseur n’a besoin que
d’être d’un type accepté par affectation, l’instruction suivante serait acceptée
(avec quelques conversions dégradantes !) :
struct etat se = { 125, 0, 31000, 1e5, 5.25} ;
7.3.6 Syntaxe de la déclaration d’un champ de bits
Un champ d’une structure ou d’une union peut donc être défini comme un
champ de bits, par une déclaration de la forme suivante :
Syntaxe de la déclaration d’un membre champ de bits
specif_type_entier [identificateur] : taille ;
specif_type_entier
Obligatoirement un type int signé ou non, c’est-à-dire
l’une des possibilités suivantes :
– signed int, équivalent à signed ;
– unsigned int équivalent à unsigned ;
– int correspond à signed int ou à unsigned int, suivant
l’implémentation.
identificateur
Nom donné au champ de bit. S’il est absent, on est en
présence d’un champ de bits anonyme dont la présence
sert simplement à « sauter » des bits auxquels on ne
cherchera pas à accéder (dans ce cas, type peut être
omis).
taille
Expression constante positive, éventuellement 0 pour un
champ de bits anonyme (pour forcer un alignement).
Remarque
Dans les chapitres relatifs aux différents types utilisables en C, nous ne fournissons généralement pas
la syntaxe exacte des déclarations correspondantes à cause de leur complexité. Celle-ci se trouve au
chapitre 16, qui récapitule l’ensemble des déclarations. Nous faisons cependant une exception pour les
champs de bits dont les déclarations sont simples. À titre indicatif, sachez qu’une telle syntaxe
s’obtient en combinant de façon appropriée les sections 2.1 et 2.2 du chapitre 16.
7.4 Exemple d’utilisation d’une structure de champs
de bits dans une union
La structure etat présentée dans l’exemple d’introduction à la section 7.2 peut
être utilisée au sein d’une union de façon à pouvoir considérer, par exemple un
mot d’état, tantôt comme un entier d’une certaine taille, tantôt comme une
structure de champs de bits. Si l’on suppose que dans l’implémentation
concernée, le type int est d’une taille correspondant à celle des unités de champ
de bits, on peut définir l’union suivante :
struct etat /* définition d'une structure */
{ unsigned int pret : 1 ; /* formée de 6 champs de bits */
unsigned int ok1 : 1 ;
signed int donnee1 : 5 ;
int : 3 ; /* champ "anonyme" - inutilisé */
unsigned int ok2 : 1 ;
signed int donnee2 : 4 ;
} ;
union { int valeur ; /* définition d'un type union (anonyme) */
struct etat bits ; /* et déclaration de mot de ce type */
} mot ;
Notez qu’ici, exceptionnellement, nous avons fusionné définition de type union et
déclaration d’objet du type.
Avec ces déclarations, il est alors possible, par exemple, d’accéder à la valeur de
mot, considéré comme un entier, en la désignant par :
[Link]
Quant aux différentes parties désignant ce mot, il sera possible d’y accéder en les
désignant par :
[Link]
[Link].ok1
[Link].donnee1
etc
8. Les énumérations
Un type énumération est un cas particulier de type entier et donc un type scalaire
(ou simple). Son seul lien avec les agrégats présentés précédemment est qu’il
forme, lui aussi, un type défini par le programmeur. On distinguera
généralement, là encore, la définition d’un tel type de la déclaration de variables
faisant appel à ce type.
Nous présenterons d’abord quelques exemples introductifs, avant d’examiner
dans toute leur généralité les déclarations associées à un tel type et leurs
propriétés.
8.1 Exemples introductifs
Nous commencerons par un exemple d’utilisation que l’on pourrait qualifier de
raisonnable, avant de montrer en quoi ce type énumération revêt, finalement, un
caractère très artificiel.
Exemple de définition et d’utilisation raisonnable
Considérons cette déclaration :
enum couleur {jaune, rouge, bleu, vert} ;
Elle définit un type énumération nommé couleur et précise qu’il comporte quatre
valeurs possibles désignées par les identificateurs jaune, rouge, bleu et vert. Ces
valeurs constituent les constantes du type couleur.
Il est possible de déclarer des variables de type couleur :
enum couleur c1, c2 ; /* c1 et c2 sont deux variables de type enum couleur */
Les instructions suivantes sont alors tout naturellement correctes :
c1 = jaune ; /* affecte à c1 la valeur jaune */
c2 = c1 ; /* affecte à c2 la valeur contenue dans c1 */
Comme on peut s’y attendre, les identificateurs correspondant aux constantes du
type couleur ne sont pas des lvalue et ne sont donc pas modifiables :
jaune = 3 ; /* interdit : jaune n'est pas une lvalue */
Les constantes d’un type énumération sont des entiers ordinaires
L’exemple précédent pourrait laisser penser aux connaisseurs du langage Pascal
que le type énuméré du C correspond au type de même nom du Pascal. En fait, la
déclaration précédente :
enum couleur {jaune, rouge, bleu, vert} ;
associe simplement une valeur de type int à chacun des quatre identificateurs
cités. Plus précisément, elle attribue la valeur 0 au premier identificateur jaune, la
valeur 1 à l’identificateur rouge, etc. Ces identificateurs sont utilisables en lieu et
place de n’importe quelle constante entière :
int n ;
long p, q ;
…..
n = bleu ; /* même rôle que n = 2 */
p = vert * q + bleu ; /* même rôle que p = 3 * q + 2 */
Une variable d’un type énumération peut recevoir une valeur quelconque
Contrairement à ce qu’on pourrait espérer, il est possible d’affecter à une
variable de type énuméré n’importe quelle valeur entière (pour peu qu’elle soit
représentable dans le type int) :
enum couleur {jaune, rouge, bleu, vert} ;
enum couleur c1, c2 ;
…..
c1 = 2 ; /* même rôle que c1 = bleu ; */
c1 = 25 ; /* accepté, bien que 25 n'appartienne pas au type enum couleur */
Qui plus est, on peut écrire des choses aussi absurdes que :
enum booleen { faux, vrai } ;
enum couleur {jaune, rouge, bleu, vert} ;
enum booleen drapeau ;
enum couleur c ;
…..
c = drapeau ; /* OK bien que drapeau et c ne soient pas d'un même type */
drapeau = 3 * c + 4 ; /* accepté */
Les constantes d’un type énumération peuvent être quelconques
Dans les exemples précédents, les valeurs des constantes attribuées aux
identificateurs apparaissant dans un type énumération étaient déterminées
automatiquement par le compilateur. Mais il est possible d’influer plus ou moins
sur ces valeurs, comme dans :
enum couleur_bis { jaune = 5, rouge, bleu, vert = 12, rose } ;
/* jaune = 5, rouge = 6, bleu = 7, vert = 12, rose = 13 */
Les entiers négatifs sont permis comme dans :
enum couleur_ter { jaune = -5, rouge, bleu, vert = 12 , rose } ;
/* jaune = -5, rouge = -4, bleu = -3, vert = 12, rose = 13 */
En outre, rien n’interdit qu’une même valeur puisse être attribuée à deux
identificateurs différents :
enum couleur_ter { jaune = 5, rouge, bleu, vert = 6, noir, violet } ;
/* jaune = 5, rouge = 6, bleu = 7, vert = 6, noir = 7, violet = 8 */
L’utilisation de enum peut remplacer avantageusement la directive #define
Compte tenu de l’absence totale de garde-fou, les types énumérés seront surtout
utilisés pour définir des constantes symboliques entières d’une manière parfois
plus pratique que ne le permettait la directive #define.
Par exemple :
enum bool {false, true } ;
pourra remplacer :
#define false 0
#define true 1 /* ou #define true false+1 */
De même :
enum couleur {jaune, rouge, bleu, vert, noir, violet, rose, marron } ;
pourra remplacer avantageusement :
#define jaune 0
#define rouge 1
#define bleu 2
#define vert 3
#define noir 4
#define violet 5
#define rose 6
#define marron 7
On notera d’ailleurs qu’avec un tel usage de la déclaration d’un type énuméré, le
nom de type ne joue plus aucun rôle. En fait, on peut l’omettre en écrivant
simplement :
enum {false, true} ;
enum {jaune, rouge, bleu, vert, noir, violet, rose, marron} ;
8.2 Déclarations associées aux énumérations
8.2.1 Déclaration d’un type énuméré
Lorsque, comme il est conseillé de le faire, on dissocie la définition du type de la
déclaration de variables du type, la définition d’un type énuméré se présente
ainsi :
Déclaration (conseillée) d’un type énuméré
enum [ identificateur ] { LISTE_d_enumerateurs }
LISTE_d_enumerateurs
Soit un seul enumerateur,
soit plusieurs
enumerateurs séparés par
des virgules
enumerateur identificateur [ = valeur
]
valeur est une
expression constante
représentable dans le
type int
Comme on peut s’y attendre, la notion de classe de mémorisation ou de
qualifieur, associés au spécificateur de type ainsi défini, n’a aucune signification
ici. Même si son usage n’est pas formellement interdit par la norme, elle est
rejetée ou ignorée de la plupart des compilateurs.
Remarque
Dans les chapitres relatifs aux différents types utilisables en C, nous ne fournissons généralement pas
la syntaxe exacte des déclarations correspondantes à cause de leur complexité. Celle-ci se trouve au
chapitre 16, qui récapitule l’ensemble des déclarations. Dans le cas des types énumérés, nous avons
fait une exception, compte tenu de la simplicité des déclarations correspondantes, lorsqu’on se limite à
la forme conseillée. À titre indicatif, cette syntaxe s’obtient en combinant de façon appropriée les
sections 2.1 et 2.4 du chapitre 16.
8.2.2 Déclaration de variables d’un type énuméré
La déclaration de variables faisant appel à type énuméré ne pose aucun problème
particulier. Elle se fait tout simplement en utilisant enum xxx comme spécificateur
de type, comme dans :
enum couleur {jaune, rouge, bleu, vert} ;
…..
const enum couleur c=bleu ; /* c est une variable constante de type enum couleur*/
enum couleur *adc ; /* adc est un pointeur sur un objet de type enum couleur */
enum couleur tc[12] ; /* tc est un tableau de 12 éléments de type enum couleur */
8.2.3 Mixage entre définition et déclaration
Comme dans le cas des structures ou des unions, on peut mixer la définition d’un
type énuméré et la déclaration de variables utilisant le type. Par exemple, ces
deux instructions :
enum couleur {jaune, rouge, bleu, vert} ;
enum couleur c1, c2 ;
peuvent être remplacées par :
enum couleur {jaune, rouge, bleu, vert} c1, c2 ;
Dans ce cas, on peut même utiliser un type anonyme, en éliminant
l’identificateur de type :
enum {jaune, rouge, bleu, vert} c1, c2 ;
On notera que cette dernière possibilité présente moins d’inconvénients que dans
le cas des structures ou des unions, car aucun problème de compatibilité de type
ne risque de se poser.
D’une manière générale, nous conseillons de réserver les définitions de type
anonyme au cas où l’on cherche simplement à définir des constantes
symboliques, sans avoir besoin de variables quelconques, comme dans cet
exemple déjà rencontré :
enum {false, true } ;
enum {jaune, rouge, bleu, vert, noir, violet, rose, marron } ;
8.2.4 Les espaces de noms
Rappelons qu’il n’existe qu’un seul espace de nom pour les structures, les unions
et les énumérations :
struct chose { ….. } ;
enum chose { ….. } ; /* incorrect */
Quant aux identificateurs définis dans l’instruction enum, leur portée n’a aucune
raison d’être limitée au seul type concerné. En effet, ici, contrairement à ce qui
se passait pour les structures, un tel identificateur s’emploie seul, sans aucun
suffixe permettant d’en préciser la portée :
enum couleur {jaune, rouge, bleu, vert} ;
enum bois_carte { rouge, noir } ; /* erreur : identificateur rouge déjà defini */
int rouge ; /* erreur : identificateur rouge déjà defini */
Bien entendu, la portée de tels identificateurs est celle correspondant à leur
déclaration : globale ou locale à un bloc.
8.2.5 La représentation interne des constantes d’énumération
Dans une instruction de définition de type énuméré, on peut associer à un
identificateur donné n’importe quelle valeur représentable dans le type int.
Néanmoins, la norme laisse l’implémentation libre de choisir le codage utilisé
pour représenter les constantes ou les variables du type correspondant.
Par exemple, avec :
enum couleur {jaune, rouge, bleu, vert} ;
enum couleur c1, c2 ;
rien n’empêche l’implémentation d’utiliser un seul octet pour représenter les
valeurs jaune, rouge, bleu, vert, ainsi que les variables c1 et c2.
Dans ce cas, des limitations plus restrictives que celles découlant de l’usage du
type int peuvent apparaître, de sorte que des instructions acceptées en
compilation peuvent conduire à un résultat différent de celui escompté. Par
exemple, avec nos précédentes déclarations :
c1 = bleu + 1000 ; /* il n'est pas certain qu'on obtienne 1002 dans c1 */
En revanche, si n est de type int, aucun problème comparable ne se posera avec :
n = bleu + 1000 ; /* n reçoit toujours la valeur 1002 */
En effet, si la valeur de bleu n’est pas représentée sur un int, elle sera convertie
en int avant l’évaluation de l’expression bleu+1000.
Cette incertitude sur la représentation interne des constantes d’énumération
rejaillit sur la taille des variables de ce type. En effet, la norme reste
suffisamment ambiguë sur ce point, de sorte qu’on rencontre en pratique des
comportements différents qui dissuadent fortement d’assimiler le type enum au
type int. Voici quelques exemples :
enum couleur {jaune, rouge, bleu, vert} ;
enum couleur *adc ; /* adc est un pointeur sur un objet de type enum couleur */
int *adi ;
…..
sizeof(enum couleur) /* pas nécessairement égal à sizeof(int) (parfois 1) */
adi=adc ; /* généralement admis */
adi+1 == adc+1 /* pas nécessairement vrai ; l'unité pouvant différer */
D’ailleurs, certaines implémentations disposent d’options de compilation
permettant d’imposer que la représentation interne d’un type énuméré soit
identique à celle de int.
1. L’opérateur & étant plus prioritaire que l’opérateur ".", il n’est pas nécessaire d’écrire &(p.x) ou &(p.y).
2. En revanche, ce sera le cas pour les qualifieurs appliqués à des champs, comme on le verra à la section
2.1.4.
3. Certaines implémentations disposent d’options permettant d’influer sur les contraintes d’alignement
utilisées. La norme C11 en propose une standardisation (voir annexe B consacrée aux normes C99
etC11). Dans ce cas, on peut parfois imposer aux champs d’un type donné une contrainte différente de
celle imposée aux variables du même type.
4. Toutefois, certains compilateurs se contentent d’un message d’avertissement qui n’interdit pas
l’exécution. Dans ce cas, le résultat de la comparaison dépend de l’implémentation.
5. Même remarque que précédemment.
6. Même remarque que précédemment.
7. Certains compilateurs fournissent cependant un message d’avertissement pour signaler une imbrication
douteuse d’accolades.
8. Alors que, dans les autres cas, int équivaut à signed int.
12
La définition de synonymes
avec typedef
En langage C, le type d’une variable se définit par une instruction de déclaration
associant un déclarateur à un spécificateur de type. Dans les cas les plus simples,
le déclarateur correspond à l’idenficateur de la variable, comme dans :
int n ; /* le spécificateur de type int est associé au déclarateur n, forme ici */
/* d'un simple identificateur de variable */
Mais le déclarateur ne se réduit pas toujours à un identificateur :
int v[3] ; /* le spécificateur de type int est associé au déclarateur v[3] */
Une telle déclaration s’interprète ainsi :
• v[3] est de type int ;
• donc v est un tableau de 3 int.
Certes, le nom de type correspondant à v est int[3]. Mais sauf dans certains cas
particuliers (opérateur de cast ou sizeof), ce nom de type ne peut pas être utilisé
tel quel. En particulier, il est impossible d’en faire un spécificateur de type, en
écrivant :
int[3] v ; /* incorrect même s'il semble que v est de type int[3] */
Précisément, l’instruction typedef permet de donner un nom à un type
quelconque, aussi complexe soit-il, puis d’utiliser ce nom comme spécificateur
de type pour simplifier la déclaration d’objets de ce type ou d’un type dérivé. On
dit souvent que typedef permet de définir des synonymes de types. On notera bien
que typedef ne crée pas de nouveau type à proprement parler.
Nous commencerons par présenter cette instruction typedef de manière informelle
à partir de quelques exemples, avant d’examiner en détail ses possibilités et ses
limitations.
1. Exemples introductifs
Examinons quelques exemples intuitifs d’utilisation de typedef.
1.1 Définition d’un synonyme de int
Une déclaration telle que :
int entier ;
définit l’identificateur entier comme une variable de type . Si l’on fait
int
précéder cette déclaration du mot-clé typedef :
typedef int entier ;
on définit entier comme étant un identificateur de synonyme du type int. Ce
synonyme peut ensuite être utilisé pour déclarer des objets de ce type ou d’un
type dérivé, comme dans :
entier n, p ; /* n et p sont de type int */
entier *ad1, *ad2 ; /* ad1 et ad2 sont du type pointeur sur int */
En fait, on obtiendrait un résultat comparable en définissant le symbole entier
par #define :
#define entier int
Comme on peut le voir sur ces exemples, l’intérêt de typedef reste limité dans le
cas des types de base, puisqu’il permet simplement dans les déclarations
ultérieures de remplacer un spécificateur de type par un autre qui lui est
synonyme. Considérons maintenant des exemples plus intéressants.
1.2 Définition d’un synonyme de int *
Une déclaration telle que :
int *ptr_int ;
définit l’identificateur ptr_int comme une variable du type pointeur sur des int. Si
l’on fait précéder cette déclaration du mot-clé typedef :
typedef int *ptr_int ;
on définit l’identificateur ptr_int comme étant un synonyme du type int *. Ce
synonyme peut ensuite être utilisé pour déclarer des objets de ce type, comme
dans :
ptr-int p1, p2 ; /* p1 et p2 sont des pointeurs sur des int */
Qui plus est, le synonyme défini par typedef peut être utilisé dans une déclaration
faisant intervenir n’importe quelle sorte de déclarateur. Par exemple, avec :
ptr-int adi, *t[10] ;
• adi est un pointeur sur un int ;
• *t[10] est un pointeur sur un int ;
• t[10] est un pointeur sur un pointeur sur un int ;
• t est un tableau de 10 pointeurs sur un pointeur sur un int.
On notera bien que, cette fois, il ne serait pas possible d’aboutir au même
résultat avec #define puisque, avec :
#define ptr_int int *
la déclaration :
ptr_int p1, p2 ;
conduirait, après prétraitement, à :
int * p1, p2 ; /* p1 serait bien un pointeur sur un int, mais p2 serait un int */
Quant à la déclaration :
Ptr_int adi, *t[10] ;
elle deviendrait :
int * adi, *t[10] ; / t serait un tableau de pointeurs sur un int */
1.3 Définition d’un synonyme de int[3]
Une déclaration telle que :
int vect[3] ;
définit l’identificateur vect comme étant du type « tableau de 3 entiers ».
Si l’on fait précéder cette déclaration du mot-clé typedef :
typedef int vect[3] ;
on définit l’identificateur vect comme étant un synonyme du type « tableau de 3
entiers ». Ce synonyme peut ensuite être utilisé pour déclarer des objets de ce
type, comme dans :
vect v1, v2 ; /* v1 et v2 sont des tableaux de 3 int */
ou même dans :
vect *ad_v ; /* ad_v est un pointeur sur des tableaux de 3 int */
Ici l’utilisation de #define ne serait guère satisfaisante puisque, avec :
#define vect int [3]
nos déclarations précédentes deviendraient :
int [3] v1, v2 ; /* incorrecte et sans signification */
int [3] * ad_v ; /* incorrecte et sans signification */
1.4 Définition d’un synonyme d’un type structure
L’instruction :
struct article { int numero, qte ;
float prix ;
} ;
définit un type structure de nom struct article. Avec :
typedef struct article { int numero, qte ;
float prix ;
} s_article ;
on définit l’identificateur s_article comme étant un synonyme du type struct
article, de sorte qu’on peut ensuite déclarer :
s_article art1, art2 ;
Ici, typedef ne présente guère plus d’intérêt qu’avec les types de base, si ce n’est
d’éviter l’emploi du mot-clé struct1 dans les déclarations ultérieures… Cette
possibilité se trouve cependant largement exploitée dans certains anciens
programmes.
2. L’instruction typedef d’une manière générale
La section 1 a fourni des exemples introductifs assez simples. Ici, nous
examinerons l’ensemble des possibilités de typedef, telles qu’elles découlent de sa
syntaxe. Nous commenterons certains points relativement peu triviaux, et
d’ailleurs peu usités. Notez que d’autres commentaires concernant les
possibilités de typedef figurent à la section 4, dans la mesure où ils se recoupent
avec la manière d’utiliser les synonymes ainsi définis.
2.1 Syntaxe
D’une manière générale, l’instruction de déclaration typedef se présente ainsi :
Syntaxe de l’instruction typedef
typedef specificateur_de_type [qualifieurs] LISTE_de declarateurs ;
LISTE_de declarateurs
Soit un seul declarateur,
soit plusieurs declarateurs
séparés par des virgules
specificateur_de_type
Type de base, nom d’un – la dernière possibilité
type structure, union ou autorise l’imbrication
énumération ou des définitions de
identificateur de synonymes, voir
synonyme défini section 2.3 ;
préalablement par typedef
– l’attribut de signe fait
partie du spécificateur
de type, voir section
4.1.1.
declarateur
Déclarateur quelconque,
y compris tableau
fonction
Possibilités autorisées
mais déconseillées :
– plusieurs déclarateurs
dans une seule
instruction, voir
section 2.2 ;
– omission de la
dimension d’un
tableau, voir section
4.2.
Voir discussion à propos
de la présence/absence
du nom des arguments
dans un déclarateur de
fonction à la section 4.3
qualifieurs
const et volatile Conseillés uniquement
pour les objets pointés,
voir section 4.1.2
Cette instruction associe chacun des déclarateurs au specificateur_de_type
mentionné, éventuellement qualifié par les qualifieurs (communs, comme dans
une déclaration ordinaire, à chacun des déclarateurs).
Remarques
1. Aucune classe de mémorisation ne peut apparaître à ce niveau – d’ailleurs, elle n’aurait aucune
signification.
2. Le chapitre 16 récapitule l’ensemble des instructions de déclarations. À ce titre, on y trouve donc
théoriquement la syntaxe de l’instruction typedef. Toutefois, elle n’apparaît que comme un cas très
particulier de déclaration dans laquelle, le mot-clé typedef prend curieusement la place d’une
classe de mémorisation, ce qui en masque fortement le rôle. C’est ce qui justifie que nous ayons
repris ici la syntaxe complète de cette instruction.
2.2 Définition de plusieurs synonymes
Bien que cela ne soit guère lisible, il est possible, comme dans une déclaration
usuelle, d’associer plusieurs déclarateurs à un même spécificateur de type et,
partant, définir plusieurs synonymes en une seule instruction. Par exemple :
typedef int entier, *ptr_int, vect[3] ;
joue le même rôle que :
typedef int entier ;
typedef int *ptr_int ; /* ou encore : typedef entier *ptr_int ; */
typedef int vect[3] ; /* ou encore : typedef entier vect [3] ; */
2.3 Imbrication des définitions de synonyme
Une définition de synonyme par typedef peut utiliser un synonyme préalablement
défini, comme dans :
typedef int *ptr_int ; /* ptr_int : type pointeur sur int */
typedef ptr_int tab_ptr_int[10] /* tab_ptr_int : type tableau de 10 pointeurs */
/* sur des int */
La seconde déclaration pourrait également s’écrire :
typedef int *tab_ptr_int [10] ;
3. Utilisation de synonymes
3.1 Un synonyme peut s’utiliser comme spécificateur
de type
D’une manière générale, une déclaration associe toujours un spécificateur de
type à un ou plusieurs déclarateurs. Or un synonyme s’utilise en lieu et place
d’un spécificateur de type. Par exemple, avec :
typedef int vecteur [3] ; /* vecteur est un synonyme de tableau de 3 int */
la déclaration :
vecteur *ad_v ; /* ad_v est un pointeur sur un tableau de 3 int */
associe bien le déclarateur *ad_v au spécificateur de type vecteur. Une déclaration
ne faisant pas appel à un synonyme aurait dû associer un autre spécificateur de
type (int) à un autre déclarateur ((*ad_v)[3]) :
int (*ad_v)[3] ; /* attention aux parenthèses */
Autrement dit, tout se passe comme si la définition de synonymes permettait
d’enrichir la liste des spécificateurs de type dont on dispose. À ce propos, on
pourrait tout à fait imaginer retrouver en C des déclarations d’une forme plus
classique, associant effectivement un type à un identificateur. Il suffirait pour
cela de définir systématiquement chacun des types nécessaires par une
instruction typedef.
3.2 Un synonyme n’est pas un nouveau type
Contrairement aux types définis par l’utilisateur, comme les structures ou les
unions, un idenficateur défini par typedef ne constitue pas un nouveau type, mais
simplement un synonyme d’un type. Deux types synonymes sont considérés
comme étant identiques. Cette remarque n’a en fait d’importance que dans le cas
d’affectations globales entre structures ou unions. Ainsi, avec :
struct article { int numero, qte ; float prix ; } ;
typedef struct article s_article ; /* s_article est synonyme de struct article */
typedef s_article s_art ; /* s_art est synonyme de s_article */
struct article art1 ;
s_article art2 ;
s_art art3 ;
les types struct article, s_article et sont identiques, de sorte que les
s_art
affectations suivantes sont légales :
art1 = art2 ; /* correct */
art1 = art3 ; /* correct */
En revanche, avec :
struct article1 { int numero, qte ; float prix ; } ;
typedef struct article1 s_art1 ;
struct article2 { int numero, qte ; float prix ; } ;
typedef struct article2 s_art2 ;
s_art1 art1 ;
s_art2 art2 ;
les types s_art1 et s_art2 ne sont plus identiques et l’affectation suivante est
incorrecte :
art1 = art2 ; /* incorrect */
3.3 Un synonyme peut s’utiliser à la place d’un nom
de type
Un synonyme défini par typedef peut s’utiliser à la place d’un nom de type, dans
l’un des trois cas suivants :
• comme opérande de l’opérateur de cast ;
• comme opérande de l’opérateur sizeof ;
• dans la déclaration d’un type d’argument de fonction, au sein d’un prototype.
Voici quelques exemples :
typedef int vect[3] ;
typedef int *ptr_int ;
…..
float fct (vect, ptr_int) ; /* équivalent à float fct (int[3], int *) ; */
sizeof(vect) /* équivalent à sizeof (int[3]) */
sizeof (ptr_int) /* équivalent à sizeof (int *) */
(ptr_int) ad /* équivalent à (int *) ad */
Remarque
Le cas des synonymes de type tableau, sans dimension, est examiné à la section 4.2. De même, celui
des synonymes de type fonction est examiné plus en détail à la section 4.3.
4. Les limitations de l’instruction typedef
L’instruction typedef souffre de quelques limitations liées :
• à sa syntaxe même :
– l’impossibilité d’introduire après coup un attribut de signe ;
– une certaine ambiguïté au niveau de l’emploi des qualifieurs.
• au flou qui existe dans la norme au sujet de la notion de type, et notamment
dans le cas des tableaux sans dimensions et des listes d’arguments de
fonctions.
4.1 Limitations liées à la syntaxe de typedef
4.1.1 Impossibilité d’introduire après coup des attributs de signe
Comme l’attribut de signe fait partie intégrante d’un spécificateur de type, il
n’est pas possible, contrairement à ce qui produit pour les qualifieurs, de les
introduire après coup dans une déclaration de variable utilisant un synonyme :
typedef long gd_entier /* gd_entier est un synonyme de long */
/* (donc, en fait, de signed long) */
unsigned gd_entier n ; /* incorrect */
Signalons toutefois que certaines anciennes implémentations acceptaient cette
possibilité.
4.1.2 Attention à l’utilisation de qualifieurs
Comme le montre la syntaxe de l’instruction typedef, la norme n’interdit pas en
théorie des déclarations telles que :
typedef const int c_entier, c_vect[3] ; /* c_entier : type int constant */
/* c_vect : type tableau constant de 3 int */
typedef const int *ptr_ec ; /* ptr_ec : type pointeur sur un int constant */
typedef int * const c_ptr_e ; /* c_ptr_e : type pointeur constant sur un int */
typedef volatile int v_int ; /* v_int : type int volatile */
Cependant, on ne perdra pas de vue que la signification exacte de const associé à
un spécificateur de type dépend du déclarateur associé. S’il s’agit d’un pointeur,
c’est l’objet pointé qui est constant ; sinon, c’est l’objet lui-même qui est
constant.
Lorsque const porte sur l’objet lui-même et non sur l’objet pointé, on peut le
préciser dans la définition du synonyme par typedef, comme dans nos exemples.
Mais on peut aussi n’apporter cette précision qu’à déclaration de l’objet lui-
même. Par exemple, au lieu de :
typedef const int c_vect[3] ;
c_vect v = {10, 15, 24} ; /* sans l'initialisation, il y a erreur */
on pourra toujours procéder ainsi :
typedef int vect[3] ;
const vect v = {10, 15, 24} ;
En revanche, lorsque const porte sur des objets pointés, il doit obligatoirement
être mentionné lors de la définition du synonyme, comme dans nos précédents
exemples. En effet, compte tenu de la syntaxe des déclarations, il n’est plus
possible d’introduire ce qualifieur par la suite. Par exemple, ayant déclaré :
typedef int *ptr_int ; /* ptr_int : type pointeur sur un int */
il n’est plus possible d’utiliser le type ptr_int pour désigner un pointeur sur un int
constant. Avec :
const ptr_int ad ;
on obtient simplement un pointeur constant sur un int.
Les mêmes remarques valent pour le qualifieur volatile.
Toutes ces considérations portent à donner le conseil suivant :
N’utiliser const dans les définitions de synonymes par typedef que pour des objets pointés et jamais
pour les objets eux-mêmes.
4.2 Cas des tableaux sans dimension
Comme l’indique la section 2.4.2 du chapitre 6, la dimension peut être omise
d’un déclarateur de tableau dans quelques cas : présence d’un initialiseur,
argument muet ou redéclaration. Cette liberté se retrouve théoriquement dans la
définition de types synonymes par typedef. Ainsi, l’instruction suivante est-elle
correcte :
typedef int vec_sans_dim [] ; /* vec_sans_dim est un synonyme du type int[] */
Néanmoins, ce synonyme vec_sans_dim ne pourra être utilisé que dans des
situations où la dimension du tableau n’est pas requise. Voici quelques exemples
corrects :
void f1 (vec_sans_dim) ; /* déclaration d'une fonction f1 */
/* definition d'une fonction f2 */
int f2 (vec_sans_dim v) /* en-tête correct */
{ ….. }
vec_sans_dim w = {5, 2, 11} ; /* correct */
En revanche, voici des exemples d’instructions qui seront rejetées :
vec_sans_dim v ; /* incorrect : la dimension de v est nécessaire */
… = sizeof (vec_sans_dim) /* idem */
On notera bien qu’un synonyme tel que vec_sans_dim reste d’un intérêt très limité
puisqu’il n’existe aucun moyen de définir par la suite la dimension effective
d’un tel tableau. Par exemple, l’instruction suivante n’a pas de sens :
vec_sans_dim t [12] ; /* incorrect */
4.3 Cas des synonymes de type fonction
L’instruction typedef permet de définir des synonymes de n’importe quel type, et
en particulier de type fonction. Dans ce cas, il est alors théoriquement possible
de nommer ou non les arguments. Les deux possibilités ne sont pas équivalentes
comme on va le voir.
4.3.1 Sans noms d’arguments
L’instruction :
typedef void f_type1 (char *, float) ;
définit l’identificateur f_type1 comme synonyme du type : « fonction recevant
deux arguments de type char * et float et ne renvoyant aucune valeur ».
Les déclarations suivantes ne posent alors aucun problème particulier :
f_type1 f, g ; /* comme si l'on avait déclaré : */
/* void f (char *, float) ; */
/* void g (char *, float) ; */
int h (f_type1 *, int) ; /* comme si l'on avait déclaré : */
/* int h (void (*)(char *, float), int) ; */
Un synonyme tel que f_type1 peut aussi intervenir, comme n’importe quel
spécificateur de type, dans l’en-tête de fonctions disposant d’arguments de type
fonction, par exemple :
int h (f_type1 *fct, int n) /* comme si l'on avait écrit : */
/* int h (void (*fct)(char *, float), int n) */
{ ….. }
En revanche, ce synonyme n’est pas utilisable dans l’en-tête de la définition de
fonctions de type f_type1 :
f_type1 fct /* en-tête de fct : incorrect : il manque les noms des arguments */
{ ….. }
En effet, cet en-tête serait interprété ainsi :
void fct (char *, float) /* il manque les noms des arguments */
{ ….. }
4.3.2 Avec noms d’argument
Le contre-exemple précédent pourrait inciter à introduire des noms d’arguments
dans les définitions de synonymes de type fonction, ce qui est théoriquement
possible. Par exemple, le synonyme du type f_type1 aurait pu être défini comme
ceci :
typedef void f_type1 (char *c, float y) ;
Cela ne nuira nullement aux déclarations de prototypes utilisant f_type1,
puisqu’un prototype peut indifféremment contenir ou non les noms
d’arguments :
f_type1 f, g ; /* comme si l'on avait déclaré : */
/* void f (char *c, float y) ; */
/* void g (char *c, float y) ; */
int h (f_type1 *, int) ; /* comme si l'on avait déclaré : */
/* int h (void (*)(char *c, float y), int) ; */
En revanche, si l’on cherche à utiliser le synonyme f_type1 dans l’en-tête de
fonctions de ce type, il faudra alors absolument utiliser dans leur définition les
noms d’arguments imposés par le synonyme :
f_type1 h /* comme si l'on avait écrit void h (char *c, float y) */
{ ….. }
Cette contrainte sera encore plus sensible si l’on cherche à définir plusieurs
fonctions de ce type.
En pratique, nous vous conseillons d’appliquer la règle suivante :
Ne jamais utiliser de noms d’arguments dans les définitions de synonymes de type fonction.
L’application stricte de cette règle interdit d’utiliser un synonyme de type
fonction dans un en-tête de fonction et donc élimine les problèmes évoqués dans
ce paragraphe. En revanche, elle n’interdit pas d’utiliser un tel synonyme dans
l’en-tête d’une fonction ayant des arguments de ce type, ce qui, comme on l’a vu
à la section 4.3, ne pose pas de problème particulier.
1. Le mot-clé struct n’est plus indispensable en C++.
13
Les fichiers
Généralement, en programmation, le terme de fichier désigne un archivage
permanent : l’information créée par un programme peut être conservée à volonté
pour être exploitée ou modifiée ultérieurement par un autre programme. A priori,
une telle définition exclut les entrées-sorties standards, du moins lorsqu’elles
sont utilisées en mode conversationnel, c’est-à-dire associées au clavier et à
l’écran. En fait, comme nous l’avons vu au chapitre 9, les entrées-sorties
standards peuvent être souvent redirigées vers un fichier ; de plus, à toute
fonction d’entrée-sortie standard correspond une fonction plus générale
s’appliquant à un fichier de type texte (par exemple, fscanf pour scanf, fprintf pour
printf…).
Autrement dit, les entrées-sorties standards apparaissent bien en C comme un cas
particulier de fichier. Il ne s’agit que d’un cas particulier, dans la mesure où les
informations ainsi échangées sont soumises à ce que l’on nomme un
« formatage », ce qui revient à dire qu’au bout du compte, les échanges portent
sur des caractères.
Or en C comme dans tous les autres langages, l’archivage d’informations dans
un fichier peut se faire sans formatage, c’est-à-dire par simple recopie de
l’information binaire telle qu’elle se trouve en mémoire. Il existe donc d’autres
fonctions destinées à des fichiers qui, cette fois, ne possèdent pas d’équivalent
pour les entrées-sorties standards.
De plus, lorsque le support physique sur lequel se trouve le fichier le permet, on
pourra, au lieu de se contenter d’un accès séquentiel, mettre en œuvre ce que
l’on nomme un accès direct à l’information.
Ce chapitre est consacré à l’étude de l’ensemble des possibilités offertes par le
langage C en matière de gestion de fichiers.
Dans un premier temps, nous rappellerons quelques notions générales
relativement indépendantes des langages : enregistrement, fichiers formatés et
fichiers binaires, accès séquentiel ou direct. Nous montrerons ce que deviennent
ces notions en C, lequel, une fois de plus, se révélera quelque peu original. Nous
proposerons une classification des différentes opérations réalisables sur des
fichiers, ce qui nous amènera à distinguer les opérations binaires, les opérations
formatées et les opérations mixtes.
Puis, nous fournirons des principes généraux relatifs au traitement des erreurs de
gestion de fichiers.
Nous étudierons alors en détail :
• les fonctions d’écriture et de lecture binaire fread et fwrite ;
• les fonctions d’écriture et de lecture formatée fprintf, fscanf, fputs et fgets ;
• les fonctions d’écriture et de lecture mixtes fputc, putc, fgetc, getc.
Nous verrons ensuite comment mettre en œuvre l’accès direct à l’aide des
fonctions fseek, ftell, fsetpos et fgetpos. Enfin, nous examinerons les différentes
possibilités d’ouverture de fichier offertes par la fonction fopen.
Signalons que quelques fonctions concernant les fichiers seront simplement
évoquées ici et décrites dans l’annexe A. Il s’agit de rename, remove, tmpnam, tmpfile,
fflush et ungetc.
1. Généralités concernant le traitement des fichiers
Cette section rappelle les principales notions relatives au traitement de fichiers,
indépendamment du langage employé. Cela nous permettra, dans la section
suivante, de mieux faire comprendre en quoi le C, une fois de plus, est quelque
peu original dans sa façon de les mettre en œuvre. La lecture de la présente
section est d’autant plus recommandée que l’on est familiarisé avec un autre
langage. En effet,l’expérience montre que les connaisseurs d’autres langages ont
quelques difficultés pour s’adapter à la rusticité de la démarche du C en matière
de gestion de fichiers.
1.1 Notion d’enregistrement
Dans bon nombre de langages, un fichier apparaît comme une collection
ordonnée d’enregistrements. L’accès aux informations contenues dans un fichier
se fait alors en lisant ou en écrivant toujours au moins un enregistrement
complet.
Certains langages imposent des enregistrements de taille fixe, d’autres acceptent
des enregistrements dont la taille peut varier au sein d’un même fichier, du
moins tant que l’on ne souhaite pas réaliser d’accès direct.
1.2 Archivage de l’information sous forme binaire ou
formatée
Généralement, on peut choisir entre deux manières de représenter l’information
dans un fichier :
• sous forme brute, dite la plupart du temps « binaire » ;
• sous forme formatée.
La « forme binaire » consiste à recopier purement et simplement dans le fichier
l’information telle qu’elle figure en mémoire. Bien entendu, les fichiers ainsi
créés ne peuvent être ensuite exploités que dans l’environnement dans lequel ils
ont été créés ou, pour le moins, dans un environnement utilisant le même
codage. Par exemple, on trouvera beaucoup d’implémentations où l’on sait
représenter des entiers sur 32 bits en complément à deux1. En revanche, il sera
plus difficile de trouver deux implémentations codant exactement de la même
manière des flottants sur 32 ou 64 bits. Même, en cas d’utilisation de la norme
IEEE 754, des divergences peuvent apparaître, liées à l’ordonnancement des
différents octets et/ou à l’emplacement de ce que l’on nomme souvent les « bits
de poids faible » et les « bits de poids fort ».
La « forme formatée » consiste à exprimer l’information sous la forme d’une
suite de caractères (ou, pour être plus précis, d’octets contenant des codes de
caractères). Généralement, les nombres y sont exprimés en décimal, parfois en
hexadécimal ou en octal. Certes, les fichiers ainsi créés ne pourront être
exploités que dans des environnements utilisant le même codage pour les
caractères. Par rapport à la situation précédente, il faut cependant noter qu’il est
assez fréquent que deux machines emploient le même code de caractères. En
plus, on peut généralement se limiter à certains caractères tels que les chiffres,
les signes, la lettre e et le point décimal2. C’est pourquoi la forme formatée est
souvent utilisée pour « porter » des données d’un environnement à un autre et ce
malgré le coût supplémentaire en temps induit par les conversions nécessaires,
aussi bien lors de la lecture que lors de l’écriture.
1.3 Accès séquentiel ou accès direct
L’accès aux informations contenues dans le fichier a lieu en lisant ou en écrivant
au moins un enregistrement complet. On distingue traditionnellement deux types
d’accès : l’accès séquentiel et l’accès direct.
L’accès séquentiel consiste à accéder aux informations de manière séquentielle,
c’est-à-dire suivant l’ordre où elles apparaissent (ou apparaîtront) dans le fichier,
aussi bien en lecture qu’en écriture.
Dans le cas d’un accès direct, on accède, aussi bien en lecture qu’en écriture, à
n’importe quel enregistrement repéré alors par son numéro d’ordre. La plupart
du temps, l’accès direct n’est possible que si certaines conditions sont réalisées,
notamment :
• le fichier doit résider sur un support physique permettant un accès rapide à une
quelconque de ses parties, ce qui exclut généralement les bandes
magnétiques3 ;
• les enregistrements doivent être tous de la même taille ; cette contrainte se
justifie par le fait qu’elle permet précisément de repérer l’emplacement exact
d’un enregistrement sur le support physique à partir de son numéro.
1.4 Fichiers et implémentation
En général, les normes des langages n’imposent pas la manière dont
l’implémentation représente les enregistrements sur le support physique. En
particulier, il est fréquent qu’on y trouve, en plus de l’information effectivement
manipulée par le programme, des informations complémentaires telles que :
début d’enregistrement, fin d’enregistrement longueur de l’enregistrement… Ces
informations peuvent différer d’une implémentation à une autre, ce qui peut
compliquer le portage de données, même si les machines concernées utilisent le
même codage.
Par ailleurs, on rencontre encore quelques implémentations dans lesquelles se
fait la distinction entre enregistrement logique et enregistrement physique.
• L’enregistrement logique correspond tout simplement à la notion
d’enregistrement telle qu’elle est vue par le programme, c’est-à-dire telle
qu’elle a été précédemment décrite.
• L’enregistrement physique correspond à un regroupement de plusieurs
enregistrements logiques en vue de former un bloc de données plus important.
De tels regroupements peuvent être justifiés pour des questions d’efficacité. En
effet, avec les supports magnétiques actuels, l’accès à l’information n’est pas
totalement direct puisqu’il peut nécessiter le parcours de tout ou partie d’une
« piste ». Dans ces conditions, on minimise effectivement le temps d’échange
d’information en accroissant la taille des blocs échangés. Le même type de
raisonnement s’applique aux bandes magnétiques, pour lesquelles
interviennent alors des questions d’accélération et de décélération.
On notera que dans les implémentations qui font intervenir la notion
d’enregistrement physique, on peut également trouver dans le fichier des
informations complémentaires les concernant, analogues à celles qui pouvaient
concerner les enregistrements logiques.
En général, la notion d’enregistrement physique est prise en charge par le
système d’exploitation. Elle reste transparente au programmeur tant qu’il
travaille dans un langage donné sur une machine donnée.
2. Le traitement des fichiers en C
La section précédente a présenté la notion de fichier telle qu’elle est mise en
œuvre dans bon nombre de langages. En C, les choses sont assez particulières.
Récapitulées dans le tableau 13.1, elles sont décrites en détail dans les sections
indiquées.
Tableau 13.1 : le traitement des fichiers en C
N’existe plus en C : on peut accéder Voir
Enregistrement individuellement à n’importe quel octet du section
fichier. 2.1
L’ouverture d’un fichier associe son nom Voir
Flux externe à un nom interne, à savoir une section
adresse de type FILE *. 2.2
– n’existe que dans certaines Voir
Distinction section
implémentations ;
fichier 2.3
binaire/fichier – dans ce cas, elle n’est pas intrinsèque au
formaté fichier, mais faite au moment de son
ouverture.
– binaire : fread, fwrite ; Voir
Types
section
d’opérations – formaté : fscanf, fprintf, fgets, fputs ; 2.4
d’entrées-sorties
– mixte : fgetc, getc, fputc, putc.
– pas de fonction de lecture ou d’écriture Voir
Accès spécifique à un type d’accès ; section
séquentiel/direct 2.5
– on peut agir sur le pointeur de fichier, au
niveau de l’octet.
Automatique par défaut. On peut définir Voir
son propre tampon, à l’aide des fonctions section
Gestion du setbuf et setvbuf. 2.6
tampon et
annexe
A
Possibilités réservées généralement au Voir
Gestion des système : renommer (rename) ou détruire annexe
fichiers (remove) des fichiers, créer un fichier A
temporaire (tmpnam et tmpfile).
– par redirection des entrées-sorties au Voir
Connexion d’un annexe
niveau du système, lorsque cette
fichier à une A
possibilité existe ;
unité standard
– par la fonction freopen.
2.1 L’absence de la notion d’enregistrement en C
La notion d’enregistrement n’existe plus véritablement en C puisqu’un fichier y
est considéré comme une collection ordonnée d’octets. On pourrait, à la rigueur,
dire qu’il y a des enregistrements dont la longueur est égale à un octet, mais ce
serait quelque peu tendancieux dans la mesure où bon nombre d’informations
occuperont plusieurs octets… Quoi qu’il en soit, en C, le fichier ne possède
aucune structure intrinsèque. Par exemple, il sera toujours possible d’y écrire
une information occupant 16 octets et de la relire ensuite comme deux
informations de 8 octets, ou encore comme une information de 8 octets suivie de
deux informations de 2 octets et de 4 caractères…
On notera bien que comme cela a été dit à la section 1.4, d’une manière générale,
l’implémentation peut ajouter, pour ses besoins propres, des informations
complémentaires ; celles-ci resteront totalement transparentes au programme, du
moins tant qu’on exploitera en C des fichiers créés en C sur la même machine.
2.2 Notion de flux
Dans bon nombre de langages, les fichiers ne se manipulent pas directement par
leur nom, dit souvent « nom externe », mais par un « nom interne » choisi
librement par le programme. Il peut s’agir, suivant le cas, d’un identificateur ou
d’un numéro. L’association entre le nom interne et le nom externe se fait lors de
l’ouverture du fichier ou, parfois, par des commandes passées à l’environnement
avant l’exécution du programme.
En C, on retrouve un mécanisme analogue, à savoir qu’au moment de
l’ouverture par fopen, on associe un nom externe de fichier à un nom interne.
Cependant, là encore, C est quelque peu particulier dans la mesure où ce nom
interne est en fait un pointeur sur une structure de type FILE (synonyme prédéfini
dans stdio.h). Par exemple, avec la déclaration suivante, on définira
l’identificateur fich comme un nom interne de fichier :
FILE *fich ; /* attention, FILE et non struct FILE */
/* car FILE est prédéfini par typedef */
On dit d’une telle variable pointeur qu’elle représente un « flux ».
On notera bien qu’aucune structure de type FILE n’est réservée par cette
déclaration. Celle-ci sera créée automatiquement lors de l’ouverture du fichier
par fopen, c’est à ce moment-là que sera effectuée la correspondance entre nom
interne et nom externe, comme dans :
fich = fopen ("c:\\donnees\\essai", "rb") ; /* associé le fichier de nom */
/* c:\donnees\essai au flux fich */
/* attention au doublement de \ */
D’une manière générale, la structure de type FILE ainsi associée à un fichier
comporte un certain nombre d’informations permettant d’assurer la bonne
gestion du fichier. On peut citer :
• un pointeur à l’intérieur du fichier ;
• un indicateur d’erreur ;
• un indicateur de fin de fichier ;
• un pointeur sur le tampon associé au fichier, s’il en existe un.
En ce qui concerne le contenu exact de cette structure FILE, on pourra toujours le
connaître en examinant le fichier stdio.h, mais on ne perdra pas de vue que celui-
ci peut varier d’une implémentation à une autre.
Remarque
La norme n’impose pas à une structure de type FILE d’être copiable. Autrement dit, elle ne garantit pas
que les instructions suivantes puissent fonctionner correctement :
FILE *fich1 ;
FILE *fich2 ;
fich1 = fopen ("c:\donnees\essai", "rb") ;
…
*fich2 = *fich1 ; /* recopie la structure FILE d'adresse fich1 dans */
/* celle d'adresse fich2 */
fwrite (…., fich2) ; /* comportement indéterminé */
En toute rigueur, cette précaution de la norme permet à une implémentation d’introduire dans une
structure FILE des informations liées à son adresse. Par exemple, on peut imaginer que l’adresse du
tampon associé au fichier se définisse par rapport à celle de la structure FILE correspondante et non de
façon absolue.
2.3 Distinction entre fichier binaire et fichier formaté
2.3.1 La distinction est ambiguë en C
À la section 1.2, on a indiqué qu’il existait deux manières d’archiver
l’information dans un fichier : sous forme binaire ou sous forme formatée. Dans
certains langages, on distingue clairement les deux types de fichiers
correspondants et il n’est alors pas question de lire sous forme formatée un
fichier créé en binaire ou l’inverse. En C, malheureusement, compte tenu à la
fois de son historique et de l’équivalence existant entre octet et caractère, les
choses sont beaucoup plus floues.
En effet, la norme n’impose rien. Elle laisse l’implémentation libre de distinguer
ou non les deux sortes de fichiers. Et encore, dans ce cas, la distinction ne
s’effectue pas de façon intrinsèque au niveau des fichiers eux-mêmes, mais
simplement au moment de leur ouverture. On parlera, lorsque l’implémentation
prévoit la distinction, non plus de fichiers binaires ou formatés mais de mode
d’accès binaire ou texte. Comme on s’en doute, des confusions pourront
apparaître dès lors qu’on lira un fichier suivant un mode différent de celui ayant
présidé à sa création.
À titre de justification historique, on peut dire que cette tolérance de la norme
émane d’un souhait du comité ANSI de favoriser le développement de nouveaux
compilateurs C, sans remettre en cause la définition initiale du langage (faite par
Kernighan et Ritchie). Or cette dernière était fortement basée sur des
environnements UNIX dans lesquels la distinction entre fichiers binaires et
fichiers texte n’existe pas.
Dans un premier temps, nous mettrons en évidence l’intérêt qu’il peut y avoir,
dans certains environnements, à distinguer les deux modes d’accès. À cet effet,
nous examinerons des exemples forts répandus, à savoir le cas des caractères de
fin de ligne et de fin de fichier. Nous verrons ensuite comment la norme propose
un cadre très large pour cette distinction facultative.
2.3.2 Exemples usuels où la distinction se justifie
Considérons la représentation de la fin de ligne. En C, elle apparaît
obligatoirement en mémoire sous la forme d’un seul caractère dont le code
dépend de l’implémentation et qu’on note \n. Or, dans certaines
implémentations, la fin de ligne est représentée non pas par un, mais par deux
caractères, par exemple, dans les environnements PC : retour chariot (\r) et fin
de ligne (\n).
Dans ces conditions, si l’on souhaite introduire le caractère de fin de ligne dans
un fichier utilisable ultérieurement comme du texte (par un éditeur, par un
programme de liste à l’écran ou sur imprimante…), il faut :
• remplacer chaque demande d’écriture dans le fichier du seul caractère de fin de
ligne \n par l’écriture des deux caractères associés (\r et \n) ;
• remplacer chaque lecture dans le fichier du couple formé des deux caractères
en question (\r et \n dans cet ordre) par la transmission du seul caractère \n.
Bien entendu, de telles substitutions ne doivent absolument pas être réalisées
lorsque l’on souhaite conserver une information sous forme binaire. Par
exemple, ce n’est pas parce que l’un des octets représentant un entier sur 32 bits
contient une valeur correspondant au code (interne) de \n qu’il faudra pour autant
en modifier la valeur et, de plus, écrire 5 octets au lieu de 4 dans le fichier.
Il est donc bien nécessaire dans une telle implémentation de distinguer les deux
modes d’accès : binaire ou texte.
2.3.3 Notion de fichier de type texte
Les exemples cités précédemment sont les plus répandus. Mais la norme laisse
toute liberté à une implémentation de tenir compte d’autres particularités en
imposant des contraintes aux caractères figurant dans un fichier traité en mode
texte. Il s’agit essentiellement :
• des conditions de réversibilité ;
• du découpage en lignes.
Conditions de réversibilité
Le langage C considère tout fichier comme une suite d’octets. Dans ces
conditions, il n’est pas possible à une implémentation d’interdire formellement
la présence de certains caractères dans un fichier utilisé en mode texte.
En revanche, une implémentation peut n’assurer la réversibilité des informations
que sous certaines conditions. On dit qu’il y a réversibilité lorsque l’on retrouve
bien, lors de la lecture d’un fichier, les informations qu’on y a préalablement
enregistrées. Pour parvenir à cette réversibilité, il n’est pas nécessaire que les
caractères soient codés dans le fichier de la même façon qu’en mémoire4. Il suffit
simplement que les lectures et les écritures procèdent à des transcodages
inverses l’un de l’autre. La norme fixe cependant des conditions minimales, en
assurant que, quelle que soit l’implémentation, il doit y avoir réversibilité
lorsque le fichier texte ne contient que des caractères imprimables, des fins de
ligne (\n) et des tabulations horizontales (\t), à l’exception des espaces de fin de
ligne (qui peuvent ne pas se retrouver à la lecture).
Découpage en ligne
Par ailleurs, la norme prévoit qu’une implémentation puisse imposer à un fichier
traité en mode texte d’être structuré en lignes, c’est-à-dire qu’on y trouve des
caractères de fin de ligne (\n5). En soi, cette contrainte semble peu importante :
on pourrait imaginer un fichier formé d’une « grande ligne » ! Mais la norme
autorise par ailleurs une implémentation à limiter la taille des lignes de fichier
traités en mode texte, sans toutefois que cette limite puisse être inférieure à 254.
On peut donc rencontrer des implémentations où un fichier traité en mode texte
doit effectivement être découpé en lignes d’une taille inférieure à une limite
donnée.
Remarques
1. La contrainte de découpage en ligne peut devenir sensible, même lorsqu’on crée en C un fichier en
mode texte. Considérez, par exemple, cette simple instruction, en supposant qu’on a redirigé la
sortie standard stdout vers un fichier :
for (i=0 ; i<1000 ; i++) printf ("%5d" , i) ;
Le fichier étant formé de 5 000 caractères, sans fin de ligne, on n’est pas assuré de le relire
convenablement !
2. Les fichiers créés par un éditeur ou par un traitement de texte satisfont généralement à la contrainte
de découpage en ligne. En revanche, ils possèdent parfois leurs propres conditions de réversibilité
qui peuvent différer légèrement de celles du C. Par exemple, dans ces logiciels, le caractère de
tabulation peut se trouver transformé en une suite d’espaces (ou l’inverse), ce qui n’est pas le cas en
C.
Définition d’un fichier de type texte
On dira que, dans une implémentation donnée, un fichier est de type texte s’il
satisfait aux conditions particulières imposées par cette implémentation. Bien
entendu, si l’implémentation ne fait aucune distinction entre fichier binaire et
fichier formaté, tout fichier peut être considéré comme étant de type texte.
Par ailleurs, tout fichier satisfaisant aux contraintes minimales évoquées
précédemment pour un code donné des caractères pourra être considéré comme
un fichier de type texte dans toute implémentation utilisant le même code. En
revanche, si un fichier ne satisfait plus à ces contraintes minimales, rien
n’empêche qu’il puisse être considéré comme étant de type texte dans une
implémentation et pas dans une autre employant le même code.
2.4 Opérations applicables à un fichier et choix du
mode d’ouverture
Lorsque l’implémentation le prévoit, le choix entre mode d’accès binaire ou
texte a lieu au moment de l’ouverture du fichier (par la fonction fopen), ce qui est
relativement logique. Mais, assez curieusement, ce choix n’implique aucune
contrainte quand aux opérations réalisables avec le fichier. Dans ces conditions,
si l’on souhaite bien maîtriser ce qui risque de se produire dans les différentes
situations possibles, il est alors nécessaire de distinguer le mode d’ouverture du
fichier des opérations qu’on y réalise.
Le mode d’ouverture du fichier permet de choisir entre les deux possibilités
précédemment évoquées :
• binaire : les informations manipulées en mémoire par le programme sont
absolument identiques à celles qui figurent dans le fichier ;
• texte : les informations manipulées par le programme peuvent différer de celles
qui figurent dans le fichier ; ces différences peuvent être inexistantes dans
certaines implémentations mais elles peuvent aller jusqu’au transcodage de
tous les caractères (cas rare) en passant par la situation fort répandue de
changement de représentation de la fin de ligne.
Signalons qu’on parle également de flux binaire ou de flux texte pour désigner
un flux associé à un fichier ouvert en mode binaire ou en mode texte.
Indépendamment de ce mode d’ouverture, les opérations qu’on réalise sur le
fichier peuvent, elles aussi, se répartir en deux catégories :
• transfert brut (ou transfert binaire) : fread, fwrite ;
• formatage : fscanf, fprintf, fgets et fputs.
En outre, il existe des opérations que nous qualifierons de « mixtes », dans la
mesure où elles sont difficilement classables dans l’une des deux catégories
précédentes. Il s’agit des opérations portant sur des caractères6. Dans ce cas, le
fait de manipuler des caractères fait penser à un formatage ; néanmoins, comme
en C, il y a totale équivalence entre caractère et octet, on peut aussi considérer
qu’il s’agit là d’opérations de transfert brut. Par exemple, on verra qu’un appel
de fputc est un cas particulier d’appel de fwrite avec une longueur de 1.
En théorie, toute opération, quelle que soit sa nature, peut s’appliquer à un mode
d’ouverture quelconque. On notera d’ailleurs que dans les implémentations qui
ne distinguent pas les deux modes d’ouverture, aucune restriction ne peut être
imposée. En revanche, dans les implémentations qui distinguent les deux modes
d’ouverture, on devine que certaines opérations sont plutôt adaptées à un mode
d’ouverture donné.
En particulier, le bon sens impose que l’on applique les fonctions de simple
transfert (binaire) aux flux binaires (fichiers ouverts en mode binaire), de façon à
assurer l’identité entre l’information en mémoire et celle dans le fichier. En
revanche, en ce qui concerne les fonctions de formatage, les choses sont
nettement plus nuancées. En effet, suivant les circonstances, on peut adopter
deux attitudes opposées :
1. On souhaite, dans une implémentation donnée, écrire des fichiers susceptibles
d’être lus par des logiciels manipulant des textes ou encore lire des fichiers
créés par de tels logiciels. Dans ce cas, il est nécessaire d’utiliser des flux
texte (fichiers ouverts en mode texte), afin d’assurer le transcodage nécessaire
entre code interne et code externe.
2. On est certain que les fichiers seront toujours manipulés par des programmes
C dans la même implémentation et on ne souhaite pas se voir imposer des
contraintes sur leur contenu (voir section 2.3.3). Dans ce cas, on peut se
permettre d’utiliser des flux binaires (fichiers ouverts en mode binaire).
On notera cependant que c’est la première attitude qui correspond le mieux à la
notion de fichier formaté telle qu’on la rencontre dans les autres langages.
Quand aux fonctions que nous avons qualifiées de mixtes, les deux attitudes
décrites seront tout à fait envisageables. La première (flux texte) reviendra à
privilégier la signification des caractères manipulés. Par exemple, dans une
implémentation où la fin de ligne est représentée par deux caractères, la lecture
de ces deux caractères produira un seul caractère \n. La seconde attitude (flux
binaire) reviendra à ne considérer ces caractères que comme de simples octets.
Par exemple, dans la même implémentation, la lecture des deux caractères
représentant une fin de ligne produira cette fois deux caractères.
Remarque
Dans les implémentations où la distinction entre flux texte et flux binaire existe, on a toujours intérêt à
traiter un fichier suivant le mode qui a été utilisé lors de sa création. Dans le cas contraire, on court des
risques de transcodage, donc en particulier de mauvaise interprétation des fins de ligne.
Le tableau 13.2 récapitule ces diverses considérations :
Tableau 13.2 : opérations sur les fichiers et type de flux (mode d’ouverture)
Type
Opérations Usage
de flux
Binaires Binaire Utilisation recommandée.
(fread, Texte Utilisation fortement déconseillée.
fwrite)
Formatées Texte Utilisation normale : vrais fichiers texte
(fscanf, exportables ou importables dans des
fprintf,
implémentations utilisant le même codage des
fgets, fputs)
caractères1.
Binaire Utilisation particulière : fichiers créés et utilisés
en C dans la même implémentation.
Mixtes Binaire On ne s’intéresse aux caractères que pour les
(fgetc, fputc, octets qu’ils représentent.
getc, putc) Texte On s’intéresse aux caractères en tant que tels.
1. Si l’on se limite au jeu standard et qu’on respecte la contrainte de découpage en lignes, le fichier peut
être importé ou exporté depuis ou vers toute implémentation utilisant un code ASCII étendu (voir section
1.3 du chapitre 2).
2.5 Accès séquentiel et accès direct
Dans certains langages, le type d’accès (séquentiel ou direct) est figé au moment
de la création du fichier. En C, il n’en est rien. Le choix entre accès séquentiel ou
direct n’est même pas fait à l’ouverture du fichier, ni d’ailleurs par la suite. En
effet, il n’existe pas de fonctions spécifiques à chaque mode d’accès, mais
simplement des fonctions susceptibles d’agir sur un pointeur dans le fichier,
pointeur qui indique tout simplement l’emplacement auquel commencera la
prochaine opération de lecture ou d’écriture.
Par ailleurs, dans la plupart des langages, l’accès direct se fait en désignant un
enregistrement donné. En C, la notion d’enregistrement est quasi absente et
l’accès direct peut se faire, en réalité, à un octet quelconque de rang donné. Bien
entendu, en contrepartie d’une telle souplesse, il faudra être en mesure de
calculer correctement le numéro de l’octet voulu.
2.6 Le tampon et sa gestion
Toutes les entrées-sorties avec un fichier font appel à un emplacement mémoire
nommé tampon. Son existence permet de minimiser les temps d’échange
d’information avec le périphérique concerné. Par exemple, en cas d’écriture
séquentielle, ce n’est que lorsque ce tampon est rempli qu’il est recopié dans le
fichier.
Ce tampon est alloué dynamiquement par la fonction d’ouverture du fichier et
généralement, il n’est pas nécessaire de s’en préoccuper. Si l’on souhaite
simplement en connaître la taille, il suffit de consulter la valeur de la constante
entière BUFSIZ définie dans stdio.h. Mais il est également possible, pour des
applications particulières, de :
• définir l’emplacement qu’on souhaite voir utiliser comme tampon, soit d’une
taille égale à BUFSIZ (fonction setbuf) soit d’une taille différente (fonction
setvbuf) ;
• supprimer l’utilisation de tout tampon (fonction setvbuf) ; on notera cependant
que, dans ce cas, il n’existera que peu de périphériques avec lesquels les
échanges seront directs ; notamment pour les unités telles que les disques, les
CD-Rom, les DVD, les clés USB… il existera toujours un tampon géré par le
système d’exploitation lui-même ;
• agir sur la manière dont le tampon est alimenté (fonction setvbuf).
Les fonctions setbuf et setvbuf sont décrites à l’annexe A.
3. Le traitement des erreurs de gestion de fichier
3.1 Introduction
En langage C, comme dans tout langage, les sources d’erreur en matière de
gestion de fichiers sont assez nombreuses. On peut citer :
• Un échec lors de l’ouverture susceptible de se produire dans les cas suivants :
– on cherche à ouvrir en écriture un fichier inexistant ;
– on ne dispose pas des droits d’accès voulus, soit au fichier, soit au
répertoire ;
– l’unité ou le répertoire correspondant est saturé ;
– une erreur matérielle s’est produite (voir ci-après).
• La rencontre d’une fin de fichier, lors d’une lecture. Cette situation ne
correspond pas nécessairement à une véritable erreur, dans la mesure où l’on
peut être amené à lire l’ensemble des informations d’un fichier, sans savoir
nécessairement combien il en contient. Nous en verrons plusieurs exemples.
• Le manque de place sur l’unité concernée, lors de l’écriture dans un fichier. On
n’oubliera pas qu’il existe un tampon dans lequel s’accumule l’information
avant qu’elle ne soit effectivement transférée dans le fichier. Dans ces
conditions, cette erreur risque d’être détectée plus tardivement que prévu, voire
lors de la fermeture du fichier.
• Une erreur matérielle, cas rare car généralement pris en charge par le système
d’exploitation.
Les erreurs que nous avons qualifiées de matérielles sont généralement
interceptées par le système d’exploitation lui-même et elles ne sont donc pas
communiquées au programme. Dans ce cas, le système d’exploitation se charge
de signaler l’erreur à l’utilisateur et de lui proposer de la réparer, lorsque cela est
possible. Lorsque l’erreur n’est pas réparable ou que l’utilisateur ne parvient pas
à la réparer, le système se charge généralement de mettre fin au programme
concerné7.
Par ailleurs, il faut ajouter certains risques spécifiques au langage C, et qui sont
liés :
• à l’existence d’une structure de type FILE, laquelle peut se trouver détruite
accidentellement ou, tout simplement, ne pas exister (pour peu que l’ouverture
du fichier se soit mal déroulée) ;
• à la manipulation du pointeur, lorsqu’on souhaite réaliser un accès direct, ce
pointeur risquant d’être placé à un endroit inadapté à l’opération concernée.
3.2 La détection des erreurs en C
La plupart des erreurs, qu’il s’agisse d’erreur d’ouverture, de lecture ou
d’écriture, dès lors qu’elles ne sont pas interceptées par le système
d’exploitation, ne mettent jamais fin à l’exécution du programme. Les
conséquences en sont alors assez déroutantes, dans la mesure où, la plupart du
temps, les opérations ultérieures sur le fichier concerné n’aboutissent plus ; mais
rien ne permet au programme de s’en apercevoir tant qu’il ne cherche pas
explicitement à déceler ces erreurs. Or sur ce plan, le langage C n’offre
malheureusement pas de mécanisme homogène, comme nous allons le voir en
examinant les différents risques existants et les outils mis à notre disposition.
3.2.1 Les risques liés à la structure FILE – valeur de retour de
fopen
Le mécanisme de gestion de fichiers en C, à savoir la création, uniquement au
moment de l’ouverture du fichier, d’une structure de type FILE, peut facilement
induire des erreurs de programmation. En effet, toute fonction réalisant une
opération sur un fichier s’attend à recevoir l’adresse d’une telle structure. Or la
norme ne précise pas comment doit réagir le programme dans le cas où cette
adresse est incorrecte ou lorsque la structure en question a été partiellement
endommagée. En pratique, cela peut aller d’un code de retour correspondant à
une erreur (c’est généralement le cas lorsque l’adresse de la structure est nulle)
jusqu’à un comportement aberrant du programme.
Bien entendu, un programme au point ne devrait pas être concerné par cette
remarque. Encore faut-il qu’il s’assure que les ouvertures de fichier se déroulent
convenablement, en examinant systématiquement la valeur de retour de la
fonction d’ouverture de fichier fopen.
3.2.2 Une détection des erreurs avec ferror peu efficace
La norme prévoit, dans la structure FILE associée à un fichier, l’existence d’un
indicateur d’erreur (de type vrai/faux, c’est-à-dire nul ou non nul). On peut à tout
moment en connaître la valeur grâce à la fonction ferror.
Théoriquement, lorsqu’un tel indicateur est positionné par une fonction, il le
reste tant que l’on n’appelle pas clearerr ou rewind qui le remettent à zéro (faux).
Cela devrait théoriquement permettre de ne tester un tel indicateur qu’à certains
moments stratégiques. Malheureusement :
• Cet indicateur n’existe que lorsque la structure de type FILE a été créée. En
particulier, en cas d’échec d’ouverture, on ne disposera pas de structure FILE,
donc pas d’indicateur d’erreur. Ce point n’est cependant pas très gênant si l’on
a pris soin d’examiner la valeur de retour de fopen.
• Par ailleurs, et surtout, la norme n’est pas très explicite en ce qui concerne les
fonctions et les erreurs susceptibles d’intervenir sur la valeur de cet indicateur.
Certes, elle précise que toute lecture est équivalente à une suite d’appels de
fgetc et que toute écriture est équivalente à une suite d’appels de fputc. Mais
elle n’indique pas si cette équivalence porte sur le positionnement de
l’indicateur d’erreur. En pratique, on rencontre des implémentations où bon
nombre d’erreurs, hormis les erreurs matérielles, ne sont pas répercutées sur
l’indicateur d’erreur. Fort heureusement et comme le prévoit la norme, ces
erreurs sont toujours répercutées sur la valeur de retour de la fonction
concernée.
3.2.3 Un indicateur errno peu fiable
Il existe également un indicateur général, nommé errno, défini dans errno.h. Il
s’utilise comme une variable scalaire et il est susceptible d’être positionné par
les différentes fonctions de la bibliothèque standard. Cependant :
• cet indicateur errno est indifférencié : il n’en existe qu’un seul pour toutes les
fonctions de la bibliothèque standard, donc a fortiori un seul pour tous les
fichiers manipulés ;
• par ailleurs, la norme n’impose pratiquement rien quant à l’action que doivent
exercer la plupart des fonctions sur errno dont les valeurs (lorsqu’elles
existent !) sont, de surcroît, dépendantes de l’implémentation.
3.2.4 Les risques spécifiques à l’accès direct
Comme nous l’avons déjà évoqué à la section 2.5, l’accès direct se fonde sur les
mêmes opérations que l’accès séquentiel, en se contentant d’agir sur un pointeur
de fichier. Or une telle action peut elle aussi entraîner des erreurs, par suite d’un
mauvais positionnement de ce pointeur :
• en cas de lecture dans un fichier, il y aura erreur si le pointeur est placé à
l’extérieur du fichier, soit avant son début, soit après sa fin ;
• en cas d’écriture dans un fichier, il n’y aura erreur que si le pointeur est placé
avant son début ; dans tous les autres cas, une écriture sera toujours possible, à
moins que l’on ne dépasse la capacité de l’unité.
Cette fois, comme on le verra plus précisément à la section 7, la détection des
erreurs ne pourra pas se faire sans ambiguïté si l’on se contente d’examiner la
valeur de retour des fonctions ayant agi sur le pointeur. Il faudra :
• soit examiner la valeur de retour des fonctions réalisant les opérations de
lecture ou d’écriture ultérieures ;
• soit effectuer soi-même un test sur la position qu’on cherche à attribuer au
pointeur et éviter les valeurs incorrectes.
3.2.5 Détection de la fin de fichier avec feof
La structure de type FILE associée à un fichier comporte un indicateur de fin de
fichier qui peut être examiné à l’aide de la fonction feof. Mais, comme
l’indicateur d’erreur, il n’existe que lorsque ladite structure a été créée. En
particulier, en cas d’échec d’ouverture, on ne disposera pas de structure FILE,
donc pas d’indicateur de fin de fichier. Là encore, les choses ne seront pas graves
si l’on a pris soin d’examiner la valeur de retour de fopen.
Cet indicateur n’est positionné que lorsqu’une fin de fichier a interrompu
prématurément une lecture. En particulier, la lecture des derniers octets d’un
fichier ne permettra pas d’en détecter la fin ; il faudra tenter de lire au-delà,
c’est-à-dire au moins un octet inexistant !
Par sa nature même, un tel indicateur n’intervient qu’en cas de lecture. En effet,
il est toujours possible d’écrire en n’importe quel endroit d’un fichier, pour peu
que l’on dispose de la place nécessaire sur l’unité concernée.
Cet indicateur s’avère extrêmement pratique pour lire séquentiellement un
fichier dont on ne connaît pas la taille. Bien entendu, il sera préférable de savoir
comment le fichier est organisé, à moins de le traiter octet par octet ! À ce
propos, on verra que lorsque l’on n’est pas certain de relire un fichier de la
même manière qu’on l’a créé, il pourra être utile de distinguer une fin normale
(c’est-à-dire rencontrée sans qu’on ait lu aucun octet) d’une fin anormale (c’est-
à-dire rencontrée après avoir lu un nombre insuffisant d’octets).
Dans le cas de l’accès direct, les choses sont moins évidentes. Autant on est
certain que la rencontre d’une fin de fichier en cours de lecture positionne bien
l’indicateur, autant la norme reste floue en ce qui concerne le cas où, avant
lecture, le pointeur est déjà placé en dehors du fichier, c’est-à-dire avant son
début ou après sa fin. En pratique, de nombreuses implémentations positionnent
l’indicateur de fin de fichier dans ce cas mais il n’est probablement pas
raisonnable de compter là-dessus pour réaliser un programme portable !
Remarques
1. La norme autorise une implémentation à introduire des octets (de valeur nulle) de remplissage à la
fin d’un fichier créé en mode binaire. Elle ne précise pas comment la fin de fichier doit s’opérer
dans ce cas : avant ou après les octets de remplissage. En pratique, les implémentations utilisant
cette possibilité sont en voie de disparition.
2. L’indicateur de fin de fichier est remis à zéro :
– lorsque l’on appelle la fonction clearerr ;
– lorsqu’on lit avec succès de nouvelles informations dans le fichier.
3.2.6 La valeur de retour des différentes fonctions
D’après ce qui précède, on voit que, exception faite des erreurs d’ouverture et de
la détection de la fin de fichier, la détection des autres erreurs de gestion de
fichiers passera de préférence par l’examen de la valeur de retour des différentes
fonctions de lecture ou d’écriture. Même à ce niveau, les choses seront loin
d’être homogènes puisque :
• certaines fonctions fourniront un pointeur, d’autres un entier ; certaines parfois
renverront la valeur EOF (en général, -1) en cas d’erreur, laquelle pourra
éventuellement n’avoir aucun rapport avec une fin de fichier ;
• la nature des erreurs distinguées pourra varier avec la fonction concernée ;
• la norme reste floue en ce qui concerne le fait que l’existence d’un tampon peut
très bien, dans certains cas, retarder la détection d’une erreur d’écriture ;
• il y a parfois recoupement entre la valeur de retour et l’indicateur de fin de
fichier.
3.2.7 Conseils de programmation
Le tableau 13.3 récapitule le comportement que nous vous suggérons fortement
d’adopter vis-à-vis des différents outils de détection d’erreurs que nous venons
d’examiner.
Tableau 13.3 : le traitement des erreurs de gestion de fichiers
Outil
Appréciation Remarque
considéré
À examiner Ne protège pas du risque de
Valeur de systématiquement détérioration de la structure FILE
fopen
associée au fichier.
Valeur de Peu exploitable
ferror
Peu exploitable, Exploitable dans une implémentation
Indicateur d’une façon donnée, si on ne vise pas la portabilité.
errno
portable
Valeur des Peu fiable Il est préférable de tester soi-même la
fonctions position du pointeur ou les valeurs des
agissant fonctions de lecture ou d’écriture
sur le appelées ultérieurement.
pointeur
Exploitable On pourra en outre examiner la valeur
Valeur de uniquement après des fonctions de lecture pour
feof une lecture distinguer une fin normale d’une fin
anormale.
Valeur des À examiner Démarche spécifique à chaque
fonctions systématiquement fonction et qui sera explicitée sous
de lecture forme d’un canevas dans la section
ou spécifique à la fonction.
d’écriture
4. Les entrées-sorties binaires : fwrite et fread
En matière de traitement de fichiers en C, comme l’explique en détail la section
2, on distingue :
• le mode d’ouverture du fichier (binaire ou texte), ce qui amène à parler de flux
binaire ou de flux texte ; certaines implémentations ne font pas cette
distinction, comme l’autorise la norme ;
• le type des opérations que l’on y effectue : binaires ou formatées, et ce
indépendamment du mode d’ouverture ;
• le type d’accès au fichier : séquentiel ou direct.
Ici, nous étudierons en détail les opérations binaires réalisables avec les
fonctions fwrite et fread. A priori, il est plutôt recommandé de les utiliser sur des
flux binaires. Nous préciserons cependant quelles seraient les conséquences
d’une utilisation sur des flux texte. Par ailleurs, nous nous plaçons dans un
contexte séquentiel, ce qui n’est nullement restrictif dans la mesure où,
rappelons-le, l’accès direct ne se distingue de l’accès séquentiel que par
l’utilisation de fonctions supplémentaires agissant sur le pointeur de fichier. Ces
fonctions seront étudiées à la section 7.
Auparavant, nous vous proposons, à titre introductif, deux exemples simples de
programmes représentant l’utilisation de fwrite et de fread sur des flux binaires en
accès séquentiel.
4.1 Exemple introductif de création séquentielle d’un
fichier binaire
Le programme suivant se contente d’enregistrer, sous forme binaire, dans un
fichier ouvert en mode binaire, une succession d’informations fournies par
l’utilisateur. Chaque information correspond à un point d’un plan défini par un
nom formé d’une lettre et deux coordonnées entières.
Exemple de création séquentielle d’un fichier binaire8
#include <stdio.h>
int main()
{
struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
FILE * sortie ; /* flux associé au fichier à créer */
printf ("nom du fichier a creer : ") ;
scanf ("%80s", nomfich) ;
sortie = fopen (nomfich, "wb") ;
printf ("donnez le nom (* pour finir) et les coordonnees des points :\n") ;
while (1)
{ scanf (" %c%d%d",&[Link], &bloc.x, &bloc.y) ; /* notez l'espace avant %c */
if ([Link] == ‘*') break ;
fwrite (&bloc, sizeof(bloc), 1, sortie) ;
}
fclose (sortie) ;
}
nom du fichier a creer : points
donnez le nom (* pour finir) et les coordonnees des points :
a 1 5
s 12 32
x 5 11
y 15 7
t 11 14
* 0 0
Nous définissons un modèle de structure nommé point destiné à contenir les
informations relatives à un point et nous définissons une variable de ce type
nommée bloc. Nous avons déclaré un tableau de caractères nomfich destiné à
contenir, sous la forme d’une chaîne, le nom du fichier que l’on souhaite créer.
Nous déclarons ensuite la variable sortie comme étant un pointeur sur un objet de
type FILE, ce qui revient à dire que sortie est un flux (voir section 2.2). L’appel :
sortie = fopen (nomfich, "wb") ;
réalise ce que l’on nomme une ouverture de fichier. La fonction fopen (voir
section 8) possède deux arguments :
• le nom du fichier concerné, fourni sous forme d’une chaîne de caractères ; ici,
nous avons prévu que ce nom ne dépassera pas 80 caractères (le nombre 81
tenant compte du caractère \0) ; notez qu’en général il pourra comporter une
information (chemin, répertoire…) permettant de préciser l’endroit où se
trouve le fichier ;
• une indication, fournie elle aussi sous forme d’une chaîne, précisant ce que l’on
souhaite faire avec ce fichier ; ici, on trouve wb (abréviation de write binary)
qui permet de réaliser une ouverture :
– en écriture – si le fichier cité n’existe pas, il sera créé par fopen ; s’il existe
déjà, son ancien contenu deviendra inaccessible. Autrement dit, après
l’appel de cette fonction, on se retrouvera dans tous les cas en présence
d’un fichier vide ;
– en mode d’accès binaire – cette dernière indication n’est en fait utile que
dans les implémentations qui distinguent les deux modes d’accès binaire et
texte.
La fonction fopen fournit en résultat un pointeur sur une structure FILE qu’elle a
alloué dynamiquement, du moins lorsque l’opération d’ouverture a réussi. Dans
le cas contraire, elle fournirait un pointeur nul. Ici, nous n’avons pas tenu compte
de cette particularité.
Le remplissage du fichier est réalisé par la répétition de l’appel :
fwrite (&bloc, sizeof(bloc), 1, sortie) ;
La fonction fwrite possède quatre arguments précisant :
• l’adresse d’un bloc d’informations – ici &bloc ;
• la taille d’un bloc, en octets – ici sizeof(bloc) ; notez l’emploi de l’opérateur
9
sizeof qui assure la portabilité du programme ;
• le nombre de blocs de cette taille que l’on souhaite transférer dans le fichier –
la plupart du temps, on emploiera, comme ici, la valeur 1 ; nous verrons
cependant que fwrite permet de transférer plusieurs blocs consécutifs de même
taille à partir d’une adresse donnée ;
• le nom du flux concerné, c’est-à-dire finalement l’adresse de la structure de
type FILE décrivant le fichier (ici sortie).
Enfin, la fonction fclose réalise ce que l’on nomme une fermeture de fichier. Elle
force l’écriture dans le fichier du tampon qui lui est associé. En effet, chaque
appel à fwrite provoque l’écriture d’informations dans le tampon et ce n’est que
lorsque ce dernier est plein qu’il est « vidé » dans le fichier. Dans ces conditions,
on voit qu’après le dernier appel de fwrite, il est nécessaire de forcer le transfert
des dernières informations accumulées dans le tampon.
Remarques
1. Nous avons utilisé une structure pour y ranger les différentes informations relatives à un point. On
peut dire que nous avons en quelque sorte reconstitué plus ou moins artificiellement une notion
d’enregistrement, en écrivant toujours des informations de même taille dans le fichier. Cependant,
rien ne nous oblige à procéder ainsi. Nous aurions pu tout aussi bien utiliser trois variables scalaires
nommées par exemple nom, x et y. Dans ce cas, l’information lue par :
scanf (" %c%d%d", &nom, &x, &y) ;
aurait été recopiée dans le fichier par trois appels successifs à fwrite :
fwrite (&nom, sizeof(nom), 1, sortie) ;
fwrite (&x, sizeof(x), 1, sortie) ;
fwrite (&y, sizeof(y), 1, sortie) ;
On notera bien qu’il n’est pas certain alors que les deux méthodes soient totalement équivalentes.
En effet, comme la structure peut contenir des octets de remplissage ou d’alignement (voir section
3.1 du chapitre 11), la première méthode peut amener à écrire plus d’octets que la seconde. Comme
on s’en doute, il est généralement indispensable d’utiliser la même méthode pour l’écriture et pour
la relecture des informations, sous peine de risquer d’obtenir des résultats assez fantaisistes.
2. Ici, nous n’avons pas cherché à traiter les erreurs, ni à l’ouverture, ni à l’écriture. Notamment, en
cas d’erreur d’ouverture, les appels à fwrite pourraient ne pas aboutir, sans que le programme ne
soit capable de s’en apercevoir. La section 4.3.4 présentera une version du présent programme
adaptée à la prise en compte des erreurs.
4.2 Exemple introductif de liste séquentielle d’un
fichier binaire
Voici maintenant un programme qui permet de lister le contenu d’un fichier
quelconque tel qu’il a pu être créé par le programme précédent.
Exemple de liste séquentielle d’un fichier binaire10
#include <stdio.h>
int main()
{
struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
FILE * entree ; /* flux associé au fichier à lister */
printf ("nom du fichier a lister : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "rb") ;
while (1)
{ fread (&bloc, sizeof(bloc), 1, entree) ;
if (feof(entree)) break ;
printf ("%c %d %d\n", [Link], bloc.x, bloc.y ) ;
}
printf ("*** fin de fichier ***\n") ;
fclose (entree) ;
}
nom du fichier a lister : points
a 1 5
s 12 32
x 5 11
y 15 7
t 11 14
*** fin de fichier ***
Les déclarations sont identiques à celles du programme précédent, hormis le fait
que le flux correspondant au fichier se nomme ici entree. Mais on trouve cette
fois dans l’appel de la fonction fopen l’indication rb (abréviation de read binary).
Elle correspond toujours à une ouverture en mode d’accès binaire. En revanche,
l’indication r indique que le fichier en question ne sera utilisé qu’en lecture. Il
est donc nécessaire qu’il existe déjà. Dans le cas contraire, la fonction fopen (voir
section 8) fournirait un pointeur nul. Ici, nous n’avons pas tenu compte de cette
particularité.
La lecture dans le fichier se fait par un appel de la fonction fread :
fread (&bloc, sizeof(bloc), 1, entree) ;
dont les arguments sont comparables à ceux de fwrite. Mais, cette fois, la
condition d’arrêt de la boucle est :
feof (entree)
Celle-ci prend la valeur vrai (non nul) lorsque la fin du fichier a été rencontrée.
Notez bien que la fonction feof ne travaille pas par anticipation, ce qui signifie
qu’il n’est pas suffisant d’avoir lu le dernier octet du fichier pour que cette
condition prenne la valeur vrai. Il est nécessaire d’avoir tenté de lire au-delà11.
C’est ce qui explique que nous ayons examiné cette condition après l’appel de
fread et non avant. Une construction telle que :
while (!feof(entree))
{ fread(…) ;
printf …) ;
}
ne serait pas satisfaisante car la fin de fichier n’apparaîtrait qu’après un échec de
la dernière lecture. Comme ici nous n’examinons pas le code de retour de fread,
le dernier bloc serait listé deux fois !
Remarque
Ici, nous n’avons cherché à traiter les erreurs ni à l’ouverture, ni à l’écriture. En cas d’erreur
d’ouverture, les appels à fread pourraient ne pas aboutir, sans que le programme ne soit capable de
s’en apercevoir. La section 4.4.4 présentera une version du présent programme adaptée à la prise en
compte des erreurs.
4.3 La fonction fwrite
La fonction fwrite permet d’écrire sous forme binaire (brute) des informations sur
un flux. Notez qu’on parle d’écriture sur un flux, plutôt que d’écriture dans un
fichier, dans la mesure où l’opération a lieu au niveau du tampon associé au
fichier et non pas directement au niveau du fichier lui-même.
4.3.1 Prototype
size_t fwrite (const void *adr, size_t taille, size_t nblocs, FILE
*flux) (stdio.h)
adr
Adresse en mémoire à partir
de laquelle seront prélevées
les informations à écrire
dans le fichier.
taille
Taille en octets de chaque
bloc
nblocs
Nombre de blocs à écrire Un bloc est écrit en totalité
ou pas écrit du tout.
flux
Flux concerné
Valeur de Nombre de blocs Décrite en détail à la
retour effectivement écrits dans le section 4.3.3
fichier.
4.3.2 Rôle
Cette fonction écrit, sur le flux spécifié, au maximum nblocs consécutifs de taille
octets chacun prélevés en mémoire à partir de l’adresse adr. On notera qu’on lui
indique non pas directement un nombre d’octets, mais un nombre de blocs d’une
taille donnée. En cas de fonctionnement normal, il revient au même d’écrire
nblocs de taille octets ou un seul bloc de nblocs*taille octets, de sorte qu’en
général, on choisira nbloc égal à un. En revanche, les deux possibilités diffèrent
en cas d’erreur d’écriture car un bloc est soit écrit dans sa totalité, soit non écrit.
Avec un seul bloc de nblocs*taille octets, aucun bloc peut n’avoir été écrit alors
qu’avec nblocs de taille octets, on aura pu écrire les premiers…
Dans les implémentations où la distinction existe, cette fonction est
généralement utilisée sur des flux binaires (fichiers ouverts en mode binaire).
Son utilisation sur des flux texte n’est pas formellement interdite, elle reste
néanmoins fortement déconseillée, compte tenu des transcodages qu’elle risque
d’induire (voir section 2.3).
Remarque
Quand on souhaite écrire des informations dans un fichier octet par octet, sans donner à chacun d’eux
une signification sous forme de caractère, il n’est pas nécessaire d’utiliser fwrite sur des blocs d’un
octet. On peut alors utiliser des fonctions telles que fputc ou putc, tout en conservant une ouverture
en mode binaire qui garantit le transfert sans transcodage. En effet, ces deux appels sont équivalents,
aux valeurs de retour près :
putc (c, flux)
fwrite (&c, 1, 1, flux)
4.3.3 Valeur de retour
La valeur de retour de fwrite correspond au nombre de blocs effectivement écrits.
En cas de fonctionnement normal, cette valeur est, bien sûr, égale à nblocs. En
revanche, il se peut que la fonction ne puisse écrire autant d’informations que
souhaité, dans l’un des cas suivants :
• manque de place sur l’unité concernée ;
• pointeur positionné avant le début du fichier, dans le cas où l’on a utilisé l’une
des fonctions d’action sur le pointeur, c’est-à-dire fseek ou fsetpos ;
• erreur matérielle sur le périphérique concerné, elle est généralement prise en
compte par le système d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée, soit plus souvent parce que l’ouverture du fichier
s’est mal déroulée, l’adresse contenue dans flux étant alors nulle.
Rappelons que la notion d’erreur d’écriture reste quelque peu floue en raison de
la présence d’un tampon qui peut influer sur le moment où les erreurs d’écriture
sont effectivement détectées.
4.3.4 Détection des erreurs
Comme l’a expliqué de façon générale la section 3, le traitement des erreurs
d’écriture nécessite l’examen de la valeur de retour de fwrite. Il y aura erreur dès
lors que cette valeur est inférieure au nombre de blocs à écrire (voir section
4.3.3) et les causes sont assez diverses. Néanmoins, moyennant les hypothèses
suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• on a vérifié que le pointeur n’était pas positionné avant le début du fichier.
Les causes d’erreur de lecture se limitent alors à :
• un manque d’espace sur l’unité ;
• une erreur matérielle si, exceptionnellement, elle n’est pas interceptée par le
système d’exploitation.
Voici un canevas utilisable lorsque ces hypothèses sont vérifiées :
Canevas de détection des erreurs de fwrite
int nb_ecrits ;
int nblocs ;
FILE *fich ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné avant le début du fichier */
/* - le programme est au point */
nb_ecrits = fwrite (…, …, nblocs, fich) ;
nb_ecrits
== nblocs
La lecture s’est déroulée normalement.
< nblocs
Erreur d’écriture : en général, manque de place, plus
rarement erreur matérielle
Exemple
Voici comment nous pourrions adapter le programme de la section 4.1, afin de
prendre en compte les possibilités d’erreur d’écriture ou d’ouverture. Rappelons
que l’erreur d’ouverture correspond généralement à un manque de droits d’accès
au fichier (lorsqu’il existe) ou au répertoire, plus rarement à un manque de place
sur l’unité, exceptionnellement à une erreur matérielle.
Exemple de création séquentielle d’un fichier binaire12, avec prise en compte des
erreurs
#include <stdlib.h> /* pour exit */
#include <stdio.h>
int main()
{
struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
int nblocs ;
FILE * sortie ; /* flux associé au fichier à créer */
printf ("nom du fichier a creer : ") ;
scanf ("%80s", nomfich) ;
sortie = fopen (nomfich, "wb") ;
if (!sortie) { printf ("*** impossible de creer le fichier***\n") ;
exit(-1) ;
}
printf ("donnez le nom (caractere) et les coordonnees des points :\n") ;
while (1) /* boucle tant qu'il y a des données et pas d'erreur d'écriture */
{ scanf (" %c%d%d", &[Link], &bloc.x, &bloc.y) ; /* notez espace avant %c */
if ([Link] == ‘*') break ; /* fin de données */
nblocs = fwrite (&bloc, sizeof(bloc), 1, sortie) ;
if (nblocs != 1)
{ printf ("*** erreur d'ecriture dans fichier ***\n") ;
break ; /* sortie de boucle en cas d'erreur d'écriture */
}
}
fclose (sortie) ;
}
4.4 La fonction fread
La fonction fread permet de lire sous forme binaire (brute) des informations sur
un flux.
4.4.1 Prototype
size_t fread (void *adr, size_t taille, size_t nblocs, FILE
*flux) (stdio.h)
adr
Adresse en mémoire à partir de
laquelle seront rangées les
informations lues dans le fichier.
taille
Taille, en octets, de chaque bloc
nblocs
Nombre de blocs à lire
flux
Flux concerné
Valeur de Nombre de blocs effectivement lus Décrite en détail à
retour la section 4.4.3
4.4.2 Rôle
Cette fonction lit, sur le flux spécifié, au maximum nblocs de taille octets chacun
et les range à partir de l’adresse adr. On notera qu’on indique non pas
directement un nombre d’octets, mais un nombre de blocs d’une taille donnée.
En cas de fonctionnement normal, il revient au même de lire nblocs de taille
octets ou un seul bloc de nblocs*taille octets. En revanche, les deux possibilités
diffèrent en cas d’erreur ou de rencontre de fin de fichier, puisque la valeur de
retour indique alors le nombre de blocs qu’on a pu lire en entier. Avec un bloc de
nblocs*taille octets, il se peut qu’aucun bloc n’ait pu être lu, alors qu’avec nblocs
de taille octets, on aura pu lire les premiers…
Dans les implémentations où la distinction existe, cette fonction est
généralement utilisée sur des flux binaires (fichiers ouverts en mode binaire).
Son utilisation sur des flux texte n’est pas formellement interdite. Elle reste
néanmoins fortement déconseillée, compte tenu des transcodages qu’elle risque
d’induire (voir section 2.3).
Remarques
1. Si un bloc n’a pu être lu en entier, la norme précise que sa valeur est indéterminée. Elle n’indique
pas si celle des blocs suivant reste ou non inchangée. En pratique, cet aspect est peu important.
2. Si l’on demande à fread de lire 0 bloc ou des blocs de taille nulle, le contenu d’adresse adr est
inchangé, ainsi que l’état du flux.
3. Quand on souhaite lire un fichier octet par octet, sans donner à chacun une signification sous forme
de caractère, il est envisageable de faire appel à des fonctions telles que fgetc ou getc, tout en
utilisant une ouverture en mode binaire qui garantit le transfert sans transcodage.
4.4.3 Valeur de retour
La valeur de retour de fread correspond au nombre de blocs effectivement lus. En
cas de fonctionnement normal, cette valeur est bien sûr égale à nblocs. En
revanche, il se peut très bien que la fonction ne puisse délivrer autant
d’informations que souhaité. Cela peut se produire dans l’un des cas suivants :
• rencontre de fin de fichier ;
• pointeur positionné hors fichier (si l’on a utilisé la fonction fseek ou fsetpos) ;
• erreur matérielle sur le périphérique concerné : elle est généralement prise en
compte par le système d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée, soit plus souvent parce que l’ouverture du fichier
s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
Par exemple, considérons un fichier créé par le programme de la section 4.1 et
supposons qu’au lieu de le relire « naturellement » bloc par bloc par :
fread (&bloc, sizeof(bloc), 1, entree) ;
on le relise par « paquets de 6 blocs » de cette manière (on suppose qu’on
dispose de la place nécessaire à l’adresse tab_bloc et que ret est de type int) :
ret = fread (tab_bloc, sizeof(bloc), 6, entree) ;
Si notre fichier ne contient que 5 blocs, on obtiendra la valeur 5 dans ret et l’on
aura bien transféré 5*sizeof(bloc) octets à l’adresse tab_bloc. De plus, feof(entree)
vaudra alors vrai puisqu’on aura bien cherché à lire au-delà de la fin du fichier.
Remarque
À la suite d’une erreur (y compris rencontre d’une fin de fichier), le pointeur de fichier n’est pas
défini.
4.4.4 Détection des erreurs
Comme cela a été expliqué de façon générale à la section 3, le traitement des
erreurs de lecture nécessite l’examen de la valeur de retour de fread. Il y aura
erreur dès lors que cette valeur est inférieure au nombre de blocs à lire (voir
section 4.4.3), et les causes seront assez diverses. Néanmoins, moyennant les
hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• on a vérifié que le pointeur n’était pas positionné avant le début du fichier ou
après sa fin.
Les causes d’erreur de lecture se limitent alors à :
• une erreur matérielle, si elle n’est pas interceptée par le système
d’exploitation ;
• la rencontre d’une fin de fichier au cours de la lecture.
La fin de fichier peut être détectée à l’aide de la fonction feof. Si l’on lit plusieurs
blocs, il peut être utile de distinguer les deux cas :
• fin normale – la fin de fichier a été rencontrée, alors qu’aucun bloc n’a pu être
lu ; c’est ce qui doit se produire si l’on relit un fichier de la même manière
qu’il a été créé ;
• fin anormale – la fin de fichier a été rencontrée, alors qu’on a pu lire un certain
nombre de blocs ; cela peut se produire si l’on relit (volontairement ou non) le
fichier d’une manière différente de sa création.
Voici un canevas utilisable lorsque ces hypothèses sont vérifiées, en supposant
qu’on souhaite distinguer la fin normale de fichier de la fin anormale :
Canevas de détection des erreurs avec fread : cas général
FILE *fich ;
int nblocs ;
int nb_lus ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné en dehors du fichier */
/* - le programme est au point */
nb_lus = fread (…, …, nblocs, fich) ;
feof (fich) nb_lus
== nblocs
NON La lecture s’est déroulée normalement.
< nblocs
Erreur de lecture (rare : problème matériel)
== 0
OUI Fin de fichier normale (mais voir remarque
ci-après)
!= 0
Fin de fichier anormale
À titre indicatif, ce canevas se simplifie notablement si l’on ne lit qu’un bloc à la
fois :
Canevas de détection des erreurs avec fread, lorsqu’on ne lit qu’un bloc à la
fois
FILE *fich ;
int nb_lus ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné en dehors du fichier */
/* - le programme est au point */
nb_lus = fread (…, …, 1, fich) ;
feof (fich) nb_lus
NON 1 La lecture s’est déroulée normalement.
0 Erreur de lecture (rare : problème matériel)
OUI - Fin de fichier, obligatoirement normale
(mais voir remarque ci-après). nb_lus vaut
alors obligatoirement 0.
Remarque
La valeur de retour peut valoir 0, alors que des octets ont été lus ; simplement, on en a obtenu moins
que demandé, ce qui peut amener à conclure un peu abusivement à une fin normale de fichier. Il
n’existe aucun moyen de connaître, dans ce cas, le nombre d’octets effectivement lus, à moins de lire
le fichier octet par octet. Bien entendu, un tel problème ne se pose pas tant qu’on relit le fichier de
façon identique à sa création.
Exemple
Voici comment nous pourrions adapter le programme de la section 4.2, afin de
prendre en compte les possibilités d’erreur de lecture ou d’ouverture. Rappelons
que l’erreur d’ouverture correspond généralement à un fichier inexistant ou à un
manque de droits d’accès, exceptionnellement à une erreur matérielle.
Exemple de liste séquentielle d’un fichier binaire13, avec prise en compte des
erreurs
#include <stdio.h>
#include <stdlib.h> /* pour exit */
int main()
{
struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
int nblocs ;
FILE * entree ; /* flux associé au fichier à lister */
printf ("nom du fichier a lister : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "rb") ;
if (!entree) { printf ("*** impossible d'ouvrir ce fichier ***\n") ;
exit(-1) ;
}
while (1) /* on s'interrompt en cas de fin de fichier ou d'erreur */
{ nblocs = fread (&bloc, sizeof(bloc), 1, entree) ;
if (feof (entree)) { printf ("*** fin de fichier ***\n") ;
break ;
}
if (nblocs != 1) { printf ("*** erreur de lecture ***\n") ;
break ;
}
printf ("%c %d %d\n", [Link], bloc.x, bloc.y ) ;
}
fclose (entree) ;
}
5. Les opérations formatées avec fprintf, fscanf, fputs
et fgets
En matière de traitement de fichiers en C, comme l’explique en détail la section
2, on distingue :
• le mode d’ouverture du fichier (binaire ou texte), ce qui amène à parler de flux
binaire ou de flux texte ; certaines implémentations ne font pas cette
distinction, comme l’autorise la norme ;
• le type des opérations que l’on y effectue : binaires ou formatées ;
• le type d’accès au fichier : séquentiel ou direct.
Ici, nous étudions les opérations formatées réalisables avec les fonctions fprintf,
fscanf, fputs et fgets. A priori, celles-ci sont plutôt destinées à des flux texte.
Cependant ces opérations formatées peuvent, dans des circonstances
particulières, être utilisées sur des flux binaires (voir section 2.4). En outre, nous
nous plaçons ici dans un contexte séquentiel, ce qui n’est nullement restrictif,
dans la mesure où l’accès direct ne se distingue de l’accès séquentiel que par
l’utilisation de fonctions agissant sur le pointeur de fichier et qui seront étudiées
à la section 7.
Rappelons qu’outre les fonctions fscanf, fprintf, fputs et fgets, il existe des
fonctions que nous avons qualifiées de mixtes, c’est-à-dire à la limite entre les
opérations binaires et les opérations formatées. Elles sont étudiées à la section 6.
Nous commencerons par présenter deux exemples de création et de lecture
formatées sur des flux texte en accès séquentiel, avant de présenter les fonctions
fscanf, fprintf, fputs et fgets de manière générale.
5.1 Exemple introductif de création séquentielle d’un
fichier formaté
La section 4.1 vous a présenté un exemple de programme qui se contente
d’enregistrer séquentiellement dans un fichier, sous forme binaire, une
succession d’informations fournies par l’utilisateur ; chaque information
correspondait à un point d’un plan défini par un nom formé d’une lettre et par
deux coordonnées entières. Ici, nous vous proposons un programme effectuant la
même chose, mais en conservant les informations sous forme formatée. Pour
l’instant, nous ne tenons pas compte des différents risques d’erreur.
Exemple de création séquentielle d’un fichier formaté14
#include <stdio.h>
int main()
{
char nom ;
int x, y ;
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères */
FILE * sortie ; /* flux associé au fichier à créer */
printf ("nom du fichier a creer : ") ;
scanf ("%80s", nomfich) ;
sortie = fopen (nomfich, "w") ; /* dans certaines implémentations, */
/* le mode d'ouverture "wt" est utilisable */
printf ("donnez le nom (* pour finir) et les coordonnees des points :\n") ;
while (1)
{ scanf (" %c%d%d", &nom, &x, &y) ; /* notez l'espace avant %c */
if (nom == ‘*') break ;
fprintf (sortie, "%c %d %d\n", nom, x, y) ;
}
fclose (sortie) ;
}
nom du fichier a creer : [Link]
donnez le nom (* pour finir) et les coordonnees des points :
c 12 34
f 8 121
z 152 95
x 25 74
* 0 0
On notera bien sûr certaines similitudes avec le programme de la section 4.1. En
particulier, on utilise un tableau nomfich pour stocker le nom du fichier à créer,
ainsi qu’une variable nommée sortie pour le flux associé au fichier. En revanche,
les informations courantes correspondant à un point ne sont plus rangées dans
une structure ; cela aurait été possible mais totalement superflu ici. L’appel :
sortie = fopen (nomfich, "w") ;
peut aussi s’écrire, dans certaines implémentations :
sortie = fopen (nomfich, "wt") ;
Il réalise une ouverture du fichier dont le nom figure à l’adresse nomfich, dans le
mode w ou wt (abréviation de write text), ce qui correspond à une ouverture :
• en écriture – si le fichier cité n’existe pas, il sera créé ; s’il existe déjà, son
ancien contenu deviendra inaccessible ; autrement dit, après l’appel de cette
fonction, on se retrouve dans tous les cas en présence d’un fichier vide ;
• en mode texte.
Le remplissage du fichier est assuré par la répétition de l’instruction :
fprintf (sortie, "%c %d %d\n", nom, x, y) ;
La fonction fprintf joue le même rôle que printf, avec cette seule différence
qu’elle envoie ses informations sur un flux quelconque (ici sortie), au lieu de les
envoyer au flux prédéfini stdout. En particulier, les caractères obtenus par le
formatage des informations sont exactement les mêmes dans les deux cas15.
Le format que nous avons utilisé appelle plusieurs remarques.
Tout d’abord, nous nous sommes contenté de séparer les différentes informations
par un espace. Certes, ce n’est pas obligatoire, mais cela s’avérera fort précieux
pour distinguer les informations lors de leur relecture.
Par ailleurs, nous n’avons pas imposé de gabarit, ce qui signifie que le nombre
d’octets occupés par une information dépendra de sa valeur. Il s’agit là d’une
différence importante par rapport aux opérations binaires qui conduisaient
toujours à une information de taille fixe. Bien entendu, rien ne nous empêcherait
d’utiliser un format conduisant dans tous les cas à un nombre d’octets fixe, par
exemple «%c%8d%8d».
Enfin, nous avons prévu un caractère de fin de ligne à la suite des informations
relatives à un point. Là encore, ce n’est nullement obligatoire mais cela facilitera
la consultation éventuelle du fichier à l’aide d’un éditeur de texte. Ce point peut
s’avérer fort précieux lors de la mise au point du programme, puisqu’il ne sera
pas nécessaire de développer en parallèle un programme de relecture pour tester
le programme de création (ce qui peut être le cas avec les opérations binaires si
l’on ne dispose pas d’un programme utilitaire permettant de visualiser les
différents octets d’un fichier). Ici, par exemple, le fichier ainsi créé se présentera
tout naturellement comme ceci :
c 12 34
f 8 121
z 152 95
x 25 74
Si, en revanche, nous n’avions pas introduit de fin de ligne, en utilisant le
format :
"%c %d %d"
notre fichier se serait présenté ainsi (ce qui n’aurait pas pour autant gêné sa
relecture par un autre programme) :
c 12 34f 8 121z 152 95x 25 74
5.2 Exemple introductif de liste séquentielle d’un
fichier formaté
Voici maintenant un programme permettant de lister le contenu d’un fichier
quelconque tel qu’il a pu être créé par le programme précédent :
Exemple de liste séquentielle d’un fichier formaté16
#include <stdio.h>
int main()
{
char nom ;
int x, y ;
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères */
FILE * entree ; /* flux associé au fichier à lister */
printf ("nom du fichier a lister : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "r") ; /* dans certaines implémentations, */
/* le mode d'ouverture "rt" est utilisable */
while (1)
{ fscanf (entree, " %c%d%d", &nom, &x, &y) ; /* attention : espace avant %c */
if (feof(entree)) break ;
printf ("%c %d %d\n", nom, x, y ) ;
}
printf ("*** fin de fichier ***\n") ;
fclose (entree) ;
}
nom du fichier a lister : [Link]
c 12 34
f 8 121
z 152 95
x 25 74
*** fin de fichier ***
Les déclarations sont identiques à celles du programme précédent, hormis le fait
que le flux se nomme maintenant entree. Cette fois, le mode d’ouverture du
fichier est r ou, dans certaines implémentations rt (abréviation de read text) ;
cela indique que le fichier ne sera utilisé qu’en lecture et qu’il est ouvert en
mode texte.
On notera qu’il est nécessaire que le fichier existe déjà. Dans le cas contraire, la
fonction fopen (voir section 8) fournirait un pointeur nul. Ici, nous ne tenons pas
compte de cette particularité.
La lecture du fichier se fait par la répétition de l’instruction :
fscanf (entree, " %c%d%d", &nom, &x, &y) ;
La fonction fscanf joue exactement le même rôle que scanf, avec cette seule
différence qu’elle lit ses informations sur un flux quelconque (ici entree), au lieu
de les lire sur le flux prédéfini stdin. En particulier, elle exploite le format de la
même manière et elle présente les mêmes risques de blocage éventuel sur un
caractère invalide, problème dont nous ne préoccupons pas ici.
On notera bien que nous avons prévu un espace avant le code de format %c, afin
d’éviter qu’une lecture prenne en compte pour c le caractère de fin de ligne non
encore consommé par la lecture précédente (le problème aurait été le même dans
le cas d’une lecture au clavier).
En ce qui concerne la présentation des informations affichées par printf, nous
nous contentons d’un format libre. Ainsi, dans le cas présent, ces informations
sont affichées exactement comme si l’on avait consulté le fichier concerné avec
un simple éditeur de texte. Mais, rien ne nous aurait empêché d’utiliser un
format plus élaboré. Par exemple, avec :
printf ("point %c, abscisse : %6d, ordonnee : %6d\n", nom, x, y) ;
la liste se serait présentée ainsi :
point c, abscisse : 12, ordonnee : 34
point f, abscisse : 8, ordonnee : 121
point z, abscisse : 152, ordonnee : 95
point x, abscisse : 25, ordonnee : 74
*** fin de fichier ***
Comme dans le programme de la section 4.2, nous interrompons la boucle
lorsque feof (entree) prend la valeur vrai, c’est-à-dire lorsque la fin du fichier a
été rencontrée. Rappelons que la fonction feof ne travaille pas par anticipation, ce
qui signifie qu’il n’est pas suffisant d’avoir lu le dernier octet du fichier pour que
cette condition prenne la valeur vrai. Il est nécessaire d’avoir tenté de lire au-
delà17 ; c’est ce qui explique que nous ayons examiné cette condition après
l’appel de fscanf et non avant. Une construction telle que :
while (!feof(entree))
{ fscanf(…) ;
printf …) ;
}
ne serait pas satisfaisante car la fin de fichier n’apparaîtrait qu’après un échec de
la dernière lecture. Comme ici nous n’examinons pas le code de retour de fscanf,
les informations relatives au dernier point seraient listées deux fois !
5.3 La fonction fprintf
5.3.1 Syntaxe
Compte tenu de ce que fprintf, comme printf, est une fonction à arguments
variables, son prototype n’est guère parlant en ce qui concerne la nature des
arguments attendus par cette fonction, exception faite pour le premier qui
correspond au format. C’est pourquoi nous vous fournissons ici en parallèle ce
que nous nommons – par abus de langage puisqu’il ne s’agit plus d’une
instruction –, la syntaxe de l’appel de fprintf (les crochets signifient que leur
contenu est facultatif) :
La fonction fprintf
fprintf (flux, format [,liste_d_expressions] )
int fprintf (FILE * flux, const char * format, …) (stdio.h)
flux
Flux concerné
format
Pointeur sur une chaîne – la notion de format
de caractères est introduite à la
correspondant au section 2.1 du
format. Il peut donc chapitre 9 ;
s’agir indifféremment : – le contenu détaillé du
– d’une variable, voire format est étudié aux
d’une expression de sections 3 et 4 du
type char * ; chapitre 9.
– d’une constante
chaîne (laquelle est
traduite, par le
compilateur, en un
pointeur constant de
type char *).
liste_d_expressions Suite d’expressions – les arguments
séparées par des effectifs seront
virgules d’un type en soumis aux
accord avec le code de conversions usuelles,
format correspondant comme avec printf
(voir section 2.2.3 du
chapitre 9) ;
– le cas de désaccord
entre format et liste
d’expressions est
étudié à la section 2.3
du chapitre 9.
Valeur de retour Nombre de caractères Discussion à la section
transmis au flux 5.3.3
(éventuellement 0), une
valeur négative en cas
d’erreur
5.3.2 Rôle
La fonction fprintf envoie sur le flux mentionné les caractères obtenus en
convertissant en une suite de caractères les valeurs mentionnées dans la liste
d’arguments, en tenant compte du format indiqué. Elle travaille exactement
comme printf en ce qui concerne les points suivants, décrits en détail au chapitre
9 :
• les éventuelles conversions des arguments ;
• l’analyse des codes de format ;
• le comportement en cas de non-concordance entre les expressions et les codes
de format, que ce soit en type ou en nombre.
On notera bien que le nombre d’octets écrits sur le flux peut varier d’un appel à
un autre pour une même instruction. Par ailleurs, aucun séparateur n’est introduit
implicitement par fprintf entre les différentes informations. Si, comme c’est
souvent le cas, de tels séparateurs s’avèrent nécessaires pour une bonne relecture
du fichier, ils devront être prévus explicitement dans le format (ou
éventuellement générés par un gabarit de taille suffisante).
Dans les implémentations où la distinction existe, cette fonction est
généralement utilisée sur des flux texte. Dans ce cas, on n’oubliera pas que
l’implémentation peut imposer des contraintes aux fichiers ainsi créés (voir
section 2.3) et en particulier un découpage en lignes. Une banale répétition telle
que la suivante pourrait poser problème :
for (i=0 ; i<1000 ; i++) fprintf (sortie, "%5d", n) ;
En effet, il n’est pas certain qu’on pourra retrouver tous les caractères ainsi écrits
lors d’une relecture ultérieure du fichier. En général, il sera plus prudent
d’introduire explicitement des fins de ligne.
Si, dans une implémentation où la distinction existe, on utilise (volontairement
ou par erreur) cette fonction sur des flux binaires, les caractères produits par le
formatage seront purement et simplement recopiés dans le fichier. Par exemple,
\n restera un seul caractère, même si, dans l’implémentation, la fin de ligne doit
être représentée par deux caractères. En soi, ce n’est pas fondamentalement
gênant, pour peu que l’on relise ultérieurement le fichier ainsi créé en mode
binaire. En revanche, il n’est plus du tout certain que ce fichier soit exploitable
par d’autres programmes, en particulier par des éditeurs de texte.
5.3.3 Valeur de retour
Comme printf, fprintf fournit le nombre de caractères écrits lorsque l’opération
s’est bien déroulée. En théorie, ce nombre peut être égal à zéro, sans qu’il
s’agisse d’une erreur : c’est ce qui se produira, par exemple, si l’on écrit une
chaîne de longueur nulle avec le code %s. On obtiendra une valeur négative
(attention, pas nécessairement EOF ici) en cas d’erreur, c’est-à-dire dans l’un des
cas suivants :
• manque de place sur l’unité concernée (ce qui, avec printf, ne pouvait
apparaître qu’en cas de redirection de la sortie standard) ; rappelons qu’on
risque d’être prévenu plus tard que prévu, compte tenu de l’existence d’un
tampon ;
• pointeur positionné avant le début du fichier (si l’on a utilisé la fonction fseek
ou fsetpos) ;
• erreur matérielle sur le périphérique concerné, elle est généralement prise en
compte par le système d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée, soit plus souvent parce que l’ouverture du fichier
s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
5.3.4 Détection des erreurs
Comme l’a expliqué de façon générale la section 3, le traitement des erreurs
nécessite l’examen de la valeur de retour de fprintf. Il y aura erreur dès lors que
cette valeur est négative. Les causes d’erreur sont assez diverses. Néanmoins,
moyennant les hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• on a vérifié que le pointeur n’était pas positionné avant le début du fichier.
Les causes d’erreur de lecture se limitent alors à :
• un manque d’espace sur l’unité ;
• une erreur matérielle, si elle n’est pas interceptée par le système d’exploitation.
Voici un canevas utilisable lorsque ces hypothèses sont vérifiées :
Canevas de détection des erreurs avec fprintf
FILE * fich ;
int ncar ;
/* on suppose que : - l'ouverture s'est bien
déroulée */
/* - le pointeur n'est pas positionné avant le début du fichier */
/* - le programme est au
point */
ncar = fprintf (fich, …) ;
ncar < 0
Non La lecture s’est déroulée normalement.
Oui Erreur de lecture : manque d’espace sur l’unité, plus
rarement problème matériel
5.4 La fonction fscanf
5.4.1 Syntaxe
Compte tenu de ce que fscanf, comme scanf, est une fonction à arguments
variables, son prototype n’est guère parlant en ce qui concerne la nature des
arguments attendus par cette fonction, exception faite pour le premier qui
correspond au format. C’est pourquoi nous vous fournissons ici en parallèle ce
que nous nommons – par abus de langage puisqu’il ne s’agit plus d’une
instruction –, la syntaxe de l’appel de fscanf (les crochets signifient que leur
contenu est facultatif) :
fscanf (flux, format [,liste_d_adresses] )
int fscanf (FILE * flux, const char * format, …) ; (stdio.h)
flux
Flux concerné
format
Pointeur sur une chaîne de – la notion de format a été
caractères correspondant introduite à la section
au format. Il peut donc 6.1 du chapitre 9 ;
s’agir indifféremment : – le contenu détaillé du
– d’une variable, voire format est étudié aux
d’une expression de type sections 7 et 8 du
char * ; chapitre 9.
– d’une constante chaîne
(laquelle est traduite, par
le compilateur, en un
pointeur constant de type
char *).
liste_d_adresses
Suite d’expressions Le cas de désaccord entre
(séparées par des virgules) format et liste
de type « pointeur sur des d’expressions est étudié à
lvalue » d’un type en la section 6.3 du chapitre 9
accord avec le code de
format correspondant
Valeur de Nombre de valeurs lues Discussion à la section
retour correctement et affectées à 5.4.3
des éléments de la liste
5.4.2 Rôle
La fonction fscanf lit des caractères sur le flux mentionné, les convertit en tenant
compte du format indiqué et affecte les valeurs obtenues aux différentes adresses
indiquées dans la liste. Elle travaille exactement comme scanf, en ce qui concerne
les points suivants, décrits en détail au chapitre 9 :
• l’analyse des codes de format ;
• le comportement en cas de non-concordance entre les codes de format et les
lvalue pointées, que ce soit en type ou en nombre.
De même que le nombre d’octets écrits par fprintf pouvait varier d’un appel à un
autre, le nombre d’octets lus par fscanf dans le fichier peut varier d’un appel à un
autre.
Dans les implémentations où la distinction existe, cette fonction est
généralement utilisée sur des flux texte. Dans ce cas, il peut y avoir
transformation de certains caractères (voir section 2.3). Si, dans une
implémentation où la distinction existe, on utilise (volontairement ou par erreur)
cette fonction sur des flux binaires, les caractères lus dans le fichier ne seront pas
transformés avant le formatage. Par exemple, dans une implémentation où la fin
de ligne est représentée par deux caractères, on obtiendra effectivement deux
caractères et non un seul \n comme ce serait le cas avec une ouverture en mode
texte. En soi, cela peut présenter un intérêt pour relire un fichier formaté créé
volontairement en mode binaire en C (voir section 5.3.2).
5.4.3 Valeur de retour
Comme scanf, fscanf fournit le nombre de valeurs lues correctement ou la valeur
EOF dans les cas suivants :
• rencontre de fin de fichier avant qu’une première conversion18 ait pu être
effectuée ;
• pointeur positionné hors fichier ; ce risque n’apparaît que si l’on utilise fseek
avec une valeur non fournie par ftell, ce qui est théoriquement déconseillé
dans ce cas (voir section 7.3) ;
• erreur matérielle sur le périphérique concerné ; elle est généralement prise en
compte par le système d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée, soit plus souvent parce que l’ouverture du fichier
s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
On notera que, comme avec scanf, il y a un recoupement partiel entre cette valeur
de retour et la valeur fournie par feof (voir section 6.6.3 du chapitre 9). Cette
remarque pourra intervenir dans la détection des erreurs de lecture, comme nous
le verrons ci-après.
5.4.4 Détection des erreurs
A priori, on pourrait penser que fscanf pose les mêmes problèmes que scanf en
matière d’erreur de lecture. Cependant, scanf travaille généralement avec une
entrée conversationnelle, tandis que fscanf travaille généralement avec un fichier.
Aussi les risques d’erreur de données sont-ils ici moins cruciaux. En effet, dans
bon nombre de cas, on pourra supposer que le fichier à lire est correct sur le plan
de son organisation, hypothèse impossible à formuler dans le cas d’entrée au
clavier ! Bien entendu, il faudra quand même éviter que le programme ne se
comporte de façon aberrante si cette hypothèse n’est pas respectée, en particulier
qu’il boucle à la rencontre d’un caractère invalide. Mais dans un tel cas, il ne
sera plus indispensable de chercher à obtenir à tout prix des informations
correctes. On pourra par exemple se contenter, suivant les cas :
• de sauter une ligne incorrecte si cela n’a pas trop d’incidence sur la suite du
programme et si le fichier est effectivement structuré en lignes ;
• d’interrompre la lecture du fichier…
Comme l’explique de façon générale la section 3, le traitement des erreurs
nécessite l’examen de la valeur de retour de fscanf. Il y aura erreur dès lors que
cette valeur est négative et les causes sont assez diverses. Néanmoins,
moyennant les hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• on a vérifié que le pointeur n’était pas positionné avant le début du fichier.
Les causes d’erreur de lecture se limitent alors à :
• la rencontre de la fin de fichier avant que la première conversion n’ait pu
commencer ;
• une erreur matérielle, si elle n’est pas interceptée par le système d’exploitation.
Par ailleurs, avec fscanf, il devient généralement indispensable de gérer
convenablement la rencontre d’une fin de fichier, dès lors qu’il s’agit d’un
critère d’arrêt normal de traitement d’un fichier de taille quelconque19. Pour cela,
nous examinerons la valeur de la fonction feof plutôt que la valeur de retour de
fscanf, qui est moins précise sur ce point. Là encore, dans certains cas, on pourra
chercher à distinguer :
• le cas où la fin de fichier a été atteinte normalement, c’est-à-dire lorsqu’aucun
caractère n’a été lu par fscanf ;
• le cas où la fin de fichier a été atteinte anormalement, c’est-à-dire lorsque
certains caractères ont pu être lus. Ce cas est en effet révélateur d’une
mauvaise structure du fichier (il se peut cependant que cette mauvaise
structure se soit manifestée lors de lectures antérieures).
Si l’on souhaite effectuer cette distinction, on conjuguera l’examen de la valeur
de feof avec celle de fscanf. En effet, seul le cas où feof est vrai et où fscanf
renvoie EOF peut être considéré comme normal20.
Voici un canevas utilisable pour détecter les différentes sortes d’erreur, en
supposant qu’on souhaite distinguer la fin normale de la fin anormale et que ces
hypothèses sont vérifiées.
Canevas de détection des erreurs avec fscanf
FILE * fich ;
int nv_lues, nv_attendues ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné en dehors du fichier */
/* - le programme est au point */
nv_lues = fscanf (fich, …..) ;
feof (fich) nv_lues
==
NON nv_attendues La lecture s’est déroulée normalement.
<
nv_attendues Erreur de lecture, généralement arrêt
prématuré, dû à des données incorrectes,
rarement problème matériel
== EOF
OUI Fin de fichier rencontrée « normalement »
!= EOF
Fin de fichier rencontrée « anormalement »
Exemple
Voici comment nous pourrions adapter le programme de la section 5.2, afin de
prendre en compte les possibilités d’erreur de lecture ou d’ouverture. Rappelons
que l’erreur d’ouverture correspond généralement à un fichier inexistant ou à un
manque de droits d’accès, exceptionnellement à une erreur matérielle.
Exemple de liste séquentielle d’un fichier formaté21, avec prise en compte des
erreurs
#include <stdio.h>
#include <stdlib.h> /* pour exit */
int main()
{
char nom ;
int x, y ;
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
FILE * entree ; /* flux associé au fichier à lister */
int nval ;
printf ("nom du fichier a lister : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "r") ; /* dans certaines implémentations, */
/* le mode d'ouverture "rt" est utilisable */
if (!entree) { printf ("*** impossible d'ouvrir ce fichier ***\n") ;
exit(-1) ;
}
while (1)
{ nval = fscanf (entree, " %c%d%d", &nom, &x, &y) ;
if (feof(entree))
if (nval == EOF) { printf ("*** fin de fichier normale ***\n") ;
break ;
}
else { printf ("*** fin de fichier anormale ***\n") ;
break ;
}
if (nval != 3) { printf ("*** erreur de lecture ***\n") ;
break ;
}
printf ("%c %d %d\n", nom, x, y ) ;
}
fclose (entree) ;
}
5.5 La fonction fputs
À l’image de puts qui transmet une chaîne à la sortie standard stdout, fputs
transmet une chaîne à un flux quelconque.
5.5.1 Prototype
int fputs (const char *chaine, FILE *flux) (stdio.h)
chaine
Adresse d’une chaîne de
caractères
flux
Flux concerné
Valeur de Valeur non négative quand Discussion à la section
retour le déroulement a été correct, 5.5.3
EOF en cas d’erreur
5.5.2 Rôle
La fonction fputs transmet au flux mentionné les différents caractères de la chaîne
d’adresse chaine. Comme avec puts, le caractère de fin de chaîne n’est pas
transmis. En revanche, contrairement à ce que faisait puts, fputs n’envoie aucun
caractère de fin de ligne. Ce manque d’homogénéité entre les deux fonctions se
traduit notamment par une différence entre les fichiers obtenus par fputs d’une
part, par puts accompagné d’une redirection de stdout vers un fichier d’autre part.
Par exemple, avec :
for (i=0 ; i< 3 ; i++) puts ("bonjour") ;
si l’on a redirigé la sortie vers un fichier, ce dernier se présentera ainsi :
bonjour
bonjour
bonjour
En revanche, si on suppose que fich est un flux associé à un fichier ouvert en
écriture en mode texte, avec :
for (i=0 ; i< 3 ; i++) fputs ("bonjour", fich) ;
le fichier obtenu se présentera ainsi :
bonjourbonjourbonjour
Dans les implémentations où la distinction existe, cette fonction est
généralement utilisée avec des flux texte. Son utilisation sur des flux binaires
n’est pas interdite mais elle reste déconseillée. On notera que, pour un type de
flux donné, les deux instructions suivantes sont équivalentes (si l’on ne
s’intéresse pas aux valeurs de retour) :
fputs (ch, fich) ;
fwrite (ch, strlen(ch), 1, fich) ;
Il est généralement conseillé d’utiliser la première forme avec un flux texte et la
seconde avec un flux binaire.
De façon comparable, pour un type de flux donné, les deux instructions
suivantes sont toujours équivalentes (tant que l’on ne s’intéresse pas aux valeurs
de retour) :
fputs (ch, fich) ;
fprintf (fich, "%s", ch) ;
On notera que, avec puts et printf, l’équivalence ne s’obtiendrait que moyennant
l’introduction d’un caractère de fin de ligne dans le format de printf :
puts (ch) ; /* envoie une fin de ligne sur la sortie standard */
/* alors que fputs n'en envoie pas dans le fichier */
printf ("%s\n", ch) ;
5.5.3 Valeur de retour
Comme puts, fputs fournit une valeur non négative22 (dont la signification n’est
pas imposée par la norme) lorsque l’opération s’est convenablement déroulée et
la valeur EOF en cas d’erreur, cette situation pouvant se produire dans les cas
suivants :
• manque de place sur l’unité concernée (ce qui, avec puts, ne pouvait apparaître
qu’en cas de redirection de la sortie standard) ;
• pointeur positionné avant le début du fichier (si l’on a utilisé la fonction fseek
ou fsetpos) ;
• erreur matérielle sur le périphérique concerné (panne, disquette absente ou
déverrouillée) ; elle est généralement prise en compte par le système
d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée, soit plus souvent parce que l’ouverture du fichier
s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
5.5.4 Détection des erreurs
Comme l’a expliqué de façon générale la section 3, le traitement des erreurs
nécessite l’examen de la valeur de retour de fputs. Il y aura erreur dès lors que
cette valeur est égale à EOF et les causes sont assez diverses. Néanmoins,
moyennant les hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle), ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• le pointeur n’est pas positionné avant le début du fichier.
Les causes d’erreur de lecture se limitent alors à :
• un manque d’espace sur l’unité ;
• une erreur matérielle, si elle n’est pas interceptée par le système d’exploitation.
Voici un canevas utilisable, en supposant que ces hypothèses sont vérifiées :
Canevas de détection des erreurs avec fputs
int ret ;
/* on suppose que : - l'ouverture s'est bien
déroulée */
/* - le pointeur n'est pas positionné avant le début du fichier
*/
/* - le programme est au
point */
ret = fputs (…) ;
ret
!= EOF
L’écriture s’est déroulée normalement.
== EOF
Erreur d’écriture : manque de place sur l’unité, plus
rarement problème matériel
5.6 La fonction fgets
La vocation de gets était de lire des chaînes sur l’unité standard d’entrée stdin.
Celle de fgets est de lire des chaînes sur un flux quelconque. Cependant, avec
gets, une chaîne lue devait obligatoirement se terminer par \n (ou à la rigueur par
une fin de fichier). Ce n’est plus indispensable avec fgets car un argument
supplémentaire permet de limiter le nombre de caractères pris en compte et
introduits en mémoire.
5.6.1 Prototype
char * fgets (char *chaine, int n, FILE *flux) (stdio.h)
chaine
Adresse à laquelle seront rangés les
caractères lus.
n
n-1 représente le nombre maximal de
caractères à lire.
flux
Flux concerné
Valeur de Adresse de la chaîne lue quand l’opération discussion
retour s’est bien déroulée (ce qui est encore le cas à la section
si la fin de fichier a été rencontrée après 5.6.3
lecture d’au moins un caractère), le pointeur
NULL en cas d’erreur.
5.6.2 Rôle
Cette fonction lit une suite de caractères sur le flux mentionné en les rangeant en
mémoire, à partir de l’adresse chaine. La lecture s’interrompt soit en cas d’erreur,
soit lorsque l’une des conditions suivantes est satisfaite :
• n-1 caractères ont été lus ; la valeur n-1, plutôt que n, est justifiée par le fait que n
désigne le nombre maximal de caractères qui seront introduits en mémoire,
compte tenu d’un zéro de fin de chaîne ;
• rencontre d’un caractère de fin de ligne23 au cours de la lecture des n-1
caractères ; ce caractère \n est alors recopié en mémoire, contrairement à ce qui
se produit avec gets ou gets_s (en C11) ;
• rencontre d’une fin de fichier. Dans ce cas cependant, si aucun caractère n’a
encore été lu, il s’agira d’une erreur. Si au moins un caractère a été lu, la
lecture sera considérée comme normale. Cela revient à dire qu’une fin de
fichier peut servir de délimiteur d’une chaîne à condition qu’il ne s’agisse pas
d’une chaîne vide.
Dans toutes les situations qui correspondent à une lecture sans erreur, un zéro de
fin de chaîne est introduit à la suite des caractères lus ; on notera bien que le
nombre de caractères introduits en mémoire est donc au plus égal à n.
En cas d’erreur, la norme prévoit que le contenu de l’emplacement d’adresse
chaine est indéterminé. En pratique, on y trouve tout ou partie des caractères lus
avant l’erreur.
Dans les implémentations où la distinction existe, cette fonction est
généralement utilisée avec des flux texte (fichier ouverts en mode texte) ; son
utilisation sur des flux binaire n’est pas interdite mais elle reste déconseillée.
On trouvera à la section 4.6 du chapitre 10 une comparaison entre le code %s avec
scanf et la fonction gets (ou gets_s en C11). Elle se généralise tout naturellement à
fscanf et fgets.
5.6.3 Valeur de retour
En ce qui concerne sa valeur de retour, fgets fournit, comme gets, l’adresse de la
chaîne lue lorsque l’opération s’est bien déroulée, le pointeur NULL en cas
d’erreur. Cette situation peut se produire dans les cas suivants :
• pointeur positionné hors fichier ; ce risque n’apparaît que si l’on utilise fseek
avec une valeur non fournie par ftell, ce qui est théoriquement déconseillé
dans ce cas (voir section 7.2.3) ;
• erreur matérielle sur le périphérique concerné ; elle est généralement prise en
compte par le système d’exploitation ;
• rencontre de la fin de fichier alors qu’aucun caractère n’a encore été trouvé ;
rappelons que si un caractère au moins a pu être trouvé, il ne s’agit plus d’une
erreur ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée24, soit plus souvent parce que l’ouverture du
fichier s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
On notera bien qu’une chaîne non terminée par \n, suivie d’une fin de fichier,
provoque l’activation de l’indicateur de fin de fichier, sans que fgets ne signale
d’erreur ; en revanche, la lecture d’une chaîne vide, suivie d’une fin de fichier,
amène fgets à signaler une erreur…
5.6.4 Précautions algorithmiques
Comme on peut le voir à la section 5.6.2, selon que fgets a ou non trouvé le
nombre maximal de caractères prévus, la chaîne lue en mémoire peut ou non
comporter une fin de ligne. Cela peut poser des problèmes, même dans des cas
très simples. Par exemple, supposons qu’on souhaite faire la liste sur la sortie
standard d’un fichier texte dont on ne connaît pas la taille maximale des lignes.
Pour d’évidentes questions pratiques, il faudra limiter, par exemple à la valeur
longueur, la taille des lignes affichées. On acceptera alors que l’affichage d’une
ligne du fichier puisse s’étendre sur plusieurs lignes de la sortie standard. Dans
ces conditions, on peut envisager tout naturellement la construction suivante :
while (fgets(ligne, longueur, fich))
puts (ligne) ;
Mais alors les lignes de taille inférieure à longueur seront introduites en mémoire
avec leur caractère \n. Leur affichage par puts (qui introduit un \n supplémentaire)
produira donc une ligne blanche supplémentaire. Cela concerne également les
lignes comportant exactement longueur-1 caractères, puisqu’on aura affaire à :
• une première lecture de longueur-1 caractères (sans \n) dont l’affichage par puts
fournira une ligne correcte ;
• une seconde lecture du seul caractère \n (non consommé par la lecture
précédente) dont l’affichage par puts fournira une ligne blanche.
Quant aux lignes de plus de longueur caractères, elles seront découpées en
morceaux de longueur-1 caractères. Chacun des morceaux sera affiché sur une
ligne, sauf le dernier qui sera suivi, lui aussi, d’une ligne blanche.
En définitive, cette construction sera généralement peu satisfaisante. En effet, si
le fichier ne comporte aucune ligne de plus de longueur caractères, on obtiendra
systématiquement un double interligne à l’affichage. Tout au plus la construction
pourrait-elle convenir si la plupart des lignes du fichier étaient de taille
supérieure à longueur…
Remarque
On trouvera un exemple d’utilisation de fgets, appliquée à l’entrée standard, à la section 3.7 du
chapitre 10.
5.6.5 Détection des erreurs
Le traitement des erreurs de lecture nécessite l’examen de la valeur de retour de
fgets (voir section 3). Il y aura erreur dès lors que cette valeur est égale à NULL et
les causes seront assez diverses (voir section 5.6.3). Néanmoins, moyennant les
hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• on a vérifié que le pointeur n’était pas positionné avant le début du fichier ou
après sa fin.
Les causes d’erreur de lecture se limitent alors à :
• une erreur matérielle, si elle n’est pas interceptée par le système
d’exploitation ;
• la rencontre d’une fin de fichier sans qu’aucun caractère n’ait pu être lu.
En général, on souhaitera distinguer la seconde situation de la première, en
utilisant la fonction feof. Par ailleurs, on pourra chercher à distinguer :
• une fin de fichier normale – on a cherché à lire une chaîne et on a trouvé
directement la fin de fichier ;
• d’une fin de fichier anormale – on a trouvé la fin de fichier à la suite d’une
chaîne.
Cette distinction aura surtout un intérêt dans le cas d’un fichier texte supposé ne
contenir que des chaînes (lignes) séparées par des fin de ligne. La fin anormale
correspond alors au cas où la dernière chaîne n’est pas terminée par une fin de
ligne.
Voici un canevas utilisable, en supposant que ces hypothèses sont vérifiées :
Canevas de détection des erreurs avec fgets
FILE * fich ;
char * ad ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné en dehors du fichier */
/* - le programme est au point */
ad = fgets (…, …, fich) ;
feof (fich) ad
!= NULL
NON La lecture s’est déroulée
normalement.
== NULL
Erreur de lecture (problème
matériel rare)
!= NULL
OUI Fin de fichier anormale
(une chaîne non terminée
par une fin de ligne a été
lue).
== NULL
Fin de fichier normale
(aucune chaîne n’a été lue).
6. Les opérations mixtes portant sur des caractères
Il est généralement logique, dans les implémentations qui font la distinction,
d’utiliser les opérations binaires sur des flux binaires et les opérations formatées
sur des flux texte. C’est ce qui a été étudié dans les paragraphes précédents.
Mais, comme l’a expliqué la section 2, il existe en C des fonctions travaillant sur
des caractères, donc des octets. Celles-ci peuvent être utilisées sur les deux types
de flux. Il s’agit de :
• la fonction fputc et la macro putc pour l’écriture de caractères ;
• la fonction fgetc et la macro getc pour la lecture de caractères.
Signalons qu’il existe également une fonction très particulière, ungetc, qui permet
en quelque sorte d’annuler la dernière lecture d’un (et d’un seul) caractère. On
en trouvera la description à l’annexe A.
6.1 La fonction fputc et la macro putc
Alors que putchar envoie un caractère sur la sortie standard, fputc et putc envoient
un caractère sur un flux quelconque. Tandis que fputc est une fonction, putc est
une macro de même prototype réalisant exactement la même chose. Son intérêt
peut se justifier pour obtenir un léger gain de temps d’exécution, au détriment
des risques habituels inhérents aux macros, en particulier des « effets de bord »
(voir section 2.8 du chapitre 15).
6.1.1 Prototypes
int fputc (int c, FILE *flux) (stdio.h)
int putc (int c, FILE *flux) (stdio.h)
c
Valeur qui sera transmise au flux, après
conversion en unsigned char.
flux
Flux concerné
Valeur de Valeur du caractère réellement écrit, EOF en Discussion
retour cas d’erreur à la section
6.1.4
6.1.2 Rôle
A priori, ces fonctions envoient un caractère (donc un octet) sur le flux concerné.
Cependant, elles reçoivent en premier argument non pas une valeur de type char,
comme on pourrait s’y attendre, mais une valeur de type int. Cette curiosité
semble se justifier par le souhait d’harmoniser ces fonctions avec les fonctions
similaires de lecture fgetc et getc, lesquelles doivent effectivement pouvoir
fournir un int et non un char.
Quoi qu’il en soit, la norme prévoit que la valeur de type int reçue en argument
sera convertie en unsigned char, avant d’être transmise au flux. Dans ces
conditions, si, comme c’est généralement le cas, on transmet à cette fonction un
argument de type caractère, on fera apparaître l’une des deux séries de
conversions : unsigned char à int à unsigned char ou signed char à int à unsigned char.
Celles-ci sont examinées en détail à la section 9.6 du chapitre 4. En théorie,
seule la première série conserve le motif binaire. En pratique, la seconde le fait
également dans toutes les implémentations. Ainsi, avec :
unsigned char uc = ‘0xd2' ;
signed char sc = ‘0xd2' ;
char c = ‘0xd2' ;
int n = 0xd2 ;
les quatre instructions suivantes auront exactement le même rôle (envoi du
caractère de code hexadécimal 0xd2 dans le flux de nom fich :
fputc (uc, fich) ;
fputc (sc, fich) ;
fputc (c, fich) ;
fputc (n, fich) ;
Remarque
On trouvera un exemple de programme utilisant fputc à la section 6.2.5.
6.1.3 Précautions
Dans les implémentations qui font la distinction, ces fonctions transmettent
toujours un seul octet dans un fichier ouvert en mode binaire, alors que dans le
cas d’un fichier ouvert en mode texte, elles peuvent en transmettre plusieurs
(voir section 2.3). Il n’en reste pas moins que, pour un mode d’ouverture donné,
les deux instructions suivantes sont totalement équivalentes (dès lors qu’on ne
s’intéresse pas aux valeurs de retour) :
putc (c, fich) ;
fwrite (&c, 1, 1, fich) ;
On peut théoriquement transmettre une valeur entière quelconque aux fonctions
putc et fputc. Toutefois, si cette valeur dépasse la capacité du type unsigned char, on
fera intervenir une conversion dégradante (voir section 9.6 du chapitre 4) qui,
dans les implémentations utilisant la représentation en complément à deux,
revient à ne conserver que l’octet le moins significatif. Ainsi, dans une telle
implémentation, avec :
int i ;
…..
for (i=0 ; i<1024 ; i++) /* on écrit 1 024 caractères */
fputc (i, fich) ;
on obtiendra quatre fois la même suite de caractères, comme si l’on avait
procédé ainsi :
int i, j ;
…..
for (j=0 ; j<4 ; j++) /* on écrit 4 fois */
for (i=0 ; i<256 ; i++) /* les mêmes 256 caractères */
fputc (i, fich) ;
6.1.4 Valeur de retour
Comme putchar, fputc et putc fournissent la valeur du caractère réellement écrit,
lorsque l’opération s’est bien déroulée. On notera bien que cette valeur de retour
peut être différente de celle transmise en argument, lorsque cette dernière n’est
pas représentable dans le type unsigned char.
Par exemple, avec :
int n, res ;
FILE * fich ;
…..
res = fputc (n, fich) ;
la valeur obtenue dans res sera la même que si l’on avait écrit :
res = (unsigned char) n ;
En revanche, ces fonctions fournissent la valeur EOF en cas d’erreur, c’est-à-dire
dans l’un cas suivants :
• manque de place sur l’unité concernée (ce qui, avec putchar, ne pouvait
apparaître qu’en cas de redirection de la sortie standard) ;
• pointeur positionné avant le début du fichier (si l’on a utilisé la fonction fseek
ou fsetpos) ;
• erreur matérielle sur le périphérique concerné (panne, disquette absente ou
déverrouillée) ; elle est généralement prise en compte par le système
d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée25, soit plus souvent parce que l’ouverture du
fichier s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
6.1.5 Détection des erreurs
Comme l’explique de façon générale la section 3, le traitement des erreurs
nécessite l’examen de la valeur de retour de fputc ou de putc. Il y aura erreur dès
lors que cette valeur est égale à EOF et les causes sont assez diverses (voir section
6.1.4). Néanmoins, moyennant les hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• le pointeur n’est pas positionné avant le début du fichier.
Les causes d’erreur de lecture se limitent alors à :
• un manque d’espace sur l’unité ;
• une erreur matérielle, si elle n’est pas interceptée par le système d’exploitation.
Voici un canevas utilisable, en supposant que les dites hypothèses sont vérifiées :
Canevas de détection des erreurs avec fputc ou putc
FILE * fich ;
int c_ecrit ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné avant le debut du fichier */
/* - le programme est au point */
c_ecrit = fputc (…, fich) ; /* ou putc */
c_ecrit
!= EOF
L’écriture s’est bien déroulée.
== EOF
Erreur d’écriture : manque de place sur l’unité ou problème
matériel (rare)
6.2 La fonction fgetc et la macro getc
Alors que getchar lit un caractère sur l’entrée standard stdin, fgetc et getc lisent un
caractère sur un flux quelconque. Tandis que fgetc est une fonction, getc est une
macro de même prototype et réalisant exactement la même chose. Son intérêt
peut se justifier pour obtenir un léger gain de temps d’exécution, au détriment
des risques habituels inhérents aux macros, en particulier des « effets de bord »
(voir section 2.8 du chapitre 15).
6.2.1 Prototypes
int fgetc (FILE *flux) (stdio.h)
int getc (FILE *flux) (stdio.h)
flux
Flux concerné
Valeur de Caractère lu comme un unsigned char Discussion à la
retour et convertit en int, lorsque section 6.2.2
l’opération s’est bien déroulée, EOF
en cas de fin de fichier ou d’erreur.
6.2.2 Valeur de retour
A priori, ces fonctions lisent un caractère (donc un octet) sur le flux concerné.
Cependant, elles fournissent non pas une valeur de type char, comme on pourrait
s’y attendre, mais une valeur de type int. Cette particularité permet à la fonction
de signaler une erreur ou une fin de fichier en fournissant une valeur qui ne
puisse pas être confondue avec un caractère.
Plus précisément, cette valeur de retour est le résultat de la conversion en int du
caractère c (considéré comme unsigned char) si un caractère a pu être lu. On notera
bien qu’une telle valeur n’est jamais négative. Dans la plupart des
implémentations, elle sera comprise entre 0 et 255.
En revanche, on obtient la valeur EOF en cas d’erreur, c’est-à-dire dans l’un des
cas suivants :
• rencontre de fin de fichier ;
• pointeur positionné hors fichier (si l’on a utilisé la fonction fseek ou fsetpos) ;
• erreur matérielle sur le périphérique concerné ; elle est généralement prise en
compte par le système d’exploitation ;
• flux incorrect : la fonction n’a pas pu retrouver les informations relatives à un
fichier dans la structure d’adresse flux ; cela peut se produire soit parce que la
structure FILE a été détériorée, soit plus souvent parce que l’ouverture du fichier
s’est mal déroulée (l’adresse contenue dans flux étant alors nulle).
6.2.3 Précautions
La vocation de fgetc et de getc est de lire des caractères dans un fichier. Or elles
fournissent un résultat de type int qui est toujours positif en cas de lecture
normale. Dans ces conditions, si on considère ces déclarations :
unsigned char uc ;
signed char sc ;
char c ;
on peut s’interroger sur le résultat des lectures suivantes (fich étant un flux) :
uc = fgetc (fich) ; /* motif binaire toujours conservé */
sc = fgetc (fich) ; /* motif binaire pas toujours conservé en théorie */
/* mais conservé en pratique */
c = fgetc (fich) ; /* l'un des deux cas précédents, suivant l'implémentation */
Les deux premières font intervenir des conversions de int en unsigned char ou de
int en signed char ; la troisième fait intervenir l’une des deux conversions
précédentes, suivant l’implémentation. Ces conversions sont étudiées en détail à
la section 9.6 du chapitre 4. En théorie, seule la première conserve le motif
binaire dans tous les cas (car, ici, la valeur entière est obligatoirement
représentable en unsigned char). En pratique, la deuxième, donc aussi la troisième,
le font dans toutes les implémentations.
On notera cependant qu’en plaçant directement le résultat de la lecture dans une
variable de type caractère, il n’est pas facile de tester le cas où la valeur de retour
est égale à EOF. En effet, avec une variable uc de type unsigned char, on ne trouvera
jamais l’égalité :
if (uc == EOF) /* EOF est de type int, uc sera converti en int, ce qui */
/* conduira à une valeur non négative, jamais égale à EOF */
Avec une variable sc de type signed char, on pourra bien détecter la fin de fichier,
mais on risque aussi de la confondre avec un autre caractère.
En général, si l’on s’intéresse à cette valeur EOF, il est conseillé de procéder
ainsi :
int n ;
n = fgetc (fich) ;
…..
if (n == EOF) …
En cas de lecture satisfaisante, si cela est nécessaire, on la reconvertira en
caractère par l’une des affectations :
uc = n ;
sc = n
c = n ;
6.2.4 Détection des erreurs
Le traitement des erreurs de lecture nécessite l’examen de la valeur de retour de
fgetc (voir section 3). Il y aura erreur dès lors que cette valeur sera égale à EOF et
les causes seront assez diverses (voir section 6.2.2). Néanmoins, moyennant les
hypothèses suivantes :
• l’ouverture du fichier s’est convenablement déroulée (valeur de retour de fopen
non nulle) ;
• le programme concerné est au point et il ne risque donc pas de détruire la
structure FILE associée, ni de tenter une opération incompatible avec le mode
d’ouverture ;
• le pointeur n’est pas positionné avant le début du fichier, ni après sa fin.
Les causes d’erreur de lecture se limitent alors à :
• une erreur matérielle, si elle n’est pas interceptée par le système d’exploitation
• la rencontre d’une fin de fichier au cours de la lecture.
Comme on l’a vu à la section 3, la fin de fichier peut en fait être détectée à l’aide
de la fonction feof. On notera qu’ici, contrairement à ce qui produisait avec une
fonction comme fread, la distinction entre fin normale et fin anormale n’existe
plus, dans la mesure où on ne lit qu’un seul caractère à la fois.
Voici un canevas utilisable, en supposant que ces hypothèses sont vérifiées :
Canevas de détection des erreurs avec fgetc ou getc
FILE *fich ;
int ret ;
/* on suppose que : - l'ouverture s'est bien déroulée */
/* - le pointeur n'est pas positionné en dehors du fichier */
/* - le programme est au point */
ret = fgetc (fich) ; /* ou getc (fich) */
feof (fich) ret
!= EOF
NON La lecture s’est déroulée normalement.
== EOF
Erreur de lecture (rare : problème matériel)
-
OUI Fin de fichier (obligatoirement normale car
aucun caractère lu)
6.2.5 Exemple : copie brute d’un fichier
Voici un programme qui recopie un fichier quelconque dans un autre, quel que
soit son contenu. Pour simplifier, nous avons supposé que les noms de fichier ne
dépassaient pas 80 caractères. En revanche, nous tenons compte de toutes les
erreurs possibles.
Copie brute de fichier, avec détection de toutes les erreurs possibles
#include <stdio.h>
#include <stdlib.h> /* pour exit */
int main()
{ FILE *source, *but ;
char n_source[81], n_but[81] ;
int c_lu, c_ec ;
printf ("nom du fichier source : ") ;
scanf ("%80s", n_source) ;
printf ("nom du fichier but : ") ;
scanf ("%80s", n_but) ;
source = fopen (n_source, "rb") ;
if (!source) { printf ("*** fichier source inaccessible ***\n") ;
exit (-1) ;
}
but = fopen (n_but, "wb") ;
if (!but) { printf ("*** erreur ouverture fichier but ***\n") ;
exit (-1) ;
}
while (1) /* boucle jusqu'à fin de fichier source */
{ c_lu = fgetc (source) ;
if (feof (source)) break ; /* fin fichier normale */
if (c_lu == EOF) { printf ("*** erreur materielle de lecture ***\n") ;
break ;
}
c_ec = fputc (c_lu, but) ;
if (c_ec == EOF) { printf ("*** erreur mat ecriture ou manque place ***\n") ;
break ;
}
}
if (fclose (source)) printf ("*** erreur fermeture fichier source ***\n") ;
if (fclose (but)) printf ("*** erreur fermeture fichier but ***\n") ;
}
Remarques
1. Nous avons ouvert nos deux fichiers en mode binaire. Nous obtiendrions le même résultat en
utilisant le mode texte pour les deux fichiers : simplement, dans certaines implémentations, les
caractères manipulés en mémoire pourraient être différents de ceux contenus dans les fichiers. Mais
les éventuels transcodages auraient lieu de façon symétrique. En revanche, en ouvrant un fichier en
mode texte, l’autre en mode binaire, l’identité des deux fichiers n’est plus garantie.
2. La boucle de recopie peut se simplifier notablement si l’on suppose qu’aucune erreur n’aura lieu, en
particulier, aucun manque de place sur l’unité où réside le fichier but :
while ((c_lu=fgetc(source)) != EOF) fputc (c_lu, but) ;
7. L’accès direct
L’accès direct ne se distingue de l’accès séquentiel que par des actions
volontaires sur le pointeur de fichier, à l’aide de fonctions appropriées dont la
plus utilisée est fseek (voir section 2.5).
Nous commencerons par un exemple de programme réalisant une lecture en
accès direct sur un fichier binaire existant. Nous étudierons ensuite en détail la
fonction fseek, ainsi qu’une fonction ftell permettant de connaître la position du
pointeur à un instant donné. Nous verrons que ces fonctions sont soumises à
quelques contraintes assez naturelles dans le cas des flux texte. Puis nous
verrons comment traiter les erreurs supplémentaires induites par l’accès direct.
Nous examinerons ensuite les possibilités offertes par l’accès direct d’une
manière générale, non seulement en consultation de fichier, mais aussi en
création ou en mise à jour. Un exemple de programme vous montrera comment
accéder directement aux lignes d’un fichier texte. Enfin, nous présenterons
rapidement les fonctions fsetpos et fgetpos, dont le principal mérite est de pallier
certains risques que présentent ftell et fseek.
7.1 Exemple introductif d’accès direct à un fichier
binaire existant
La distinction entre accès séquentiel et accès direct ne fait pas intrinsèquement
partie du fichier (voir section 2.5) ; il est donc tout à fait envisageable de créer
un fichier de façon séquentielle et d’y accéder ensuite de façon directe. Voici un
programme qui exploite cette remarque en permettant à un utilisateur de
retrouver directement les informations relatives à un point d’un fichier tel que
celui créé par l’exemple de la section 4.1. On convient de repérer chaque point
par un numéro d’ordre (le premier porte donc le numéro 1) ; ici, ce numéro ne
figure pas dans le fichier.
Exemple d’accès direct à un fichier binaire existant
#include <stdio.h>
int main()
{
struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
FILE * entree ; /* flux associé au fichier à consulter */
long num ; /* numéro de point demandé par l'utilisateur */
printf ("nom du fichier a consulter : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "rb") ;
while (1) /* boucle de recherche de différents points */
{ printf (" numero du point recherche (0 pour finir) : ") ;
scanf ("%ld", &num) ;
if (num == 0) break ; /* fin de recherche */
fseek (entree, sizeof(bloc)*(num-1), SEEK_SET) ;
fread (&bloc, sizeof(bloc), 1, entree) ;
printf ("%c %d %d\n", [Link], bloc.x, bloc.y ) ;
}
fclose (entree) ;
}
nom du fichier a consulter : points
numero du point recherche (0 pour finir) : 4
y 15 7
numero du point recherche (0 pour finir) : 2
s 12 32
numero du point recherche (0 pour finir) : 5
t 11 14
numero du point recherche (0 pour finir) : 1
a 1 5
numero du point recherche (0 pour finir) : 0
Ce programme comporte un certain nombre de points communs avec le
programme de liste séquentielle de la section 5.2 : déclaration du flux, utilisation
d’une structure bloc, ouverture dans le mode rb. L’accès à l’information relative à
un point se fait également par une instruction :
fread (&bloc, sizeof(bloc), 1, entree) ;
Mais, cette fois, on a pris soin auparavant de placer le pointeur de fichier au bon
endroit par :
fseek (entree, sizeof(bloc)*(num-1), SEEK_SET) ;
Le premier argument de cette fonction correspond au flux concerné. Le
deuxième correspond à la valeur qu’on souhaite donner au pointeur. Le troisième
précise par rapport à quelle origine se fait le repérage du pointeur. Ici, il s’agit du
début du fichier, ce qui correspond à la démarche la plus fréquente. On notera
bien la formule :
sizeof(bloc)*(num-1)
dans laquelle la présence de l’expression num-1 (et non pas num) se justifie par la
volonté de numéroter les points à partir de 1 et non de 0. L’opérateur sizeof
assure, là encore, la portabilité du programme.
Remarque
Ici, nous n’avons pas cherché à traiter les erreurs, que ce soit à l’ouverture, à l’écriture ou lors
d’actions sur le pointeur. En cas d’erreur d’ouverture ou de positionnement, les appels à fread
pourraient ne pas aboutir, sans que le programme ne soit capable de s’en apercevoir. La section 7.5
présentera une version de ce programme adaptée à la prise en compte des erreurs.
7.2 La fonction fseek
Cette fonction constitue la base de l’accès direct en C puisqu’elle permet de
placer le pointeur de fichier sur un octet quelconque.
7.2.1 Prototype et rôle
int fseek (FILE *flux, long deplacement, int origine) (stdio.h)
flux
Flux concerné
deplacement
Déplacement en octets, par Signification parfois
rapport à l’origine, définie ambiguë dans le cas de flux
par le paramètre origine texte, voir section 7.2.3
origine
Origine utilisée pour
évaluer l’amplitude du
déplacement :
– SEEK_SET : début du fichier
(deplacement doit être >= 0) ;
– SEEK_CUR : position actuelle
du pointeur (deplacement
doit être< 0) ;
– SEEK_END : fin du fichier.
Valeur de Valeur nulle en cas de En pratique, on ne peut
retour succès, valeur non nulle en guère compter sur cette
cas d’échec valeur, voir discussion à la
section 7.2.2
Cette fonction place le pointeur de fichier relatif au flux concerné à un endroit
défini comme étant situé à deplacement octets de « l’origine » spécifiée par origine.
On notera que la norme ne précise pas que deplacement doit être négatif pour des
octets situés à l’intérieur du fichier, quand l’origine est SEEK_END. C’est cependant
l’hypothèse que font tout naturellement toutes les implémentations.
On emploie souvent pour origine la valeur SEEK_SET qui correspond à un
positionnement absolu. Cependant, la valeur SEEK_CUR peut se révéler précieuse
pour « sauter » une information de taille donnée.
Dans les implémentations qui font la distinction entre flux binaire et flux texte,
certaines contraintes peuvent être imposées à fseek lorsqu’elle est appliquée à un
flux texte, comme on le verra à la section 7.2.3.
Remarques
1. Avec fseek, le positionnement dans le fichier se fait sur un octet de rang donné. Lorsque, comme
cela est conseillé, le fichier est constitué d’une succession de blocs de taille fixe, on pourrait
préférer se placer directement sur un bloc de rang donné ; ce n’est pas possible sans calcul
arithmétique. Manifestement, le risque existe alors de programmer un calcul inexact et donc de
pointer sur un mauvais octet.
2. Un appel de fseek provoque toujours :
– la remise à zéro de l’indicateur de fin de fichier, même dans le cas où l’on est placé à la fin du
fichier ;
– l’annulation de tous les effets d’éventuels appels précédents de ungetc ; cette fonction est
rarement utilisée et son rôle exact est décrit à l’annexe A.
7.2.2 Valeur de retour
La norme précise que la valeur de retour de fseek est une valeur nulle lorsque la
requête formulée a pu être satisfaite et une valeur non nulle dans le cas contraire.
Toutefois, la notion de requête satisfaite est relativement floue, d’autant plus que
l’appel de fseek n’entraîne en général aucune action sur le fichier lui-même. En
pratique, on rencontre bon nombre d’implémentations dans lesquelles fseek
renvoie 0 dans les cas suivants, bien qu’ils correspondent manifestement à une
erreur :
• tentative de positionnement du pointeur avant le début du fichier, en lecture ou
en écriture ;
• tentative de positionnement du pointeur au-delà de la fin du fichier, en cas de
lecture.
En revanche, on notera bien qu’il est normal que fseek renvoie 0 lorsqu’on
cherche à positionner le pointeur au-delà de la fin d’un fichier ouvert en écriture.
Toutes ces considérations montrent que, d’une manière générale, la valeur de
retour de fseek n’est guère exploitable pour la détection des erreurs. Nous vous
proposerons d’autres démarches à la section 7.5.
7.2.3 Contraintes d’utilisation de fseek
Lorsque l’implémentation distingue les flux texte des flux binaires, la norme
impose certaines limitations relativement naturelles dans l’application de fseek à
un fichier texte. En effet, dans ce cas, certains caractères (en général, les fins de
ligne) peuvent se voir représenter par plusieurs octets dans le fichier (voir
section 2.3). La détermination de la position d’un octet précis dans le fichier peut
donc s’avérer délicate. Pour tenir compte de cet aspect, la norme a prévu que,
dans le cas des flux texte, les seules possibilités d’utilisation de fseek autorisées
sont l’une des deux suivantes :
• deplacement = 0 : cela peut permettre de se placer, soit en début, soit en fin de
fichier ; l’utilisation de cette possibilité avec origine = SEEK_CUR est également
permise :
fseek (flux, 0, SEEK_CUR) ; /* place le pointeur la où il est ! */
Elle semble sans intérêt puisque n’ayant aucune action sur le pointeur. En fait,
elle permet de satisfaire les contraintes imposées par la norme lorsque l’on passe
d’une lecture à une écriture (ou l’inverse) dans un fichier ouvert en mise à jour
(voir section 8.2) ;
• deplacement a la valeur fournie par ftell et origine = SEEK_SET ; la fonction ftell,
présentée à la section 7.3, fournit précisément la position du pointeur ; dans
ces conditions, on comprend qu’il soit possible de retrouver une position déjà
atteinte, même si l’on n’est pas en mesure de calculer la valeur exacte
correspondante du pointeur.
En pratique, il n’est pas possible cependant de vérifier (à l’exécution) que la
dernière condition est réellement vérifiée ; lorsque tel n’est pas le cas, le seul
risque réellement encouru est un mauvais positionnement dans le fichier.
Remarque
La norme autorise une implémentation à ajouter des octets nuls de remplissage à la fin d’un fichier
binaire (voir section 3.2.5). Pour tenir compte de cette tolérance, la norme précise également que
fseek peut ne pas fonctionner correctement sur des flux binaires avec pour origine la valeur
SEEK_END. En pratique, les implémentations concernées sont actuellement en voie de disparition.
7.3 La fonction ftell
7.3.1 Prototype, rôle et valeur de retour
long ftell (FILE *flux) (stdio.h)
flux
Flux concerné
Valeur de Position courante (exprimée en octets, par rapport au début
retour du fichier) du pointeur de fichier associé au flux indiqué ou
-1 (attention, ici pas nécessairement EOF) en cas d’erreur
Cette fonction fournit la position courante du pointeur de fichier associé au flux
indiqué. On peut l’utiliser pour mémoriser une position à laquelle on souhaite
revenir ultérieurement :
long position ;
…..
position = ftell (fich) ; /* on mémorise une position */
…..
fseek (fich, position, SEEK_SET) ; /* pour y revenir plus tard */
En toute rigueur, la norme précise que, dans le cas de flux texte, le résultat de
ftell n’est pas obligé de correspondre à une vraie position dans le fichier, mais
qu’il peut toujours être utilisé ultérieurement par fseek pour retrouver ladite
position. Les instructions précédentes sont donc toujours correctes, quel que soit
le type de flux. En outre, on constate qu’en pratique, ftell fournit bien une
position exprimée en octets. Le seul problème qui se pose est que ce nombre
d’octets ne correspond pas nécessairement au nombre de caractères qu’on
rencontrerait en lisant séquentiellement le fichier texte en question.
7.3.2 Intérêt de ftell
Lorsqu’on est en mesure de déterminer exactement l’adresse d’un emplacement
dans un fichier, la fonction ftell présente peu d’intérêt. En effet, elle ne permet
d’obtenir que l’adresse d’un emplacement auquel on est déjà parvenu. En
revanche, cette fonction peut s’avérer précieuse dans les cas suivants :
• pour revenir en certains points stratégiques d’un fichier qu’on exploite le plus
souvent de façon séquentielle ;
• pour déterminer la taille d’un fichier (voir section 7.3.3) ;
• pour créer un index sur différents blocs d’un fichier, alors même que ces blocs
sont de taille variable ; ceci est particulièrement intéressant dans le cas d’un
fichier texte pour lequel il n’est pas toujours possible de compter les octets ;
nous en verrons précisément un exemple à la section 7.6.
7.3.3 Comment déterminer la taille d’un fichier avec ftell
Pour déterminer la taille d’un fichier, il suffit de se positionner en fin de fichier
avec fseek, puis de faire appel à la fonction ftell qui restitue la position courante
du pointeur de fichier.
Voici les instructions qui déterminent la taille du fichier associé au flux fich :
long taille ;
…..
fseek (fich, 0, SEEK_END) ;
taille = ftell (fich) ;
En théorie, ces instructions s’appliquent indifféremment à un flux texte ou à un
flux binaire. A priori, il semble plus naturel de les appliquer à des flux binaires,
même si, comme cela a été dit à la section 7.2.3, elles risquent de ne pas
fonctionner dans certaines implémentations en voie de disparition, compte tenu
de la présence possible d’octets de remplissage en fin de fichier.
En pratique, si on applique ces instructions au même fichier ouvert en mode
texte, on obtient le même résultat, qui correspond au nombre d’octets composant
le fichier. Simplement, ce nombre peut être plus grand que le nombre de
caractères que l’on obtiendrait en lisant le fichier en mode texte.
7.4 Les possibilités de l’accès direct
D’une manière générale, l’accès direct peut s’utiliser dans différentes
circonstances :
• pour accélérer la consultation d’un fichier existant ;
• pour la mise à jour d’un fichier existant ;
• pour la création d’un nouveau fichier.
Nous présenterons ces différentes possibilités en examinant les contraintes
qu’elles imposent et la manière de les mettre en œuvre en C.
7.4.1 Accélération de la consultation d’un fichier existant
Lorsqu’un fichier a été créé par des opérations binaires, on connaît exactement le
nombre d’octets créés par chaque opération. L’accès direct à un tel fichier peut
donc théoriquement être mis en œuvre. Il sera cependant nettement facilité par le
découpage du fichier en blocs de taille fixe, comme dans l’exemple de la section
7.1. Bien entendu, dans les implémentations qui distinguent les flux binaires des
flux texte, on travaillera alors avec des flux binaires.
En revanche, lorsqu’un fichier a été créé par des opérations formatées, il est
généralement difficile d’aboutir à une structure suffisamment simple pour
permettre la détermination d’une valeur significative du pointeur. En outre, dans
les implémentations qui distinguent les flux binaires des flux texte, le comptage
exact des octets pourra être délicat. On aura alors tendance à limiter l’accès à
certains emplacements précis qu’on aura pu repérer préalablement par une
lecture séquentielle ou à l’aide d’une table d’index (nous en verrons un exemple
à la section 7.6).
7.4.2 Mise à jour d’un fichier existant
On parle de mise à jour en cas de modification quelconque d’un fichier. Un cas
particulier de mise à jour réside dans l’extension d’un fichier, c’est-à-dire dans
l’ajout d’informations, de façon séquentielle, à partir de sa fin. Cette extension
peut être réalisée moyennant un mode d’ouverture approprié (a ou ab), sans qu’il
soit nécessaire d’agir sur le pointeur de fichier (il sera directement placé en fin
de fichier).
Les autres formes de mise à jour requièrent le recours à l’accès direct. On notera
qu’après une action sur le pointeur de fichier, il est permis d’exécuter plusieurs
lectures consécutives qui se déroulent alors de façon séquentielle. Il en va de
même pour des écritures consécutives. En revanche, la norme interdit qu’on
passe d’une lecture à une écriture ou d’une écriture à une lecture sans qu’on ait
agi explicitement sur le pointeur (généralement par fseek, mais éventuellement
par rewind ou fgetpos). Pour ce faire, on pourra faire appel à l’instruction suivante,
qui ne modifie pas la valeur du pointeur :
fseek (fich, 0, SEEK_CUR) ;
En ce qui concerne l’ouverture du fichier, on emploiera généralement le mode r+
(ou rb+), qui suppose l’existence du fichier et autorise à la fois la lecture et
l’écriture. Même si la mise à jour peut se faire sans aucune lecture, le mode w+
(ou wb+) n’est pas utilisable pour un fichier existant, puisqu’il crée toujours un
nouveau fichier. Comme on le verra à la section 8.2.2, il existe d’autres
possibilités, très peu utilisées en pratique.
Bien entendu, la mise à jour d’un fichier en accès direct demande un minimum
d’organisation du fichier pour permettre le calcul correct des valeurs du pointeur
et pour pouvoir remplacer un bloc d’information par un bloc de même taille. Là
encore, l’utilisation de blocs de taille fixe facilitera les choses. Ce ne sera
d’ailleurs pas toujours suffisant, dans la mesure où la mise à jour ne peut pas
toujours se faire simplement à partir d’un numéro de bloc. Par exemple, dans un
fichier de type répertoire téléphonique, on cherchera probablement à accéder à
une personne par son nom plutôt que par son numéro d’ordre dans le fichier. On
peut ainsi être amené à créer un « index », table de correspondance entre un nom
et sa position dans le fichier.
Les fichiers formatés se prêteront difficilement à la mise à jour, car ils
posséderont rarement une structure simple. En outre, dans les implémentations
qui distinguent les flux binaires des flux texte, le comptage des octets sera sujet à
caution. Enfin, la norme autorise une implémentation à tronquer un fichier texte
au niveau de la dernière écriture. Cela signifie que, en cas de modification d’une
information, on risque de perdre tout ce qui se trouve à sa suite.
7.4.3 Création d’un nouveau fichier en accès direct
En théorie, dès lors qu’un fichier a été ouvert dans un mode permettant
l’écriture, l’accès direct permet de le remplir de façon quelconque. Par exemple,
on pourrait imaginer un programme de création d’un fichier binaire de points,
analogue à celui créé à la section 4.1, qui laisse l’utilisateur fournir les points
dans l’ordre de son choix. Or si l’on procède ainsi, rien n’oblige l’utilisateur à
fournir des informations pour tous les points du fichier. Cette remarque prend
encore plus de relief lorsqu’on sait que, dès que l’on écrit le énième octet d’un
fichier, bon nombre de systèmes réservent la place pour tous les octets
précédents26.
Dans ces conditions, on comprend qu’à partir du moment où rien n’impose à
l’utilisateur de ne pas « laisser de trous » lors de la création du fichier, il faudra
être en mesure de repérer ces trous lors d’éventuelles consultations ultérieures du
fichier. Plusieurs techniques existent à cet effet :
• avant d’exécuter le programme de création du fichier, on peut par exemple
commencer par « initialiser » tous les emplacements du fichier à une « valeur
spéciale », dont on sait qu’elle ne pourra pas apparaître comme valeur
effective ;
• on peut aussi gérer une table des emplacements inexistants, cette table devant
alors être conservée (de préférence) dans le fichier lui-même.
Quoi qu’il en soit, on voit que les contraintes évoquées à propos de la mise à
jour s’appliquent ici : il est préférable de prévoir une structure en blocs de taille
fixe et d’éviter les fichiers formatés.
7.5 Détection des erreurs supplémentaires liées à
l’accès direct
Ni l’indicateur de fin de fichier, ni la valeur de retour de fseek ne sont
exploitables pour détecter d’éventuelles erreurs de positionnement (voir section
7.2). Nous vous proposons ici deux démarches :
1. Vérifier par programme que les valeurs fournies à fseek sont correctes par
rapport à l’action prévue sur le fichier, c’est-à-dire :
– positives ou nulles ;
– inférieures à la taille du fichier en cas de lecture.
En pratique, en cas d’écriture, on aura intérêt :
– soit à s’imposer, plus ou moins arbitrairement, une borne supérieure pour la
valeur du pointeur ;
– soit à utiliser un fichier préalablement initialisé, et donc à limiter la valeur
du pointeur à cette taille.
Si l’implémentation distingue les fichiers texte des fichiers binaires, certaines
contraintes théoriques (voir section 7.2.3) affectent l’emploi de fseek, à
savoir : n’utiliser que des valeurs préalablement obtenues par ftell. Dans ce
cas, si l’on satisfait effectivement à ces contraintes, il n’est plus nécessaire de
tester la valeur fournie à fseek.
2. Se contenter d’examiner, après coup, la valeur de retour des fonctions de
lecture ou d’écriture que vous serez amené à utiliser après l’action sur le
pointeur. Dans ce cas, un mauvais positionnement devient une source d’erreur
supplémentaire qui ne peut plus être distinguée des autres erreurs.
Appliquons chacune de ces deux démarches à notre programme de la section 7.1.
Exemple 1
Ici, nous appliquons la première démarche pour éviter un mauvais
positionnement du pointeur.
Exemple de traitement des erreurs de positionnement dans un fichier (1)
#include <stdio.h>
#include <stdlib.h> /* pour exit */
int main()
{ struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
FILE * entree ; /* flux associé au fichier à consulter */
long num_point ; /* numéro de point demandé par l'utilisateur */
int ret ;
long nb_points ; /* taille du fichier en blocs */
/* ouverture du fichier à consulter et détermination de sa taille */
printf ("nom du fichier a consulter : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "rb") ;
if (!entree) { printf ("*** impossible d'ouvrir ce fichier ***\n") ;
exit(-1) ;
}
fseek (entree, 0, SEEK_END) ;
nb_points = ftell(entree)/sizeof(bloc) ;
printf ("votre fichier comporte %d points\n", nb_points) ;
/* boucle de recherche de différents points */
while (1)
{ printf ("numero du point recherche (0 pour finir) : ") ;
scanf ("%ld", &num_point) ;
if (num_point == 0) break ; /* fin de recherche */
if ( (num_point < 0) || (num_point > nb_points) ) /* point inexistant */
{ printf ("*** point inexistant ***\n") ;
continue ;
}
fseek (entree, sizeof(bloc)*(num_point-1), SEEK_SET) ;
ret = fread (&bloc, sizeof(bloc), 1, entree) ;
if (ret < 1)
{ printf ("*** erreur de lecture ***\n") ;
break ; /* continue ne serait pas logique ici */
}
printf ("%c %d %d\n", [Link], bloc.x, bloc.y ) ;
}
fclose (entree) ;
printf ("*** fin de recherche ***\n") ;
}
nom du fichier a consulter : points
votre fichier comporte 5 points
numero du point recherche (0 pour finir) : 3
x 5 11
numero du point recherche (0 pour finir) : 7
*** point inexistant ***
numero du point recherche (0 pour finir) : 2
s 12 32
numero du point recherche (0 pour finir) : -1
*** point inexistant ***
numero du point recherche (0 pour finir) : 5
t 11 14
numero du point recherche (0 pour finir) : 0
*** fin de recherche ***
Remarque
Nous examinons la valeur de retour de fread, bien que les causes d’erreurs soient ici limitées. Il ne
peut en effet s’agir que :
• d’une lecture d’un nombre insuffisant d’octets, ce qui ne peut se produire que si le fichier considéré
n’a pas une structure correcte ;
• d’une erreur matérielle.
En revanche, l’examen de la valeur de feof se serait avéré relativement peu utile ici : compte tenu de
notre test de numéro de point, la fin de fichier ne sera rencontrée qu’en cas de fichier de structure
incorrecte.
Exemple 2
Ici, nous appliquons la seconde démarche pour déceler, a posteriori, les
conséquences d’un mauvais positionnement dans le fichier :
Exemple de traitement des erreurs de positionnement dans un fichier (2)
#include <stdio.h>
#include <stdlib.h> /* pour exit */
int main()
{ struct point { char nom ;
int x, y ;
} ; /* modèle de structure représentant un point */
struct point bloc ; /* bloc d'informations courant */
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères) */
FILE * entree ; /* flux associé au fichier à consulter */
long num_point ; /* numero de point demandé par l'utilisateur */
int ret ;
/* ouverture du fichier à consulter */
printf ("nom du fichier à consulter : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "rb") ;
if (!entree) { printf ("*** impossible d'ouvrir ce fichier ***\n") ;
exit(-1) ;
}
/* boucle de recherche de différents points */
while (1)
{ printf (" numero du point recherche (0 pour finir) : ") ;
scanf ("%ld", &num_point) ;
if (num_point == 0) break ; /* fin de recherche */
fseek (entree, sizeof(bloc)*(num_point-1), SEEK_SET) ;
ret = fread (&bloc, sizeof(bloc), 1, entree) ;
if (ret < 1)
{ printf ("*** erreur lecture : probablement point inexistant ***\n") ;
continue ; /* plutôt que break cette fois ! */
}
printf ("%c %d %d\n", [Link], bloc.x, bloc.y ) ;
}
fclose (entree) ;
printf ("*** fin de recherche ***\n") ;
}
7.6 Exemple d’accès indexé à un fichier formaté
Dans les implémentations qui distinguent les fichiers binaires des fichiers texte,
la fonction fseek souffre de limitations pour les fichiers ouverts en mode texte
(voir section 7.2). Dans ces conditions, il n’est plus facile d’accéder directement
à un endroit quelconque d’un tel fichier. Cependant, si l’on accepte de parcourir
une première fois le fichier de manière séquentielle, il est possible de mémoriser
certaines positions intéressantes (en faisant appel à ftell) et de retrouver ensuite
ces positions, à la demande, en utilisant fseek. Dans ce cas en effet, nous sommes
bien dans les conditions normales d’exploitation de fseek pour des flux texte.
En fait, une telle démarche correspond à ce que l’on nomme souvent « accès
indexé », la liste des différentes valeurs intéressantes du pointeur portant le nom
d’index. Dans de nombreux langages, un tel index est formé des différentes
valeurs du pointeur relatives à chacun des enregistrements. En C, on peut réaliser
un index qui pointe sur des emplacements qu’on a choisis librement.
Voici, à titre d’exemple, un programme dans lequel nous réalisons un accès
indexé à chaque ligne d’un fichier formaté du type de celui créé à la section 5.1
Plus précisément, nous créons en mémoire un tableau dont chaque élément sert à
mémoriser la valeur du pointeur sur chaque début de ligne. Pour simplifier le
programme, nous nous fixons un nombre maximal de lignes NENRMAX. Pour créer
cet index, nous effectuons un premier parcours séquentiel du fichier, ligne par
ligne et nous faisons appel, pour chaque ligne, à la fonction ftell. Dans un
second temps, nous pouvons produire, à la demande de l’utilisateur, le contenu
de n’importe quelle ligne dont il fournit le numéro.
Exemple d’accès indexé à un fichier formaté
#include <stdio.h>
#include <stdlib.h> /* pour exit */
#define NENRMAX 100
int main()
{
char nom ;
int x, y ;
char nomfich[81] ; /* pour le nom du fichier (pas plus de 80 caractères */
FILE * entree ; /* flux associé au fichier à consulter */
long index [NENRMAX] ; /* pour l'index associé au fichier */
long n_lignes ; /* nombre de lignes du fichier */
long num ; /* numéro de point */
/* ouverture du fichier */
printf ("nom du fichier a consulter : ") ;
scanf ("%80s", nomfich) ;
entree = fopen (nomfich, "r") ; /* dans certaines implémentations, */
/* le mode d'ouverture "rt" peut être utilisé */
if (entree==NULL) { printf ("erreur ouverture\n");
exit (-1) ;
}
/* création de l'index par lecture séquentielle */
n_lignes = 0 ;
while (1) /* on s'arrête à la rencontre d'une fin de fichier */
{ index [n_lignes] = ftell (entree) ; /* on stocke avant de lire */
while ((fgetc(entree)!=‘\n') && (!feof(entree))) ;
if (feof(entree)) break ;
n_lignes++ ; /* mais on incrémente le compteur */
/* que si la lecture est OK */
}
printf ("votre fichier comporte %ld lignes\n", n_lignes) ;
/* consultation du fichier, à l'aide de l'index */
while (1)
{ printf ("numero de point recherche (0 pour finir) : ") ;
scanf ("%ld", &num) ;
if (num == 0) break ;
if ((num<0) || (num > n_lignes))
{ printf ("numero incorrect\n") ;
continue ;
}
fseek (entree, index[num-1], SEEK_SET) ; /* attention num-1 */
fscanf (entree, "%c %d %d", &nom, &x, &y) ;
printf ("point numero %ld : %c %d %d\n", num, nom, x, y) ;
}
fclose (entree) ;
}
nom du fichier a consulter : [Link]
votre fichier comporte 4 lignes
numero de point recherche (0 pour finir) : 2
point numero 2 : f 8 121
numero de point recherche (0 pour finir) : 4
point numero 4 : x 25 74
numero de point recherche (0 pour finir) : 6
numero incorrect
numero de point recherche (0 pour finir) : -4
numero incorrect
numero de point cherche (0 pour finir) : 0
Bien entendu, il ne s’agit que d’un exemple d’école. En pratique, il faudra
s’assurer que le nombre de lignes du fichier ne dépasse pas la limite prévue
(NENRMAX) ou, mieux, éviter d’imposer une limite en allouant dynamiquement le
tableau index. Par ailleurs, pour que la démarche présente un intérêt, il faudra
probablement que la taille des lignes soit plus importante que dans notre
exemple, de façon que le tableau index soit nettement plus petit que le fichier.
Dans le cas contraire, on ne voit pas pourquoi on ne lirait pas directement
l’ensemble du fichier en mémoire. De même, on pourra être amené à conserver
l’index dans un fichier, de manière à éviter d’avoir à le reconstruire à chaque
consultation du fichier.
7.7 Les fonctions fsetpos et fgetpos
Les fonctions fgetpos et fsetpos jouent un rôle semblable à ftell et à fseek, avec
cette différence qu’elles représentent une position dans un fichier à l’aide d’une
valeur de type tpos_t. Ce type est défini par typedef dans stdio.h et dépend de
l’implémentation. Dans ces conditions, le programmeur, ne connaissant pas le
type exact correspondant à tpos_t, est encouragé à ne transmettre à fsetpos qu’une
valeur préalablement mémorisée par fgetpos.
Bien entendu, dans le cas des flux binaires, la limitation est généralement trop
forte, et l’on a aucune raison de ne pas utiliser fseek.
En revanche, dans le cas des flux texte, l’emploi de ces fonctions oblige tout
naturellement à respecter les restrictions spécifiques aux flux texte (présentées à
la section 7.2). Mais elle interdit toute vérification de la valeur du pointeur, les
valeurs de type tpos_t n’étant théoriquement pas comparables entre elles, et
encore moins comparables à la taille du fichier. La détection des erreurs ne peut
alors se faire que suivant la seconde des méthodes proposées à la section 7.5.
Toutefois, bon nombre d’implémentations utilisent pour tpos_t un type entier, de
sorte que la protection découlant de l’utilisation de fsetpos peut devenir
relativement illusoire.
En pratique, ces fonctions sont peu utilisées. Leur syntaxe exacte est décrite dans
l’annexe A.
8. La fonction fopen et les différents modes
d’ouverture d’un fichier
8.1 Généralités
Dans tous les exemples précédents, nous avons utilisé de façon relativement
intuitive la fonction fopen avec un certain nombre de modes d’ouverture. D’une
manière générale, ce mode d’ouverture intervient à plusieurs niveaux :
• il indique si le fichier doit ou non exister et s’il peut éventuellement être créé ;
on notera qu’en C, ce mode n’intervient jamais sur le devenir du fichier, à
l’issue du programme ; le fichier est supposé être conservé (sauf si l’on utilise
tmpfile) ;
• il précise les opérations qui seront possibles avec le fichier : lecture, écriture ou
les deux ;
• dans les implémentations où la distinction se fait, il permet de choisir entre flux
binaire et flux texte ;
• dans certains cas, il indique à quel endroit du fichier (au début ou à la fin) sera
placé le pointeur après l’ouverture.
En toute rigueur, il existe deux autres fonctions d’ouverture de fichier, d’un
usage peu répandu : freopen qui emploie les mêmes modes d’ouverture que fopen
et tmpfile qui crée un fichier temporaire. Ces deux fonctions sont décrites en
détail à l’annexe A.
8.2 La fonction fopen
8.2.1 Prototype, rôle et valeur de retour
FILE * fopen (const char *nom_fichier, const char *mode) (stdio.h)
nom_fichier
Adresse d’une chaîne de caractères
(terminée par \0) précisant le nom du fichier.
On peut y trouver une indication de
répertoire ou de chemin.
mode
Adresse d’une chaîne (en général chaîne Description
constante) indiquant le mode d’ouverture complète
des modes
à la section
8.2.2
Valeur de Pointeur sur une structure FILE associée au
retour fichier lorsque l’opération a réussi, le
pointeur NULL en cas d’erreur
Le rôle de la fonction fopen est d’ouvrir le fichier concerné, dans le mode indiqué.
Elle fournit, comme valeur de retour, un flux (pointeur sur une structure de type
prédéfini FILE), ou un pointeur nul si l’ouverture a échoué. Les causes d’un tel
échec sont assez nombreuses. Les plus usuelles sont les suivantes :
• le fichier est inexistant, en cas d’ouverture en lecture ou en mise à jour ;
• on ne dispose pas des droits d’accès voulus au fichier ou au répertoire ;
• l’unité ou le répertoire est saturé, en cas de fichier à créer.
Les autres situations correspondent à des erreurs matérielles (panne ou disquette
absente ou déverrouillée) qui sont généralement prises en compte par le système
d’exploitation.
8.2.2 Le mode d’ouverture d’un fichier
La valeur de mode fait intervenir trois sortes d’indicateurs dont nous donnons la
description, avant de fournir la signification détaillée des différentes
combinaisons qu’il est possible de former.
Tableau 13.4 : les trois indicateurs du mode d’ouverture
Type
Valeurs Signification
d’indicateur
r
Principal Lecture seule d’un fichier existant
w
(obligatoire) Écriture seule dans un nouveau fichier ou
écrasement d’un fichier existant
a
Extension, c’est-à-dire ajout de nouvelles
informations au-delà de la fin, d’un fichier
existant ou nouveau. Attention, alors que r
impose l’existence du fichier et que w impose
la destruction du fichier s’il existe, ce dernier
indicateur crée le fichier s’il n’existe pas mais
ne le modifie pas s’il existe.
b
Mode - Flux binaire. Rappelons que la norme laisse
d’ouverture l’implémentation libre de faire ou non la
(facultatif) distinction entre les flux binaires et les flux
texte. Lorsque cette distinction a lieu,
l’absence de cet indicateur correspond à un
flux texte. Attention, dans d’anciennes
implémentations, on a pu rencontrer la règle
opposée (flux binaire par défaut).
- En l’absence de b, le flux sera considéré
comme texte. La norme autorise la présence,
dans ce cas, d’une autre lettre (souvent t), voir
remarque ci-après.
+
Mise à jour Il autorise à la fois la lecture et l’écriture. La
(facultatif) norme précise que dans certaines
implémentations, ce choix d’ouverture associé
à un flux texte peut conduire à traiter le fichier
comme un flux binaire.
Tableau 13.5 : les différents modes d’ouverture d’un fichier
Mode Signification
r
Ouverture en lecture seule et en mode texte d’un fichier qui
doit exister. On rencontre souvent aussi le mode rt (voir
remarque ci-après).
w
Ouverture en écriture seule et en mode texte d’un fichier qui
peut exister (son ancien contenu sera alors perdu) ou ne pas
exister (il sera alors créé). On rencontre souvent aussi le mode wt
(voir remarque ci-après).
a
Ouverture en extension et en mode texte : toutes les écritures se
feront séquentiellement à partir de la fin du fichier,
indépendamment de tout appel à fseek. Le fichier peut ne pas
exister, auquel cas il sera créé. On rencontre souvent aussi le
mode at (voir remarque ci-après).
rb
Ouverture en lecture seule et en mode binaire d’un fichier qui
doit exister.
wb
Ouverture en écriture seule et en mode binaire d’un fichier qui
peut exister (son ancien contenu sera alors perdu) ou ne pas
exister (il sera alors créé).
ab
Ouverture en extension et en mode binaire. Le fichier peut ne
pas exister, auquel cas il sera créé. Dans les deux cas, toutes les
écritures se feront séquentiellement à partir de la fin du fichier,
indépendamment de tout appel à fseek. Attention, la norme
précise que (dans ce cas de fichier binaire), il se peut que cette
fin de fichier soit située nettement au-delà de la dernière
information, compte tenu d’une éventuelle présence d’octets de
remplissage (voir section 3.2.5), ce qui enlèverait de l’intérêt à
ce mode si les implémentations concernées n’étaient pas en voie
de disparition.
r+
Ouverture en mise à jour (lecture et écriture) et en mode texte
d’un fichier qui doit exister. Dans certaines implémentations, ce
mode r+ peut ne pas s’appliquer à des flux texte, auquel cas il
sera équivalent à rb+. Une lecture ne peut pas suivre une écriture
sans qu’il n’y ait eu d’appel à fflush ou d’action sur le pointeur
de fichier (par fseek, fsetpos ou rewind). De façon semblable (mais
non identique), une écriture ne peut suivre une lecture sans qu’il
n’y ait eu d’action sur le pointeur de fichier (sauf si la fin de
fichier a été rencontrée au cours de la lecture). On rencontre
souvent aussi le mode rt+ ou r+t (voir remarque ci-après).
w+
Ouverture en mise à jour (lecture et écriture) et en mode texte
d’un fichier qui peut exister (son ancien contenu sera alors
perdu) ou ne pas exister (il sera alors créé). Dans certaines
implémentations, ce mode w+ peut ne pas s’appliquer à des flux
texte, auquel cas il sera équivalent à wb+. Une lecture ne peut pas
suivre une écriture sans qu’il n’y ait eu d’appel à fflush ou
d’action sur le pointeur de fichier (par fseek, fsetpos ou rewind).
De façon semblable (mais non identique), une écriture ne peut
suivre une lecture sans qu’il n’y ait eu d’action sur le pointeur
de fichier (sauf si la fin de fichier a été rencontrée au cours de la
lecture). On rencontre souvent aussi le mode wt+ ou w+t (voir
remarque ci-après).
a+
Ouverture en extension, en mise à jour (lecture et écriture) et en
mode texte d’un fichier qui peut ne pas exister (auquel cas il
sera créé). Dans certaines implémentations, ce mode a+ peut ne
pas s’appliquer à des flux texte, auquel cas il sera équivalent à
ab+. Le pointeur sera initialement positionné en fin de fichier.
Une lecture ne peut pas suivre une écriture sans qu’il n’y ait eu
d’appel à fflush ou d’action sur le pointeur de fichier (par fseek,
fsetpos ou rewind). De façon semblable (mais non identique), une
écriture ne peut suivre une lecture sans qu’il n’y ait eu d’action
sur le pointeur de fichier (sauf si la fin de fichier a été
rencontrée au cours de la lecture). On rencontre souvent aussi le
mode at+ ou a+t (voir remarque ci-après).
rb+ ou Ouverture en mise à jour (lecture et écriture) et en mode
r+b
binaire d’un fichier qui doit exister. Une lecture ne peut pas
suivre une écriture sans qu’il n’y ait eu d’appel à fflush ou
d’action sur le pointeur de fichier (par fseek, fsetpos ou rewind).
De façon semblable (mais non identique), une écriture ne peut
suivre une lecture sans qu’il n’y ait eu d’action sur le pointeur
de fichier (sauf si la fin de fichier a été rencontrée au cours de la
lecture).
wb+ ou Ouverture en mise à jour (lecture et écriture) et en mode
w+b
binaire d’un fichier qui peut exister (son ancien contenu sera
alors perdu) ou ne pas exister (il sera alors créé). Une lecture ne
peut pas suivre une écriture sans qu’il n’y ait eu d’appel à fflush
ou d’action sur le pointeur de fichier (par fseek, fsetpos ou rewind).
De façon semblable (mais non identique), une écriture ne peut
suivre une lecture sans qu’il n’y ait eu d’action sur le pointeur
de fichier (sauf si la fin de fichier a été rencontrée au cours de la
lecture).
ab+ ou Ouverture en extension, en mise à jour (lecture et écriture) et en
a+b
mode binaire d’un fichier qui peut ne pas exister (auquel cas il
sera créé). Le pointeur sera initialement positionné en fin de
fichier. Une lecture ne peut pas suivre une écriture sans qu’il n’y
ait eu d’appel à fflush ou d’action sur le pointeur de fichier (par
fseek, fsetpos ou rewind). De façon semblable (mais non
identique), une écriture ne peut suivre une lecture sans qu’il n’y
ait eu d’action sur le pointeur de fichier (sauf si la fin de fichier
a été rencontrée au cours de la lecture).
Remarques
1. La norme autorise une implémentation à faire suivre ce mode d’ouverture d’un ou plusieurs
caractères qui lui sont propres. En particulier, on rencontre souvent l’indication t pour les flux
texte, là où l’on trouve b pour les flux binaires. Dans ce cas, par exemple, wt sera équivalent à w.
2. La norme C11 introduit un mode d’ouverture dit « exclusif » que l’on choisit en ajoutant x à la fin
du mode (wx, wbx, w+x, wb+x, w+bx). Dans ce cas, l’ouverture échoue si le fichier existe déjà ou s’il
ne peut être créé. Sinon, le fichier est créé avec un accès « exclusif » au programme ou au thread
concerné (à condition, toutefois, que l’environnement sache gérer cette possibilité !).
9. Les flux prédéfinis
Comme on l’a vu au chapitre 9, il existe une unité standard d’entrée à laquelle
accèdent automatiquement des fonctions telles que scanf. Mais il existe aussi un
nom de flux prédéfini, stdin, associé automatiquement à cette entrée standard, de
sorte qu’on peut également accéder à cette unité en appliquant des fonctions
telles que fscanf au flux stdin.
La même remarque s’applique à la sortie standard avec printf et au flux prédéfini
stdout avec fprintf.
Rappelons que bon nombre de systèmes permettent d’effectuer une redirection
des entrées ou des sorties à l’aide de commandes appropriées au moment du
lancement du programme.
Dans tous les cas, quelle que soit la nature de l’entrée standard (périphérique
conversationnel ou fichier), aucune ouverture de fichier n’est nécessaire pour
utiliser ces flux prédéfinis stdin et stdout.
Par ailleurs, on peut toujours, au cours de l’exécution d’un programme, modifier
le périphérique associé à un flux prédéfini en utilisant la fonction freopen. Celle-ci
a un objectif plus général puisqu’elle ferme le fichier associé à un flux, avant
d’en ouvrir un nouveau en l’associant au même flux. Elle peut donc s’appliquer,
entre autres choses, aux flux prédéfinis. Par exemple :
freopen ("resultat", "w", stdout) ;
ferme le fichier associé au flux stdout et ouvre, en l’associant à stdout, le fichier
nommé resultat, dans le mode w. Cette fonction freopen fournit le même code de
retour que fopen.
On notera cependant que, par sa nature même, cette fonction ne permettra pas,
par la suite, d’associer à nouveau une unité standard à un flux prédéfini
puisqu’elle requiert un nom de fichier et non un nom de flux27.
Enfin, il existe une unité standard d’affichage des messages d’erreurs, à laquelle
on peut également accéder par le biais du nom de flux prédéfini stderr. Parfois,
cette unité est identique à l’unité de sortie standard. Là encore, on peut modifier
son affectation en faisant appel à freopen.
1. Et encore, dans ce cas, l’ordre des octets à l’intérieur de l’objet pourra dépendre de l’implémentation.
2. Ces caractères auront toujours le même codage dans les différents codes ASCII étendus, puisqu’ils
appartiennent au code ASCII dit « restreint » (voir section 1.3 du chapitre 2).
3. Cette notion d’accès rapide n’a cependant qu’un caractère relatif. En effet, théoriquement, rien
n’interdirait à une implémentation d’appliquer le formalisme de l’accès direct à une bande magnétique,
pour peu que l’on accepte les nombreux rembobinages qui en découleraient.
4. En général, cette différence de codage ne concerne qu’une petite partie des caractères (c’est le cas de la
fin de ligne sur PC) mais la norme n’interdit pas à une implémentation de réaliser un transcodage total…
5. L’implémentation reste libre d’imposer ou non ce caractère avant la fin de fichier. Autrement dit,
certaines implémentations peuvent accepter que la dernière ligne ne soit pas terminée par \n.
6. La norme regroupe les fonctions traitant les caractères et les chaînes et c’est ainsi que vous les trouverez
classées dans l’annexe A « Bibliothèque standard ». Pour notre part, nous préférons classer les opérations
sur les chaînes dans les opérations formatées, même si ce formatage se résume à l’introduction ou à la
prise en compte d’une fin de ligne.
7. Il s’agit alors de ce que l’on nomme une « fin anormale » au chapitre 21.
8. Pour simplifier, nous parlons de fichier binaire mais, pour être précis, il faudrait parler de fichier créé par
des opérations binaires (ici fwrite) en mode binaire.
9. Mais malheureusement, pas la portabilité du fichier !
10. Pour simplifier, nous parlons de fichier binaire mais, pour être précis, il faudrait parler de fichier lu par
des opérations binaires (ici fread) en mode binaire.
11. Contrairement à ce qui se passe, par exemple, en Pascal.
12. Pour simplifier, nous parlons de fichier binaire mais, pour être précis, il faudrait parler de fichier créé
par des opérations binaires (ici fwrite) en mode binaire.
13. Pour simplifier, nous parlons de fichier binaire mais, pour être précis, il faudrait parler de fichier lu par
des opérations binaires (ici fread) en mode binaire.
14. Pour simplifier, nous parlons de fichier formaté mais, pour être précis, il faudrait parler de fichier créé
par des opérations formatées (ici fprintf) en mode texte.
15. Bien entendu, dans le cas d’un fichier, les différents caractères sont stockés tels quels (après
éventuellement un transcodage lié à l’ouverture en mode texte), alors que, dans le cas d’un écran,
certains pouvaient éventuellement réaliser une action particulière : saut de ligne, tabulation, alarme
sonore (cloche)…
16. Pour simplifier, nous parlons de fichier formaté mais, pour être précis, il faudrait parler de fichier lu par
des opérations formatées (ici fscanf) en mode texte.
17. Contrairement à ce qui se passe, par exemple, en Turbo Pascal, dans lequel la détection de fin de fichier
fonctionne en quelque sorte « par anticipation ».
18. Mais après avoir éventuellement sauté des espaces blancs si le premier code de format le permettait.
19. Avec scanf, il ne s’agissait pas véritablement d’un critère d’arrêt. Néanmoins, la détection d’une fin de
fichier au clavier constituait une bonne précaution.
20. Bien qu’en toute rigueur on ait pu lire des espaces blancs avant la rencontre de la fin de fichier.
21. Pour simplifier, nous parlons de fichier formaté mais, pour être précis, il faudrait parler de fichier lu par
des opérations formatées (ici fscanf) en mode texte.
22. Comme avec puts, la norme n’est pas plus précise.
23. Ne pas oublier que le mode d’ouverture peut influer sur la détection de cette condition.
24. Mais on peut aussi aboutir à un comportement indéterminé du programme, la norme n’imposant rien de
particulier.
25. Mais dans ce cas, on peut aussi aboutir à un comportement indéterminé du programme, la norme
n’imposant rien de particulier.
26. De toute façon, tous les systèmes réservent toujours la place d’un nombre minimal d’octets, de sorte que
le problème évoqué existe toujours, au moins pour certains octets du fichier.
27. Certaines implémentations, toutefois, associent des noms de fichiers fictifs aux unités standards.
14
La gestion dynamique
De manière tout à fait classique, le langage C permet de manipuler aisément des
informations contenues dans des variables. La gestion de l’emplacement
mémoire correspondant est faite automatiquement en fonction de la classe
d’allocation de la variable : statique, automatique ou, exceptionnellement,
registre. Mais par le biais de pointeurs, il est également possible de manipuler
des informations situées dans des emplacements alloués et libérés selon les
besoins par des appels à des fonctions standards appropriées. C’est cet aspect
que nous aborderons dans ce chapitre, qui nous amènera, après quelques
considérations d’ordre général et des exemples introductifs, à étudier les
fonctions standards malloc, free, calloc et realloc. Nous terminerons par quelques
exemples de techniques utilisant la gestion dynamique : gestion de tableaux de
taille inconnue lors de la compilation ou de taille variable pendant l’exécution,
listes chaînées.
1. Intérêt de la gestion dynamique
Si l’on exclut le cas particulier d’une information placée dans un registre, les
objets manipulés par un programme peuvent se classer en trois catégories selon
la manière dont l’emplacement mémoire qui leur est alloué est géré. On
distingue ainsi :
• les objets statiques ;
• les objets automatiques ;
• les objets dynamiques.
Les objets statiques correspondent aux variables de classe statique. Ils occupent
un emplacement parfaitement défini, sinon par la compilation, du moins avant le
début de l’exécution du programme.
Les objets automatiques correspondent aux variables de classe automatique.
Contrairement aux précédents, ils n’ont pas d’emplacement défini a priori. En
effet, ils ne sont créés que lors de l’entrée dans un bloc et détruits lors de sa
sortie, donc en définitive suivant la manière dont le programme s’exécute. Ils
sont souvent gérés sous la forme de ce que l’on nomme une pile, laquelle croît
ou décroît selon les besoins du programme.
Les objets dynamiques n’ont pas non plus d’emplacement a priori, mais leur
création ou leur libération dépend de demandes explicites faites lors de
l’exécution du programme. Leur gestion, qui cette fois ne peut plus se faire à la
manière d’une pile, est indépendante de celle des données automatiques. Plus
précisément, elle se fait généralement dans ce que l’on nomme un « tas » (heap
en anglais), c’est-à-dire un ensemble d’octets sans structure particulière, dans
lequel on cherche à allouer ou à libérer de l’espace en fonction des besoins.
On peut dire que la gestion des objets statiques ou automatiques reste
transparente au programmeur, qui n’a besoin de se préoccuper que des
déclarations des variables correspondantes ; seuls les objets dynamiques sont
véritablement créés sur son initiative par des appels de fonctions.
D’une manière générale, l’emploi d’objets statiques présente certains défauts
intrinsèques. Citons deux exemples :
• Les objets statiques ne permettent pas de définir des tableaux de dimensions
variables, c’est-à-dire dont les dimensions peuvent être fixées lors de
l’exécution et non dès la compilation. Il est alors nécessaire d’en fixer
arbitrairement une taille limite, ce qui conduit généralement à une mauvaise
utilisation de l’ensemble de la mémoire.
• La gestion statique ne se prête pas aisément à la mise en œuvre de listes
chaînées, d’arbres binaires, etc. objets dont ni la structure, ni l’ampleur ne sont
généralement connues lors de la compilation du programme.
Les mêmes contraintes pèsent sur les objets automatiques puisque, bien
qu’alloués dans la pile pendant l’exécution, leur taille doit être connue à la
compilation.
La gestion dynamique permettra de pallier ces défauts en donnant au
programmeur l’opportunité d’allouer et de libérer de la mémoire dans le tas, au
fur et à mesure de ses besoins.
Remarque
Les objets statiques ou automatiques sont toujours situés dans des variables. Ils peuvent cependant être
manipulés par le biais de pointeurs, sans que cela n’ait alors de lien avec la gestion dynamique. Les
objets dynamiques, quant à eux, ne peuvent être manipulés que par le biais de pointeurs.
2. Exemples introductifs
Voici deux exemples commentés de gestion dynamique. Ils sont basés sur la
fonction d’allocation la plus répandue qu’est malloc, mais ils seraient faciles à
transposer à calloc.
2.1 Allocation et utilisation d’un objet de type double
Supposons que nous souhaitions disposer d’un emplacement dynamique pour un
élément de type double.
Nous pouvons procéder ainsi :
#include <stdlib.h> /* pour le prototype de malloc */
…..
double *add ; /* pointeur sur un objet de type double */
…..
add = malloc (sizeof(double)) ; /* allocation pour un élément de type double */
La fonction malloc alloue dans le tas un emplacement ayant la taille précisée dans
son unique argument et elle fournit en retour l’adresse correspondante que nous
affectons ici à la variable add. Notez l’emploi de l’opérateur sizeof qui assure la
portabilité de notre instruction d’appel de malloc : un appel tel que malloc (8) ne
serait portable que dans des implémentations où le type double occupe 8 octets.
Nous pouvons ensuite utiliser cet emplacement par le biais de la valeur de la
variable pointeur add. Par exemple, pour y placer la valeur 1.25, nous pourrons
procéder ainsi :
*add = 1.25 ;
La valeur de cet objet pourra être ensuite être utilisée classiquement, comme
dans :
x = 5 * *add + 3 ; /* le premier * est l'opérateur de multiplication, */
/* le second est l'opérateur d'indirection */
Si, au fil du déroulement du programme, l’emplacement alloué à notre objet ne
s’avère plus utile, nous pourrons le libérer par l’appel de la fonction free, à
laquelle nous en fournirons simplement l’adresse :
free (add) ;
Cet emplacement ainsi rendu disponible pourra éventuellement être utilisé lors
d’une prochaine demande d’allocation dynamique.
Remarques
1. Comme nous le verrons plus loin, le résultat fourni par malloc est de type void *. Nous le
convertissons par affectation en double *, ce qui est légal en C et ne présente aucun risque de
modification de l’adresse correspondante.
2. Ici, la variable add semble associée en permanence au même objet, mais il ne s’agit nullement d’une
obligation.
3. La variable pointeur add a une durée de vie dépendant de sa classe d’allocation. En revanche,
l’emplacement mémoire alloué par malloc (dont l’adresse figure actuellement dans add) ne sera
désalloué que par un éventuel appel à free ou lors de la fin du programme.
2.2 Cas particulier d’un tableau
La même démarche peut être utilisée pour des objets d’un type quelconque, qu’il
s’agisse d’un type de base ou d’un type structuré. Le cas des tableaux est
cependant particulier. Supposons que nous souhaitions disposer d’un
emplacement dynamique pour un tableau de 100 éléments de type long. Nous
pouvons alors procéder ainsi :
#include <stdlib.h> /* pour le prototype de malloc */
…..
long *adr ; /* pointeur sur un objet de type long */
…..
adr = malloc (100*sizeof(long)) ; /* allocation pour 100 éléments de type long */
Comme précédemment, la fonction malloc alloue dans le tas un emplacement
ayant la taille requise et fournit en retour l’adresse correspondante que nous
affectons ici à la variable adr. Ici encore, l’emploi de l’opérateur sizeof assure la
portabilité.
Nous pouvons ensuite utiliser cet emplacement par le biais de la valeur de adr.
Par exemple, pour placer la valeur 1 dans chacun de nos 100 éléments, nous
pouvons certes procéder ainsi :
for (i=0 ; i<100 ; i++) *(adr+i) = 1 ;
Mais compte tenu de la définition de l’opérateur [], nous pouvons aussi procéder
ainsi :
for (i=0 ; i<100 ; i++) adr[i] = 1 ;
et ce bien que adr n’ait pas été déclaré comme un tableau.
Si, au fil du déroulement du programme, l’emplacement alloué à notre tableau ne
s’avère plus utile, nous pourrons le libérer par l’appel de la fonction free, à
laquelle nous en fournirons simplement l’adresse :
free (adr) ; /* libère l'emplacement d'adresse adr */
Cet emplacement ainsi rendu disponible pourra éventuellement être utilisé lors
d’une prochaine demande d’allocation dynamique. Il est très important de savoir
que la fonction free se fonde uniquement sur l’adresse reçue pour en déduire la
taille de l’emplacement à libérer. En particulier, elle ne peut pas se baser sur le
type de son argument qui, de toute façon, sera converti en void *. Dans ces
conditions, l’adresse fournie initialement par malloc est indispensable à la
libération ultérieure de l’emplacement correspondant ; il faut donc éviter de la
modifier, à moins d’en faire une copie. Ainsi, dans notre exemple, il ne serait pas
judicieux de procéder ainsi pour placer des 1 dans tous nos éléments :
for (i=0 ; i<100 ; i++, adr++) *adr = 1 ; /* déconseillé car on perd la valeur */
/* de adr, et on ne pourra plus libé- */
/* rer l'emplacement correspondant */
Il sera beaucoup plus raisonnable de procéder comme suit (adt étant supposé de
type long *) :
for (adt = adr ; adt < adr+100 ; adt++) *adt = 1 ;
Remarque
Ici, nous avons utilisé un pointeur sur un long, et non pas un pointeur sur un tableau de 100 objets de
type long :
long (*adr) [100] ; /* pointeur sur tableau de 100 long */
…..
adr = malloc (100*sizeof(long)) ; /* allocation pour 100 éléments de type long */
La chose aurait certes été possible, mais l’utilisation du tableau serait devenue inutilement
compliquée : compte tenu de l’arithmétique des pointeurs, il aurait fallu convertir adr en long *.
L’élément de rang i aurait alors dû être désigné de l’une des deux façons suivantes :
*((long *)adr + i)
((long *) adr) [i]
3. Caractéristiques générales de la gestion dynamique
Avant d’étudier en détail chacune des fonctions standards relatives à la gestion
dynamique, examinons leurs caractéristiques communes, tant sur le plan de leur
mise en œuvre que sur celui des risques qu’elles induisent.
Tableau 14.1 : les caractéristiques générales de la gestion dynamique
– malloc : allocation d’un emplacement Voir section
mémoire de taille donnée, sans 4
initialisation ;
Voir section
– calloc : allocation de plusieurs 6
emplacements mémoire consécutifs, avec
Possibilités
initialisation à zéro binaire ; Voir section
– realloc : modification de la taille d’un 7
emplacement, sans perte de son contenu ;
– free : libération d’un emplacement Voir section
mémoire d’adresse donnée. 5
Les trois fonctions d’allocation (malloc, Voir section
Absence de calloc et realloc) fournissent un pointeur 3.1
typage des générique (void *) qui peut être ici converti
objets sans risque en un pointeur de type
quelconque.
Généralement différente de celle utilisée Voir section
Notation
pour les objets statiques ou automatiques, 3.2
des objets
avec une exception pour les tableaux.
– ceux inhérents aux pointeurs ; Voir section
3.3
Risques – ceux liés au type size_t des arguments
exprimant une taille d’objet ou un nombre
d’objets.
– au moins celles inhérentes au type size_t ; Voir
section 3.4
– certaines implémentations peuvent limiter
Limitations
la taille des objets à une valeur très
inférieure à la taille disponible.
3.1 Absence de typage des objets
A priori, on aurait souhaité que les fonctions d’allocation de mémoire soient
typées, c’est-à-dire qu’elles tiennent compte d’un type d’objet pour décider de la
taille à allouer. Ce sera effectivement le cas en C++. En revanche, en C, comme
on a pu le voir dans les exemples d’introduction, le programmeur devra lui-
même préciser la taille en octets de l’emplacement voulu. Fort heureusement, il
pourra s’appuyer sur l’opérateur sizeof pour réaliser des programmes portables.
Cette remarque sera encore plus pertinente dans le cas des structures pour
lesquelles il est parfois difficile de définir la taille exacte, même dans une
implémentation donnée.
Les trois fonctions d’allocation que sont malloc, calloc et realloc fournissent une
adresse sous la forme d’un pointeur générique, c’est-à-dire de type void *. Ce
dernier peut être converti implicitement (notamment par affectation), ou
explicitement par l’opérateur de cast, en un pointeur de n’importe quel type (voir
section 7 du chapitre 7). Ainsi, dans l’exemple introductif, nous avons utilisé une
conversion implicite en long * :
adr = malloc (100 * sizeof(long)) ;
Mais il aurait été totalement équivalent d’écrire :
adr = (long *) malloc (100 * sizeof(long)) ;
On notera bien que si, en théorie, un tel résultat peut être converti dans n’importe
quel type, en pratique, un type donné s’imposera tout naturellement. Ainsi, dans
l’exemple introductif de la section 2.2, le choix du type long * pour adr est crucial
puisque c’est lui qui permet d’attribuer l’adresse voulue à une expression telle
que adr + i. Bien entendu, si le résultat de malloc n’a pas a être utilisé dans des
calculs de pointeurs, on peut toujours l’affecter à une variable de type void *. Ce
sera le cas si l’on souhaite simplement conserver cette adresse pour la
retransmettre ultérieurement à free ou à une fonction qui ignore le vrai type des
objets situés à cette adresse.
Remarque
Rappelons que certaines implémentations peuvent imposer le respect de contraintes d’alignement à
certains objets. Dans ces contions, il est nécessaire que la conversion du résultat des fonctions
d’allocation dynamique n’entraîne pas de modification de l’adresse correspondante. Dans ce but, la
norme a prévu que l’adresse fournie par ces fonctions respecte la plus forte contrainte d’alignement
existant dans l’implémentation, de sorte que l’on peut tranquillement ignorer le problème.
3.2 Notation des objets
Comme le montre l’exemple de la section 2.1, un objet alloué de façon
dynamique ne peut pas être utilisé avec le même formalisme qu’un objet contenu
dans une variable automatique ou statique : il est nécessaire de recourir à
l’opérateur de déréférenciation *1. Autrement dit, pour un objet de type donné, il
existe deux façons différentes de le désigner selon qu’il s’agit d’une variable ou
d’un objet dynamique. Cette remarque contribue quelque peu à obscurcir les
programmes. Elle restera malheureusement valable pour C++, dans lequel seule
la technique d’allocation de mémoire sera effectivement typée.
Il existe toutefois une exception dans le cas des tableaux : comme on a pu le voir
dans l’exemple de la section 2.2, il est possible, bien que non obligatoire, de
retrouver les mêmes notations dans les deux cas.
3.3 Risques et limitations
3.3.1 Liés aux pointeurs
La gestion dynamique se fait par le biais de pointeurs. Or, à partir du moment où
il dispose d’un pointeur, le programmeur peut faire n’importe quel usage de
l’adresse correspondante, et en particulier écraser des informations situées en
dehors de la zone allouée.
Même la fonction free de libération de mémoire générera ses propres risques.
Certes, comme on ne lui communique que l’adresse de l’emplacement à libérer,
on ne risque pas de lui transmettre une mauvaise taille. En revanche, comme on
le verra, il sera possible, par erreur, de libérer deux fois un même emplacement,
et souvent d’aboutir à un plantage du programme. Plus simplement, on pourra lui
transmettre une mauvaise adresse. Bien entendu, la norme se contente dans ce
cas de dire que le comportement du programme est indéterminé. En pratique, les
conséquences pourront être catastrophiques dans la mesure où, même en
l’absence de plantage, on pourra être amené à libérer une zone utilisée par
ailleurs. À titre indicatif, dans les implémentations qui conservent l’information
de longueur à l’intérieur de la zone elle-même2, le fait de fournir une mauvaise
adresse amènera à libérer un nombre d’octets quelconque à partir de cette
adresse !
3.3.2 Liés au type size_t des arguments de taille
Les arguments précisant la taille des emplacements à allouer sont du type size_t,
synonyme prédéfini par typedef sous la forme d’un type entier non signé
dépendant de l’implémentation. Si l’on fournit un argument effectif d’un type
entier (signé ou non) différent de celui correspondant à size_t, sa valeur sera, au
vu du prototype, convertie en non signé. Cette conversion est parfaitement
légale, mais elle peut ne pas préserver la valeur d’origine dans deux cas :
• la valeur est supérieure à la capacité du type size_t ;
• la valeur est négative.
Le premier cas ne peut se produire que dans les implémentations où le type
associé à size_t n’est pas unsigned long. Dans ce cas, la valeur effectivement reçue
par la fonction d’allocation sera inférieure à celle prévue, ce qui peut avoir des
conséquences assez graves, comme le montre cet exemple :
unsigned long n, i ;
char *ad ;
…..
ad = malloc (n) ;
for (i=0 ; i<n ; i++) /* si la valeur de n est supérieure à la capacité du type */
*ad = 0 ; /* size_t, on place des zéros en dehors de la zone allouée */
Le second cas peut se produire si la valeur d’origine est d’un type signé et que,
suite à une erreur de programmation ou à un dépassement de capacité, elle
devient négative. Sa conversion en non signé pourra alors conduire à l’allocation
d’un nombre important d’octets, probablement inutiles.
D’une manière générale, dès lors qu’un tel argument résulte d’un calcul, on
limitera considérablement les risques en le déclarant de type size_t. Voici
comment éviter les problèmes posés par l’exemple précédent :
Size_t lg ;
…..
lg = …..
adr = malloc (lg) ; /* ici, on est sûr que la valeur effectivement reçue */
/* par malloc est identique à celle figurant dans lg */
3.4 Limitations
Indépendamment de la taille du tas, la norme n’interdit pas à une implémentation
de limiter la taille des objets qu’on peut allouer dynamiquement. En particulier,
la valeur maximale représentable dans le type correspondant à size_t induit une
limite naturelle. En pratique, on rencontre des implémentations où la taille des
objets est limitée à une faible valeur, par exemple 64 Ko, alors que la taille du tas
est nettement supérieure, par exemple de plusieurs dizaines de Mo. Dans ce cas,
calloc permettra cependant d’allouer en une seule fois plusieurs blocs de cette
taille, donc de repousser, voire d’annuler totalement toute limitation.
4. La fonction malloc
La fonction malloc permet d’allouer dynamiquement un emplacement formé d’un
nombre donné d’octets consécutifs, sans l’initialiser.
4.1 Prototype
void *malloc (size_t taille) (stdlib.h)
taille
Nombre d’octets à allouer – taille est de type size_t
d’où, des risques d’erreur
(voir section 3.3.2) et une
limitation de la taille des
objets (voir section 3.4) ;
– si taille = 0, le
comportement est
théoriquement
indéterminé (voir section
4.2).
Valeur de – adresse de l’emplacement – respecte la contrainte
retour alloué lorsque l’opération d’alignement la plus forte
a réussi ; (voir section 3.1) ;
– le pointeur NULL dans le cas – à tester systématiquement
contraire. (voir section 4.2).
4.2 La valeur de retour et la gestion des erreurs
Comme cela a été dit à la section 3.1, le résultat fourni par malloc est un pointeur
générique, respectant la plus forte contrainte d’alignement de l’implémentation
qui peut être converti sans risque en un pointeur de type quelconque.
Cette valeur de retour est le pointeur nul (NULL) dans le cas où l’allocation
mémoire a échoué. La plupart du temps, cet échec se produit parce qu’il ne reste
plus suffisamment de place dans le tas. Mais de plus, et assez curieusement,
lorsque la valeur de taille est nulle, la norme se contente de dire que le
comportement de malloc peut dépendre de l’implémentation. En pratique, on peut
obtenir soit l’adresse d’un bloc de taille nulle (ce qui n’a guère d’intérêt), soit
une valeur de retour nulle. Cela signifie qu’une valeur de retour nulle ne traduit
pas nécessairement un manque de place.
Dans un programme opérationnel, il est bien entendu nécessaire de distinguer les
deux situations. Dans le cas où l’on a pris soin, comme indiqué à la section 3.3.2,
de transmettre à malloc un argument de type size_t, les choses restent simples, par
exemple :
size_t lg ;
…..
if (lg > 0) { adr = malloc (lg) ;
if (adr == NULL) { /* manque de place dans le tas */ }
}
En revanche, si lg n’est pas de type size_t, outre les risques déjà évoqués à la
section 3.3.2, on court celui que malloc reçoive une valeur nulle alors que celle de
lg ne l’est pas. Dans ce cas, l’échec de malloc n’est plus lié à un manque de
place…
Remarques
1. La manière dont est gérée l’information de taille associée à chaque bloc alloué dépend
manifestement de l’implémentation. Dans certains cas, l’implémentation fait précéder chaque zone
d’octets dits « de service » destinés à en contenir la longueur.
2. Il existe des implémentations où la nature même du type size_t limite la taille des objets à une
valeur bien inférieure à celle du tas (voir section 3.4). Il est alors fréquent que la fonction calloc,
destinée à allouer plusieurs blocs consécutifs, permette d’outrepasser cette limite qui porte non pas
sur la totalité de l’allocation mémoire, mais simplement sur la taille de chaque bloc.
3. Par la nature même de la gestion dynamique, des appels successifs de malloc ne conduisent pas
nécessairement à des emplacements contigus, ni même d’adresses croissantes ou décroissantes. Si
l’on souhaite allouer un emplacement pour un tableau d’objets, il est absolument nécessaire :
– soit de le faire en une seule fois par malloc, comme dans l’exemple introductif ;
– soit d’utiliser calloc.
4. Compte tenu de la manière dont le tas est géré, suite à des allocations et libérations successives, il
peut se trouver fragmenté, c’est-à-dire comporter plusieurs emplacements disponibles non
consécutifs. Dans ces conditions, il peut arriver que, bien que disposant globalement d’un nombre
d’octets suffisants, on ne puisse trouver les octets contigus voulus. Certains environnements
disposent d’un mécanisme dit de « ramasse-miettes » dont le principal défaut est que son
déclenchement n’est pas facile à prévoir et qu’il peut alors compromettre la bonne exécution de
programmes dans lesquels le temps d’exécution est primordial.
5. La fonction free
void free (void * adr) (stdlib.h)
adr
Adresse de – si adr = NULL, il ne se passe rien ;
l’emplacement
à libérer – le comportement du programme est
indéterminé en cas de double libération
d’un même emplacement ou si la valeur de
adr n’a pas été obtenue comme valeur de
retour de malloc, calloc ou realloc.
Cette fonction demande de libérer l’emplacement du tas dont l’adresse est
fournie en unique argument. Quel que soit son type, l’argument effectif sera
converti en void *, ce qui ne modifie pas l’adresse initiale.
On notera bien que l’on ne communique pas à free la taille de l’emplacement à
libérer. Cette taille doit donc être déduite (par free ou par le mécanisme de
gestion de mémoire de l’implémentation) de la seule information d’adresse.
C’est la raison pour laquelle la norme précise qu’il est nécessaire de transmettre
à free une valeur fournie préalablement par l’une des fonctions d’allocation
dynamique malloc, calloc ou realloc. En conséquence, un emplacement mémoire
ne peut être libéré que dans sa totalité et jamais en partie (excepté dans le cas
particulier d’utilisation de realloc).
Un appel de free avec un argument nul l’amène à ne rien faire :
adr = NULL ;
free (adr) /* ne fait rien */
Par ailleurs, la norme précise que la double libération d’un même emplacement
conduit à un comportement indéterminé du programme. En pratique, celui-ci
dépend du mécanisme de gestion du tas. Souvent, on risque alors de libérer un
emplacement3 alloué à d’autres objets, avec des conséquences catastrophiques.
D’une manière générale, il est prudent de faire suivre tout appel de free d’une
mise à NULL du pointeur correspondant :
free (adr) ; /* il n'est pas utile de tester la valeur de adr car free (NULL) */
/* ne présente aucun risque */
adr = NULL ; /* même si on fait maintenant free(adr) par erreur, il ne se */
/* passera rien */
6. La fonction calloc
La fonction calloc permet d’allouer dynamiquement un emplacement pour
plusieurs blocs consécutifs de même taille, en les initialisant à zéro binaire.
6.1 Prototype
void *calloc ( size_t nb_blocs, size_t taille ) (stdlib.h)
nb_blocs
Nombre de blocs
consécutifs de taille octets
à allouer
taille
Nombre d’octets par bloc – taille est de type size_t
d’où des risques d’erreur
(voir section 3.3.2) et une
limitation de la taille des
blocs (voir section 3.4) ;
– si taille = 0 ou si nb_blocs =
0, le comportement est
théoriquement
indéterminé (voir section
6.3).
Valeur – adresse de l’emplacement – respecte la contrainte
de retour alloué lorsque l’opération d’alignement la plus forte
a réussi ; (voir section 3.1) ;
– le pointeur NULL dans le cas – à tester systématiquement
contraire. (voir section 6.3).
6.2 Rôle
Contrairement à malloc qui n’initialisait pas la zone allouée, calloc initialise tous
les octets alloués à zéro. On notera cependant qu’une telle initialisation ne
correspond à des valeurs nulles que pour les types entiers. Pour les types
flottants, un motif binaire nul ne correspond généralement pas à une valeur
nulle4 ; il en va parfois de même pour le type pointeur.
En théorie, calloc permet d’allouer plusieurs blocs de même taille. Mais comme
ces blocs sont contigus, les différences entre les deux appels suivants sont
mineures :
malloc (n*p) ; /* ces deux appels allouent un emplacement */
calloc (n, p) ; /* de même taille : n * p octets */
En effet, en dehors de l’absence d’initialisation dans le premier cas, ces
différences ne concernent que les limitations portant sur la taille des objets : dans
certaines implémentations, elles pourront se trouver reculées avec calloc par le
fait qu’elles porteront alors sur chaque bloc et non sur la totalité de la zone. En
particulier, lorsque le type size_t impose une limitation à une valeur que nous
noterons taille_max, on voit que calloc peut permettre d’allouer en une seule fois
une place mémoire (de plusieurs blocs) pouvant atteindre, en théorie, taille_max *
5
taille_max .
6.3 Valeur de retour et gestion des erreurs
Le résultat fournit par calloc est un pointeur générique respectant la plus forte
contrainte d’alignement de l’implémentation et qui peut être converti sans risque
en un pointeur de type quelconque (voir section 3.1).
Cette valeur de retour est le pointeur nul (NULL) dans le cas où l’allocation
mémoire a échoué. La plupart du temps, cet échec se produit parce qu’il ne reste
plus suffisamment de place dans le tas. Mais comme pour malloc, la norme
prévoit que le comportement de calloc peut dépendre de l’implémentation en cas
d’une demande de taille nulle. Cette fois, cela peut se produire, dès lors que
l’une des deux valeurs nb_blocs ou taille est nulle. En pratique, on aboutit au
même comportement qu’avec malloc : adresse d’une zone de taille nulle ou valeur
de retour nulle. Cela signifie, là encore, qu’une valeur de retour nulle ne traduit
pas nécessairement un manque de place.
Dans un programme opérationnel, il est bien sûr nécessaire de distinguer les
différentes situations. Si l’on a pris soin, comme indiqué à la section 3.3.2,
d’utiliser pour les deux arguments de calloc, des valeurs de type size_t, les choses
sont nettement plus simples :
size_t l_bloc, n_blocs ;
…..
if (l_bloc * n_blocs > 0)
{ adr = calloc (l_bloc, n_blocs) ;
if (adr == NULL) { /* manque de place dans le tas */ }
}
En revanche, si l’on n’a pas utilisé d’argument de type size_t, outre les risques
déjà évoqués à la section 3.3.2, on court celui que calloc reçoive une valeur nulle
pour l’un des ses deux arguments, alors que celles de l_bloc et de n_blocs ne le
sont pas. Dans ce cas, l’échec de calloc n’est plus lié à un manque de place.
6.4 Précautions
Bien que l’appel de calloc semble porter sur plusieurs blocs, une zone allouée par
calloc ne peut être libérée qu’en une seule fois par free. Il n’est pas question
d’essayer de n’en libérer qu’un morceau, notamment en cherchant à
communiquer à free l’adresse d’un des blocs ainsi alloués :
char * ad ;
ad = calloc (n, p) ;
…..
free (ad + 3*p) ; /* erreur probable lors de l'execution : on transmet à free */
/* une adresse qui n'est pas celle d'une zone allouée par */
/* l'une des fonctions alloc, calloc ou realloc */
Autrement dit, quelle que soit la manière d’allouer un emplacement :
ad = malloc (n*p) ;
ad = calloc (n, p) ;
la seule façon de libérer le bloc correspondant est :
free (ad) ;
7. La fonction realloc
Par sa nature, la fonction free libère toujours un emplacement dans sa totalité. La
fonction realloc peut pallier très légèrement ce défaut. En effet, elle permet de
libérer une partie d’une zone préalablement allouée (par malloc, calloc ou realloc)
dans la mesure où il s’agit de sa fin. En outre, elle permet d’accroître un
emplacement donné tout en conservant son contenu. Nous commencerons par
deux exemples introductifs, avant de décrire cette fonction en détail.
7.1 Exemples introductifs
7.1.1 Réduction de la taille d’un emplacement
Si on a alloué initialement un emplacement pour un tableau de 100 entiers par :
adr = malloc (100 * sizeof(long) ) ;
il est possible de réduire cet emplacement par l’appel :
adr = realloc (adr, 60 * sizeof(long)) ;
Les 40*sizeof(long) octets correspondant à la fin de la zone initiale seront libérés.
Pour aboutir à un résultat comparable sans utiliser realloc, il aurait fallu procéder
ainsi :
adr = malloc (100 * sizeof (long)) ;
…..
adr1 = malloc (60 * sizeof(long)) ; /* allocation d'un nouvel emplacement */
for (i=0 ; i<60 ; i++)
6
adr1[i] = adr[i] ; /* recopie du début de l'ancien emplacement */
free (adr) ; adr = NULL ; /* libération de l'ancien emplacement */
On notera qu’avec cette deuxième démarche, l’adresse de la nouvelle zone est
toujours différente de celle de la première et qu’il a fallu procéder à une recopie.
Avec realloc, il est très probable (bien que non certain) que l’adresse n’aurait pas
changé et qu’on aurait économisé une recopie.
7.1.2 Augmentation de la taille d’un emplacement
Si on a alloué initialement un emplacement pour un tableau de 100 entiers par :
adr = malloc (100 * sizeof(long) ) ;
il est possible d’accroître cet emplacement par l’appel :
adr = realloc (adr, 130 * sizeof(long)) ;
Pour aboutir à un résultat comparable sans utiliser realloc, il aurait fallu procéder
ainsi :
adr = malloc (100 * sizeof (long)) ;
…..
adr1 = malloc (130 * sizeof(long)) ; /* allocation d'un nouvel emplacement */
for (i=0 ; i<100 ; i++)
adr1[i] = adr[i] ; /* recopie de l'ancien emplacement */
free (adr) ; adr = NULL ; /* libération de l'ancien emplacement */
/* on pourrait aussi utiliser : */
/* memcpy (adr1, adr, 60*sizeof(long)) ; */
On notera, cette fois encore, qu’avec cette seconde démarche, l’adresse de la
nouvelle zone est toujours différente de celle de la première et qu’il a fallu
procéder à une recopie. Avec realloc, il est possible que l’environnement ait
profité d’éventuels octets disponibles à la suite de la zone inutile pour ne pas
effectuer de recopie.
7.2 Prototype
void realloc (void *adr, size_t taille) (stdlib.h)
adr
Adresse de l’emplacement La valeur de adr doit :
dont on souhaite modifier la
taille. – soit avoir été fournie
préalablement par malloc,
calloc ou realloc ;
– soit être NULL (avec alors,
de préférence, taille non
nulle) → realloc joue le
même rôle que malloc
(voir section 7.3).
taille
Nouvelle taille souhaitée – taille est de type size_t
d’où des risques d’erreur
(voir section 3.3.2) et une
limitation de la taille des
blocs (voir section 3.4) ;
– taille peut être nulle (avec
adr non NULL) → libération
comme avec free (voir
section 7.3) ;
– si taille et adr sont tous
deux nuls →
comportement
théoriquement
indéterminé (voir section
7.3).
Valeur de – adresse de l’emplacement – respecte la contrainte
retour alloué lorsque l’opération d’alignement la plus forte
a réussi ; (voir section 3.1) ;
– le pointeur NULL si – à tester systématiquement
l’opération a échoué ou (voir section 7.4).
lorsque taille est nulle.
7.3 Rôle
Comme le montrent les exemples de la section 7.1, le rôle usuel de realloc
consiste à modifier la taille d’un emplacement préalablement alloué
dynamiquement. Il correspond au cas où la valeur de adr est non nulle. La norme
n’oblige alors pas cette fonction à conserver l’adresse initiale de la zone, même
lorsque la taille demandée est inférieure à l’ancienne. En revanche, elle assure
que le contenu de la zone initiale est conservé7, à concurrence de la nouvelle
taille requise. Autrement dit :
• lorsque la nouvelle taille demandée est supérieure à l’ancienne, le contenu de
l’ancienne zone est intégralement conservé ;
• lorsque la nouvelle taille est inférieure à l’ancienne, le début de l’ancienne zone
(c’est-à-dire taille octets) verra son contenu inchangé.
Lorsque la valeur de adr est nulle, realloc se contente d’allouer un nouvel
emplacement de la taille voulue, comme si on avait simplement appelé malloc.
Ces deux appels sont équivalents :
realloc (NULL, taille) ; /* alloue un nouvel emplacement de taille octets */
malloc (taille) ; /* même chose */
Cette équivalence reste vraie lorsque taille est nulle, ce qui signifie que le
comportement de realloc est théoriquement indéterminé dans ce cas. En pratique,
on peut obtenir soit l’adresse d’un bloc de taille nulle (ce qui n’a guère
d’intérêt), soit une valeur de retour nulle.
Lorsque la valeur de taille est nulle, sans que celle de adr ne le soit, realloc se
contente de libérer l’emplacement d’adresse adr. Ces deux instructions sont
équivalentes :
realloc (adr, 0) ; /* libere l'emplacement d'adresse adr */
free (adr) ; /* même chose */
7.4 Valeur de retour
Le résultat fournit par realloc est un pointeur générique respectant la plus forte
contrainte d’alignement de l’implémentation qui peut être converti sans risque en
un pointeur de type quelconque (voir section 3.1).
Cette valeur de retour est le pointeur nul (NULL) dans les cas suivants :
• suite à une demande d’accroissement de la zone, l’allocation a échoué ; le
contenu de la zone reste alors inchangé ;
• la valeur de taille est nulle, sans que celle de adr le soit ; l’appel est alors
interprété comme une demande de libération par free (avec cette différence que
free ne disposait pas de valeur de retour).
En pratique, dans certaines implémentations, on peut obtenir la valeur de retour
NULL lorsque taille et adr sont tous deux nuls (la norme prévoyant un
comportement indéterminé dans ce cas).
On voit que, là encore, une valeur de retour nulle ne traduit pas nécessairement
un manque de place. Dans un programme opérationnel, il est bien entendu
nécessaire de distinguer les deux situations. Les réflexions à propos de malloc de
la section 4.2 se transposent directement à realloc.
7.5 Précautions
Après un appel tel que :
realloc (ad, …) ;
la valeur de ad ne doit en aucun cas être utilisée puisque l’emplacement
correspondant pourra avoir été libéré. On pourrait envisager de procéder ainsi :
ad = realloc (ad, …) ; /* l'ancienne valeur de ad est remplacée par */
/* l'adresse de la nouvelle zone */
Toutefois, un problème se poserait en cas d’échec de realloc, puisque la zone
préalablement pointée par ad n’aurait pas été libérée et qu’on n’en posséderait
plus l’adresse. Il est plus judicieux de procéder ainsi :
adr = realloc (ad, … ) ;
if (adr != NULL) ad = NULL ; /* par précaution */
8. Techniques utilisant la gestion dynamique
Voici trois exemples de situations dans lesquelles les possibilités de gestion
dynamique du langage C s’avèrent utiles :
• gestion de tableaux dont la taille n’est connue qu’au moment de l’exécution ;
• gestion de tableaux dont la taille varie pendant l’exécution ;
• gestion de listes chaînées.
8.1 Gestion de tableaux dont la taille n’est connue
qu’au moment de l’exécution
Par la nature même du langage C, la dimension d’un tableau doit être connue
dans l’instruction de déclaration qui effectue la réservation de l’emplacement
mémoire correspondant, quel que soit l’emplacement d’une telle déclaration,
globale ou locale. Bien entendu, il existe des circonstances où cette dimension
n’est pas nécessaire, notamment dans le cas d’un tableau figurant en argument
muet dans un en-tête de fonction. Il n’en reste pas moins que le tableau qui sera
fourni en argument effectif aura, quant à lui, une dimension parfaitement définie
à la compilation.
Grâce aux possibilités de gestion dynamique du langage C, il est facile de
manipuler un tableau dont la dimension n’est connue qu’au moment de
l’exécution. Voici, par exemple, comment on pourra disposer d’un tableau
d’éléments de type long dont le nombre est défini par l’utilisateur :
long * adr ; /* pointeur sur un objet de type long */
size_t n_el ;
…..
printf ("combien d'elements : ") ;
scanf ("%d", &n_el) ;
adr = malloc (n_el * sizeof(long)) ; /* allocation pour tableau de n_el long */
Un élément de rang i de ce tableau pourra alors se noter indifféremment adr[i] ou
*(adr+i).
8.2 Gestion de tableaux dont la taille varie pendant
l’exécution
Lorsque la taille d’un tableau doit varier au fil de l’exécution, on peut utiliser
une démarche rustique mais simple consistant à définir une taille maximale,
connue à la compilation, qu’on est certain de ne pas dépasser. Mais on peut
améliorer les choses en recourant aux possibilités de gestion dynamique.
S’il s’agit d’un tableau qu’on est amené uniquement à agrandir ou à rétrécir par
la fin, on pourra faire appel à realloc. En revanche, si, comme c’est le plus
probable, on a besoin de pouvoir supprimer ou ajouter un élément en position
quelconque, tout en conservant les autres, il peut être judicieux d’utiliser un
tableau de pointeurs sur chacun des éléments. Ces derniers ne seront plus
nécessairement contigus en mémoire, mais grâce à l’existence du tableau de
pointeurs (formé, lui, d’éléments consécutifs), on pourra accéder directement à
n’importe quel élément du tableau.
Par exemple, supposons que l’on ait besoin d’un tableau d’éléments du type
structure point défini ainsi :
struct point { int num ;
float x ;
float y ;
} ;
Si l’on suppose que le nombre initial d’éléments est n_elem (il peut bien sûr s’agir
d’une variable), on commencera par allouer un tableau de pointeurs par :
struct point **adp ; /* notez les deux * : adp est du type pointeur sur un */
/* pointeur sur un objet de type struct point */
…..
adp = malloc (n_elem * sizeof (struct point *) ) ;
Puis, on allouera chacun des n_elem éléments (de type point) en plaçant son
adresse dans l’élément correspondant du tableau adp :
for (i=0 ; i<n_elem ; i++)
adp[i] = malloc (sizeof(struct point)) ;
À partir de là, adp[i] contient l’adresse du point de rang i, *adp[i] est un objet de
type struct point correspondant au point de rang i. Les champs d’un tel point se
notent simplement :
adp[i]-> num, adp[i]->x ou adp[i]->y.
Voici un exemple simple de définition et d’utilisation d’une fonction qui se
fonde sur ce mécanisme pour créer un tableau dont le nombre d’éléments lui est
fourni en argument, tandis que leurs valeurs sont lues en données. On notera
qu’il est nécessaire de lui fournir l’adresse de la variable adp, c’est-à-dire
finalement une valeur de type struct point ***.
Gestion de tableaux dont la taille varie lors de l’exécution
#include <stdio.h>
#include <stdlib.h>
struct point { int num ;
float x ;
float y ;
} ;
void init (struct point ***, int) ; /* déclaration de init */
int main()
{ int n_elem ; /* nombre d'éléments */
struct point **ad ; /* ad : adresse du tableau de pointeurs */
printf ("combien d'elements : " ) ;
scanf ("%d", &n_elem) ;
init (&ad, n_elem) ;
}
void init (struct point ***adp, int n_elem)
{ int i ;
*adp = malloc (n_elem * sizeof (struct point *) ) ;
for (i=0 ; i<n_elem ; i++)
(*adp)[i] = malloc (sizeof(struct point)) ;
for (i=0 ; i<n_elem ; i++)
{ printf("numero, x, y : ") ;
scanf("%d %f %f", &((*adp)[i]->num), &((*adp)[i]->x), &((*adp)[i]->y)) ;
}
}
En pratique, il faudra bien entendu disposer d’autres fonctions telles que :
• suppression d’un élément de rang donné ;
• insertion d’un nouvel élément à un rang donné…
De telles opérations nécessiteront alors théoriquement une nouvelle allocation du
tableau de pointeurs, ainsi que la recopie de certaines de ses valeurs. En
revanche, elles ne nécessiteront aucune recopie des éléments eux-mêmes. Il
suffira seulement soit d’une allocation d’un nouvel emplacement par malloc, soit
d’une suppression d’un emplacement par free.
Dans ces conditions, on voit que le « prix à payer » pour pouvoir accéder
directement à un élément quelconque se limite à la gestion du tableau de
pointeurs lui-même et non plus du tableau d’éléments. La différence deviendra
d’autant plus sensible que les éléments concernés seront de grande taille.
En pratique, on pourra améliorer les temps d’exécution en surdimensionnant le
tableau de pointeurs, de manière à éviter sa réallocation systématique lors de
chaque opération.
8.3 Gestion de listes chaînées
La section précédente a montré comment gérer un tableau dont la taille et le
contenu évoluent au fil de l’exécution. Dans certains cas, les accès aux éléments
sont peu fréquents par rapport aux insertions ou aux suppressions. Il peut alors
s’avérer plus judicieux de constituer ce que l’on nomme une « liste chaînée »,
dans laquelle :
• un pointeur désigne le premier élément ;
• chaque élément comporte un pointeur sur l’élément suivant.
Dans ce cas, l’allocation ou la suppression d’un nouvel élément nécessite
seulement quelques opérations simples, comme nous allons voir en appliquant
cette démarche aux mêmes éléments de type point que précédemment :
struct point { int num ;
float x ;
float y ;
} ;
Chaque élément doit donc contenir un pointeur sur un élément de même type.
Ainsi, nous pourrons adapter notre précédente structure de la manière suivante :
struct element { int num ;
float x ;
float y ;
struct element * suivant ;
} ;
Ici, dans la description du modèle element, nous avons été amené à utiliser un
pointeur sur ce même modèle.
Nous vous proposons un exemple simple de fonction créant une telle liste à
partir d’informations fournies en données. Il existe deux façons d’en ordonner
les éléments :
• ajouter chaque nouvel élément à la fin de la liste ; le parcours ultérieur de la
liste se fera alors dans le même ordre que celui dans lequel les données ont été
introduites ;
• ajouter chaque nouvel élément au début de la liste ; le parcours ultérieur de la
liste se fera alors dans l’ordre inverse de celui dans lequel les données ont été
introduites.
Nous avons choisi ici de programmer la seconde méthode, laquelle se révèle
légèrement plus simple que la première.
Notez que le dernier élément de la liste (donc, dans notre cas, le premier lu) ne
pointera sur rien. Or lorsque nous chercherons ensuite à utiliser notre liste, il
nous faudra être en mesure de savoir où elle s’arrête. Certes, nous pourrions, à
cet effet, conserver l’adresse de son dernier élément. Mais il est plus simple
d’attribuer au champ suivant de ce dernier élément une valeur fictive dont on sait
qu’elle ne peut apparaître par ailleurs. La valeur NULL fait très bien l’affaire.
En ce qui concerne l’adresse de début de la liste, nous convenons qu’elle sera
placée par la fonction de création en un emplacement dont on lui aura transmis
l’adresse en argument.
Voici un exemple de définition et d’utilisation d’une telle fonction, en supposant
que nous fournissons conventionnellement un point de numéro nul pour signaler
la fin des données :
#include <stdio.h>
#include <stdlib.h>
struct element { int num ;
float x ;
float y ;
struct element * suivant ;
} ;
void creation (struct element **) ; /* déclaration de la fonction création */
int main()
{
struct element *debut ; /* debut contiendra l'adresse de début de liste */
creation (&debut) ;
}
void creation (struct element **adeb)
{
int num ;
float x, y ;
struct element *courant ;
*adeb = NULL ;
while (1)
{ printf("numero x y : ") ;
scanf ("%d %f %f", &num, &x, &y);
if (num == 0) break ;
courant = (struct element *) malloc (sizeof(struct element)) ;
courant -> num = num ;
courant -> x = x ;
courant -> y = y ;
courant -> suivant = * adeb ;
*adeb = courant ;
}
}
En pratique, il faudra là encore disposer d’autres fonctions telles que :
• insertion d’un nouvel élément dans la liste ;
• suppression d’un élément de la liste…
Contrairement à ce qui se passait à la section 8.2, de telles opérations ne
nécessiteront qu’une seule allocation ou suppression d’un emplacement
correspondant à un élément, à condition cependant de disposer de son adresse.
Or, l’obtention de cette adresse nécessitera souvent un parcours d’une partie de
la liste, que l’on cherche à localiser un élément à partir de son contenu ou de son
rang dans la liste.
En définitive, le choix entre les deux représentations proposées aux sections 8.2
et 8.3 ne pourra se faire qu’à partir de la connaissance de la statistique des
opérations à mettre en œuvre.
1. En toute rigueur, cette remarque s’applique également à des objets statiques ou dynamiques auxquels on
accéderait par pointeur. Il s’agit cependant d’une situation rare et qui, de toute façon, n’enlève rien au
fait qu’il existe bien en C deux notations différentes pour accéder à des objets d’un type donné.
2. En toute rigueur, cette longueur figure alors dans les octets précédant l’adresse effectivement fournie par
malloc.
3. En outre, sa taille sera probablement différente de celle de l’emplacement préalablement libéré.
4. Qui plus est, un motif binaire nul a de grandes chances de correspondre à un flottant non normalisé dans
certaines implémentations, ce qui peut conduire à une erreur d’exécution.
5. Cependant, dans les implémentations dans lesquelles la limitation porte sur la taille des objets eux-
mêmes, cette remarque perd tout son intérêt.
6. On peut aussi procéder ainsi : memcpy (adr1, adr, 60*sizeof(long)).
7. Il peut y avoir recopie. Cela va de soi en cas de demande d’accroissement de la zone, alors qu’on ne
dispose pas de suffisamment d’octets à sa suite. Mais cela peut également se produire en cas de demande
de diminution de la zone, si le mécanisme de gestion de la mémoire l’a jugé bon, par exemple pour éviter
de trop fragmenter le tas.
15
Le préprocesseur
En langage C, la traduction d’un fichier source se déroule obligatoirement en
deux étapes indépendantes : un prétraitement et une compilation proprement
dite. On parle de préprocesseur pour désigner le programme réalisant la première
phase alors que, la plupart du temps, prétraitement et compilation sont réalisés
consécutivement par un seul et même programme.
Le travail du préprocesseur consiste en un traitement du texte du fichier source
fondé sur l’interprétation d’instructions particulières, structurées en lignes et
qu’on nomme des directives.
Dans ce chapitre, nous examinerons tout d’abord quelques règles générales
concernant l’écriture de ces directives. Puis, après en avoir proposé une
classification, nous les passerons en revue. Nous commencerons par l’importante
directive #define qui sert à définir ce que l’on nomme des symboles et des
macros. Nous poursuivrons avec les directives de compilation conditionnelle qui
permettent de conserver ou d’exclure certaines parties du fichier source. Nous
aborderons ensuite la directe #include, fort répandue, utilisée pour incorporer ce
que l’on nomme des fichiers en-tête. Enfin, nous étudierons quelques directives,
d’usage peu répandu, à savoir #line, #error et #pragma.
1. Généralités
1.1 Les directives tiennent compte de la notion de
ligne
Contrairement à ce qui se passe pour les instructions du langage proprement
dites, les directives du préprocesseur sont sensibles à la notion de ligne. Plus
précisément, une directive commence toujours sur une nouvelle ligne du fichier
source et occupe au minimum toute cette ligne.
Une directive peut s’étendre sur plusieurs lignes, à condition que chacune de ses
lignes, sauf la dernière, se termine par le caractère \. Ce dernier ne doit alors être
suivi d’aucun autre caractère, pas même un espace ou un commentaire (si tel
était le cas, le caractère \ et les suivants seraient alors simplement considérés
comme faisant partie de la directive).
Par exemple, la directive suivante (on suppose qu’il n’y a pas d’espace après \) :
#define fct_longue(x, y, z) \
abs (x) + log (y*y) \
- exp (x+z)
est équivalente à :
#define fct_longue(x, y, z) abs (x) + log (y*y) - exp (x+z)
ou encore à :
#define fct_longue(x, y, z) abs (x) + log (y*y) \
- exp (x+z)
En revanche, si l’on considère ces deux lignes :
#define NMAX nv+\ /* à suivre */
5
la directive #define se limitera à une seule ligne et, dans la suite du fichier source,
le symbole NMAX sera remplacé par nv+\. Quant à la ligne suivante (ici, 5), elle sera
transmise telle quelle au compilateur et, en général, elle provoquera une erreur.
1.2 Les directives et le caractère #
Pour le préprocesseur, une directive se définit comme étant une ligne qui
commence par le symbole #. Cependant, la norme accepte la présence d’espaces
blancs (autres que fin de ligne) ou de commentaires avant ou après ce caractère.
Ainsi, bien qu’ils présentent peu d’intérêt, ces exemples sont acceptés par la
norme :
/* incorporation d'un en-tête */ # include "exple.h"
#
/* taille par défaut */ /* du tableau */ define TAILLE 100
En général, on exploitera surtout la possibilité de placer des espaces avant le #,
en vue d’indenter convenablement certaines directives faisant l’objet de
compilation conditionnelle. Dans la suite, nous accolerons toujours le nom de la
directive au caractère # comme le veut l’usage.
Remarque
Certaines anciennes implémentations peuvent ne pas respecter la norme sur ce point, en refusant les
espaces blancs ou les commentaires avant ou après le caractère #.
1.3 La notion de token pour le préprocesseur
Comme on l’a vu à la section 7 du chapitre 2, le préprocesseur d’abord, le
compilateur ensuite, analysent un programme source en le décomposant en
entités insécables nommées tokens. Les tokens reconnus par le préprocesseur
sont presque les mêmes que ceux reconnus par le compilateur. Il existe
cependant quelques différences portant sur les mots-clés, les noms de fichier en-
tête et les caractères de ponctuation.
En ce qui concerne les mots-clés, ils sont ignorés du préprocesseur qui les
considère comme des identificateurs usuels. En conséquence, il devient possible,
volontairement ou non, de modifier la signification d’un mot-clé par une
directive #define, comme dans cet exemple (déconseillé) :
#define int float /* accepté : dans toute la suite du fichier source */
/* int sera remplacé par float */
Quant aux tokens relatifs aux noms de fichiers en-tête, ils sont de l’une des deux
formes <xxxxx> ou "xxxxx", xxxxx désignant des caractères quelconques. On notera
que la seconde forme de ce token sera interprétée comme une constante chaîne,
dès lors qu’elle n’aura pas été reconnue comme nom de fichier en-tête.
Par ailleurs, les symboles de ponctuation [, ], (, ), { et } ne sont pas encore
reconnus comme des opérateurs ou des délimiteurs et ils n’ont donc pas besoin
d’être appariés. Les directives suivantes seront correctes, indépendamment de
leur intérêt :
#define bizare1(n,i) n[i
#define bizare2 f(
En revanche, bien que l’on ait parfois tendance à réduire le préprocesseur à un
traitement de texte, celui-ci reconnaît toutes les constantes numériques, y
compris les constantes caractères ou flottantes. Nous verrons qu’il est même
capable, comme le compilateur, d’évaluer des expressions constantes
apparaissant dans certaines directives.
1.4 Classification des différentes directives du
préprocesseur
Voici une classification des différentes directives que nous allons étudier et un
résumé très succinct de leur rôle.
Tableau 15.1 : les différentes directives du préprocesseur
2. La directive de définition de symboles et de macros
La directive #define offre en fait deux possibilités de nature quelque peu
différente, à savoir la définition de symboles d’une part, la définition de macros
d’autre part.
Dans le cas de la définition de symboles, le rôle du préprocesseur se limite à une
simple substitution d’un symbole par un texte quelconque. Bien qu’il ne s’agisse
nullement d’une obligation, l’usage veut que ce texte corresponde à une
constante caractère, numérique ou chaîne.
Dans le cas de la définition de macros, intervient, un peu comme dans une
fonction, la notion de paramètre. Certes, le préprocesseur réalise ici encore une
substitution. Mais celle-ci n’est plus systématique puisqu’elle dépend des valeurs
effectives des différents paramètres. Là encore, le texte ainsi obtenu peut être
quelconque, même si l’usage veut que ces macros conduisent à un ensemble
complet et cohérent formant une expression.
Nous commencerons par des exemples introductifs qui, malgré leur simplicité,
correspondent à une utilisation habituelle de la directive #define. Nous étudierons
ensuite sa syntaxe, ainsi que les algorithmes exacts qui président aux différentes
substitutions, ce qui permettra d’interpréter les formes les plus complexes et les
moins répandues.
2.1 Exemples introductifs
2.1.1 Définition de simples symboles
Une directive telle que :
#define NBMAX 5
demande de remplacer le symbole NBMAX par le texte 5, et cela chaque fois que ce
symbole apparaîtra dans la suite du fichier source. Par exemple, les instructions
suivantes :
int n = NBMAX ;
float tab [NBMAX] [2*NBMAX] ;
seront ainsi transformées par le préprocesseur, avant d’être transmises au
compilateur :
int n = 5 ;
float tab [5] [2*5] ;
On notera bien que le préprocesseur se contente de remplacer le symbole NBMAX.
En particulier, l’expression 2*5 sera transmise sous cette forme au compilateur,
qui saura l’évaluer en tant qu’expression constante1.
Il est possible d’utiliser dans le texte de substitution un symbole préalablement
défini :
#define NBMAX 5
….
#define TAILLE NBMAX + 1
Chaque fois que l’identificateur TAILLE apparaîtra dans la suite du programme, il
sera systématiquement remplacé par 5+1. En effet, comme on peut s’y attendre,
après un premier remplacement de TAILLE par NBMAX+1, le préprocesseur examinera
à nouveau le résultat obtenu afin d’y détecter d’éventuels symboles à remplacer
à leur tour. D’ailleurs, si l’on inverse l’ordre des deux directives précédentes, on
aboutira au même résultat, du moins dans la partie du fichier source suivant la
dernière d’entre elles.
Il est même possible de demander de substituer un « texte vide » à un symbole.
Par exemple, avec cette directive :
#define RIEN
tous les symboles RIEN figurant dans la suite du programme seront remplacés par
un texte vide. Tout se passera donc comme s’ils ne figuraient pas dans le
programme. Une telle possibilité n’est pas aussi fantaisiste qu’il y paraît au
premier abord puisqu’elle pourra intervenir dans la compilation conditionnelle.
En effet, le préprocesseur se basera alors sur l’existence ou l’inexistence de
symboles.
Dans les précédents exemples, à chaque symbole correspondait une simple
valeur. Voici d’autres exemples analogues :
#define PI 3.14159265
#define VRAI 1
#define FAUX 0
#define MESSAGE "bonjour"
#define C_FIN_LIGNE ‘\n'
On dit souvent qu’on a ainsi défini des constantes symboliques, c’est-à-dire des
symboles qui correspondent à des valeurs constantes. Effectivement, le texte à
remplacer correspondait à une constante. Mais il ne s’agit pas d’une obligation,
ce texte pourrait être plus conséquent, comme dans ces exemples :
#define bonjour printf("bonjour")
#define affiche printf("resultat %d\n", a)
#define ligne printf("\n")
L’utilisation des symboles correspondants se ferait ainsi :
bonjour ; /* remplacé par printf ("bonjour") ; */
ligne ; /* remplacé par printf ("\n") ; */
En théorie, compte tenu du fonctionnement du préprocesseur, les textes associés
à un symbole peuvent être absolument quelconques, autrement dit ne
correspondre ni à une constante, ni à une expression, ni à une instruction. En
pratique, on verra qu’il est conseillé de se limiter à une expression complète.
2.1.2 Définition de macros
La directive suivante2 :
#define carre(a) a*a
ressemble aux précédentes, avec cette différence que le symbole à substituer se
présente sous la forme d’un identificateur suivi d’un paramètre entre
parenthèses. Elle demande au préprocesseur de remplacer dans la suite du fichier
source tous les textes de la forme :
carre(a)
dans lesquels a représente en fait un texte quelconque par :
a*a
Par exemple :
carre(z) deviendra z*z
carre(valeur) deviendra valeur*valeur
carre(12) deviendra 12*12
La macro précédente ne disposait que d’un seul paramètre, mais il est possible
d’en faire intervenir plusieurs en les séparant, classiquement, par des virgules :
#define dif(a,b) a-b
Dans ce cas :
dif(x,z) deviendrait x-z
dif(valeur+9,n) deviendrait valeur+9-n
Là encore, les définitions peuvent s’imbriquer. Ainsi, avec les deux définitions
précédentes, des macros carre et valeur, le texte :
dif(carre(p),carre(q))
conduira le préprocesseur à reconnaître un appel de macro nommée dif, avec
comme paramètres carre(p) et carre(q). Comme on peut s’y attendre3 et comme on
le verra à la section 2.3, ces paramètres seront remplacés par p*p et q*q, ce qui
conduira au texte :
dif(p*p,q*q)
et donc finalement à :
p*p-q*q
Là encore, compte tenu du fonctionnement du préprocesseur, il n’existe aucune
contrainte théorique sur la nature des textes associés aux différents paramètres
d’une macro. Le texte généré peut donc être quelconque. En pratique cependant,
on verra qu’il est fortement recommandé de se limiter à des expressions
complètes, à l’image de ce que font les macros de la bibliothèque standard.
2.2 La syntaxe de la directive #define
Comme le montrent les exemples précédents, la directive #define peut prendre
deux formes différentes que par souci de clarté nous présenterons de façon
séparée :
• définition de symboles ;
• définition de macros.
2.2.1 Définition de symboles
La directive #define pour la définition de symboles4
#define^identificateur liste_de_remplacement
identificateur
Formé suivant les règles habituelles concernant les
identificateurs
^
Représente un ou plusieurs espaces blancs autres que
fin de ligne et/ou un commentaire.
liste_de_remplacement
Texte quelconque qui, dans la suite du fichier source,
sera substitué à identificateur.
Commentaires
1. Le texte à substituer se présente obligatoirement sous la forme d’un
identificateur ; En particulier, les directives suivantes seraient rejetées par le
préprocesseur :
#define 1 UN /* incorrect (heureusement !) : 1 n'est pas un idenficateur */
#define 1TRUC 1 /* incorrect : 1TRUC n'est pas un identificateur */
2. La syntaxe n’impose pas explicitement de séparateur entre l’identificateur et
la liste de remplacement, même si on utilise en général un ou plusieurs
espaces. En fait, la norme prévoit simplement que l’identificateur se termine à
la rencontre d’un nouveau token. Ainsi, la définition suivante est acceptée
puisqu’un caractère de ponctuation est en soi un token :
#define a,b 5 /* théoriquement correcte */
Elle conduira à remplacer toute apparition de a par le texte :
,b 5
exactement comme si l’on avait défini :
#define a ,b 5
3. La liste de remplacement peut, quant à elle, comporter n’importe quels
caractères, y compris des espaces blancs (à l’exception de la fin de ligne qui
terminerait la directive). La seule restriction est qu’elle ne peut pas se
terminer par le caractère \, qui indiquerait alors que la directive se poursuit
sur la ligne suivante. En revanche, ce caractère peut apparaître sans problème
à l’intérieur de la liste5. Voici quelques exemples :
#define MESS "bonjour\nmonsieur\n" /* correct : les \ font partie de la chaîne
*/
#define AFF_PART printf ("resultat", /* curieux mais correct */
Quant à l’exemple suivant, il est ambigu :
#define CHOSE "bonjour /* pas de guillemets de fin de chaîne : ambigu */
En effet, comme on peut s’y attendre, un caractère autre qu’un espace blanc
n’appartenant pas à un token du préprocesseur, est retransmis tel quel au
compilateur. Mais les caractères ‘ et " font exception et, dans ce cas, la norme
prévoit que le comportement soit indéterminé. En pratique, certaines
implémentations signalent une erreur au prétraitement. D’autres peuvent
l’accepter avec des conséquences plus ou moins satisfaisantes. L’instruction
suivante :
printf (CHOSE") ;
peut, après prétraitement, conduire aussi bien à :
printf ("bonjour") ;
qu’à :
printf ("bonjour /* pas de guillemets de fin de chaîne : ambigu */") ;
2.2.2 Définition de macros
La directive #define pour la définition de macros6
#define^idendificateur(liste_de_parametres) liste_de_remplacement
identificateur
Formé suivant les règles
habituelles concernant les
identificateurs
^
Représente un ou plusieurs
espaces blancs autres que fin de
ligne et/ou un commentaire.
liste_de_parametres
Liste de zéro, un ou plusieurs On peut y
identificateurs, obligatoirement trouver des
différents les uns des autres, espaces blancs.
séparés par des virgules
liste_de_remplacement
Texte quelconque qui sera Les règles
substitué, dans la suite du fichier exactes dites
source, à un appel de macro de la « d’expansion
forme identificateur(…). de macro » sont
décrites à la
section 2.3.
Commentaires
1. Comme dans la forme précédente, le texte à substituer forme obligatoirement
un identificateur. En particulier, ces directives seraient rejetées par le
préprocesseur :
#define 1(x,y) x+y /* incorrect (heureusement !) : 1 n'est pas un idenficateur
*/
#define 1TRUC(z) z*z /* incorrect : 1TRUC n'est pas un
identificateur */
2. Aucun caractère, pas même un espace, ne doit apparaître entre l’identificateur
et la parenthèse ouvrante précédant la liste de paramètres. Ainsi, avec :
#define somme (a,b) a+b
…
z = somme(x,5) ;
le préprocesseur générerait le texte :
z = (a,b) a+b(x,5) ;
En revanche, à l’intérieur des parenthèses, il est possible d’utiliser n’importe
quel espace blanc, voire un commentaire :
#define somme(a,b) …..
#define somme(a, b) …..
#define somme(a /* premier paramètre */ , b /* deuxième paramètre */) …..
3. Si, généralement, la séparation entre la parenthèse suivant la liste de
paramètres et la liste de remplacement est faite par un simple espace, la
norme ne l’impose nullement :
#define somme(a,b)a+b /* théoriquement correcte */
4. Il est possible de définir une macro sans paramètres. Dans ce cas, son rôle est
analogue à une définition de symbole avec une différence de notation au
niveau de son utilisation, la macro nécessitant les parenthèses :
#define s printf("bonjour")
#define sm() printf("bonjour")
…..
s ; /* utilisation du symbole s */
sm () ; /* utilisation de la macro sm : parenthèses vides obligatoires */
En général, on réserve la définition de symbole au cas où ce dernier
correspond à une simple valeur. La définition de macros sans paramètre
correspond plutôt à une notion de fonction sans argument, c’est-à-dire
réalisant un calcul (éventuellement différent d’un appel à un autre) et/ou une
action. On en trouve un bon exemple dans la macro getchar de la bibliothèque
standard.
5. La liste de remplacement peut, quant à elle, comporter n’importe quels
caractères, y compris des espaces blancs (à l’exception de la fin de ligne qui
terminerait la directive). La seule restriction est qu’elle ne peut pas se
terminer par le caractère \, qui indiquerait alors que la directive se poursuit
sur la ligne suivante. Bien entendu, comme pour les définitions de symboles,
ce caractère \ peut apparaître sans problème à l’intérieur de la définition.
2.3 Règles d’expansion d’un symbole ou d’une macro
2.3.1 Généralités
Lorsque, dans le fichier source, le préprocesseur rencontre un symbole ou un
nom de macro préalablement défini par #define, il procède à un certain nombre
d’opérations qu’on regroupe sous le terme « expansion ». En général, ce
processus d’expansion apparaît comme relativement naturel, de sorte qu’il n’est
pas nécessaire d’expliciter l’algorithme employé par le préprocesseur. C’est ce
qui se passait dans nos exemples d’introduction.
Cependant, il existe quelques situations particulières dont l’interprétation
échappe plus ou moins à l’intuition, à savoir :
• lorsque des constantes chaîne apparaissent dans une liste de remplacement ;
• lorsque l’on utilise les opérateurs # et ## décrits aux sections 2.4 et 2.5 ;
• en présence de définitions récursives ;
• lorsque l’on tente de générer une directive par une autre.
Dans ces différents cas, la connaissance de l’algorithme utilisé par le
préprocesseur devient indispensable. Pour le décrire, il est nécessaire de
distinguer différentes opérations élémentaires que nous nommerons « phases ».
Deux d’entre elles sont triviales :
• identification – il s’agit de la reconnaissance des symboles ou des appels de
macros, avec leurs paramètres effectifs ;
• substitution – il s’agit du remplacement d’un symbole ou d’un paramètre de
macro par le texte correspondant, tel qu’il est imposé par la directive #define.
Mais il faut tenir compte de ce qu’un paramètre effectif de macro peut à son tour
contenir des symboles ou des appels de macro. Dans ce cas, ceux-ci sont
expansés, à quelques exceptions près. Lorsque cette phase d’expansion des
paramètres a lieu, elle se fait avant la phase de substitution évoquée
précédemment.
Enfin, comme on peut s’y attendre, compte tenu des possibilités de définitions
dépendantes, il peut être nécessaire de répéter les trois phases précédentes pour
aboutir à une expansion totale.
L’algorithme exact d’expansion se définit par :
• les conditions dans lesquelles chacune des phases (identification, substitution,
expansion des paramètres effectifs) est ou non appliquée ;
• l’ordre dans lequel elles s’enchaînent ;
• les conditions dans lesquelles elles peuvent être répétées.
Il est récapitulé dans le tableau suivant et étudié en détail aux sections indiquées.
Remarque
L’ordre des phases 2 et 3 est important. Il montre que l’expansion des appels imbriqués va des niveaux
les plus profonds vers les niveaux les moins profonds.
Tableau 15.2 : les quatre phases d’expansion d’un symbole ou d’une macro
– pour les symboles ou les noms de Voir
1.
macro, il suffit qu’il s’agisse d’un token section
Identification
préprocesseur ; 2.3.2
d’un symbole
ou d’un nom – pour les paramètres de macro, on se
de macro et base tout naturellement sur les
de ses parenthèses et les virgules, mais la
paramètres norme ne précise pas le comportement
en cas d’erreur.
– si un paramètre identifié en phase 1 Voir
2. Expansion contient un symbole ou une macro, il y section
des symboles a expansion totale (phases 1, 2, 3 et 4) ; 2.3.3
ou appels de
macros – exception en présence d’un opérateur # – # décrit à
figurant dans ou ##. la section
les 2.4
paramètres – ## décrit à
identifiés la section
2.5
3. – n’a pas lieu dans les chaînes Voir
Substitution constantes ; section
d’un symbole 2.3.4
ou des – n’a pas lieu dans les directives du
paramètres préprocesseur, excepté if, elif, include et
line.
d’une macro
– si le texte généré contient encore des Voir
symboles ou des appels de macro, on section
4. Répétition 2.3.5
répète les phases 1 à 4 ;
du processus
– exception en cas de récursivité directe
ou croisée des définitions.
2.3.2 Phase 1 : Identification du symbole ou des paramètres de
macros
Cette phase consiste simplement en la reconnaissance par le préprocesseur d’un
symbole ou d’un appel de macro. Dans ce dernier cas, elle comporte en outre la
reconnaissance des différents textes à associer à chacun des paramètres de la
macro.
Identification du symbole
La reconnaissance d’un symbole au sein de la partie du fichier source suivant sa
définition par #define ne pose pas de problème particulier, pour peu que le
symbole en question constitue effectivement un identificateur, donc un token.
Par exemple, le symbole MAXI sera bien reconnu dans :
n = MAXI + 3 ;
Il ne le sera naturellement pas dans :
n = MAXIMUM + 2 ;
pMAXI = 5 ;
On n’oubliera pas que, pour le préprocesseur, un mot-clé n’est qu’un simple
identificateur, de sorte que la directive suivante est correcte, même si elle est
contre nature, et donc fortement déconseillée :
#define int float
Elle amènerait le préprocesseur à remplacer, dans la suite du fichier source,
chaque occurrence de int par float.
Les mêmes considérations s’appliquent tout naturellement à un nom de macro.
Identification des paramètres d’une macro
Lorsqu’il reconnaît un nom de macro, le préprocesseur cherche à en identifier les
différents paramètres. Pour ce faire, il se base tout naturellement sur la présence
de virgules. Par exemple, avec la définition :
#define truc(a, b, c) …..
la rencontre, dans la suite du fichier source, de :
p = truc (x, 2, b) + 6 ;
amène le préprocesseur à reconnaître un appel de la macro truc, avec comme
paramètres x, 2 et b.
Respect du nombre de paramètres
Comme on peut s’y attendre, il est nécessaire qu’un appel de macro comporte
autant de paramètres effectifs que la définition a prévu de paramètres formels.
Par exemple, avec la définition :
#define somme(a,b) a+b
les appels suivants seront incorrects :
somme (x) /* incorrect : il manque un paramètre */
somme (1, 5, z) /* incorrect : il y a trop de paramètres */
On notera bien, à ce propos, qu’il n’existe qu’un seul espace de noms pour tous
les symboles définis par #define, ce qui signifie qu’il n’est pas possible de définir
plusieurs macros de même nom, même si elles possèdent un nombre différent de
paramètres. De même, on ne peut pas définir un symbole et une macro de même
nom7.
Les imbrications de parenthèses sont bien détectées par le préprocesseur
Avec une définition de cette sorte :
#define truc(a, b, c) …..
il est tout à fait possible d’utiliser un appel tel que :
truc ( x*(z+t), (x+y)*(c=getchar(),c+1), p)
Les trois paramètres effectifs de truc seront :
x*(z+t)
(x+y)*(c=getchar(),c+1)
p
En revanche, il faut prendre garde au fait que des symboles tels que [ et ] ou { et
} n’ont aucune raison d’être appariés par le préprocesseur, dans la mesure où ils
ne sont pas reconnus comme parties d’opérateur, mais comme simples caractères
de ponctuation. Ainsi, un appel tel que :
truc (t[i, j], {1, 2, 3}, z)
conduirait le préprocesseur à considérer que cet appel comporte les six
paramètres effectifs suivants :
t[i j] {1 2 3} z
Il serait rejeté par le préprocesseur. En revanche, l’appel suivant :
truc (t[i, j], z)
conduirait aux trois paramètres effectifs suivants :
t[i j] z
et les conséquences n’en seraient perçues qu’à la compilation.
L’appel d’une macro peut s’étendre sur plusieurs lignes
L’appel d’une macro n’est plus une directive du préprocesseur. Il peut donc tout
naturellement apparaître à n’importe quel endroit d’une instruction, et
éventuellement s’étendre sur plusieurs lignes. Voici un exemple :
#define truc(a, b, c) …..
…..
res = truc (a*x*x + b*x + c,
y*z - sqrt(x), 5) ;
2.3.3 Phase 2 : Expansion éventuelle des paramètres de macro
Si un paramètre de macro identifié dans la phase précédente contient lui-même
un symbole ou un appel de macro, on lui applique le processus complet
d’expansion, c’est-à-dire les quatre phases – identification des paramètres,
expansion éventuelle des paramètres de macro, substitution et répétition
éventuelle. Mais il existe une exception :
Les paramètres précédés de l’opérateur # et les paramètres précédés ou suivis de l’opérateur ## ne sont
pas expansés avant leur substitution.
On trouvera aux sections 2.4 et 2.5 des exemples d’utilisation de ces opérateurs.
Ici, nous nous contenterons d’exemples usuels.
Exemple 1
Supposons que l’on ait défini ces deux macros8 :
#define NMAX 5
#define chose(a) 2*a
et que l’on trouve, dans la suite du fichier source :
chose (NMAX+1)
En phase 1, le préprocesseur reconnaîtra l’appel de la macro chose, avec comme
paramètre NMAX+1. En phase 2, il procédera à l’expansion de ce paramètre. Ici, cela
reviendra à la substitution de NMAX par 5, donc finalement au paramètre 5+1. Le
préprocesseur abordera alors la phase 3 en substituant ce texte au symbole a de la
macro chose, ce qui le conduira finalement au texte :
2*5+1
Exemple 2
Supposons que l’on ait défini ces deux macros :
#define machin(x,y) x-y
#define bidule(a,b) a+b
Si, dans la suite du fichier source, on trouve :
machin (bidule(u,3), z)
en phase 1, le préprocesseur reconnaîtra l’appel de la macro machin avec comme
paramètres bidule(u,3) et z. Comme le premier paramètre comporte un appel de
macro (ici bidule), cet appel sera expansé, ce qui conduira au texte u+3. En
définitive, on se trouvera en présence d’un appel de machin avec comme
paramètres u+3 et z, comme si l’on avait écrit directement :
machin (u+3, z)
Bien entendu, dans la phase suivante, le préprocesseur procédera alors à la
substitution des paramètres de machin, ce qui conduira finalement à :
u+3-z
Exemple 3
Supposons que l’on ait défini ces macros :
#define chose(x) x+2
#define machin(a) a+5
#define truc(n) n-1
Si, dans la suite du fichier source, on trouve :
chose(machin(truc(z)))
en phase 1, le préprocesseur reconnaîtra un appel de la macro chose, avec comme
paramètre machin(truc(z)). En phase 2, ce paramètre sera entièrement expansé, ce
qui conduira à reconnaître un appel de la macro machin avec comme paramètre
truc(z). Ce dernier sera à son tour expansé, ce qui conduira à reconnaître un appel
de la macro truc, avec paramètre z, ce qui, après substitution, fournira z-1. La
substitution dans la macro machin fournira z-1+5 ; finalement, après substitution
dans la macro chose, on obtiendra le texte :
z-1+5+2
Remarques
1. Dans ces trois exemples, le fait que l’expansion des paramètres ait lieu avant leur substitution
importe peu. On aboutirait au même résultat en inversant l’ordre des opérations. En revanche, il
n’en ira plus de même en présence de l’un des opérateurs # et ##.
2. Ces trois exemples font intervenir les phases 1, 2 et 3, éventuellement de façon récursive, mais
jamais la phase 4 qui, comme on le verra à la section 2.3.5, n’intervient qu’en cas de définitions
imbriquées (à ne pas confondre avec des appels imbriqués).
2.3.4 Phase 3 : Substitution d’un symbole ou des paramètres
d’une macro
Une fois réalisées les phases 1 et 2, le préprocesseur procède à la substitution des
paramètres et des symboles. Cette phase, qui correspond précisément à ce qu’on
attend de la directive #define, ne pose pas de problème particulier et l’on en
trouve de nombreux exemples en différents endroits de ce chapitre. Toutefois, il
existe des circonstances où cette substitution n’a pas lieu, à savoir dans les
chaînes ou caractères constants et dans certaines directives. Par ailleurs, pour
d’évidentes questions de lisibilité des programmes, une substitution ne peut
jamais servir à fabriquer une directive.
Aucune substitution n’a lieu à l’intérieur des chaînes constantes ou des
constantes caractère
Comme une chaîne constante ou une constante caractère constitue un token pour
le préprocesseur, il est normal que la substitution des paramètres ou des
symboles ne soit jamais effectuée dans ces deux cas.
Ainsi, avec ces instructions :
#define AFFICHE(y) printf("valeur de y %d",y)
…
AFFICHE(a) ;
AFFICHE(c+5) ;
le texte généré par le préprocesseur sera :
printf("valeur de y %d",a) ;
printf("valeur de y %d",c+5) ;
Le même phénomène se produirait avec une simple définition de symbole :
#define NB_VAL 12
…..
printf ("donnez NB_VAL valeurs") ;
Aucune substitution ne sera opérée dans l’instruction printf.
Nous verrons cependant à la section 2.5 que l’opérateur ##, introduit par la norme
ANSI, permet de pallier cette absence de remplacement dans les chaînes.
Remarque
Certains anciens compilateurs effectuaient systématiquement les substitutions à l’intérieur des chaînes
et des caractères. Cette démarche n’était pas nécessairement plus logique que celle qui est adoptée par
la norme puisque, avec :
#define AFFICHE(a) printf("valeur de a %d",a)
…
AFFICHE(z) ;
le préprocesseur générait l’instruction suivante (le a du texte valeur ayant été lui aussi remplacé) :
printf ("vzleur de z %d", z) ;
La substitution n’a pas lieu dans certaines directives
Comme on le verra dans l’étude détaillée de chacune des directives, la
substitution des symboles et des macros n’a lieu que dans certaines d’entre elles.
Les exceptions seront toutefois dictées par le bon sens. Par exemple, avec :
#define VALEUR 12
la substitution du symbole VALEUR sera bien réalisée dans :
#if (VALEUR <10)
alors qu’elle ne le sera pas dans :
#ifdef VALEUR
Le tableau 15.3 précise ce qu’il en est pour l’ensemble des directives :
Tableau 15.3 : substitution dans les directives
Directives Substitution Remarques
#define
non
#include
oui, sauf dans "…" et dans <…> exception analogue à celle
concernant les constantes
chaînes
#ifdef
#ifndef non ici, la substitution n’aurait
aucun sens
#if
#elif oui substitution logique
puisqu’on détermine la
valeur d’expressions
#line
oui
#error
aucune indication de la
norme
#pragma
aucune indication de la
norme
Une substitution de symbole ou de macro ne peut jamais générer une
directive du préprocesseur
Pour d’évidentes raisons de simplicité et de lisibilité des programmes, la norme a
prévu une limitation aux substitutions concernant les directives du
préprocesseur :
Une ligne n’est traitée comme une commande de préprocesseur que si elle commence9 par # avant tout
traitement des symboles et des macros.
Cette règle autorise la substitution dans certaines directives, comme l’indique le
tableau 15.3, et nous en verrons des exemples pour #if et #include. En revanche,
les constructions suivantes seront rejetées par le préprocesseur :
#define truc include
…..
#truc "exple.h" /* #truc n'est pas une directive et il n'y aura pas */
/* expansion de truc en include */
#define truc #include /* certaines implémentations rejettent cette directive */
/* considérant # comme l'opérateur décrit à la section 2.4 */
…..
truc "exple.h" /* même si truc est remplacé par #include, cette ligne */
/* ne sera pas considérée comme une directive */
#define chose(x) #include x /* rejetée par certaines implémentations
qui */
/* considèrent # comme l'opérateur decrit à la section
2.4 */
…..
chose ("exple.h") /* même si la substitution a lieu, cette
ligne */
/* ne sera pas considérée comme une
directive */
2.3.5 Phase 4 : Répétition du processus d’expansion sur le texte
obtenu
Pour tenir compte des possibilités de définitions dépendantes, après les trois
phases précédentes, le préprocesseur examine le texte ainsi obtenu pour
déterminer s’il contient encore des appels de macros ou de symboles. Si tel est le
cas, le préprocesseur procède à leur expansion (phases 1, 2 et 3). Le processus se
répète (phase 4) jusqu’à ce qu’il ne subsiste plus aucun symbole ou appel de
macro, avec une exception prévue par la norme pour éliminer le risque de
définitions récursives.
Une seule exception a lieu :
Le processus d’expansion s’interrompt si l’on y rencontre à nouveau un appel de la macro que l’on est
en train d’expanser et ce aussi bien pour la première répétition (appel récursif direct) que des suivantes
(appel récursif croisé).
Exemple 1
Il s’agit d’un exemple cité en introduction et dont l’interprétation était intuitive.
Considérons :
#define NBMAX 5
#define TAILLE NBMAX+1
Lorsqu’il identifie, en phase 1, le symbole TAILLE dans la suite du fichier source,
le préprocesseur le remplace, en phase 3, par NBMAX+1. Comme le texte obtenu
contient encore un symbole, le préprocesseur applique la phase 4 ; le symbole
NBMAX alors identifié est remplacé en phase 3 par 5.
Exemple 2
Si truc et chose sont définis comme des macros10 :
#define chose(x,y) x-y
#define truc(a,b) a + chose(a, b+a)
l’appel :
truc (x+2, chose(3, z))
conduira tout d’abord le préprocesseur à identifier, en phase 1, les deux
paramètres x+2 et chose(3, z). Avant substitution du second paramètre, il y aura
expansion de l’appel chose(3, z) en 3-z, ce qui amènera le préprocesseur à
effectuer finalement les substitutions suivantes :
a par x+2
b par 3-z
On aboutira finalement à :
x+2 + chose(x+2, 3-z+x+2)
Ce résultat contient, à son tour, un appel de macro, ce qui amène le
préprocesseur à procéder, en phase 4, à l’expansion de chose, avec comme
paramètres effectifs x+2 et 3-z+x+2. Au final, on aboutit à :
x+2 + x+2-3-z+x+2
Exemple 3
Voici un exemple d’appel récursif direct. Considérons :
#define recur(x) x ? x*recur(x-1) : 1
…..
y = recur (8) ;
Dans un premier temps, l’appel recur(8) conduira le préprocesseur à générer le
texte :
8 ? recur(8-1) : 1
À ce niveau, le préprocesseur devrait à nouveau examiner le texte ainsi généré et
procéder à l’expansion de l’appel recur(8-1). Comme recur est la macro en cours
d’expansion, le processus s’arrête là. C’est donc le texte suivant qui sera
transmis au compilateur11 :
y = 8 ? recur(8-1) : 1 ;
Exemple 4
Voici un exemple de définition récursive croisée. Considérons :
#define recur1(x) recur2(2*x)
#define recur2(x) recur1(x+1)
…..
y = recur1 (a) ;
Dans un premier temps, l’appel recur1(a) amènera le préprocesseur à générer le
texte :
recur2 (2*a)
La deuxième expansion l’amènera à :
recur1 (2*a+1)
Comme recur1 est le nom de la macro en cours d’expansion, le processus s’arrête
là et c’est donc le texte suivant qui sera transmis au compilateur12 :
recur1 (2*a+1)
Remarque
On ne confondra pas la récursivité des définitions qui bloque la répétition du processus d’expansion,
avec la récursivité des appels qui, quant à elle, ne pose pas de problème. En voici un exemple :
#define truc(x) x+2
…..
y = truc(truc(a)) ;
Dans un premier temps, on identifie un appel de truc avec le paramètre truc(a), lequel est
entièrement expansé en a+2 avant d’être substitué dans l’appel, ce qui conduit à :
y = a+2+2 ;
2.4 L’opérateur de conversion en chaîne : #
Les substitutions de paramètres ou de symboles ne se font pas à l’intérieur des
chaînes de caractères (voir section 2.3.4). Toutefois, la norme a introduit un
opérateur particulier, connu uniquement du préprocesseur, qui permet de pallier
cette lacune. Nous commencerons par le présenter à partir d’un exemple.
2.4.1 Exemple introductif
Exemple d’utilisation de l’opérateur #
#define affiche(y) printf("valeur de " #y " : %d\n",y)
#include <stdio.h>
int main()
{ int a=5, c=15 ;
affiche(a) ;
affiche(c+5) ;
}
valeur de a : 5
valeur de c+5 : 15
La notation #y dans la définition de la macro affiche précise que le paramètre y
doit être remplacé, lors de l’expansion de la macro, par la chaîne de caractères
correspondant au paramètre y. Ainsi, l’expansion de :
affiche(a) ;
conduit à :
printf("valeur de " "a" " : %d\n",a) ;
ce qui, compte tenu de la concaténation des chaînes adjacentes, conduit
finalement à :
printf("valeur de a : %d\n",a) ;
2.4.2 L’opérateur #
Les caractères concernés peuvent être quelconques
Comme on s’en doute, cet opérateur ne peut s’utiliser qu’associé à un paramètre
muet, dans la liste de remplacement d’une macro. Un mécanisme est prévu pour
permettre au paramètre effectif correspondant de contenir des caractères \ ou ".
Ces derniers sont simplement précédés de \ dans l’expansion de la macro, afin
de leur conserver leur signification première. Par exemple, avec la définition :
#define chaine(x) #x
l’appel :
chaine (essai) ;
conduira au texte :
"essai"
Mais l’appel :
chaine (ceci est un "texte")
conduira bien au texte :
"ceci est un \"texte\""
qui représente bien la chaîne formée des caractères suivants :
ceci est un "texte"
De même, l’appel :
chaine (c:\compta\fact\)
conduira à :
"c:\\compta\\fact\\"
De même, un mécanisme d’élimination des espaces blancs figurant avant ou
après le paramètre effectif a été prévu, de manière à permettre un usage naturel.
Par exemple, avec :
chaine ( nouvel essai )
on obtiendra la même chose que si l’on avait écrit :
chaine (nouvel essai)
Remarque
La concaténation des chaînes adjacentes n’est pas faite par le préprocesseur, mais seulement lors de la
compilation. Considérons alors ces instructions :
#define nomfich "donnees"
#define chaine(x) #x
…..
#include chaine(c:\compta\fact\) nomfich
La dernière ligne conduira après substitution à :
#include "c:\\compta\\fact\\" "donnees"
qui, suivant les implémentations, pourra correspondre à une directive invalide ou être interprété
comme :
#include "c:\\compta\\fact\\"
L’opérateur # bloque l’expansion des paramètres effectifs
La présence d’un opérateur # devant un paramètre formel bloque l’expansion
éventuelle du paramètre effectif correspondant, dès lors que ce dernier est lui-
même un symbole ou une macro. Voici un exemple simple :
#define MAXI 15
#define chaine(x) #x
…..
chaine(MAXI) /* fournit la chaîne "MAXI" et non pas la chaîne "15" */
Si l’on voulait que MAXI soit effectivement expansé (donc, ici, remplacé par 15),
avant l’application de l’opérateur #, il faudrait obligatoirement procéder en deux
étapes, comme dans cet exemple :
#define MAXI 15
#define chaine(x) #x
#define g_chaine(x) chaine(x)
…..
g_chaine(MAXI) /* fournit la chaîne "15" */
En effet, cette fois, l’argument effectif MAXI correspondant à l’argument muet x de
g_chaine serait effectivement expansé en 15 avant d’être substitué dans
l’expression de g_chaine.
On notera que ce mécanisme pourrait s’appliquer quelle que soit la complexité
de l’argument de g_chaine.
Voici un exemple :
#define MAXI 15
#define chaine(x) #x
#define g_chaine(x) chaine(x)
#define chose(x) truc (x+1)
#define truc(y) MAXI + y
…..
g_chaine(chose(MAXI+3)) /* fournit la chaîne : "15 + 15+3+1" */
chaine(chose(MAXI+3)) /* fournit la chaîne : "chose(MAXI+3)" */
2.5 L’opérateur de concaténation de tokens : ##
2.5.1 Introduction
Avec une définition de symbole telle que :
#define truc bidule
on ne pourra jamais créer un identificateur (ou plus généralement un token)
formé de la réunion de bidule et d’autre chose, ne serait-ce, par exemple, que
bidule1. En effet, la notation truc1 désignerait un nouvel identificateur et, en aucun
cas, l’expansion de truc, suivie de 1. Quant à truc 1, il désignera obligatoirement
la succession de deux tokens différents, en l’occurrence bidule et 1.
La même remarque s’applique avec encore plus d’acuité à une macro. Par
exemple, avec :
#define nom(x, y) …..
on ne pourra pas parvenir à fabriquer un token de la forme fichierx ou xy (x et y
désignant les valeurs des paramètres effectifs de l’appel de la macro nom).
L’opérateur ## a précisément été introduit par la norme pour pallier cette lacune.
En voici un premier exemple :
#define concat(x,y) x ## y
…..
concat(v1, res) = 3 ; /* génère l'instruction : v1res = 3 */
La notation x ## y dans la liste de remplacement associée à concat signifie que les
paramètres effectifs correspondants à x et y doivent simplement être mis bout à
bout, sans aucun caractère séparateur quel qu’il soit.
2.5.2 L’opérateur ##
Il doit être précédé et suivi d’un token
Par sa nature même, cet opérateur n’a de signification que s’il est associé à deux
tokens. Il ne peut donc jamais apparaître, ni au début, ni à la fin d’une liste de
remplacement. Généralement, au moins l’un des deux tokens concernés est un
paramètre, mais ce n’est pas une obligation.
Voici quelques exemples :
#define nom_fich(x) fich ## x
…..
nom_fich(2) /* génère : fich2 */
#define bizare(x) fi ## ch ## x /* correct, mais équivalent à : */
/* #define bizare(x) fich ## x */
…..
bizare(2) /* génère : fich2 */
On notera que l’utilisation d’espaces blancs de part et d’autre de cet opérateur
n’est pas indispensable. Si l’on en introduit, ceux-ci ne feront pas partie de
l’expansion de la macro correspondante.
L’opérateur ## bloque l’expansion des paramètres effectifs
La présence d’un opérateur ## devant ou derrière un paramètre formel bloque
l’expansion éventuelle du paramètre effectif correspondant, dès lors que ce
dernier est lui-même un symbole ou une macro. Voici un exemple simple :
#define VERSION 5
#define nom_fich(x) fich ## x
…..
nom_fich(VERSION) /* fournit fichVERSION et non fich5 */
Si l’on voulait que VERSION soit effectivement expansé (donc, ici, remplacé par 5),
avant l’application de l’opérateur ##, il faudrait, comme avec l’opérateur #,
obligatoirement procéder en deux étapes, comme dans cet exemple :
#define nom_fich(x) fich ## x
#define g_nom_fich(x) nom_fich(x)
…..
#define VERSION 5
g_nom_fich(VERSION) /* fournit bien fich5 */
On notera que, comme avec l’opérateur #, ce mécanisme pourrait s’appliquer
quelle que soit la complexité de l’argument de g_nomfich. Voici un exemple :
#define MAXI 15
#define nom_fich(x) fich ## x
#define g_nom_fich(x) nom_fich(x)
#define chose(x) truc (x+1)
#define truc(y) MAXI + y
…..
g_nom_fich(chose(MAXI+3)) /* fournit : fich15 + 15+3+1 */
nom_fich(chose(MAXI+3)) /* fournit : fichchose(15+3) */
Remarque
Dans le second cas, on pourrait s’attendre à obtenir fichchose(MAXI+3) et non fichchose (15+3). En
fait, l’identification des arguments de nom_fich conduit effectivement à chose(MAXI+3). Comme dans
la définition de nom_fich, cet argument est précédé de ##, il n’est pas expansé, ce qui conduit à :
fichchose(MAXI+3)
Mais on applique à nouveau le processus d’expansion à ce texte (phase 4), ce qui conduit cette fois à
remplacer MAXI par 15, d’où le résultat obtenu.
2.6 Exemple faisant intervenir les deux opérateurs # et
##
À titre d’illustration du rôle des opérateurs # et ##, voici un petit programme
combinant les deux :
Exemple d’utilisation des opérateurs # et ##
#define VERSION 5
#define g_chaine(x) chaine(x)
#define chaine(x) #x
#define g_nom_fich(x) nom_fich(x)
#define nom_fich(x) fich ## x
int main()
{ printf (chaine(nom_fich(VERSION)) "\n") ; /* pas de virgule avant "\n" */
printf (chaine(g_nom_fich(VERSION)) "\n") ;
printf (g_chaine(nom_fich(VERSION)) "\n") ;
printf (g_chaine(g_nom_fich(VERSION)) "\n") ;
}
nom_fich(VERSION)
g_nom_fich(VERSION)
fichVERSION
fich5
2.7 La directive #undef
Il est possible d’annuler une définition de symbole ou de macro, soit pour la
rendre indéfinie, soit pour pouvoir en fournir ensuite une nouvelle définition.
Voici un exemple :
….. /* ici, NB_MAX n'est pas défini */
#define NB_MAX 100
….. /* ici, NB_MAX sera remplacé par 100 */
#undef NB_MAX
….. /* ici, NB_MAX n'est plus défini */
#define NB_MAX 200
….. /* ici, NB_MAX sera remplacé par 200 */
En pratique, la redéfinition d’un symbole ou d’une macro au sein d’un même
fichier source n’est guère conseillée et l’on utilise surtout la directive #undef pour
annuler une définition de symbole utilisé en compilation conditionnelle. On peut
ainsi permettre à un symbole donné de n’exister que dans certaines parties d’un
fichier source.
Bien que cela soit d’un usage peu répandu, la directive #undef peut également être
utilisée pour annuler une définition de macro de la bibliothèque standard (voir
annexe).
Signalons enfin que la norme autorise une redéfinition de symbole ou de macro,
alors que sa définition n’a pas été annulée, à condition qu’elle soit identique :
mêmes noms d’arguments et même définition, aux espaces blancs près13. Cette
possibilité, nommée parfois « redéfinition bénigne » n’a guère d’intérêt, si ce
n’est de permettre d’inclure plusieurs fois un même fichier en-tête. Cela dit, dans
un tel cas, on peut se protéger des risques d’inclusions multiples par des
instructions appropriées de compilation conditionnelle étudiées à la section 3.3.
2.8 Précautions à prendre
D’une manière générale, la directive #define est extrêmement puissante, par la
variété des remplacements qu’elle permet de réaliser. En même temps, elle
apparaît comme très rudimentaire car elle se contente d’effectuer des
substitutions de texte sans aucune vérification de cohérence. Nous examinerons
ici quelques précautions qu’il est nécessaire de prendre dans son utilisation.
2.8.1 Priorités
Les expressions générées par des macros peuvent induire des priorités
d’opérateur différentes de celles qu’on attend.
Considérons ces exemples :
#define DOUBLE(x) x + x
On constate que si le premier appel de macro conduit à un résultat correct, le
second ne fournit pas, comme on aurait pu l’escompter, le double de l’expression
figurant en paramètre.
En fait, ce problème, lié aux priorités relatives des opérateurs, peut être résolu en
introduisant des parenthèses dans la définition de la macro. Encore faut-il en
introduire suffisamment, comme le montrent ces exemples :
#define DOUBLE(x) (x+x)
#define DOUBLE(x) (x)+(x)
#define DOUBLE(x) ((x)+(x))
2.8.2 Effets de bord
L’utilisation de macro peut conduire à des effets de bord, c’est-à-dire à des
actions non désirées par le programmeur. Considérons à nouveau la macro
précédente, avec une autre sorte d’appel :
#define DOUBLE(x) ( (x) + (x) )
…..
DOUBLE(x++)
Le texte généré par le préprocesseur sera le suivant :
( (x++) + (x++) )
On peut considérer qu’elle induit un effet de bord. En effet, la notation :
DOUBLE(x++)
conduit à incrémenter deux fois la variable x. De plus, elle ne fournit pas
vraiment son double. Par exemple, si x contient la valeur 5, l’exécution du
programme ainsi généré conduira à calculer 5+6.
Un tel risque n’existe pas dans le cas des fonctions, compte tenu du mécanisme
de transmission des arguments.
2.8.3 Absence de typage
Les symboles définis par #define n’ont aucune raison d’être typés. Par exemple,
avec des définitions telles que :
#define PI 3.14159265
#define VRAI 1
#define FAUX 0
#define MESSAGE "bonjour, comment allez-vous ?"
on peut avoir l’impression que les identificateurs PI, VRAI, FAUX et MESSAGE sont
respectivement de type flottant, entier, entier ou char *. En fait, il n’en est rien
pour le préprocesseur qui acceptera de faire la substitution dans n’importe quel
contexte. Les conséquences de mauvaise utilisation de ces symboles
n’apparaîtront éventuellement que lors de la compilation proprement dite.
On pourrait penser que comme la norme ANSI a introduit le qualifieur const pour
permettre notamment de définir des constantes symboliques, il devient possible
de se passer de la directive #define. Effectivement, si l’on compare :
#define N_MAX 25
const int n_max = 25 ;
la seconde instruction donne bien un type au symbole n_max, ce qui n’est pas le
cas de la première pour N_MAX. Dans ces conditions, pourquoi continuer d’utiliser
la première instruction ? Parce que n_max ne peut pas apparaître dans une
expression constante, alors que N_MAX le peut :
float x [N_MAX+2] ; /* correct */
float x [n_max+1] ; /* incorrect */
float x [N_MAX] ; /* incorrect */
En C++
En C++, les variables ayant reçu le qualifieur const pourront apparaître dans des expressions
constantes. En principe, cela enlève beaucoup d’intérêt à la définition de symboles (mais pas à celle
des macros) dans ce langage. Mais comme la plupart des programmeurs en C++ ont d’abord fait leurs
armes en C, ils ont acquis un certain nombre d’habitudes dont ils ne se départissent pas pour autant en
C++.
2.8.4 Lisibilité des programmes
Autant l’emploi de #define pour paramétrer certaines valeurs possède un intérêt
manifeste, autant certains abus peuvent contribuer à obscurcir un programme.
Considérez par exemple ces directives :
#define entier int
#define flottant double
#define debut {
#define fin }
#define boucle(i, maxi) for (i=0 ; i<maxi ; i++)
Elles semblent partir d’une bonne intention : permettre d’écrire un programme
avec des termes plus parlants. Ainsi, ces instructions :
entier a, b ;
entier * p ;
…..
while (1) debut ….. fin
boucle (k, 10) debut ….. fin
seront remplacées, avant compilation, par :
int a, b ;
int * p ;
…..
while (1) { ….. }
for (k=0 ; k<10 ; k++) { ….. }
Il n’en reste pas moins que l’utilisation de noms symboliques pour les types
présente de grands risques. En effet, avec :
#define ptr_entier int *
…..
ptr_entier pe1, pe2 ;
le préprocesseur générera :
int *pe1, pe2 ;
ce qui n’est pas nécessairement ce qu’on attend. Si l’on tient absolument à
définir des types symboliques, il sera préférable d’utiliser l’instruction typedef.
Par ailleurs, l’utilisation de termes soi-disant plus explicites que les mots-clés du
langage présente quelques inconvénients. Notamment, ces termes ne sont pas
immédiatement compréhensibles par celui qui n’a pas participé à leur définition.
En abusant de ces possibilités, on risque fort de passer d’un langage à un petit
nombre de mots-clés parfaitement définis à un langage plus parlant à un grand
nombre de mots-clés mal définis. En fait, un tel usage ne peut guère se justifier
que dans des environnements très spécialisés.
2.8.5 Risques d’incohérences différées
Le texte substitué peut être absolument quelconque et il n’est pas nécessaire a
priori qu’il forme un ensemble cohérent. Ainsi, une définition aussi grotesque
que la suivante n’a en soi rien d’illégal :
#define truc printf ("%d"
L’utilisation du symbole truc dévient bien sûr tout aussi saugrenue. Ainsi :
truc, n) ;
générera une instruction correcte :
printf ("%d", n) ;
tandis que :
truc() ;
générera une instruction incorrecte :
printf ("%d"() ;
Une faute fort répandue consiste à écrire des définitions telles que :
#define N = 5 /* ou même #define N=5 */
#define P = 12; /* ou même #define P=12 */
Aucune erreur ne sera bien sûr détectée par le préprocesseur lui-même. Mais les
instructions suivantes :
int t[N] ;
j = 2 *(P-3) ;
i = 2*P+5 ;
deviendront, après prétraitement :
int t[=5] ; /* erronée */
j = 2 * (12;-3) ; /* erronée */
i = 2*12;+5 ; /* syntaxiquement correct mais… */
Comme, la plupart du temps, vous ne connaîtrez pas le texte généré par le
préprocesseur, vous vous trouverez simplement en présence d’un diagnostic de
compilation concernant une instruction apparemment correcte, ce qui rendra
d’autant plus délicat le diagnostic de l’erreur. Le troisième exemple est encore
plus incidieux, dans la mesure où les instructions générées sont acceptées par le
compilateur, alors qu’elles ne feront probablement pas ce que l’on espérait.
D’une manière générale, on conseille de définir des listes de remplacement,
aussi bien pour les macros que pour les symboles, qui constituent une expression
complète et correcte en soi. Dans ce cas, l’usage du symbole ou de la macro peut
se faire de façon syntaxique comparable à celle d’une fonction.
2.9 Les symboles prédéfinis
La norme impose à toute implémentation de définir les cinq symboles suivants :
Tableau 15.4 : les symboles prédéfinis
Symbole Signification Remarques
__LINE__
Numéro de ligne à Peut être modifié par la
l’intérieur du fichier directive #line.
source : constante décimale
__FILE__
Nom du fichier source : La norme ne précise pas si
chaîne de caractères le chemin fait ou non partie
constante de ce nom.
__DATE__
Date à laquelle a lieu la – la valeur reste la même
compilation : chaîne pour toute la durée de la
constante de la forme "mmm jj compilation ;
aaaa", avec :
– si l’implémentation ne
– mmm : nom de mois, sous la peut pas fournir la vraie
même forme que celle date, elle doit quand
générée par la fonction même fournir une valeur.
asctime ;
– jj : numéro de jour dans
le mois ;
– aaaa : année.
__TIME__
Heure à laquelle a lieu la – la valeur reste la même
compilation : chaîne pour toute la durée de la
constante de la forme compilation ;
"hh:mm:ss" correspondant à
celle générée par la fonction – si l’implémentation ne
asctime peut pas fournir l’heure
véritable, elle doit quand
même fournir une valeur.
__STDC__
Constante 1 pour indiquer On peut indifféremment
que l’implémentation est tester l’existence ou la
conforme à la norme ANSI valeur de ce symbole. Effet,
s’il n’est pas défini, sa
valeur sera prise égale à 0
(voir section 3.2.4).
Exemple
Exemple d’utilisation de symboles prédéfinis
#include <stdio.h>
int main()
{ printf ("compilation fichier %s, le %s, a %s\n",
__FILE__, __DATE__, __TIME__) ;
#ifdef __STDC__
printf ("--- C standard ANSI ----\n") ;
#endif
printf ("ligne source no %5d\n", __LINE__) ;
}
compilation fichier macpred.c, le Sep 17 1997, a [Link]
--- C standard ANSI ----
ligne source no 8
3. Les directives de compilation conditionnelle
Un certain nombre de directives permettent d’incorporer ou d’exclure des
portions du fichier source dans le texte qui sera analysé par le préprocesseur. Ces
directives se classent en deux catégories selon la condition qui régit
l’incorporation :
• existence ou inexistence de symboles ;
• valeur d’une expression.
3.1 Compilation conditionnelle fondée sur l’existence
de symboles
La directive #ifdef (abréviation de If Defined) se base sur l’existence d’un
symbole, tandis que #ifndef (abréviation de If Not Defined) se base sur son
inexistence. Commençons par un exemple d’introduction, avant d’examiner ces
possibilités de manière générale.
3.1.1 Exemple introductif
Considérons ces instructions :
#define MISE_AU_POINT
…..
#ifdef MISE_AU_POINT /* attention : pas de parenthèses ici */
….. /* instructions 1 : incorporées si MISE_AU_POINT est defini */
#else
….. /* instructions 2 : incorporées si MISE_AU_POINT n'est pas defini */
#endif
….. /* instructions 3 : incorporées dans tous les cas */
#ifdef MISE_AU_POINT
….. /* instructions 4 : incorporées si MISE_AU_POINT est defini */
#endif
Si l’identificateur MISE_AU_POINT a été préalablement défini par #define – aussi bien
sous la forme d’un symbole que d’une macro –, les instructions 1, 3 et 4 seront
incorporées et analysées par le préprocesseur. C’est ce qui se passe dans le cas
présent. En revanche, si ce symbole n’a pas été défini, seules les instructions 2 et
3 seront incorporées et analysées. C’est ce qui se produirait si l’on supprimait la
directive de définition de MISE_AU_POINT ou si l’on introduisait un peu plus loin
(mais avant #ifdef) une autre directive :
#undef MISE_AU_POINT
On pourrait aboutir au même résultat en utilisant la directive #ifndef de façon
appropriée :
#define MISE_AU_POINT
…..
#ifndef MISE_AU_POINT
….. /* instructions 2 : incorporées si MISE_AU_POINT n'est pas defini */
#else
….. /* instructions 1 : incorporées si MISE_AU_POINT est defini */
#endif
….. /* instructions 3 : incorporées dans tous les cas */
#ifndef MISE_AU_POINT
#else
….. /* instructions 4 : incorporées si MISE_AU_POINT est defini */
#endif
On constate toutefois qu’ici #ifndef s’avère moins pratique que #ifdef pour
l’incorporation des « instructions 4 », dans la mesure où il n’y a rien à incorporer
dans le cas où le symbole MISE_AU_POINT n’est pas défini.
3.1.2 La directive #ifdef
On distingue deux formes différentes, l’une avec directive #else, l’autre sans :
La directive ifdef14
#ifdef^idenfitificateur[^] #ifdef^idenfitificateur[^]
lignes_1 lignes_1
#else[^] #endif[^]
lignes_2
#endif[^]
^
Représente un ou plusieurs espaces blancs (autres que fin de
ligne) et/ou un ou plusieurs commentaires.
lignes_1 et 0, 1 ou plusieurs lignes de contenu quelconque
lignes_2
N.B. : les crochets ([ et ]) signifient que leur contenu est facultatif.
Si le symbole identificateur a déjà été défini pour le préprocesseur – autrement
dit, s’il a préalablement fait l’objet d’une directive #define qui n’a pas été annulée
par une directive #undef –, le préprocesseur incorpore les lignes 1 et ignore les
lignes 2 (lorsqu’elles existent, c’est-à-dire dans la première forme). Dans le cas
contraire, il ignore les lignes 1 et, si elles existent, incorpore les lignes 2.
Les lignes ainsi incorporées sont à leur tour analysées par le préprocesseur, ce
qui signifie qu’on peut y trouver n’importe quelle directive, et en particulier
d’autres directives de compilation conditionnelle. L’imbrication de telles
directives est examinée en détail à la section 3.3.
Comme on peut s’y attendre, la directive #ifdef n’est soumise à aucune expansion
de macro. Voici un exemple un peu curieux illustrant ce point :
#define UN 1
#define TRUC UN
#undef UN
…..
#ifdef TRUC /* TRUC n'est pas remplacé par UN */
….. /* ces instructions sont bien incorporées */
#endif
Remarque
Une faute courante consiste à utiliser des parenthèses dans cette directive en écrivant :
#ifdef (TRUC) /* incorrect */
3.1.3 La directive #ifndef
Elle joue le même rôle que #ifdef, en inversant simplement les instructions
incorporées ou ignorées. Nous nous contenterons d’en donner la syntaxe.
La directive ifndef15
#ifndef^idenfitificateur[^] #ifndef^idenfitificateur[^]
lignes_1 lignes_1
#else[^] #endif[^]
lignes_2
#endif[^]
^
Représente un ou plusieurs espaces blancs (autres que fin de
ligne) et/ou un ou plusieurs commentaires.
lignes_1 et 0, 1 ou plusieurs lignes de contenu quelconque
lignes_2
N.B. : les crochets ([ et ]) signifient que leur contenu est facultatif.
3.2 Compilation conditionnelle fondée sur des
expressions
Là encore, nous commencerons par quelques exemples d’introduction, avant
d’examiner cette possibilité de manière générale.
3.2.1 Exemples introductifs
Considérons :
#define CODE 1
…..
#if CODE == 1
lignes_1
#endif
#if CODE == 2
lignes_2
#endif
Ici, ce sont les lignes_1 qui seront incorporées et analysées par le préprocesseur.
Mais il s’agirait des lignes_2 si nous remplacions la première directive par :
#define CODE 2
tandis qu’aucune ligne ne serait incorporée avec :
#define CODE 5
Il existe également une directive #elif qui permet de condenser les choix
imbriqués, basés sur #if, alors qu’il n’existait rien de comparable pour les choix
basés sur #ifdef. Par exemple, nos précédentes instructions pourraient s’écrire :
#if CODE == 1
lignes_1
#elif CODE == 2
lignes_2
#endif
3.2.2 L’opérateur defined
La directive #ifdef se fonde uniquement sur l’existence ou l’inexistence d’un
symbole, tandis que la directive #if se fonde sur une expression arithmétique que
le préprocesseur est capable d’évaluer. On verra que cette expression peut
comporter bon nombre d’opérateurs arithmétiques. En outre, elle peut faire appel
à un opérateur unaire particulier, connu uniquement du préprocesseur, noté
defined. Ce dernier fournit la valeur 1 si son opérande correspond à un symbole
connu et la valeur 0 sinon. Voici comment nous pourrions réécrire l’exemple de
la section 3.1.1, en faisant appel à cet opérateur et à la directive #if :
#define MISE_AU_POINT
…..
#if defined(MISE_AU_POINT)
….. /* instructions 1 : incorporées si MISE_AU_POINT est defini */
#else
….. /* instructions 2 : incorporées si MISE_AU_POINT n'est pas defini */
#endif
….. /* instructions 3 : incorporées dans tous les cas */
#if defined(MISE_AU_POINT)
…..
#endif /* instructions 4 : incorporées si MISE_AU_POINT est defini */
Bien entendu, il existe des circonstances où defined s’avère plus indispensable
que dans cet exemple, notamment lorsque l’on doit recourir à une condition
faisant intervenir cet opérateur mêlé à d’autres. Par exemple :
#if (CODE==1) && defined (MISE_AU_POINT) /* les parenthèses sont facultatives */
/* lignes_1 */
#endif
ne pourraient s’écrire qu’ainsi sans le recours à defined :
#if CODE==1
#ifdef MISE_AU_POINT
/* lignes_1 */
#endif
#endif
Quant à :
#if (CODE==1) || defined (MISE_AU_POINT)
/* lignes_1 */
#endif
elles ne pourraient s’écrire sans defined qu’au prix d’une duplication des lignes_1 :
#if CODE==1
/* lignes_1 */
#endif
#ifdef MISE_AU_POINT
/* lignes_1 */
#endif
Remarques
1. L’opérande de defined n’est naturellement soumis à aucune expansion. Voici un exemple, un peu
curieux mais correct, dérivé de celui présenté pour la directive #ifdef :
#define UN 1
#define TRUC UN
#undef UN
…..
#if defined(TRUC) /* TRUC n'est pas remplacé par UN */
….. /* ces instructions sont bien incorporées */
#endif
2. Il existe deux notations pour l’opérateur defined, l’une avec parenthèses, l’autre sans. Ainsi
defined(TRUC) est-il équivalent à defined TRUC. Bien que la norme ne se prononce pas sur la
priorité de cet opérateur, on peut penser que, pour éviter tout problème, elle est supérieure à celle de
tous les autres. Par précaution, il reste préférable d’utiliser la forme parenthésée.
3.2.3 Syntaxe de la directive #if 16
#if^expression_constante[^] #if^expression_constante[^]
lignes_1 lignes_1
#else[^] #endif[^]
lignes_2
#endif[^]
^
Représente un ou plusieurs
espaces blancs (autres que
fin de ligne) et/ou un ou
plusieurs commentaires.
expression_constante
Évaluée ainsi : – expression
– expansion des macros ; constante entière
définie à la
– remplacement de tout section 14.2.2 du
symbole non défini par la chapitre 4 ;
valeur 0 ;
– discussion à la
– constantes caractère section 3.2.4.
déconseillées.
Il doit alors s’agir d’une
expression constante
entière, dans laquelle
l’opérateur sizeof et
l’opérateur de cast sont
interdits. En revanche,
defined peut apparaître.
lignes_1 et lignes_2 0, 1 ou plusieurs lignes de
contenu quelconque
N.B. : les crochets ([ et ]) signifient que leur contenu est facultatif.
Remarque
Aucune parenthèse n’est imposée par cette syntaxe de #if, contrairement à celle de l’instruction if
elle-même. Toutefois, comme une expression peut toujours être placée entre parenthèses, il reste
possible d’en placer. Par exemple, ces deux directives sont équivalentes :
#if CODE==1
#if (CODE==1)
3.2.4 Les expressions constantes pour le préprocesseur
D’une manière générale, la directive #if peut se fonder sur n’importe quelle
expression entière, pour peu que celle-ci soit calculable par le préprocesseur lui-
même : on parle « d’expression constante » pour le préprocesseur. La notion
d’expression constante entière a déjà été présentée à la section 14.2.2 du chapitre
4 pour le compilateur. Elle se transpose au préprocesseur, moyennant quelques
adaptations nécessaires concernant :
• les expansions de macros et de symboles ;
• le remplacement par 0 de tout symbole non défini ;
• les risques liés à l’utilisation de constantes caractère ;
• le type entier utilisé par le préprocesseur pour les calculs ;
• l’interdiction naturelle des opérateurs de cast et sizeof.
Expansion préalable des macros et des symboles
Avant toute évaluation d’une expression constante, le préprocesseur procède à
l’expansion des macros, excepté bien entendu dans l’opérande de l’opérateur
defined. Voici un exemple correct :
#define MAXI 10
#define MINI 5
#define CARRE(x) ((x)*(x))
…..
#if CARRE(MAXI)-CARRE(MINI)>120 /* deviendra #if ((10)*(10))-((5)*(5)))>120 */
…..
#endif
Remplacement par 0 de tout symbole non défini
Tout identificateur non remplacé par une constante est remplacé par la valeur 0.
Ce point est particulièrement dangereux puisqu’il concerne, non seulement tout
symbole non défini, mais tout mot-clé ou tout identificateur de variable (future)
du programme. Ainsi, avec les constructions suivantes, on n’obtiendra aucun
diagnostic d’erreur :
#define NPTS 12
#if NPTS<=NPT_MAX /* NPT_MAX n'est pas défini, donc pris ici égal à 0 */
….. /* instructions jamais prises en compte */
#endif
const int n = 15 ;
#if n==15 /* n n'est pas défini pour le préprocesseur, donc pris ici égal à 0 */
….. /* instructions jamais prises en compte */
#endif
Risques liés à l’utilisation de constantes caractère
En théorie, les constantes caractère telles que ‘a' ou ‘s' sont admises comme des
cas particuliers de constantes entières et elles correspondent, comme on pourrait
s’y attendre, à la valeur du code du caractère. Mais la norme n’impose pas
l’identité de codage entre le jeu de caractère d’exécution et le jeu de caractère
source. Ainsi, rien ne garantit que le résultat de ces deux comparaisons soit le
même :
#if (‘a'==92) /* ‘a' possède une certaine valeur pour le préprocesseur */
if (‘a'==92) /* qui n'est pas nécessairement la même pour le compilateur */
/* ou lors de l'exécution */
Cela peut s’avérer gênant dans des circonstances aussi simples que :
#define L_DEBUT ‘c'
#define L_FIN ‘r'
Dans ce cas, la valeur de l’expression L_DEBUT – L_FIN ne sera pas nécessairement
la même pour le préprocesseur et pour le compilateur.
Le type entier utilisé pour les calculs
Les évaluations des opérations entières effectuées par le préprocesseur
conduisent aux mêmes résultats qu’obtiendrait le compilateur. Toutefois, les
seuls types de constantes réellement utilisés par le préprocesseur sont les types
long et unsigned long. Cette remarque est de peu d’importance en pratique, hormis
dans les cas où un dépassement de capacité risque de se produire avec le
compilateur et pas avec le préprocesseur. Observez cet exemple exécuté dans
une implémentation où les int sont codés sur 16 bits et les long sur 32 bits :
Lorsque le préprocesseur et le compilateur ne calculent pas de la même manière
#include <stdio.h>
#define NBRE 1000
int main()
{
#if NBRE*NBRE==1000000
printf ("Preproc : ok\n") ;
#else
printf ("Preproc non ok\n") ;
#endif
if (NBRE*NBRE==1000000)
printf ("Compil : ok\n") ;
else
printf ("Compil : non ok\n") ;
}
Preproc : ok
Compil : non ok
L’opérateur de cast est interdit
La norme précise explicitement que l’opérateur de cast est interdit dans les
expressions constantes du préprocesseur. En fait, il semble que cette précision
est superflue, dans la mesure où comme le préprocesseur ne reconnaît aucun
mot-clé, l’opérateur de cast lui apparaîtra comme un banal identificateur et le
contexte dans lequel il est utilisé conduira à une erreur.
La construction suivante est incorrecte, au sens de la norme :
#define VALEUR 34.58
…..
#if (int)VALEUR==34 /* incorrect : cast interdit ici */
…..
#endif
On notera bien que l’erreur détectée par le préprocesseur dans ce cas provient
non pas de l’utilisation du mot int lui-même, mais simplement de ce que la
construction (int)VALEUR est incorrecte, au même titre que le serait, par exemple,
(TRUC)VALEUR.
Certaines implémentations acceptent l’opérateur de cast dans les expressions
constantes. En général, elles acceptent également des opérandes flottants (voir
remarque ci-après). La construction précédente est alors acceptée.
L’opérateur sizeof est théoriquement interdit
Pour les mêmes raisons que celles qui sont évoquées à propos de l’opérateur de
17
cast, l’opérateur sizeof ne peut pas être utilisé dans les expressions constantes
du préprocesseur, de sorte que la construction suivante est incorrecte :
#if sizeof(double)==8
…..
#endif
Elle conduit en effet le préprocesseur à remplacer tout symbole non défini (donc
ici sizeof et double) par 0, ce qui signifie que la condition devient 0(0).
En pratique cependant, on rencontre des implémentations qui acceptent cette
construction et qui peuvent alors l’interpréter de différentes façons :
• reconnaître l’opérateur sizeof et remplacer l’identificateur double par zéro, donc
aboutir à l’expression sizeof(0)==8 ; on ne sait alors pas si 0 est un int ou un
long : le préprocesseur ne calcule que sur des long, alors que pour le
compilateur, 0 serait un int… ;
• reconnaître à la fois sizeof et double et aboutir à la comparaison espérée.
Manifestement, l’utilisation de sizeof dans les expressions constantes du
préprocesseur est à proscrire dans les programmes censés portables.
Remarque
La définition d’une expression constante entière nécessite que la plupart des opérandes soient entiers.
En particulier, avec :
#define TAUX 20.6
l’expression TAUX>20 est incorrecte. Cependant, certaines implémentations vont plus loin que la norme
en acceptant la présence de flottants dans des expressions constantes entières.
3.3 Imbrication des directives de compilation
conditionnelle
Les directives de compilation conditionnelle peuvent s’imbriquer, sans que cela
ne pose de problème particulier. La règle de correspondance entre else et if est la
même que celle utilisée pour l’instruction if elle-même. Voici un exemple :
#if condition_1
….. /* incorporées si condition_1 est vraie */
#else
….. /* incorporées si condition_1 est fausse */
#if condition_2
….. /* incorporées si condition_1 est fausse et condition_2 vraie */
#else
….. /* incorporées si condition_1 et condition_2 sont fausses */
#endif
….. /* incorporées si condition_1 est fausse */
#endif
Dans les implémentations qui respectent la norme sur le plan des espaces blancs
pouvant apparaître avant et après #, on peut rendre ces instructions plus claires
en introduisant des indentations :
#if condition_1
…..
#else
…..
#if condition_2
…..
#else
…..
#endif
…..
#endif
On notera bien que, comme le fait remarquer la norme aux concepteurs de
compilateurs, bien que certaines instructions ne soient pas incorporées, elles
doivent néanmoins être analysées par le préprocesseur en vue simplement de
compter convenablement les correspondances entre #if, #else et #endif.
3.4 Exemples d’utilisation des directives de
compilation conditionnelle
A priori, il est toujours possible de se passer de ces directives en utilisant des
instructions appropriées dans le programme source lui-même. Néanmoins, grâce
à ces directives, seules les instructions utiles à un moment donné sont
véritablement compilées et introduites dans le programme exécutable. Cela
conduit d’une part à un gain de temps d’exécution puisque certains tests
disparaissent, d’autre part à un gain sur la taille du code généré. Ces avantages
restent néanmoins contrebalancés par le manque de lisibilité qui découle de
l’utilisation des directives du préprocesseur, notamment lorsque
l’implémentation n’accepte pas leurs indentations.
Nous vous proposons ici quelques exemples simples illustrant les circonstances
dans lesquelles la compilation conditionnelle est traditionnellement utilisée.
3.4.1 Mise au point de programme
Comme on l’a vu dans l’exemple introductif de la section 3.1.1, il est facile
d’activer ou de désactiver à volonté des instructions de programme, en vue de
faciliter sa mise au point. Il peut s’agir, par exemple, d’impressions de valeurs de
certaines variables… Cependant, ces possibilités restent limitées par
l’impossibilité de se baser alors sur des conditions faisant apparaître des valeurs
de certaines variables. Par exemple, si l’on souhaite introduire, à l’intérieur
d’une boucle de compteur i, une impression lors du dixième tour, il sera
impossible de procéder ainsi :
#define MISE_AU_POINT
…..
#if (defined(MISE_AU_POINT)) && (i==10) /* accepté mais i vaut toujours 0 */
printf (…..) ;
#endif
Certes, on pourra procéder ainsi :
#define MISE_AU_POINT
…..
#ifdef MISE_AU_POINT
if (i == 10) printf (…..) ;
#endif
ou encore ainsi :
#define MISE_AU_POINT 1 /* il est nécessaire de donner une valeur au symbole */
….. /* sinon on obtiendrait : */
if ( (i==10) && (MISE_AU_POINT)) /* if ((i==10) && ()) */
printf (…..) ; /* qui serait incorrecte en C */
3.4.2 Adaptation d’un programme à l’implémentation
Lorsqu’on doit réaliser un programme susceptible de s’adapter à différentes
implémentations, il est fréquent qu’un certain nombre de paramètres dépendent
de l’implémentation. Dans ce cas, les possibilités de compilation conditionnelle
s’avèrent précieuses. Pour le montrer, supposons que nos besoins d’adaptation se
limitent aux deux points suivants :
• disposer d’un type entier signé nommé int4, correspondant à une taille donnée
de 4 octets (notez que de tels types sont introduits par la norme C99) ;
• connaître la taille maximale des objets qu’on peut allouer dynamiquement.
Voici trois démarches possibles :
1. Définir un symbole correspondant à l’implémentation concernée et se fonder
sur l’existence de ce symbole pour incorporer ce qui est spécifique à
l’implémentation :
#define PC /* on pourrait trouver ici, suivant le cas, SUN32, SUN64, HP … */
…..
#ifdef PC
typedef int4 long
#define TAILLE_MAX 64000ul
#endif
…..
#ifdef SUN64
typedef int4 short
#define TAILLE_MAX 2000000000ul
#endif
…..
2. Inclure un fichier en-tête de nom donné dont le contenu devra alors être
adapté à chaque implémentation :
#include "config.h"
Le fichier config.h se présentera comme sur l’un de ces deux exemples :
/* Pour PC */ /* pour SUN64 */
typedef int4 long typedef int4 short
#define TAILLE_MAX 64000ul #define TAILLE_MAX 2000000000ul
3. Prévoir autant de fichiers en-tête que d’implémentations différentes et inclure
le fichier en-tête voulu, en déduisant son nom d’un symbole représentant
l’implémentation (voir sections 2.4, 2.5 et 4) :
#define IMPLE PC /* on pourrait trouver ici : SUN32, SUN64, HP … */
#define chaine(x) #x
#define g_chaine(x) chaine(x)
#define concat(x,y) x##y
…..
#include g_chaine(concat(IMPLE,.h) /* devient ici : #include "PC.h" */
/* avec d'autres valeurs de IMPLE, on pourrait obtenir : */
/* #include "SUN32.h" #include "SUN64.h" #include "HP.h" */
4. La directive d’inclusion de fichier source
4.1 Généralités
La directive #include permet d’incorporer dans le fichier source un texte en
provenance d’un autre fichier qu’on nomme généralement fichier en-tête. Le
contenu de ce fichier vient alors, en quelque sorte, remplacer la directive #include
et il est analysé par le préprocesseur, comme s’il avait été directement placé à cet
endroit dans le fichier source. En théorie, aucune contrainte ne pèse sur
l’emplacement de cette directive, pas plus que sur le contenu du fichier en-tête
correspondant. En pratique, on verra qu’il est fortement conseillé de procéder
comme avec les fichiers en-tête standards, à savoir de limiter leur contenu à des
déclarations, ne procéder qu’à des inclusions à un niveau global et se protéger
des inclusions multiples.
La directive #include peut prendre deux formes différentes, se distinguant par les
caractères qui entourent le nom du fichier en-tête concerné :
#include <stdio.h> /* forme utilisée pour les fichiers en-tête standards */
#include "monent.h" /* forme plutôt réservée aux fichiers en-têtes definis */
/* par le programmeur */
Avec la première forme, la recherche du fichier se fait dans un emplacement bien
défini, imposé par l’implémentation. C’est bien entendu celui où se trouvent les
fichiers en-tête standards.
Avec la seconde forme de la directive #include, la recherche se fait suivant une
démarche dépendant de l’implémentation. Elle a souvent lieu, par défaut, dans le
même répertoire que celui où se trouve le fichier source que l’on est en train de
compiler. Généralement, il est possible de préciser un répertoire particulier à ce
niveau. Théoriquement, la norme autorise une implémentation à traiter la
seconde forme exactement comme la première, mais cette situation se rencontre
très rarement.
Par ailleurs, il est possible de faire suivre l’indication #include d’un texte
quelconque contenant d’éventuels appels de macros ou de symboles. Bien
entendu, il est alors nécessaire que ce texte conduise, après expansion, à l’une
des deux formes voulues. Nous en verrons des exemples à la section 3.2.
4.2 Syntaxe
La directive #include18
#include^nom_fichier
^
Représente un ou plusieurs
espaces blancs (autres que
fin de ligne) et/ou un ou
plusieurs commentaires.
nom_fichier
Texte qui, après expansion – peut être éventuellement
des éventuelles macros ou suivi d’espaces blancs ou
remplacement des de commentaires ;
symboles, doit conduire à – les règles d’écriture de
un résultat de l’une des nom_fichier, ainsi que la
deux formes : façon dont le
– <nom> préprocesseur lui fait
– "nom" correspondre un fichier
donné peuvent dépendre
de l’implémentation.
Remarque
La concaténation des chaînes adjacentes n’a lieu qu’après le prétraitement, de sorte qu’en général,
l’instruction suivante est incorrecte :
#include "fich" "1a" /* pas équivalente à #include "fich1a" */
On peut souvent contourner la difficulté en faisant appel aux opérateurs # et ##, comme dans le second
exemple ci-après.
Exemple 1
Voici un exemple faisant appel à une expansion à l’intérieur du texte nom_fichier :
#if VERSION!=3
#define fic_ent "config0.h"
#else
#define fic_ent "config.h"
…..
#endif
#include fic_ent /* après expansion : fournit soit #include "config0.h" */
/* soit #include "config.h" */
Exemple 2
Voici un exemple qui montre comment incorporer un fichier dont le nom
s’adapte à un numéro de version défini par un symbole (ici VERSION) :
#define VERSION 5
#define g_chaine(x) chaine(x)
#define chaine(x) #x
#define g_nom_fich(x) nom_fich(x)
#define nom_fich(x) config ## x ## .h
…..
#include g_chaine(g_nom_fich(VERSION)) /* devient : #include "config5.h" */
4.3 Précautions à prendre
En théorie, la directive #include pourrait servir à incorporer n’importe quoi ! En
pratique, il est conseillé de respecter la vocation des fichiers en-tête en
s’inspirant de ce qui se fait dans les fichiers en-tête standards, à savoir :
• n’y introduire que des déclarations ou définitions de symboles ou de macros ;
• placer les directives #include à un niveau global.
En revanche, nous verrons que l’imbrication des directives #include ne pose pas
de problèmes majeurs pour peu qu’on se protège des risques d’inclusions
multiples.
4.3.1 Restrictions sur le contenu des fichiers en-tête
En théorie, le fichier incorporé par #include peut contenir n’importe quel texte ;
comme avec les définitions de macros, aucune cohérence n’est imposée au
contenu de ce fichier, de sorte que rien n’interdirait d’y trouver des morceaux
d’instructions ou de directives. Il est manifestement plus raisonnable de se
limiter à des ensembles cohérents : instructions complètes, directives
conditionnelles complètes, c’est-à-dire pas de #ifdef ou de #if sans son #endif…
Cette prudence n’est pas suffisante si, à l’image de ce que l’on fait avec les
fichiers en-tête standards, on souhaite pouvoir incorporer un fichier en-tête à un
niveau global. En effet, dans ce cas, il est nécessaire qu’il ne contienne aucune
instruction exécutable.
En définitive, on conseille de limiter le contenu d’un fichier en-tête à :
• des déclarations ;
• des définitions de symboles ou de macros ;
• des directives de compilation conditionnelle ;
• d’autres directives #include (l’imbrication des directives #include est examiné ci-
après).
Par ailleurs, même en respectant ces contraintes, il n’est généralement pas
possible d’incorporer plusieurs fois un même fichier en-tête dans un même
fichier source à un niveau global. En effet, la duplication de certaines
déclarations peut conduire à des erreurs. Ce n’est pas le cas pour une déclaration
de variable globale sans initialisation ou pour une déclaration de fonction. En
revanche, cela se produira avec une déclaration de variable globale avec
initialisation, une déclaration de type (structure, union ou énumération) ou une
définition de synonyme par typedef.
Dans ces conditions, il est préférable de protéger systématiquement tous les
fichiers en-tête des risques d’inclusion multiple, comme on apprendra à le faire à
la section 4.3.2.
4.3.2 Imbrication des directives #include
Les directives #include peuvent s’imbriquer sans problèmes. Autrement dit, un
fichier en-tête peut très bien contenir, à son tour, une directive #include. Bien
entendu, les choses restent assez faciles à maîtriser, dès lors qu’on a respecté les
consignes précédentes concernant le contenu de tels fichiers. Il est simplement
nécessaire de prendre quelques précautions afin d’éviter :
• les imbrications récursives ;
• un trop grand nombre d’imbrications ;
• les inclusions multiples d’un même fichier.
Éviter les imbrications récursives
On provoque rarement une récursion directe, c’est-à-dire une incorporation d’un
fichier par lui-même, comme dans :
/* fichier repet.h */
…..
#include repet.h
En revanche, le cas peut se produire de manière indirecte, comme dans :
/* fichier "A.h" */ /* fichier "B.h" */
#include "B.h" #include "A.h"
De telles situations ne doivent pas être confondues avec le cas où l’on incorpore
plusieurs fois un même fichier en-tête, que nous examinons ci-dessous. Il n’en
reste pas moins que la protection contre les doubles inclusions serait efficace
dans les situations évoquées ici, même si ces dernières résultent d’une faute
manifeste de conception.
Il existe une limite au nombre d’imbrications
La norme autorise une implémentation à fixer un nombre maximal
d’imbrications de directives #include. Sa valeur ne peut cependant pas être
inférieure à 8. En pratique, ce n’est jamais pénalisant.
Se protéger contre les inclusions multiples
Les inclusions récursives n’ont aucune raison d’apparaître dans un programme
bien conçu. En revanche, des dépendances (non récursives) entre fichiers en-tête
peuvent légitimement exister. En voici un exemple simple :
/* un fichier source */ /* fichier A.h */ /* fichier B.h */
#include "A.h" #include "C.h" #include "C.h"
#include "B.h" ….. …..
…..
Les instructions contenues dans C.h vont se trouver dupliquées avant compilation
du fichier source. Même si, comme il est conseillé, il ne s’agit pas d’instructions
exécutables, certaines peuvent poser problème : déclaration de variable globale
avec initialisation, définitions de type ou de synonymes.
En fait, il est possible de faire en sorte que le contenu du fichier C.h ne soit
incorporé qu’une seule fois. Il suffit de procéder ainsi :
/* fichier C.h protégé contre les inclusions multiples */
#ifndef C_H
#define C_H
…..
#endif
Le symbole C_H est déterminé de façon qu’il ne puisse être associé qu’à un seul
fichier en-tête.
Remarques
1. La protection contre les inclusions multiples d’un fichier en-tête est un argument supplémentaire
plaidant en faveur de son inclusion à un niveau global. Dans le cas contraire en effet, les
éventuelles déclarations y figurant pourraient faire défaut, comme le montre cet exemple :
void fct1(…)
{
#include "truc.h" /* déclarations introduites à un niveau local a fct1 */
…..
}
int fct2 (…)
{
#include "truc.h" /* truc.h a déjà été inclus dans ce fichier source par */
… /* le préprocesseur ; s'il est protégé contre les */
/* inclusions multiples, il ne sera pas inclus à nouveau */
/* et les déclarations correspondantes feront défaut */
}
2. La technique proposée ici pour l’ensemble d’un fichier en-tête peut être utilisée pour éviter les
définitions multiples de variables globales, de types ou de synonymes. C’est d’ailleurs ce qui se
passe à l’intérieur de certains fichiers en-tête prédéfinis, comme dans cet exemple correspondant au
type size_t :
#ifndef _SIZE_T
#define _SIZE_T
typedef unsigned long size_t
#endif
En C++
En C++, on associera généralement un fichier en-tête à chaque classe ou à chaque groupe de classes.
La notion d’héritage créera alors obligatoirement des dépendances entre ces différents fichiers, de
sorte que la protection contre les inclusions multiples deviendra encore plus cruciale qu’en C.
5. Directives diverses
En dehors de celles étudiées précédemment, il existe quatre directives d’intérêt
très relatif :
• la directive vide, analogue à une instruction vide ;
• la directive line qui permet de modifier le numéro de ligne du fichier source,
ainsi qu’éventuellement son nom ;
• la directive error qui permet d’interrompre le traitement du préprocesseur, en
l’assortissant d’un message d’erreur de son choix ;
• la directive pragma qui permet à une implémentation de disposer de directives
supplémentaires, sans compromettre la portabilité du programme.
5.1 La directive vide
La directive vide19
#^
^
Représente un ou plusieurs espaces blancs (autres que fin de
ligne) et/ou un ou plusieurs commentaires.
Son principal intérêt est de pouvoir introduire des lignes vides ou des
commentaires. Certes, le même résultat pourrait être obtenu sans le caractère #.
Mais cela permet de mieux visualiser d’éventuelles liaisons entre directives
destinées au préprocesseur.
5.2 La directive #line
La directive #line20
#line^numero [nom_fichier]
^
Représente un ou plusieurs espaces
blancs (autres que fin de ligne) et/ou
un ou plusieurs commentaires.
numero
Texte qui, après expansion des La prochaine ligne
éventuelles macros ou remplacement portera le numéro
de symboles, doit conduire à une numero.
constante entière positive, inférieure
ou égale à 32 767.
nom_fichier
Texte qui, après expansion des Peut être
éventuelles macros ou remplacement éventuellement
des symboles, doit conduire à un suivi d’espaces
résultat de la forme "nom". blancs ou de
commentaires.
Cette directive permet d’imposer un nouveau numéro de ligne qui prend effet à
partir de la directive #line ainsi analysée. Elle permet également de modifier le
nom du fichier source.
5.3 La directive #error
La directive #error21
#error^texte
^
Représente un ou plusieurs espaces blancs (autres que fin de
ligne) et/ou un ou plusieurs commentaires.
texte
Texte qui accompagnera le message d’erreur du
préprocesseur.
Cette directive demande au préprocesseur d’interrompre le traitement en
affichant un message d’erreur accompagné du texte figurant à la suite.
En voici un exemple simple :
…..
#if defined(SUN)
…..
#elif defined(PC)
…..
#else
#error aucune implementation n'est definie
#endif
5.4 La directive #pragma
La directive #pragma22
#pragma^[texte]
^
Représente un ou plusieurs espaces blancs (autres que fin de
ligne) et/ou un ou plusieurs commentaires.
texte
Texte respectant éventuellement certaines règles imposées
par l’implémentation.
Cette directive permet à une implémentation de disposer de directives
complémentaires, sans pour autant déroger à la norme. En effet, toute directive
pragma non reconnue par une implémentation est purement et simplement ignorée.
1. Le préprocesseur est lui aussi capable d’évaluer des expressions constantes, mais uniquement lorsqu’elles
apparaissent dans certaines directives.
2. Bien que nous ne l’ayons pas fait ici, par souci de simplicité, nous verrons à la section 2.8 qu’il est très
fortement conseillé d’introduire des parenthèses en écrivant #define carre(a) ((a)*(a)).
3. Ici, l’ordre des opérations n’est pas important. Si le préprocesseur avait d’abord été conduit au texte
carre(p)-carre(q), il serait parvenu au même résultat.
4. Théoriquement, le caractère # peut en outre être précédé ou suivi de certains espaces blancs ou de
commentaires, comme l’indique la section 1.2.
5. Ce qui sera le cas même s’il n’est suivi que d’un banal espace guère visible dans la liste !
6. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
7. La norme autorise ce que l’on nomme parfois une éredéfinition bénigneé, c’est-à-dire une définition
identique à la précédente, aux espaces blancs près. Toutefois, certaines implémentations ne respectent
pas la norme en acceptant une redéfinition quelconque. Elles fournissent généralement un message
d’avertissement dans ce cas.
8. Attention ici, nous n’avons pas introduit les parenthèses nécessaires au bon respect des priorités des
opérations arithmétiques, comme cela est expliqué à la section 2.8.
9. Aux espaces blancs de début près.
10. Attention ici, nous n’avons pas introduit les parenthèses nécessaires au bon respect des priorités des
opérations arithmétiques, comme cela est expliqué à la section 2.8.
11. Certaines implémentations rejettent les définitions récursives. D’autres fournissent un message
d’avertissement.
12. Même remarque que précédemment.
13. Certaines implémentations ne respectent pas la norme et acceptent une redéfinition quelconque. Elles
fournissent généralement un message d’avertissement dans ce cas.
14. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
15. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
16. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
17. Toutefois, contrairement à ce qu’elle fait pour l’opérateur de cast, la norme ne dit pas explicitement, de
façon redondante, que l’opérateur sizeof est interdit.
18. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
19. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs (voir section
1.2).
20. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
21. Même remarque que précédemment.
22. Théoriquement, le caractère # peut en outre être précédé ou suivi de caractères séparateurs, comme
l’indique la section 1.2.
16
Les déclarations
Ce chapitre fait le point sur l’ensemble des instructions de déclaration du
langage C. Il traite non seulement des déclarations de variables, mais aussi des
déclarations de types (structures, unions, énumérations) ainsi que des
déclarations de fonctions. Compte tenu de leur lien étroit avec leurs déclarations,
on y trouvera également les définitions des fonctions, sous leurs deux formes
ancienne et moderne.
Nous commencerons par rappeler comment se présentent les déclarations en C et
les éléments qui y apparaissent : spécificateur de type, déclarateur, classe de
mémorisation, qualifieurs, initialiseurs. Nous présenterons ensuite la syntaxe
détaillée de l’ensemble de ces déclarations. Puis, nous montrerons comment
interpréter une déclaration un peu complexe et, réciproquement, comment écrire
la déclaration correspondant à une variable, un type ou une fonction quelconque.
1. Généralités
Traditionnellement, les instructions de déclaration s’opposent aux instructions
dites exécutables : les premières fournissent des informations au compilateur
pour l’aider dans son travail, les secondes produisent effectivement des
instructions exécutables. Il en va encore ainsi en langage C1.
D’une manière générale, on peut dire qu’une déclaration en langage C sert à
définir les caractéristiques d’un identificateur. Dans le cas des identificateurs de
variables, la déclaration en définit le type. Elle peut éventuellement préciser des
valeurs initiales. Mais une déclaration peut concerner d’autres sortes
d’identificateurs, à savoir des identificateurs de type (structure, union,
énumération ou synonyme) ou des identificateurs de fonction. Malgré cette
diversité, toutes les déclarations possèdent, en théorie, la même structure dont
nous allons examiner ici les différents constituants.
1.1 Les principaux éléments : déclarateur et
spécificateur de type
Considérons pour l’instant les déclarations de variables sans initialisation. Dans
la plupart des autres langages, elles sont formées de deux parties parfaitement
distinctes :
• un type ;
• une liste d’identificateurs de variables.
En C, il n’en va ainsi que dans les cas les plus simples tels que :
unsigned int n, k ; /* n et k sont du type unsigned int */
struct enreg art1, art2 ; /* art1 et art2 sont du type struct enreg */
/* (supposé défini précédemment) */
En revanche, ce n’est plus le cas dans :
int *ad, n , *pt, t[10], *p[12] ; /* n est du type int */
/* ad et pt sont du type pointeur sur int */
/* t est un tableau de 10 int */
/* p est un tableau de 12 pointeurs sur des int */
D’une manière générale, en C, une déclaration de variable associe non pas une
liste d’identificateurs à un type, mais une liste de déclarateurs à un spécificateur
de type. Un spécificateur de type correspond toujours à un type. Dans les cas les
plus simples comme les premiers exemples, le déclarateur se réduit à un
identificateur qui se trouve donc avoir le type correspondant au spécificateur. En
revanche, dès qu’un type fait intervenir au moins l’une des notions de pointeur,
tableau ou fonction, il ne peut plus s’exprimer directement par un spécificateur
de type. Il faut alors recourir à un déclarateur qui permet de définir ce que l’on
nomme des « types dérivés » de ces spécificateurs. Dans ce cas, l’identificateur
se trouve plus ou moins englobé dans le déclarateur (*ad, *pt, t[10] et *p[12] dans
les précédents exemples).
En ce qui concerne les déclarations des autres identificateurs (types, synonymes,
fonctions), elles associent elles aussi un déclarateur à un spécificateur de type.
Néanmoins, la plupart du temps, cette association reste artificielle car elle n’est
obtenue qu’au prix d’une complexification notoire des déclarations. Par
exemple, les trois déclarations suivantes, bien que de nature totalement
différentes obéissent au même schéma théorique :
/* déclaration de variables de type structure (déconseillée car mixage */
/* définition de type et déclaration de variable du type) */
struct enreg { int x ; float y ; } art1, art2 ;
/* spécificateur de type : struct enreg { int x ; float y ; } */
/* déclarateurs : art1 et art2 */
/* déclaration (conseillée) d'un type structure */
struct enreg { int x ; float y ; } ;
/* spécificateur de type : struct enreg { int x ; float y ; } */
/* déclarateurs : inexistants */
/* déclaration (conseillee) de variables d'un type structure */
struct enreg art1, art2 ;
/* spécificateur de type : struct enreg */
/* déclarateurs : art1 et art2 */
Les choses seront encore plus artificielles avec l’instruction typedef, comme nous
le verrons un peu plus loin.
1.2 Les autres éléments
à la complexité induite par la notion de déclarateur, s’ajoute le fait qu’une
déclaration en C peut comporter certains des éléments supplémentaires suivants :
• qualifieurs (const, volatile) ;
• classe de mémorisation ;
• initialiseur.
1.2.1 Les qualifieurs
Dans une déclaration, les qualifieurs const et volatile peuvent intervenir de deux
façons différentes, soit en étant associés au spécificateur de type, soit en figurant
à l’intérieur d’un déclarateur de pointeur. Selon le cas, ils jouent un rôle
différent.
Les qualifieurs associés au spécificateur de type s’appliquent à tous les
déclarateurs correspondants. Assez curieusement, comme on a pu le voir en
détail à la section 2.5 du chapitre 7, leur rôle dépend de la nature de l’objet
concerné : dans le cas des pointeurs, ils s’appliquent aux objets pointés, pour les
autres objets, ils s’appliquent aux objets eux-mêmes :
const int n, *ad, t[5], **ad ;
/* spécificateur de type : const int, déclarateurs : n, *ad, t[5], **ad */
/* n est un int constant, ad un pointeur sur un int constant, t un tableau */
/* de int constants, ad un pointeur sur un pointeur sur un int constant */
Les qualifieurs associés à un déclarateur de pointeur concernent tout
naturellement un seul déclarateur et ils s’appliquent à l’objet pointeur
correspondant :
float * const ad1, * const *ad2, ** const ad3, * const * const ad4 ;
/* spécificateur de type : float */
/* déclarateurs : *const ad1, * const *ad2, ** const ad3 et * const * const ad4 */
/* ad1 est un pointeur constant sur un float, */
/* ad2 est un pointeur sur un pointeur constant sur un float */
/* ad3 est un pointeur constant sur un pointeur sur float */
/* ad4 est un pointeur constant sur un pointeur constant sur un float */
1.2.2 La classe de mémorisation
Le mot-clé dit « classe de mémorisation » s’applique à tous les déclarateurs. Il a
souvent une influence sur la classe d’allocation des variables correspondantes,
sans toutefois coïncider totalement avec elle. Il peut même n’avoir aucun
rapport :
static int n ; /* si cette déclaration est à un niveau global, n est de classe */
/* d'allocation statique, même si le mot-clé static est absent */
/* si cette déclaration est à un niveau local, n est de classe */
/* statique, alors qu'elle serait de classe automatique si */
/* le mot-cle static était absent */
extern float x ; /* quelle que soit le niveau de cette declaration, x est de */
/* classe statique */
En outre, le mot-clé typedef est lui aussi considéré sur le plan de la syntaxe des
déclarations comme une classe de mémorisation. Manifestement, dans ce cas, il
n’a plus rien à voir avec une quelconque classe d’allocation :
typedef int vect[3] ; /* classe de mémorisation : typedef, */
/* spécificateur de type : int, déclarateur : vect[3] */
/* vect est un synonyme de int[3] */
On voit, dans ce dernier cas, que l’homogénéité syntaxique des déclarations a été
obtenue au prix d’un artifice qui dénature complètement la signification de la
classe de mémorisation.
1.2.3 L’initialiseur
L’initialiseur sert à fournir une valeur initiale pour une variable, qu’elle soit
scalaire ou de type agrégé. Son usage est naturel et ne pose guère de problèmes,
si ce n’est celui de savoir quand il est autorisé et quand il ne l’est pas. En voici
deux exemples usuels :
int n, p = 5, q ; /* 5 est un initialiseur pour la variable p */
int q, t[] = {3, 5, 8} ; /* {3, 5, 8} est un initialiseur pour le tableau t */
2. Syntaxe générale d’une déclaration
Cette section récapitule la syntaxe complète des instructions de déclaration. Il
faut bien voir qu’il s’agit d’une syntaxe théorique qui fournit donc tous les cas
possibles. Or il existe des contraintes qui interdisent certaines combinaisons. La
plupart de ces contraintes ont un caractère relativement général et sont
suffisamment simples pour être mentionnées sous forme de commentaires
complémentaires à la syntaxe. Cependant, quelques contraintes n’ont pu trouver
leur place ici pour des questions de clarté. Leur existence est néanmoins facile à
appréhender, dès lors qu’on s’intéresse à la signification (sémantique) des
déclarations et plus seulement à leur syntaxe2. Quoi qu’il en soit, ces contraintes
figurent toujours dans les chapitres correspondants.
Nous utiliserons les quelques conventions suivantes :
Mot écrit en Mot-clé du langage, à reproduire tel quel
gras
[……]
Le contenu des crochets est facultatif sur le plan de la
syntaxe, ce qui n’empêche pas qu’il puisse être
indispensable dans certains contextes. Attention, la
syntaxe des déclarateurs de tableaux impose des crochets.
Dans ce cas, ils sont écrits en gras.
LISTE_de xxxx
Cette mention correspond à une liste de xxxx, c’est-à-dire
à un ou plusieurs xxxx séparés par des virgules. Lorsqu’il
est permis qu’aucun xxxx n’apparaisse, cette mention est
placée entre crochets.
2.1 Forme générale d’une déclaration
Déclaration
[ classe_memo ] [ qualifieurs ] [ specif_type ] [ LISTE_de
declarateurs_initialiseurs ] ;
L’ordre des trois premiers éléments est indifférent. Ils ne peuvent pas être
tous les trois absents. Si specif_type est absent, il est pris égal à int.
La LISTE_de declarateurs_initialiseurs peut être absente en cas de définition de
type (structure, énumération ou union) utilisant la forme conseillée ou en cas
de déclaration anticipée d’un tel type. Dans les deux cas, la déclaration ne
peut comporter ni de classe_memo, ni de qualifieurs.
Si classe_memo est typedef, la LISTE_de declarateurs_initialiseurs ne peut pas
contenir d’intialisation.
Une déclaration ne doit pas conduire à un objet de type tableau de fonctions,
ni à une fonction renvoyant un tableau.
classe_memo extern
static
Global ou local
auto Global ou local
register
typedef Local seulement
Local seulement
Global ou local ; artifice
syntaxique
qualifieurs const
volatile
const volatile
volatile const
specif_type
Nom d’un type de Liste aux section 1.1, 2.1
base et 4.4 du chapitre 3
Spécificateur de Décrit à la section 2.2 (il
type structure peut s’agir soit du nom du
type, soit de sa
Spécificateur de description)
type union Décrit à la section 2.3 (il
peut s’agir soit du nom du
Spécificateur de type, soit de sa
type énumération description)
Décrit à la section 2.4 (il
Identificateur de peut s’agir soit du nom du
synonyme type, soit de sa
void
description)
Défini préalablement par
typedef
Pour fonctions et
pointeurs seulement
declarateur_initialiseur declarateur [ =
initialiseur ]
declarateur décrit à la
section 2.5
initialiseur décrit ci-après
Initialiseur
valeur
{ liste_d_initialisation }
La deuxième forme ({…..}) n’est utilisable qu’avec des agrégats (tableaux,
structures, unions)
valeur
Expression d’un – les compatibilités par
type scalaire, affectation sont décrites à
structure ou union, la section 7.4 du chapitre
compatible par 4 ;
affectation avec
l’élément à – dans le cas de variables
initialiser (en ne statiques, l’expression
tenant pas compte doit être constante.
d’un éventuel
qualifieur const ici)
liste_d_initialisation LISTE_d'initialiseurs
[,] – les règles d’attribution
des valeurs aux différents
éléments d’un agrégat
sont données dans les
chapitres correspondants ;
– les valeurs terminales
doivent être des
expressions constantes,
quelle que soit la classe
d’allocation.
2.2 Spécificateur de type structure
Spécificateur de type structure
struct [ identificateur ] [ { LISTE_de declaration_de_membres } ]
La LISTE_de declaration_de_membres peut être absente :
– en cas de déclaration de variables de type structure utilisant la forme
conseillée ; la déclaration comporte alors une LISTE_de
;
declarateurs_initialiseurs
– en cas de déclaration anticipée de type ; la déclaration ne comporte alors
pas de LISTE_de declarateurs_initialiseurs.
L’identificateur peut être absent dans le cas déconseillé d’utilisation de types
anonymes.
Déclaration de membre
[ qualifieurs ] [ specif_type ] LISTE_de declarateur_de_membre
Qualifieurs et specif_type ne peuvent pas être tous les deux absents.
Si specif_type est absent, il est pris égal à int.
Si un membre est de type tableau, sa dimension doit toujours être présente.
qualifieurs const
volatile const n’est théoriquement
const volatile autorisé ici que si tous les
volatile const
champs de la liste associée
sont des pointeurs
specif_type
Nom d’un type de base Liste aux sections 1.1, 2.1 et
Spécificateur de type 4.4 du chapitre 3
structure Décrit dans cette section (il
peut s’agir soit du nom du
Spécificateur de type type, soit de sa description)
union Décrit à la section 2.3 (il peut
s’agir soit du nom du type, soit
Spécificateur de type de sa description)
énumération Décrit à la section 2.4 (il peut
s’agir soit du nom du type, soit
Identificateur de de sa description)
synonyme Défini préalablement par
void typedef
Pour pointeurs uniquement
declarateur_de- declarateur
membre declarateur_champ_de_bits Cas d’un membre usuel qui ne
devra être, ni d’un type
fonction, ni du type structure
en cours de définition ;
declarateur décrit à la section
2.5
Cas d’un membre champ de
bit ;
décrit ci-après
Déclarateur champ de bits
[ identificateur ] : taille
Le specif_type associé à ce déclarateur doit toujours être présent et il ne peut
s’agir que de int, signed [int] ou unsigned [int].
Le cas où idenficateur est absent correspond à un champ de bits « anonyme ».
taille
Expression constante – 0 correspond à un forçage
entière non négative d’alignement ;
– la valeur maximale
dépend de
l’implémentation.
2.3 Spécificateur de type union
Spécificateur de type union
union [ identificateur ] [ { LISTE_de declaration_de_membres } ]
La LISTE_de declaration_de_membres peut être absente :
– en cas de déclaration de variables de type union utilisant la forme
conseillée ; la déclaration comporte alors une LISTE_de
declarateurs_initialiseurs ;
– en cas de déclaration anticipée de type ; la déclaration complète
correspondante ne comporte alors pas de LISTE_de declarateurs_initialiseurs.
L’identificateur peut être absent dans le cas déconseillé d’utilisation de types
anonymes.
Déclaration de membre
[ qualifieurs ] [ specif_type ] LISTE_de declarateur_de_membre
Qualifieurs et specif_type ne peuvent être tous les deux absents.
Si specif_type est absent, il est pris égal à int.
Si un membre est de type tableau, sa dimension doit toujours être présente.
qualifieurs const
volatile const n’est autorisé ici que
const volatile si tous les champs de la
volatile const
liste associée sont des
pointeurs.
specif_type
Nom d’un type de base Liste aux sections 1.1, 2.1
Spécificateur de type et 4.4 du chapitre 3
structure Décrit à la section 2.2 (il
peut s’agir soit du nom du
Spécificateur de type type, soit de sa
union description)
Décrit dans cette section
Spécificateur de type (il peut s’agir soit du nom
énumération du type, soit de sa
description)
Identificateur de Décrit à la section 2.4 (il
synonyme peut s’agir soit du nom du
void
type, soit de sa
description)
Défini préalablement par
typedef
Pour pointeurs
uniquement
declarateur_de_membre
Déclarateur Cas d’un membre usuel
qui ne devra être, ni d’un
type fonction, ni du type
declarateur_champ_de_bits
union en cours de
définition ; declarateur
décrit à la section 2.5
Cas d’un membre champ
de bit, décrit à la section
2.2
2.4 Spécificateur de type énumération
Spécificateur de type énumération
enum [ identificateur ] [ { LISTE_d_enumerateurs } ]
L’identificateur peut être absent :
– en cas d’utilisation de types anonymes ;
– lorsqu’on utilise enum pour définir simplement des constantes symboliques.
enumerateur identificateur [ =
valeur ] valeur est une expression
constante représentable dans le
type int.
2.5 Déclarateur
Il peut prendre l’une des cinq formes suivantes qui, à l’exception de la première,
sont des définitions « récursives ».
Déclarateur (l’une des 5 possibilités suivantes)
identificateur
( declarateur )
declarateur_de_forme_pointeur
declarateur_de_forme_tableau
declarateur_de_forme_fonction
declarateur_de_forme_pointeur * [ qualifieurs ]
declarateur Les qualifieurs sont
choisis parmi const et
volatile.
declarateur_de_forme_tableau declarateur [ [
dimension ] ] – dimension est une
expression constante
entière sans signe ;
elle peut être omise
dans certains cas
(voir section 2.4.2 du
chapitre 6) ;
– attention, les crochets
en gras font ici partie
de la syntaxe.
declarateur_de_forme_fonction declarateur ( [
arguments ] ) La liste arguments n’est
omise que dans la
forme ancienne de
déclaration ; dans la
forme moderne,
l’absence d’argument se
traduit par (void) ; le
contenu de arguments est
décrit ci-après.
Arguments (l’une des deux possibilités suivantes)
void
LISTE_de declaration_d_argument [ , … ]
La notation … correspond à des arguments variables.
Déclaration d’arguments
[classe_memo] [ qualifieurs ] [ specif_type ] declarateur_d_argument
Le type d’un argument ne doit pas être un type fonction.
classe_memo register
auto Rarement utilisé
Inutile, rejeté par certaines
implémentations
specif_type
Nom d’un type de Liste aux sections 1.1, 2.1
base et 4.4 du chapitre 3
Spécificateur de Décrit à la section 2.2
type structure Décrit à la section 2.3
Spécificateur de Décrit à la section 2.4
type union Défini préalablement par
typedef
Spécificateur de
type énumération Pour pointeurs seulement
Identificateur de
synonyme
void
declarateur_d_argument
Déclarateur Pour la forme prototype
Déclarateur sans complet
identificateur Pour la forme prototype
usuel ; correspond à un
déclarateur usuel, duquel
on a supprimé
l’identificateur.
Lorsque specif_type est un spécificateur de type structure, union ou
énumération, il peut théoriquement s’agir aussi bien du nom du type que de
sa description. Toutefois, cette seconde possibilité n’est pratiquement jamais
utilisée. En effet, elle ne permettrait plus de définir des variables d’un type
compatible avec celui de l’argument correspondant.
3. Définition de fonction
La définition d’une fonction n’est plus, à proprement parler, une déclaration.
Néanmoins, l’en-tête d’une fonction possède suffisamment de points communs
avec sa déclaration pour qu’on regroupe leurs syntaxes respectives dans ce
chapitre. On notera que la déclaration des fonctions est déjà couverte par les
déclarations générales décrites précédemment.
3.1 Forme moderne de la définition d’une fonction
[ classe_memo ] [ qualifieurs ] [ specif_type
] decl_fonction_moderne Bloc
classe_memo extern
static
qualifieurs const
volatile Ils ne peuvent
const volatile s’appliquer qu’à des
volatile const
objets pointés.
specif_type
Nom d’un type de base LIste aux sections 1.1,
Sspécificateur de type 2.1 et 4.4 du chapitre 3
structure Décrit à la section 2.2
Spécificateur de type Décrit à la section 2.3
union Décrit à la section 2.4
Spécificateur de type Défini préalablement
énumération par typedef
Identificateur de Pour valeur de retour
synonyme absente ou pointeur
void
decl_fonction_moderne declarateur_de_forme_fonction
Décrit à la section 2.5
mais, ici, tous les
arguments doivent être
déclarés et leurs
identificateurs présents
(forme prototype
complet).
Si specif_type est absent, il est pris égal à int.
L’absence de class_memo équivaut à extern.
La valeur de retour ne peut pas être d’un type tableau ou fonction.
Lorsque specif_type est un spécificateur de type structure, union ou
énumération, il peut théoriquement s’agir aussi bien du nom du type que de
sa description. Toutefois, cette seconde possibilité n’est pratiquement jamais
utilisée. En effet, elle ne permettrait plus de définir des variables d’un type
compatible avec celui de la valeur de retour.
3.2 Forme ancienne de la définition d’une fonction
[ classe_memo ] [ qualifieurs ] [ specif_type
] decl_fonction_ancienne
LISTE_de declarations_d_arguments Bloc
classe_memo extern
static
qualifieurs const
volatile Ils ne peuvent s’appliquer
const volatile qu’à des objets pointés.
volatile const
specif_type
Nom d’un type de Liste aux sections 1.1, 2.1
base et 4.4 du chapitre 3
Spécificateur de type Décrit à la section 2.2
structure Décrit à la section 2.3
Spécificateur de type Décrit à la section 2.4
union Défini préalablement par
typedef
Spécificateur de type
énumération Pour valeur de retour
Identificateur de absente ou pointeur
synonyme
void
decl_fonction_ancienne declarateur ( [ LISTE
d_identificateurs ] ) décrit à la
declarateur
section 2.5
declaration_d_arguments
Déclarations usuelles
dans lesquelles les
identificateurs
correspondants sont
ceux des arguments
(qui doivent être tous
être déclarés)
Si specif_type est absent, il est pris égal à int.
L’absence de class_memo équivaut à extern
La valeur de retour ne peut pas être d’un type tableau ou fonction.
Lorsque specif_type est un spécificateur de type structure, union ou
énumération, il peut théoriquement s’agir aussi bien du nom du type que de
sa description. Toutefois, cette seconde possibilité n’est pratiquement jamais
utilisée. En effet, elle ne permettrait plus de définir des variables d’un type
compatible avec celui de la valeur de retour.
4. Interprétation de déclarations
4.1 Les règles
Comme l’indique la section 2.1, toute déclaration en C est de la forme suivante,
accompagnée d’un exemple :
[ classe_memo ] [ qualifieurs ] [ specif_type ] [ LISTE_de declarateurs_initialiseurs
] ;
static const int n=12, *ad, t[10], *p[12] ;
Le problème qui se pose est de définir le type correspondant à chacun des
identificateurs apparaissant dans la déclaration (n, ad, t et p dans notre exemple).
Convenons de noter :
• T le type correspondant au spécificateur de type, éventuellement qualifié par les
qualifieurs mentionnés (dans notre exemple, T = const int), mais sans la classe
de mémorisation qui n’intervient pas à ce niveau ;
• , D2… Dn les différents déclarateurs figurant dans la déclaration,
D1
éventuellement débarrassés de leur initialisation (dans notre exemple, il s’agit
des déclarateurs n, *ad, t[10] et *p[12]).
On notera que T représente toujours un type, même s’il ne s’agit pas toujours du
type qui sera attribué aux différentes variables concernées. Dans ces conditions,
on peut dire que notre déclaration associe le type T aux différents déclarateurs D1,
D2… Dn. Le problème initial devient alors le suivant : étant donné une association
de la forme suivante dans laquelle D représente l’un quelconque des Di :
T D
quel est le type de l’identificateur qui apparaît (plus ou moins englobé) dans D ?
Pour répondre à cette question, on exprime l’association précédente par
l’affirmation :
D est un T
Bien entendu, si D est un simple identificateur, aucun problème ne se pose. Dans
le cas contraire, on transforme progressivement l’affirmation initiale, en utilisant
un certain nombre de règles, jusqu’à ce que l’on aboutisse à l’association d’un
simple identificateur à un type donné. À chaque stade, on applique toujours la
première des quatre règles suivantes :
Tableau 16.1 : règles d’interprétation d’un déclarateur quelconque
Affirmation avant Affirmation après application
Règle
application de la règle de la règle
Parenthèses (D) est un T. est un T.
D
* [qualifieurs ] D est un D est un pointeur
Pointeur .
T (éventuellement qualifié par les
qualifieurs) sur T.
Tableau D [n] est un T. est un tableau de [n] T.
D
D(arg) est un T. D est une fonction renvoyant un
T et dont les arguments sont
Fonction
d’un type défini par arg (si cette
mention est présente).
4.2 Exemples
const int * p[12] ;
• affirmation initiale : *p[12] est un int constant ;
• application de la règle pointeur (ici, sans qualifieur) : p[12] est un pointeur sur
un int constant ;
• application de la règle tableau : p est un tableau de 12 pointeurs sur des int
constants.
float * const t[10] ;
• affirmation initiale : * const t[10] est un float ;
• application de la règle pointeur (ici, avec le qualifieur const) : t[10] est un
pointeur constant sur un float ;
• application de la règle tableau : t est un tableau de 10 pointeurs constants sur
des float.
int (* p)[12] ;
• affirmation initiale : (*p)[12] est un int ;
• application de la règle tableau (la règle pointeur ne doit plus s’appliquer en
premier, compte tenu des parenthèses) : (*p) est un tableau de 12 int ;
• application de la règle parenthèses : *p est un tableau de 12 int ;
• application de la règle pointeur : p est un pointeur sur un tableau de 12 int.
int (* const t[5]) (float, long) ;
• affirmation initiale : (* const t[5]) (float, long) est un int ;
• application de la règle fonction : (* const t[5]) est une fonction renvoyant un int
et recevant un float et un long ;
• application de la règle parenthèses : * const t[5] est une fonction renvoyant un
int et recevant un float et un long ;
• application de la règle pointeur (ici avec le qualifieur const) : t[5] est un
pointeur constant sur une fonction renvoyant un int et recevant un float et un
long ;
• application de la règle tableau : t est un tableau de 5 pointeurs constants sur
une fonction renvoyant un int et recevant un float et un long.
int * const t[5] (float, long) ; /* la même que précédemment, sans les parenthèses */
• affirmation initiale : * const t[5] (float, long) est un int ;
• application de la règle pointeur : t[5] (float, long) est un pointeur constant sur
un int ;
• application de la règle fonction : t[5] serait une fonction renvoyant un pointeur.
À ce stade, on voit que t serait un tableau de fonction, ce qui est interdit. La
déclaration précédente n’a donc aucune signification.
void (*signal (int , void (*)(int))) (int) ; /* prototype extrait de signal.h */
• affirmation initiale : (*signal (int, void (*)(int))) (int) est de type void ;
• application de la règle fonction : (*signal (int, void (*)(int))) est une fonction
recevant un int, sans valeur de retour ;
• application de la règle parenthèses : *signal (int, void (*)(int)) est une fonction
recevant un int, sans valeur de retour ;
• application de la règle pointeur : signal (int, void (*)(int)) est un pointeur sur
une fonction recevant un int, sans valeur de retour ;
• application de la règle fonction : signal est une fonction renvoyant un pointeur
sur une fonction recevant un int et sans valeur de retour, et dont les arguments
sont de type int et void(*)(int) ; le deuxième argument est donc de type pointeur
sur une fonction recevant un int et sans valeur de retour.
float (*(*(*p)[5])(double, char(*)(int)))(char) ;
• affirmation initiale : (*(*(*p)[5])(double, char(*)(int)))(char) est un float ;
• application de la règle fonction (*(*(*p)[5])(double, char(*)(int))) est une fonction
recevant un char et renvoyant un float ;
• application de la règle parenthèses : *(*(*p)[5])(double, char(*)(int)) est une
fonction recevant un char et renvoyant un float ;
• application de la règle pointeur : (*(*p)[5])(double, char(*)(int)) est un pointeur
vers une fonction recevant un char et renvoyant un float ;
• application de la règle fonction, puis de la règles parenthèses : *(*p)[5] est une
fonction renvoyant un pointeur vers une fonction recevant un char et renvoyant
un float et recevant deux arguments de type double et char(*)(int) ;
• application de la règle pointeur : (*p)[5] est un pointeur vers une fonction
renvoyant un pointeur vers une fonction recevant un char et renvoyant un float
et recevant deux arguments de type double et char(*)(int) ;
• application de la règle tableau, puis de la règle parenthèses : *p est tableau de 5
pointeurs vers des fonctions renvoyant un pointeur vers une fonction recevant
un char et renvoyant un float et recevant deux arguments de type double et
char(*)(int) ;
• application de la règle pointeur : p est un pointeur vers un tableau de 5
pointeurs vers des fonctions renvoyant un pointeur vers une fonction recevant
un char et renvoyant un float et recevant deux arguments de type double et
char(*)(int).
On peut également expliciter le type char(*)(int) comme étant un pointeur vers
une fonction recevant un int et renvoyant un char, d’où l’affirmation finale : p est
un pointeur vers un tableau de 5 pointeurs vers des fonctions renvoyant un
pointeur vers une fonction recevant un char et renvoyant un float et recevant deux
arguments, le premier de type double, le second de type pointeur vers une fonction
recevant un int et renvoyant un char.
5. Écriture de déclarateurs
5.1 Les règles
Ici, le problème qui se pose est l’inverse de celui étudié dans la section
précédente. On part d’une affirmation de la forme suivante, dans laquelle ident
désigne un identificateur, et T un type quelconque :
ident est un T.
Bien entendu, si T est un spécificateur de type, aucun problème ne se pose. En
revanche, si ce n’est pas le cas, on transforme progressivement l’affirmation
précédente, en utilisant un certain nombre de règles, jusqu’à ce que l’on
aboutisse à l’association d’un déclarateur à un spécificateur de type. À chaque
stade, on applique l’une des trois règles suivantes3 :
Tableau 16.2 : règles d’écriture d’un déclarateur quelconque
Affirmation avant
Affirmation après application de
Règle application de la
la règle
règle
D est un pointeur est un T (on peut
*[qualifieurs] D
(éventuellement placer des parenthèses autour de D,
Pointeur
qualifié par des mais elles sont superflues).
qualifieurs) sur T.
D est un tableau de n (D) [n] est un T.
1
T . Si D ne commence pas par *, on peut
Tableau
supprimer les parenthèses et
affirmer que : D[n] est un T.
D est une fonction (D)(arg) est un T, si le type des
renvoyant un T et arguments est spécifié (forme
dont les arguments conseillée).
sont d’un type (D)() est un T, si le type des
Fonction défini par arg (cette arguments n’est pas spécifié (forme
mention est déconseillée).
présente, comme on Dans tous les cas, si D ne commence
le conseille). pas par *, on peut supprimer les
parenthèses entourant D.
1. La valeur de n peut ne pas être précisée dans l’un des trois cas suivants : présence d’un initialiseur,
tableau en argument muet ou dans un prototype, redéclaration d’un tableau global.
5.2 Exemples
Nous reprenons ici les problèmes inverses de ceux présentés à la section 3.2.
p est un tableau de 12 pointeurs sur des int constants, autrement dit sur des
const int
• application de la règle tableau : p[12] est un pointeur sur des const int ;
• application de la règle pointeur : *p[12] est un const int ;
• la déclaration correspondante utilisera donc le qualifieur const et le spécificateur
de type int, soit, dans le cas où l’on n’introduit qu’un seul déclarateur dans la
même déclaration) :
const int *p[12] ;
t est un tableau de 10 pointeurs constants sur des float
• application de la règle tableau : (t)[10] est un pointeur constant sur des float,
soit, après suppression des parenthèses : t[10] est un pointeur constant sur des
float ;
• application de la règle pointeur : * const t[10] est un float ;
• la déclaration correspondante sera donc :
float * const t[10] ;
p est un pointeur sur un tableau de 12 int
• application de la règle pointeur : *p est un tableau de 12 int ;
• application de la règle tableau : (*p)[12] est un int (ici, comme le contenu des
parenthèses commence par *, on ne peut pas les supprimer) ;
• d’où la déclaration :
int (*p)[12] ;
t est un tableau de 5 pointeurs constants sur des fonctions renvoyant un int
et recevant un float et un long
• application de la règle tableau : t[5] est un pointeur constant sur des
fonctions… ;
• application de la règle pointeur (ici, avec le qualifieur const : * const t[5] est une
fonction renvoyant un int ;
• application de la règle fonction (attention à ne pas omettre les parenthèses ici) :
(* const t[5]) (float, long) est un int ;
• d’où la déclaration :
int (* const t[5]) (float, long) ;
Si on oubliait les parenthèses ici, on aboutirait à une déclaration d’un tableau de
fonction, laquelle serait incorrecte.
signal est une fonction renvoyant un pointeur sur une fonction recevant un
int et sans valeur de retour, et ayant deux arguments : le premier de type int,
le second de type pointeur sur une fonction recevant un int et sans valeur de
retour
Commençons par déterminer le type du second argument de la fonction. Pour
faciliter les choses, donnons-lui un nom, par exemple p :
• affirmation initiale : p est un pointeur sur une fonction recevant un int et sans
valeur de retour ;
• application de la règle pointeur : *p est une fonction recevant un int et sans
valeur de retour ;
• application de la règle fonction : (*p)(int) est de type void.
En définitive, le type de ce second argument s’obtient en privant sa déclaration :
void (*p)(int)
de son identificateur, ce qui conduit à : void (*)(int)
Déterminons maintenant la déclaration de la fonction signal :
• affirmation initiale : signal est une fonction renvoyant un pointeur sur une
fonction recevant un int et sans valeur de retour, et ayant deux arguments : le
premier de type int, le second de type void (*)(int) ;
• application de la règle fonction : signal (int, void(*)(int)) est du type pointeur
sur une fonction recevant un int et sans valeur de retour ;
• application de la règle pointeur : *signal (int, void(*)(int)) est une fonction
recevant un int et sans valeur de retour ;
• application de la règle fonction : (*signal (int, void(*)(int)))(int) est de type void.
D’où finalement la déclaration voulue :
void (*signal (int, void(*)(int)))(int) ;
p est un pointeur vers un tableau de 5 pointeurs sur des fonctions renvoyant
un pointeur vers une fonction recevant un char et renvoyant un float et
recevant deux arguments, le premier de type double, le second de type
pointeur vers une fonction recevant un int et renvoyant un char
Sans entrer dans le détail des règles utilisées, on peut voir que le type d’un
pointeur sur une fonction recevant un int et renvoyant un char est : char (*)(int).
• affirmation initiale : p est un pointeur vers un tableau de 5 pointeurs sur des
fonctions renvoyant un pointeur sur une fonction renvoyant un float et recevant
un char, et recevant deux arguments de type double et char (*)(int) ;
• application de la règle pointeur : *p est un tableau de 5 pointeurs sur des
fonctions renvoyant un pointeur sur une fonction renvoyant un float et recevant
un char, et recevant deux arguments de type double et char (*)(int) ;
• application de la règle tableau : (*p)[5] est un pointeur sur une fonction
renvoyant un pointeur sur une fonction renvoyant un float et recevant un char,
et recevant deux arguments de type double et char (*)(int) ;
• application de la règle pointeur : *(*p)[5] est une fonction renvoyant un pointeur
sur une fonction renvoyant un float et recevant un char, et recevant deux
arguments de type double et char (*)(int) ;
• application de la règle fonction : (*(*p)[5])(double, char(*)(int)) est un pointeur
sur une fonction renvoyant un float et recevant un char ;
• application de la règle pointeur : *(*(*p)[5])(double, char(*)(int)) est une
fonction renvoyant un float et recevant un char ;
• application de la règle fonction : (*(*(*p)[5])(double, char(*)(int)))(char) est un
float.
D’où la déclaration cherchée :
float (*(*(*p)[5])(double, char(*)(int)))(char) ;
1. Cette distinction aura quelque peu tendance à disparaître en C++, compte tenu de l’aspect exécutable de
certaines déclarations.
2. Voici un exemple d’une telle contrainte : la classe de mémorisation static ne peut pas s’appliquer à la
déclaration d’un champ d’une structure.
3. Ici, aucun problème de priorité n’apparaît puisque, à un moment donné, une seule règle s’impose tout
naturellement.
17
Fiabilisation
des lectures au clavier
Dans ce chapitre, nous commencerons par rappeler succinctement les problèmes
que pose scanf pour les lectures au clavier. Nous vous proposerons ensuite une
solution de remplacement fondée sur l’association de deux fonctions fgets et
sscanf, nettement plus adaptée à l’écriture de programme de qualité
professionnelle.
1. Généralités
Lorsque l’unité standard est connectée à un clavier, l’utilisation de scanf pose un
certain nombre de problèmes qui sont liés essentiellement aux points suivants.
• Il y a arrêt prématuré de la lecture en cas de rencontre d’un caractère invalide,
comme cela est expliqué à la section 6.7.2 du chapitre 9. Dans certains cas, on
peut même aboutir à un programme qui boucle sur ce caractère invalide. On en
trouvera un exemple à la section 6.7.3 du chapitre 9.
• La fin de ligne joue un rôle ambigu, tantôt de séparateur, tantôt de véritable
caractère, comme on l’a montré à la section 7.3 du chapitre 9.
• La manière dont le tampon est exploité peut conduire à un manque de
synchronisation entre la lecture au clavier et l’affichage à l’écran et, par suite,
à des confusions de la part de l’utilisateur, comme on l’a vu à la section 6.4.2
du chapitre 9.
Certes, on peut généralement améliorer la situation en examinant la valeur de
retour de scanf. Si le nombre de valeurs lues correctement est insuffisant, on peut
rechercher le prochain caractère de fin de ligne, avant d’effectuer une nouvelle
tentative de lecture. Nous en avons vu des exemples dans certaines des sections
évoquées. Cependant, une telle démarche pose encore un certain nombre de
problèmes :
• en cas d’information insuffisante, il est impossible d’en être prévenu : en effet
scanf attend silencieusement que l’utilisateur frappe une nouvelle ligne ;
• si la lecture s’est déroulée normalement, l’information excédentaire reste
disponible pour une prochaine lecture. Certes, on pourrait envisager de
rechercher la fin de ligne dans tous les cas et pas seulement en cas de lecture
incorrecte. Mais encore faut-il être certain que cette fin de ligne n’a pas déjà
été consommée, comme cela peut se produire notamment avec le code %s ; on
risquerait alors de perdre une ligne d’information…
D’une manière générale, plutôt que de chercher une solution différente à chaque
situation, nous proposons une démarche universelle. Elle consiste à remplacer
une lecture par scanf dans un format donné par deux opérations successives :
1. Lecture d’une chaîne représentant une ligne complète censée correspondre
aux informations à lire.
2. Conversion de cette chaîne en un certain nombre d’informations, en utilisant
le format en question qu’on associe, cette fois, à la fonction sscanf.
Cette démarche repose sur le fait que l’instruction :
sscanf (chaine, format, liste_variables)
effectue sur l’emplacement d’adresse chaine le même travail que scanf effectue sur
son tampon. La différence est qu’ici nous sommes maître de ce tampon ; en
particulier, nous pourrons à volonté provoquer la lecture d’une nouvelle ligne
d’information. Cela nous permettra d’ignorer d’éventuels caractères
excédentaires et de nous affranchir du problème du caractère invalide.
En ce qui concerne la lecture de la ligne d’informations, nous disposons de
plusieurs possibilités :
• utiliser le code format %s dans scanf ;
• utiliser la fonction gets qui lit une chaîne sur l’entrée standard stdin, sans
toutefois permettre d’en limiter la longueur (ce que permet la fonction gets_s
proposée de façon optionnelle par C11) ;
• utiliser la fonction fgets qui lit une chaîne dans un fichier texte quelconque en
limitant sa longueur, en l’appliquant au flux particulier qu’est stdin.
La première n’est manifestement pas du tout adaptée à notre cas, dans la mesure
où elle ne permet pas de lire les séparateurs. Par exemple, il serait impossible de
lire ainsi une ligne contenant simplement deux entiers !
A priori, la troisième méthode semble être la meilleure et la plus générale.
Cependant, nous commencerons par examiner la deuxième qui a le mérite
d’illustrer l’essentiel de la démarche proposée, en particulier la séparation entre
la lecture des informations et leur formatage. Elle peut s’avérer suffisante dans
certaines implémentations et, de surcroît, elle est facile à transposer à gets_s
(C11).
Dans les deux cas, nous illustrerons la démarche par le même exemple : lire
correctement au clavier trois informations, dans cet ordre :
• un entier n ;
• un flottant x ;
• une chaîne ch, d’au plus 30 caractères, supposée ne pas contenir d’espaces
blancs.
Pour bien mettre en évidence l’utilisation ou la non-utilisation des éventuels
caractères excédentaires, nous répéterons ce traitement en choisissant
arbitrairement comme critère d’arrêt le cas où n est nul. Par ailleurs, pour
faciliter la lecture des exemples d’exécution, nous supposerons que la longueur
des lignes est limitée à 40 caractères. Auparavant, pour mettre en évidence
l’intérêt de ces deux méthodes, nous commencerons par un programme
n’utilisant que scanf, sans amélioration d’aucune sorte.
2. Utilisation de scanf
Voici comment nous pourrions répondre à la question posée en utilisant scanf et
en examinant sa valeur de retour :
Un exemple de problème posé par scanf
#include <stdio.h>
int main()
{ int n, n_val_OK ;
float x ;
char mot [31] ;
do /* répétition jusqu'à n = 0 */
{ while (1) /* on s'arrêtera lorsque la réponse sera OK */
{ printf ("donnez un entier, un flottant et une chaine :\n") ;
n_val_OK = scanf ("%d %e %30s", &n, &x, mot) ;
if (n_val_OK == 3) break ; /* réponse OK */
printf ("*** reponse incorrecte ***\n") ;
}
printf ("merci pour l'entier %d, le flottant %e et la chaine %s\n",
n, x, mot) ;
}
while (n != 0 ) ; /* fin répétition jusqu'à n = 0 */
}
donnez un entier, un flottant et une chaine :
12 3.4 salut
merci pour l'entier 12, le flottant 3.400000e+00 et la chaine salut
donnez un entier, un flottant et une chaine :
123 4.5 bonjour 10
merci pour l'entier 123, le flottant 4.500000e+00 et la chaine bonjour
donnez un entier, un flottant et une chaine :
16 7.5 hello
merci pour l'entier 10, le flottant 1.600000e+01 et la chaine 7.5
donnez un entier, un flottant et une chaine :
*** reponse incorrecte ***
donnez un entier, un flottant et une chaine :
*** reponse incorrecte ***
donnez un entier, un flottant et une chaine :
*** reponse incorrecte ***
donnez un entier, un flottant et une chaine :
………… <--- on a dû interrompre le programme
On notera que la seconde réponse comportait simplement une information
excédentaire, de sorte que, lors de la troisième lecture, la chaîne fournie n’a pas
été consommée. Elle s’est trouvée utilisée par le code %d de la lecture suivante. À
partir de là, la lecture est restée systématiquement bloquée sur le premier
caractère de la chaîne hello… Il a fallu interrompre le programme suivant une
démarche qui dépend de l’implémentation.
3. Utilisation de gets
La fonction gets permet effectivement de lire une chaîne quelconque terminée
par un caractère de fin de ligne, ce qui correspond bien à notre objectif. Mais elle
présente le défaut de ne pas limiter le nombre de caractères effectivement pris en
compte. On court donc le risque d’avoir un débordement du tableau chaine.
Cependant :
• certaines implémentations limitent le nombre de caractères d’une ligne lue au
clavier ; il suffit alors d’allouer un emplacement mémoire correspondant à ce
maximum potentiel ;
• la fonction gets_s (facultative en C11) fonctionne comme gets, tout en disposant
d’un argument supplémentaire permettant de limiter le nombre de caractères
introduits en mémoire.
Voici comment nous pourrions utiliser, soit gets dans une implémentation où les
lignes ont une taille limitée à la valeur de la constante LG_LIGNE, soit gets_s dans
une implémentation qui en dispose :
Une première solution avec gets (ou gets_s en C11)
#include <stdio.h>
#define LG_LIGNE 40
int main()
{ int n, n_val_OK ;
float x ;
char ligne [LG_LIGNE+1], mot [31] ;
do /* répétition jusqu'à n=0 */
{ while (1) /* on s'arrêtera lorsque la réponse sera OK */
{ printf ("donnez un entier, un flottant et une chaine :\n") ;
gets (ligne) ; /* ou en C11 : gets_s (ligne, LG_LIGNE) */
n_val_OK = sscanf (ligne, "%d %e %30s", &n, &x, mot) ;
if (n_val_OK == 3) break ; /* réponse OK */
printf ("*** reponse incorrecte ***\n") ;
}
printf ("merci pour l'entier %d, le flottant %e et la chaine %s\n",
n, x, mot) ;
}
while (n != 0) ; /* fin répétition jusqu'à n = 0 */
}
donnez un entier, un flottant et une chaine :
12 3.4 salut
merci pour l'entier 12, le flottant 3.400000e+00 et la chaine salut
donnez un entier, un flottant et une chaine :
123 4.5 bonjour 10
merci pour l'entier 123, le flottant 4.500000e+00 et la chaine bonjour
donnez un entier, un flottant et une chaine :
16 7.5 hello
merci pour l'entier 16, le flottant 7.500000e+00 et la chaine hello
donnez un entier, un flottant et une chaine :
bye
*** reponse incorrecte ***
donnez un entier, un flottant et une chaine :
1 2 3 4 5 6 7 8 9
merci pour l'entier 1, le flottant 2.000000e+00 et la chaine 3
donnez un entier, un flottant et une chaine :
0 0 0
merci pour l'entier 0, le flottant 0.000000e+00 et la chaine 0
Comme on le voit, les caractères non exploités par sscanf ne se retrouvent plus à
la prochaine lecture, ce qui règle du même coup le risque de boucle infinie
rencontré précédemment.
Néanmoins :
• dans les implémentations qui ne limitent pas la longueur des lignes frappées au
clavier, on risque, avec gets, d’introduire des caractères en dehors du tableau
ligne ; même en paramétrant la taille de ligne, on n’obtient pas une solution
totalement portable ;
• la démarche proposée avec gets_s s’avère satisfaisante, mais elle n’est utilisable
que dans certaines implémentations.
Nous allons maintenant voir comment faire appel, de façon portable, à fgets pour
limiter la longueur des lignes lues.
4. Utilisation de fgets
Compte tenu des différences de comportement entre gets et fgets, nous
commencerons par vous montrer comment utiliser fgets pour supprimer le risque
de débordement, tout en aboutissant à une solution portable. Nous verrons
ensuite comment régler définitivement les quelques petits problèmes qui se
posent encore.
4.1 Pour éviter le risque de débordement en mémoire
#include <stdio.h>
#define LG_LIGNE 40
int main()
{ int n, n_val_OK ;
float x ;
char ligne [LG_LIGNE+1], mot [31] ;
do /* répétition jusqu'à n=0 */
{ while (1) /* on s'arrêtera lorsque la réponse sera OK */
{ printf ("donnez un entier, un flottant et une chaine :\n") ;
fgets (ligne, LG_LIGNE, stdin) ;
n_val_OK = sscanf (ligne, "%d %e %30s", &n, &x, mot) ;
if (n_val_OK == 3) break ;
printf ("*** reponse incorrecte ***\n") ;
}
printf ("merci pour l'entier %d, le flottant %e et la chaine %s\n",
n, x, mot) ;
}
while (n != 0) ; /* fin répétition jusqu'à n=0 */
}
donnez un entier, un flottant et une chaine :
12 3.4 salut
merci pour l'entier 12, le flottant 3.400000e+00 et la chaine salut
donnez un entier, un flottant et une chaine :
123 4.5 bonjour 10
merci pour l'entier 123, le flottant 4.500000e+00 et la chaine bonjour
donnez un entier, un flottant et une chaine :
16 7.5 hello
merci pour l'entier 16, le flottant 7.500000e+00 et la chaine hello
donnez un entier, un flottant et une chaine :
1 2 11 22 33 44 55 66 77 88 111 222 333 444 555 666 777 888
merci pour l'entier 1, le flottant 2.000000e+00 et la chaine 11
donnez un entier, un flottant et une chaine :
merci pour l'entier 444, le flottant 5.550000e+02 et la chaine 666
donnez un entier, un flottant et une chaine :
0 0 0
merci pour l'entier 0, le flottant 0.000000e+00 et la chaine 0
Tant que la réponse de l’utilisateur ne dépasse pas 40 caractères, tout se passe
comme avec gets. En revanche, lorsque cette réponse dépasse les 40 caractères,
les caractères situés au-delà du quarantième ne sont plus introduits en mémoire
au-delà de la fin du tableau, comme c’était le cas avec gets. Ces caractères se
retrouvent cependant lors de la prochaine lecture ; c’est le cas de la quatrième
réponse de notre exemple. Certes, le mal n’est pas très grave dans la mesure où,
de toute façon, aucun blocage n’apparaît. Néanmoins, le comportement peut
dérouter l’utilisateur car les caractères superflus (cas de la deuxième réponse)
en-deçà du quarantième sont effectivement ignorés, alors que ceux au-delà ne le
sont plus.
Il est possible de rendre les choses plus naturelles, comme le montre la section
suivante.
4.2 Pour ignorer les caractères excédentaires
Voici comment adapter le programme précédent de façon à régler le problème
des caractères excédentaires évoqués. Rappelons que, avec fgets :
• le caractère de fin de ligne n’est introduit en mémoire que s’il a servi à
interrompre la lecture, avant la rencontre du nombre maximal de caractères ;
• le caractère de fin de chaîne (‘\0') est toujours introduit en mémoire.
Autrement dit, il y a des caractères excédentaires dès lors que le dernier
caractère du tableau ligne est une fin de chaîne (ou ce qui revient au même, que
la longueur de la chaîne figurant dans ligne est égale à LG_LIGNE - 1), alors que
l’avant-dernier caractère n’est pas une fin de ligne.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define LG_LIGNE 40
int main()
{ int n, n_val_OK ;
float x ;
char c ;
char ligne [LG_LIGNE+1], mot [31] ;
do /* répétition jusqu'à
n=0 */
{ while (1) /* on s'arrêtera lorsque la réponse sera
OK */
{ printf ("donnez un entier, un flottant et une chaine :\n") ;
fgets (ligne, LG_LIGNE, stdin) ;
n_val_OK = sscanf (ligne, "%d %e %30s", &n, &x, mot) ;
if (n_val_OK == 3) break ;
printf ("*** reponse incorrecte ***\n") ;
}
printf ("merci pour l\'entier %d, le flottant %e et la chaine %s\n",
n, x, mot) ;
/* traitement du cas où l'utilisateur a fourni une ligne trop longue */
if ((strlen(ligne)==LG_LIGNE-1) && (ligne[LG_LIGNE-2] != ‘\n'))
do c = getchar() ; while (c!= ‘\n') ;
}
while (n != 0) ; /* fin répétition jusqu'à n=0 */
}
donnez un entier, un flottant et une chaine :
12 3.4 salut
merci pour l'entier 12, le flottant 3.400000e+00 et la chaine salut
donnez un entier, un flottant et une chaine :
123 4.5 bonjour 10
merci pour l'entier 123, le flottant 4.500000e+00 et la chaine bonjour
donnez un entier, un flottant et une chaine :
16 7.5 hello
merci pour l'entier 16, le flottant 7.500000e+00 et la chaine hello
donnez un entier, un flottant et une chaine :
1 2 11 22 33 44 55 66 77 88 111 222 333 444 555 666 777 888
merci pour l'entier 1, le flottant 2.000000e+00 et la chaine 11
donnez un entier, un flottant et une chaine :
9 9 bye
merci pour l'entier 9, le flottant 9.000000e+00 et la chaine bye
donnez un entier, un flottant et une chaine :
0 0 0
merci pour l'entier 0, le flottant 0.000000e+00 et la chaine 0
4.3 Pour traiter l’éventuelle fin de fichier et
paramétrer la taille des chaînes lues
Enfin, voici une dernière solution qui apporte encore deux améliorations à la
version précédente, à savoir la prise en compte d’une éventuelle fin de fichier et
la possibilité de paramétrer la taille du tableau mot, c’est-à-dire la taille maximale
des mots qui sont acceptés en lecture.
Rappelons qu’une fin de fichier peut apparaître même dans le cas où l’entrée
standard n’a pas été redirigée vers un fichier. En effet, dans certaines
implémentations, il est possible de « simuler » une telle fin de fichier en frappant
une certaine combinaison de touches. On notera qu’en général, dans ce cas,
aucune lecture clavier n’est possible au sein du programme concerné, de sorte
que la seule possibilité consiste à en interrompre l’exécution.
Pour paramétrer la taille du tableau mot, nous appliquons la technique exposée à
la section 4.7.2 du chapitre 10 pour créer dynamiquement le format utilisé par
sscanf, de façon que le gabarit associé au code %s se déduise de la valeur du
symbole LG_MOT, défini par #define.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define LG_LIGNE 40
#define LG_MOT 30
int main()
{ int n, n_val_OK ;
float x ;
char c ;
char ligne [LG_LIGNE+1], mot [LG_MOT+1] ;
char *ad ;
char format [21] ; /* pour des valeurs de LG_MOT allant jusqu'à 12 chiffres */
do /* répétition jusqu'à n=0 */
{ while (1) /* on s'arrêtera lorsque la réponse sera OK */
{ printf ("donnez un entier, un flottant et une chaine :\n") ;
ad = fgets (ligne, LG_LIGNE, stdin) ;
if (ad==NULL)
{ printf ("*** fin de fichier entree standard - arret programme \n") ;
exit (-1) ;
}
/* préparation dynamique du format utilisé par sscanf */
/* on obtient : "%d %e %xs" où x est la valeur de LG_MOT */
sprintf (format, "%%d %%e %%%ds", LG_MOT) ;
n_val_OK = sscanf (ligne, format, &n, &x, mot) ;
if (n_val_OK == 3) break ;
printf ("*** reponse incorrecte ***\n") ;
}
printf ("merci pour l'entier %d, le flottant %e et la chaine %s\n",
n, x, mot) ;
/* traitement du cas où l'utilisateur a fourni une ligne trop longue */
if ((strlen(ligne)==LG_LIGNE-1) && (ligne[LG_LIGNE-2] != ‘\n'))
do c = getchar() ; while (c!= ‘\n') ;
/* a-t-on rencontré une fin de fichier ? */
if (feof(stdin))
{ printf ("**** fin de fichier entree standard - arret programme \n") ;
exit (-1) ;
}
}
while (n != 0) ; /* fin répétition jusqu'à n=0 */
}
donnez un entier, un flottant et une chaine :
12 3.4 salut
merci pour l'entier 12, le flottant 3.400000e+00 et la chaine salut
donnez un entier, un flottant et une chaine :
123 4.5 bonjour 10
merci pour l'entier 123, le flottant 4.500000e+00 et la chaine bonjour
donnez un entier, un flottant et une chaine :
16 7.5 hello
merci pour l'entier 16, le flottant 7.500000e+00 et la chaine hello
donnez un entier, un flottant et une chaine :
1 2 11 22 33 44 55 66 77 88 111 222 333 444 555 666 777 888
merci pour l'entier 1, le flottant 2.000000e+00 et la chaine 11
donnez un entier, un flottant et une chaine :
9 9 bye
merci pour l'entier 9, le flottant 9.000000e+00 et la chaine bye
donnez un entier, un flottant et une chaine :
0 0 0
merci pour l'entier 0, le flottant 0.000000e+00 et la chaine 0
Remarque
La limitation de ligne et celle de mot emploient des techniques très différentes.
18
Les catégories de caractères
et les fonctions associées
Le langage C permet de définir plusieurs catégories de caractères et la
bibliothèque standard contient des fonctions permettant de tester l’appartenance
d’un caractère donné à l’une de ces catégories. Avant de décrire ces différentes
fonctions, nous commencerons par définir les onze catégories existantes. Nous
verrons que certaines d’entre elles, par exemple la catégorie des chiffres, ont un
caractère relativement intuitif tandis que d’autres pourront dépendre de
l’implémentation ou de la localisation. Enfin, nous verrons qu’il existe des
fonctions particulières de transformation de majuscules en minuscules ou de
minuscules en majuscules.
1. Généralités
1.1 Dépendance de l’implémentation et de la
localisation
Comme on peut s’y attendre, les caractères concernés sont ceux du jeu
d’exécution tel qu’il a été défini à la section 1 du chapitre 2. En plus des
caractères imposés par la norme, ce jeu peut comporter des caractères dépendant
de l’implémentation. Cette latitude se retrouvera dans le contenu de la plupart
des catégories de caractères, puisque seule la catégorie Numérique aura un
contenu totalement universel. En outre, comme on le verra au chapitre 23, la
norme autorise une implémentation à fournir plusieurs localisations, le choix de
l’une d’entre elles se faisant à l’aide de la fonction setlocale. Par défaut, on
dispose d’une localisation standard dite « C ». Dans bon nombre
d’implémentation d’ailleurs, il n’existe que cette localisation standard. On verra
que certaines des catégories de caractères ont des propriétés particulières dans la
localisation standard et ce quelle que soit l’implémentation.
1.2 Les fonctions de test
Pour chacune des onze catégories de caractères, il existe une fonction qui permet
de savoir si un caractère donné appartient ou non à la catégorie. Elles se
présentent toutes sous la forme :
int isxxxxx (int c) (ctype.h)
correspond à une abréviation dépendant du nom de la catégorie, par
xxxxx
exemple cntrl pour la catégorie Contrôle.
L’unique argument c correspond au caractère dont on cherche à tester
l’appartenance à la catégorie. On notera bien qu’il est de type int, alors qu’on
aurait pu s’attendre à signed char ou unsigned char. En fait, la norme précise qu’il
faut utiliser la valeur entière correspondant au type unsigned char. Ainsi, dans une
implémentation où les caractères sont codés sur 8 bits, on utilisera des valeurs
comprises entre 0 et 255 et non entre -128 et 127.
La justification de l’emploi d’un type entier réside dans le fait que la norme
prévoit que toutes ces fonctions puissent être appelées avec un argument égal à
EOF, lequel, par définition, est une valeur négative entière, non représentable dans
le type unsigned char (afin d’être distinguée d’un vrai caractère). La principale
motivation de cette tolérance est de permettre ce genre de construction :
if (isprint (c = getchar ()) …….
Comme on peut s’y attendre, la valeur de retour sera :
• non nulle (vrai) si le caractère appartient à la catégorie concernée ;
• 0 (faux) dans le cas contraire.
2. Les catégories de caractères
Nous vous proposons trois tableaux :
• le premier définit les différentes catégories et leurs propriétés et montre ce qui
peut dépendre de l’implémentation et/ou de la localisation ;
• le deuxième montre les relations ensemblistes existant entre ces catégories dans
le cas le plus répandu de la localisation standard ;
• le dernier fait de même, mais dans le cas d’une localisation quelconque.
Tableau 18.1 : les catégories de caractères et les fonctions de test
Catégorie Fonction Définition et propriétés
isprint
Imprimable Leur affichage occupe une position.
– on y trouve au moins les caractères du
jeu minimal d’exécution (décrit à la
section 1.3 du chapitre 2) autres que les
caractères de contrôle ;
– avec le code ASCII réduit (7 bits), leur
code est compris entre 32 et 126.
iscntrl
Contrôle Tout caractère non imprimable
– on y trouve au moins les caractères de
contrôle du jeu minimal d’exécution
(décrit à la section 1.3 du chapitre 2) : \t,
\v, \f, \a, \b, \r, \n
– avec le code ASCII réduit (7 bits), leur
code est inférieur à 32 ou égal à 127
isgraph
Graphique Tout caractère possédant un graphisme,
donc tout caractère imprimable, excepté
l’espace
isdigit
Numérique Chiffre de 0 à 9
– ne dépend ni de la localisation ni de
l’implémentation
isupper
Majuscule Lettre de A à Z ou, dans les localisations
non standards, éventuel autre caractère
différent de contrôle, numérique,
ponctuation, espace blanc
– dans la localisation standard, on ne
trouve que les lettres A à Z, quelle que soit
l’implémentation
– dans les localisations non standards,
certains des caractères autres que les
lettres de A à Z peuvent être à la fois
majuscule et minuscule
islower
Minuscule Lettre de a à z ou, dans les localisations
non standards, éventuel autre caractère
différent de contrôle, numérique,
ponctuation, espace blanc
– dans la localisation standard, on ne
trouve que les lettres a à z, quelle que soit
l’implémentation
– dans les localisations non standards,
certains des caractères autres que les
lettres de a à z peuvent être à la fois
majuscule et minuscule
isalpha
Alphabétique Majuscule, minuscule ou, dans les
localisations non standards, autre éventuel
caractère différent de contrôle, numérique,
ponctuation, espace blanc
– dans la localisation standard, cette
catégorie correspond à la réunion des
deux catégories majuscule et minuscule
et se limite aux lettres a à z et A à Z
– dans les localisations non standards, on
peut y trouver d’autres caractères qui
soient à la fois majuscule et minuscule,
l’un des deux ou ni l’un ni l’autre
isalnum
Alpha Alphabétique ou numérique
numérique
ispunct
Ponctuation Imprimable différent de alpha numérique
et de espace blanc
– dans la localisation standard, on trouve
au moins les caractères suivants du jeu
minimal d’exécution :
! " % & ‘ ( ) * + , - . / : ; < = > ? _ # [ \
] ^ { | } ~
isspace
Espace blanc Espace blanc (espace, \t, \v, \f, \r, \n) ou,
dans les localisations non standards, autre
éventuel caractère différent de alpha
numérique
isxdigit
Hexadécimal Caractère hexadécimal, c’est-à-dire l’un
des caractères 0 1 2 3 4 5 6 7 8 9 A B C D E F
a b c d e f
– ne dépend, ni de la localisation, ni de
l’implémentation
Tableau 18.2 : relations entre les différentes catégories en localisation
standard
Remarques
1. Par souci de clarté, nous n’avons pas introduit la catégorie Hexadécimal dans ce tableau.
2. Lorsque le contenu d’une catégorie est précisé, c’est qu’il est imposé par la norme.
Tableau 18.3 : relations entre les différentes catégories en localisation non
standard
Remarque
Lorsque l’on utilise la localisation standard « C », la notion de caractère alphabétique recouvre
exactement les 26 lettres majuscules et les 26 lettres minuscules de l’alphabet, les caractères accentués
n’apparaissant pas. Ce sont d’ailleurs les seuls caractères alphabétiques du code ASCII réduit (7 bits).
En revanche, dans certaines implémentations et avec certaines localisations, il est possible de trouver,
dans cette catégorie alphabétique, d’autres caractères n’entrant dans aucune des catégories suivantes :
contrôle, numérique, ponctuation, espace blanc. On notera bien qu’alors il se peut que certains de ces
caractères apparaissent à la fois comme des majuscules et comme des minuscules.
Dans tous les cas, c’est-à-dire quelle que soit la localisation choisie, on peut affirmer qu’est classé
dans la catégorie des caractères alphabétiques tout caractère qui n’appartient à aucune des catégories
suivantes : contrôle, numérique, ponctuation, espace blanc.
3. Exemples
Voici deux exemples de programmes faisant appel aux fonctions de test
d’appartenance de caractères. Le premier fournit, dans une implémentation
donnée, la liste de tous les caractères imprimables et leur code. Le second
fournit, pour chaque code de caractère, les différentes catégories auxquelles il
appartient.
3.1 Pour obtenir la liste de tous les caractères
imprimables et leur code
Pour obtenir la liste de tous les caractères imprimables d’une implémentation
#include <stdio.h>
#include <ctype.h>
#include <limits.h> /* pour CHAR_BIT */
int main()
{
int i ;
int nb_car ; /* nombre de valeurs dans le type char */
/* détermination du nombre de valeurs dans le type char */
Nb__car = 1 ;
for (i=0 ; i<CHAR_BIT ; i++) nb_car *= 2 ;
printf ("nombre de valeurs dans le type char : %d\n", nb_car) ;
/* affichage des caractères imprimables de l'implémentation avec leur code */
printf ("caracteres imprimables (avec leur code) dans votre implementation :\n");
for (i=0 ; i<nb_car ; i++) /* i pourrait être de type unsigned char */
if (isprint(i)) printf (" %c (%3d)", i, i) ;
}
nombre de valeurs dans le type char : 256
voici les caracteres imprimables de votre implementation et leur code
( 32) ! ( 33) " ( 34) # ( 35) $ ( 36) % ( 37) & ( 38) ‘ ( 39) ( ( 40) ) ( 41)
* ( 42) + ( 43) , ( 44) - ( 45) . ( 46) / ( 47) 0 ( 48) 1 ( 49) 2 ( 50) 3 ( 51)
4 ( 52) 5 ( 53) 6 ( 54) 7 ( 55) 8 ( 56) 9 ( 57) : ( 58) ; ( 59) < ( 60) = ( 61)
> ( 62) ? ( 63) @ ( 64) A ( 65) B ( 66) C ( 67) D ( 68) E ( 69) F ( 70) G ( 71)
H ( 72) I ( 73) J ( 74) K ( 75) L ( 76) M ( 77) N ( 78) O ( 79) P ( 80) Q ( 81)
R ( 82) S ( 83) T ( 84) U ( 85) V ( 86) W ( 87) X ( 88) Y ( 89) Z ( 90) [ ( 91)
\ ( 92) ] ( 93) ^ ( 94) _ ( 95) ` ( 96) a ( 97) b ( 98) c ( 99) d (100) e (101)
f (102) g (103) h (104) i (105) j (106) k (107) l (108) m (109) n (110) o (111)
p (112) q (113) r (114) s (115) t (116) u (117) v (118) w (119) x (120) y (121)
z (122) { (123) | (124) } (125) ~ (126)
3.2 Pour connaître les catégories des caractères d’une
implémentation
Nous utilisons ici un tableau de pointeurs sur les différentes fonctions de test
d’appartenance.
Pour déterminer les catégories des caractères d’une implémentation
#include <stdio.h>
#include <ctype.h>
#include <limits.h> /* pour CHAR_BIT */
int main()
{ int i, j ;
int nb_car ; /* nombre de valeurs dans le type char */
int (*f[])(int) = {isalpha, isupper, islower, isdigit, isalnum, isgraph,
isprint, iscntrl, ispunct, isspace, isxdigit } ;
char *noms [] = {"lettre", "maj", "min", "chiffre", "alphanum", "graphique",
"imprimable", "controle", "ponctuation", "esp-blanc", "hexa"} ;
/* détermination du nombre de valeurs dans le type char */
Nb_car = 1 ;
for (i=0 ; i<CHAR_BIT ; i++) nb_car *= 2 ;
printf ("nombre de valeurs dans le type char : %d\n", nb_car) ;
/* détermination des catégories pour chaque caractère */
printf ("liste des codes des caracteres et de leur(s) categorie(s) :\n") ;
for (i=0 ; i<nb_car ; i++) /* boucle sur les caractères */
{ printf ("caractere de code %d", i) ;
for (j=0 ; j<sizeof(noms)/sizeof(noms[0]) ; j++) /*boucle sur les fonctions */
if (f[j](i)) printf (" %s", noms[j]) ;
printf ("\n") ;
}
}
nombre de valeurs dans le type char : 256
liste des codes des caracteres et de leur(s) categorie(s) :
caractere de code 0 controle
caractere de code 1 controle
…..
caractere de code 8 controle
caractere de code 9 controle esp-blanc
caractere de code 10 controle esp-blanc
caractere de code 11 controle esp-blanc
…..
caractere de code 31 controle
caractere de code 32 imprimable esp-blanc
caractere de code 33 graphique imprimable ponctuation
…..
caractere de code 48 chiffre alphanum graphique imprimable hexa
caractere de code 49 chiffre alphanum graphique imprimable hexa
…..
4. Les fonctions de transformation de caractères
Ces fonctions permettent de convertir un caractère en majuscules en son
équivalent en minuscules ou l’inverse. Avec la localisation standard, seules les
26 lettres de l’alphabet sont concernées et la signification des ces fonctions est
évidente. En revanche, avec certaines localisations, comme l’indique la
remarque associée au tableau 18.3, d’autres caractères peuvent être concernés.
Dans ce cas, il n’est plus certain que chaque caractère de la catégorie majuscules
dispose d’un équivalent en minuscules ou l’inverse ; qui plus est, il n’est pas
impossible qu’un même caractère appartienne à la fois aux deux catégories,
voire qu’il soit alors, en quelque sorte, son propre équivalent.
int tolower (int c) (ctype.h)
c
Caractère à convertir
Valeur de Si isupper(c) est vrai, cette fonction fournit le caractère
retour correspondant de la catégorie Minuscule, s’il existe. Dans
tous les autres cas, le résultat est la valeur de c.
int toupper (int c) (ctype.h)
c
Caractère à convertir
Valeur de Si islower(c) est vrai, cette fonction fournit le caractère
retour correspondant de la catégorie Majuscule, s’il existe. Dans
tous les autres cas, le résultat est la valeur de c.
Exemple
Le programme suivant convertit en majuscules les caractères minuscules d’une
chaîne de caractères.
La conversion en majuscules
#include <string.h>
#include <stdio.h>
#include <ctype.h>
int main()
{ char ch[80] ; /* signe !!!!!!!!!!! */
int i ;
printf ("donnez une chaîne de caractères :\n") ;
gets (ch) ;
for (i=0 ; i<strlen(ch) ; i++)
ch[i] = toupper (ch[i]) ;
printf ("votre chaîne, après conversion en majuscules :\n%s\n", ch) ;
donnez une chaîne de caractères :
Chaîne de caractères [Années 2012 à 2013]
votre chaîne, après conversion en majuscules :
CHAîNE DE CARACTèRES [ANNéES 2012 à 2013]
Remarques
1. Ici, nous n’avons pas besoin de tester si un caractère est en minuscules avant de le convertir en
majuscules puisque, dans le cas contraire, on obtient en retour de toupper le caractère lui-même.
2. On constate que, dans l’implémentation concernée, les caractères accentués, écrits en minuscules,
n’ont pas d’équivalent en majuscules. En fait, ces caractères n’entrent même pas dans la catégorie
Minuscule.
19
Gestion des gros programmes
Les possibilités de compilation séparée du langage C s’avèrent précieuses dans
la réalisation de gros programmes car elles permettent notamment de scinder le
développement en parties relativement indépendantes. Cependant, il est
nécessaire de prendre un certain nombre de précautions au niveau de l’utilisation
des variables globales et du partage d’identificateurs communs. Celles-ci sont
récapitulées dans le tableau suivant, avant d’être étudiées en détail dans les
sections indiquées. Par ailleurs, certains conseils formulés à propos de
l’utilisation du préprocesseur s’appliquent tout naturellement aux gros
programmes, avec encore plus d’acuité. Ils apparaissent également dans ce
tableau récapitulatif, bien que le développement correspondant figure dans le
chapitre 15.
Tableau 19.1 : gestion de gros programmes
* Réserver la directive #define : Voir section
– dans le cas des symboles à des 2.8 du
définitions de constantes ; chapitre 15
– dans le cas de macros, à des Voir section
expressions complètes. 4.3 du
Utilisation de * Dans les fichiers en-tête : chapitre 15
fichiers en-tête
– ne pas placer d’instructions
exécutables ;
– se protéger contre les inclusions
multiples ;
– n’utiliser la directive #include qu’à
un niveau global.
Voir section
Avantages : 1
– grande facilité d’utilisation ;
– gain de temps à l’exécution ;
– facilite la communication entre
fonctions appelées à des niveaux
différents ;
– parfois indispensables dans les
environnements qui limitent la
taille de la pile.
Utilisation de Inconvénients :
variables – pas de notion de paramètre ;
globales – risques d’effets de bord.
Compromis conseillé :
– s’il est impossible de s’en passer, se
limiter à des variables globales
cachées dans un fichier source ;
– sinon, essayer de définir un seul
ensemble de variables globales
communes à tous les fichiers
source ; en général, il faut alors
prévoir un fichier en-tête pour leurs
définitions et un fichier en-tête
pour leurs déclarations.
– utiliser un fichier en-tête commun Voir section
Partage protégé contre les inclusions 2
d’identificateurs multiples ;
par plusieurs – dans le cas des variables globales,
fichiers source séparer leur définition de leur
déclaration.
1. Utilisation de variables globales
Si l’on en croit les puristes de la programmation structurée, on peut dire que
l’utilisation des variables globales constitue le second péché capital de la
programmation, le premier étant l’utilisation du goto. Certains programmeurs
vont jusqu’à bannir totalement l’utilisation des variables globales, parce qu’elles
comportent beaucoup plus d’inconvénients que d’avantages. Résumons
brièvement quels sont ces avantages et ces inconvénients, avant d’en tirer
quelques conseils en forme de compromis.
1.1 Avantages des variables globales
Grande facilité d’utilisation
À première vue, la facilité d’utilisation d’une variable globale peut apparaître
comme un avantage. Il faut cependant bien noter qu’il est plus facile d’introduire
une variable globale à laquelle n’importe quelle fonction pourra accéder de
façon incontrôlée, plutôt que de réfléchir à l’organisation de son programme. En
fait, le recours à une variable globale permet manifestement de gagner beaucoup
de temps lors de la conception et/ou de l’écriture d’une première version d’un
programme. En revanche, il en fait généralement perdre beaucoup plus lors de la
mise au point et surtout, lors d’une adaptation ultérieure du programme. En
définitive, cette facilité d’utilisation s’avère effectivement être un avantage pour
les petits programmes ou pour les programmes jetables, c’est-à-dire à faible
durée de vie et qu’on ne cherchera pas à réutiliser. En revanche, elle se
transforme en inconvénient majeur pour les très gros programmes. L’utilisation
judicieuse de variables globales cachées dans un fichier pourra cependant
améliorer considérablement la situation, comme nous le verrons ci-après.
Gain de temps d’exécution
L’échange d’informations par le biais d’arguments nécessite du temps machine
pour la recopie des valeurs correspondantes (même lorsqu’il ne s’agit que d’un
simple pointeur comme dans le cas des tableaux). Ce n’est pas le cas pour les
variables globales.
Facilite la communication entre des fonctions appelées à des niveaux
différents
Le recours à des variables globales peut s’avérer indispensable pour résoudre
certains problèmes d’échange indirect d’information entre des fonctions. Par
exemple, supposons que la fonction f appelle la fonction g qui, elle-même,
appelle la fonction h, alors que la fonction g a déjà été écrite et qu’il n’est plus
question de la modifier. Si h a besoin d’informations non reçues en arguments, la
seule solution consistera à prévoir des variables globales auxquelles accéderont
simultanément les fonctions f et h.
Pallie les limitations de mémoire automatique rencontrées sur certaines
machines
Sur certaines machines, la mémoire allouée de façon automatique à l’ensemble
des variables globales (généralement sous la forme d’une pile) est très limitée,
par rapport à l’ensemble de mémoire disponible. On peut ainsi être amené à
recourir aux variables globales pour échapper à ces limitations. On notera
cependant que cette possibilité doit être mise en concurrence avec l’utilisation de
la gestion dynamique, laquelle n’impose généralement pas de telles restrictions.
1.2 Inconvénients des variables globales
Malgré les différents avantages évoqués précédemment, les variables globales
présentent un certain nombre d’inconvénients qui, pour peu nombreux qu’ils
soient, sont néanmoins majeurs.
Trop grande facilité d’utilisation
Comme nous l’avons déjà évoqué à la section précédente, la trop grande facilité
d’utilisation des variables globales se transforme en un inconvénient majeur dans
le cas des gros programmes.
Plus de notion de paramètre
L’usage de variables globales fait disparaître l’aspect paramétrique (arguments)
d’une fonction. Ainsi, supposons qu’une fonction echange ait été écrite pour
échanger les valeurs de deux variables globales a et b. Pour pouvoir échanger les
valeurs de deux autres variables globales c et d, il faudrait tout d’abord les
recopier dans a et b, avant d’appeler f, puis recopier a et b dans c et d. Cela
perdrait manifestement beaucoup d’intérêt.
Risques d’effets de bord
Par leur nature même, les variables globales sont accessibles à différentes
fonctions d’un même programme. Dans le meilleur des cas, si elles sont cachées
dans un fichier source, elles ne sont accessibles qu’aux fonctions de ce fichier
source. Dans le cas contraire, elles sont accessibles à toutes les fonctions du
programme. Dans ces conditions, on voit que les variables globales sont
fortement sujettes à des modifications non désirées ou non prévues qu’on
nomme des « effets de bord ».
1.3 Conseils en forme de compromis
En fait, l’expérience montre clairement que si le programmeur C ne peut se
passer totalement de variables globales, il doit chercher à en faire un usage
modéré et contrôlé en aboutissant à un compromis dépendant de la nature du
projet concerné.
Dans un programme de petite taille ou dans un programme jetable, il n’y a guère
de raisons d’interdire l’usage des variables globales.
Au fur et à mesure que l’importance du programme s’accroît, il est nécessaire
d’affiner la conception et de bien cerner les situations dans lesquelles les
variables globales s’imposent vraiment. On peut organiser le découpage de son
programme, voire le partage des tâches de programmation entre plusieurs
équipes, en se basant sur les possibilités de variables globales cachées dans un
fichier. Dans ce cas, seules les fonctions d’un même fichier partagent les
variables globales qui y sont définies, de sorte que le développement des
fonctions des autres fichiers peut se faire, sans connaissance de ces dernières. On
peut ainsi créer un niveau intermédiaire entre la variable locale à une fonction et
la variable globale à tout un programme. On peut dire qu’on a affaire à une sorte
de variable locale à un fichier source. On notera d’ailleurs que, bien exploitées,
ces possibilités permettent de mettre en œuvre en C le principe d’encapsulation
tel qu’il apparaît en Programmation Orientée Objets.
Si l’on autorise l’utilisation d’une même variable globale dans différents fichiers
source, les dépendances qui en découlent peuvent devenir très difficiles à gérer
lors du développement, et surtout lors de l’adaptation ultérieure du produit. On
conseille, dans les gros projets, de ne recourir à de telles extrémités que dans des
circonstances exceptionnelles. Il sera généralement préférable de définir, pour
tout le programme, un seul ensemble de variables globales communes à tous les
fichiers source et de séparer leur définition de leur déclaration comme cela est
expliqué à la section 2.3.
2. Partage d’identificateurs entre plusieurs fichiers
source
Dès lors qu’on découpe un programme en plusieurs fichiers source, se posent
des problèmes de partage d’identificateurs entre ces différents fichiers. Nous
allons les examiner en considérant séparément le cas des fonctions, celui des
types et celui des variables globales.
2.1 Cas des identificateurs de fonctions
Définitions
Il est rare que la définition d’une même fonction soit fournie dans plusieurs
fichiers source d’un même programme. Si cela se produit, on aboutit de toute
façon à un diagnostic lors de l’édition de liens et ce, même si l’on a défini une
fonction de nom donné avec des arguments de type différents.
Déclarations
Une fonction peut être déclarée plusieurs fois, soit avec une portée locale, soit
avec une portée globale, à condition que les différentes déclarations soient
compatibles entre elles (voir section 4.4 du chapitre 8). En pratique, dans le cas
des gros programmes, on aura intérêt à chercher à éviter d’éventuelles erreurs
dans la recopie des prototypes des fonctions concernées. Pour ce faire, la
démarche la plus raisonnable consiste à créer des fichiers en-tête analogues à
ceux de la bibliothèque standard et contenant les déclarations voulues.
On peut penser à associer un fichier en-tête à chaque fichier source. Cette
démarche n’est cependant intéressante que si les fonctions d’un fichier source
donné ne sont pas utilisées par d’autres fichiers source. En effet, dans le cas
contraire, une même déclaration devra apparaître dans plusieurs fichiers source
et on ne s’affranchira pas totalement des risques d’erreurs évoqués
précédemment.
En général, il est préférable de regrouper dans un même fichier en-tête les
déclarations de fonctions ayant un intérêt commun ; dans certains cas, on peut
être amené à ne créer qu’un seul fichier de cette sorte pour l’ensemble des
fichiers source. Certes, on risque alors d’être amené à introduire dans un fichier
source des déclarations inutiles. Cela n’a aucune importance puisque :
• une fonction déclarée peut très bien ne pas être utilisée ;
• le code correspondant ne sera pas introduit dans le programme exécutable ; en
effet, c’est l’appel d’une fonction et non pas sa déclaration qui demande à
l’éditeur de liens d’incorporer le code correspondant ;
• c’est bien ce qui se produit, la plupart du temps, avec les fichiers en-tête
standards.
On aura toujours intérêt à introduire ces fichiers en-tête à un niveau global,
comme on le fait, sans trop y penser d’ailleurs, pour les fichiers en-tête
standards. En effet, si, comme cela est conseillé, on a protégé un tel fichier
contre les inclusions multiples (voir section 4.3.2 du chapitre 15), on risque de
« rater » certaines déclarations. C’est ce que montre l’exemple suivant, dans
lequel on suppose protos.h protégé contre les inclusions multiples :
Exemple de mauvaise utilisation de fichiers en-tête
int main()
{
#include "protos.h" /* déclarations de fonctions, introduites à un niveau */
….. /* local à la fonction main */
}
void fct (…)
{
#include "protos.h" /* protos.h a déjà été inclus dans ce fichier source par */
….. /* le préprocesseur ; s'il est protégé contre les */
/* inclusions multiples, il ne sera pas inclus à nouveau */
/* les fonctions correspondantes seront simplement */
/* considérées comme renvoyant un int */
}
Bien entendu, cette remarque s’applique également aux fichiers en-tête
standards.
2.2 Cas des identificateurs de types ou de synonymes
Cela concerne les déclarations commençant par l’un des mots-clés struct, union,
enum ou typedef. Cette fois, il n’y a plus de distinction entre définition et
déclaration. On parle parfois de « déclaration définition ».
Dans un fichier source donné, on ne peut trouver qu’une seule instruction de
déclaration globale définissant un type structure, union ou énumération ou
synonyme donné. Les identificateurs ainsi définis à un niveau global ont, comme
les autres identificateurs globaux, une portée s’étendant au fichier source. Mais
ils ne sont pas utilisables dans un autre fichier source. Certes, on pourrait
envisager d’en recopier la déclaration, avec tous les risques d’erreur que cela
comporte. Mais il est préférable de les regrouper dans un fichier en-tête qu’on
inclut dans chaque fichier source où cela est nécessaire. Là encore, comme pour
les déclarations de fonctions, il est conseillé de prévoir ces inclusions à un
niveau global. La remarque effectuée dans la section précédente à propos de la
protection contre les inclusions multiples s’applique encore à ces identificateurs.
Cependant, les risques sont moins importants puisque, dans ce cas, une absence
de déclaration conduira obligatoirement à une erreur de compilation, ce qui
n’était pas le cas avec les fonctions.
Bien entendu, il est possible de prévoir des fichiers en-tête différents pour les
prototypes de fonctions et pour les définitions de type. Dans ce cas, on pourrait
alors théoriquement envisager des inclusions locales des fichiers contenant les
définitions de type, à condition alors de ne plus les protéger contre les inclusions
multiples. Nous déconseillons toutefois cette façon de faire, car l’expérience
montre qu’elle déroute généralement le programmeur qui se trouve en présence
de règles d’inclusion différentes suivant la nature du fichier en-tête concerné.
2.3 Cas des variables globales
Ici, la distinction entre définition et redéclaration est fondamentale (voir section
8.2 du chapitre 8).
Théoriquement, une variable globale définie dans un fichier source peut être
utilisée dans un autre fichier source. Cependant, comme on l’a dit à la section
1.3, cette situation devient déconseillée dans le cas de projets importants. S’il est
malgré tout nécessaire d’en arriver là, il est recommandé de regrouper toutes les
définitions de variables globales dans un même fichier en-tête nommé ici
globdef.h.
Mais il n’est pas possible d’inclure ce fichier globdef.h dans chaque fichier source
concerné, puisque cela conduirait à des définitions multiples d’une même
variable globale dans différents fichiers source. En pratique, cette situation
conduit à un diagnostic de l’éditeur de liens.
Ici, il faudra donc s’arranger pour que :
• Ce fichier globdef.h ne soit inclus que dans un seul fichier source. En général, ce
sera celui qui contiendra la fonction main.
• Toutes les variables globales soient redéclarées dans chaque fichier source où
cela s’avérera nécessaire. Pour ce faire, le plus simple consiste à créer
systématiquement un fichier en-tête, nommé par exemple globdec.h, contenant
toutes ces redéclarations et à l’inclure dans tous les fichiers source concernés.
On notera qu’ici il est indifférent de protéger ou non ce fichier contre les
inclusions multiples puisque cette technique ne concerne qu’un fichier source
à la fois.
Il est possible que les définitions de variables globales fassent appel à des
définitions de types. Dans la section précédente, nous vous avons suggéré de les
placer dans un fichier en-tête. Ici, on pourrait penser à fusionner ce fichier et le
fichier globdef.h ; encore faudrait-il s’arranger pour que ces déclarations soient
connues dans globdec.h. Une autre solution, plus intuitive, consiste à conserver un
fichier en-tête séparé pour les définitions de type (on le suppose ici nommé
types.h) et à l’inclure partout où il est nécessaire, c’est-à-dire en particulier dans
globdef.h et dans globdec.h. En voici un exemple :
Lorsque plusieurs fichiers source doivent absolument partager des variables
globales
Fichier types.h
/* définition des types structure, union, énumération et synonymes communs à */
/* tous les fichiers source - ce fichier en-tête est protégé ici, */
/* par précaution, contre les inclusions multiples (voir remarque ci-après */
#ifndef TYPES_H
#define TYPES_H
struct enr { ….. } ;
enum couleur { rouge, vert, bleu, … } ;
…..
#endif
Fichier globdef.h
#include "types.h"
/* définition des variables globales communes à tous les fichiers source */
int g_x = 5 ;
float g_tab [100] ;
long g_coef[10][20] ;
struct enr s1 ;
enum couleur c = rouge ;
Fichier globdec.h
#include "types.h"
/* déclaration des variables globales communes à tous les fichiers source */
extern int g_x ;
extern float g_tab [100] ; /* ou g_tab[] */
extern long g_coef[10][20] ; /* ou g_coef [][20] */
extern struct enr s1 ;
extern enum couleur c ;
Fichier principal
#include "globdef.h" /* on pourrait éventuellement faire, en plus : */
/* #include "globdec.h" à condition que types.h soit */
/* protégé contre les inclusions multiples */
int main()
{ ….. }
…..
Autres fichiers source
#include "globdec.h" /* il ne faut surtout pas ajouter #include "globdef.h" */
….. /* sous peine de doubles définitions de variables */
/* (détectées généralement à l'édition de liens) */
Remarque
Dans les différents fichiers source concernés, l’inclusion de globdef.h ou de globdec.h provoque
l’inclusion de types.h, de sorte qu’il n’est pas nécessaire d’introduire une directive :
#include "types.h"
Cette situation n’est pas nécessairement évidente pour l’auteur d’un programme qui risque fort de
juger utile d’introduire une telle directive et, partant, d’aboutir à une double inclusion du fichier. Dans
ces conditions, on évitera tout problème en protégeant simplement types.h comme nous l’avons fait
dans l’exemple.
20
Les arguments variables
Le langage C permet d’écrire des fonctions qui, comme scanf ou printf, reçoivent
des arguments dont le nombre et le type sont susceptibles de varier d’un appel à
un autre. Ce chapitre montre comment y parvenir en utilisant les macros
standards va_start, va_arg et va_end, ainsi que le type prédéfini va_list qui
correspond précisément à une liste d’arguments de nombre et de type
quelconques.
Nous verrons ensuite comment une telle fonction dite « à arguments variables »
peut à son tour transmettre à une autre fonction les arguments qu’elle a ainsi
reçus, en utilisant simplement un argument de type va_list. Enfin, nous
étudierons les trois fonctions standards vprintf, vfprintf et vsprintf qui, en recevant
des arguments de type va_list, peuvent précisément être appelées depuis une
fonction à arguments variables.
1. Écriture de fonctions à arguments variables
La bibliothèque standard fournit des macros qui permettent de réaliser des
fonctions à arguments variables. Par leur nature même, elles ne sont utilisables
que si la fonction en question dispose d’au moins un argument dit fixe, c’est-à-
dire de type donné et toujours présent. Compte tenu du caractère relativement
artificiel du mécanisme proposé, nous commencerons par examiner un exemple
introductif.
1.1 Exemple introductif
Voici un premier exemple de fonction à arguments variables, dans lequel les
deux premiers arguments sont fixes, l’un étant de type int, l’autre de type char.
Les arguments suivants sont en nombre quelconque, mais on suppose ici qu’ils
sont tous de type int et qu’il en existe toujours au moins un. Par ailleurs, on
convient que le dernier vaudra -1, cette valeur servant en quelque sorte de
« sentinelle »1. Ici, par souci de simplicité, nous nous contenterons, dans la
fonction, de lister les valeurs des différents arguments (fixes ou variables) ainsi
reçus, à l’exception du dernier.
Arguments en nombre variable délimités par une sentinelle
#include <stdio.h>
#include <stdarg.h>
void essai (int par1, char par2, …) /* … signifient arguments variables */
{ va_list adpar ;
int parv ;
printf ("premier parametre : %d\n", par1) ;
printf ("second parametre : %c\n", par2) ;
va_start (adpar, par2) ; /* positionne adpar sur le premier argument variable
*/
while ( (parv = va_arg (adpar, int) ) != -1) /* récupère l'argument
courant */
printf ("argument variable : %d\n", parv) ;
va_end (adpar) ;
}
int main()
{ printf ("premier essai\n") ;
essai (125, ‘a', 15, 30, 40, -1) ;
printf ("\ndeuxieme essai\n") ;
essai (6264, ‘S', -1) ;
}
premier essai
premier parametre : 125
second parametre : a
argument variable : 15
argument variable : 30
argument variable : 40
deuxieme essai
premier parametre : 6264
second parametre : S
Dans l’en-tête de essai :
void essai (int par1, char par2, …)
les deux arguments fixes sont déclarés de manière classique. Les trois points (…)
spécifient au compilateur l’existence d’arguments variables. L’instruction :
va_list adpar ;
déclare une variable adpar du type prédéfini va_list servant à représenter une liste
d’arguments variables. Cette variable nous servira à récupérer, les uns après les
autres, les différents arguments variables. Bien que la norme ne soit pas explicite
sur le type correspondant à va_list, on peut considérer qu’il s’agit d’une sorte de
pointeur qui permettra de parcourir les différents arguments d’une liste variable.
Comme à l’accoutumée, une telle déclaration n’attribue aucune valeur à adpar.
C’est effectivement la fonction va_start qui va permettre de lui faire désigner le
premier argument variable, à partir de la connaissance du nom du dernier
paramètre fixe.
Le rôle de la fonction va_arg est double :
• d’une part, elle fournit comme résultat la valeur trouvée à l’emplacement
désigné par adpar (son premier argument), suivant le type indiqué par son
second argument (ici int) ;
• d’autre part, elle modifie la valeur de la variable adpar, de manière qu’elle
désigne l’argument variable suivant.
Ici, une instruction while nous permet de récupérer les différents arguments
variables, sachant que le dernier a pour valeur -1.
Enfin, la norme ANSI prévoit que la macro va_end doit être appelée avant de
sortir de la fonction concernée.
1.2 Arguments variables, forme d’en-tête et
déclaration
Les arguments variables n’existaient pas dans la première version du langage C,
de sorte qu’aucun formalisme n’a été prévu pour les déclarer avec la forme
ancienne d’en-tête. La norme ANSI les a introduits, en se contentant de prévoir
le formalisme correspondant dans la forme moderne de l’en-tête. L’utilisation
d’arguments variables impose donc obligatoirement la forme moderne d’en-tête,
laquelle, rappelons-le, constitue la forme conseillée.
En revanche, en ce qui concerne la déclaration d’une fonction à arguments
variables, on peut théoriquement utiliser indifféremment la forme complète ou la
forme partielle, voire l’absence de déclaration si la fonction renvoie un int.
Naturellement, la forme complète reste toujours conseillée.
En fait, le choix de la forme de la déclaration n’a d’incidence que sur les
arguments fixes, lesquels seront soumis aux conversions induites par le
prototype avec la forme complète, tandis qu’ils seront soumis aux promotions
numériques concernant les arguments de type inconnu avec la forme incomplète.
En revanche, quel que soit l’en-tête utilisé, les arguments variables sont de type
inconnu pour le compilateur. Ils seront donc soumis aux conversions prévues
dans un tel cas, à savoir les promotions numériques usuelles (char et short en int)
auxquelles s’ajoutent la promotion numérique de float en double.
Exemples
void f1 (short, …) ; /* déclaration complète de f1 */
short p ; char c ; float x ;
…..
f1 (p, c, p+2) ; /* p n'est pas converti ; c est converti en int ; */
/* p+2 est converti en int */
f1 (c, x) ; /* c est converti en short ; x est converti en double */
void f2 () ; /* déclaration partielle de f2 */
short p ; char c ; float x ;
…..
f2 (p, c, p+2) ; /* p est converti en int ; c est converti en int ; */
/* p+2 est converti en int */
f2 (c, x) ; /* c est converti en int ; x est converti en double */
Remarque
Si une fonction a pour en-tête :
void fct (int, float, …)
le compilateur ne détectera aucune erreur si vous la déclarez de l’une des façons suivantes dans un
fichier source différent :
void fct (int, …) ;
void fct (int, float, double, …) ;
Le comportement du programme n’est alors théoriquement pas défini en cas d’appel de fct. En
pratique, on aboutira à l’une des situations suivantes :
• utilisation de valeurs fantaisistes dans fct, si on l’appelle avec moins d’arguments que prévu ;
notamment, avec la première déclaration, l’appel fct (n) sera accepté, alors que f récupérera au
moins une valeur fantaisiste pour son deuxième argument fixe ;
• conversions d’argument dans un type peut-être différent de celui attendu par la fonction.
En toute rigueur, ces anomalies sont de même nature que celles auxquelles on risque d’aboutir
lorsqu’on utilise la déclaration incomplète d’une fonction.
1.3 Contraintes imposées par la norme
La norme impose quelques contraintes dans l’utilisation des macros de gestion
des arguments variables.
1.3.1 Appel de va_end
Dès lors qu’une fonction à arguments variables a appelé la macro va_start, elle
doit appeler la macro va_end avant de s’achever. Dans le cas contraire, le
comportement du programme est indéterminé. La justification de cette contrainte
vient de ce qu’elle permet à l’implémentation de gérer convenablement les
passages d’arguments entre fonctions.
Par ailleurs, comme on peut s’y attendre, il faut éviter d’appeler va_end sans avoir
préalablement appelé va_start sous peine, là encore, de comportement
indéterminé.
1.3.2 Argument fournis à va_start
La norme précise que l’argument mentionné comme second paramètre de la
macro va_start doit être le dernier des arguments fixes. Par exemple, avec :
void fct (int par1, float par2, double par3, …)
{ va_list adpar ;
…..
seul l’appel suivant est correct :
va_start (adpar, par3) ;
Les appels suivants sont théoriquement incorrects :
va_start (adpar, par2) ; /* théoriquement incorrect */
va_start (adpar, par1) ; /* théoriquement incorrect */
Toutefois, la norme ne précise pas quel doit être le comportement du programme
dans ce cas. En pratique, les choses fonctionnent comme on s’y attend : tout se
passe comme si les paramètres fixes suivant celui indiqué à va_start faisaient
partie de la liste d’arguments variables.
1.4 Syntaxe et rôle des macros va_start, va_arg et
va_end
Ces macros ne s’utilisent pas entièrement comme des fonctions et la notion de
prototype n’aurait donc pas vraiment de signification. C’est pourquoi nous
donnons ici exceptionnellement ce que nous nommons une description
syntaxique, dont le seul but est de montrer la manière dont ces macros doivent
théoriquement être utilisées. Si l’on en fait un usage différent, les conséquences
seront celles inhérentes à un mauvais usage d’une macro. Elles sont cependant
difficiles à appréhender d’une manière générale, dans la mesure où la définition
exacte de ces macros dépend de l’implémentation.
void va_start (va_list params, identificateur) (stdarg.h)
params
Liste d’arguments variables
identificateur
Nom du dernier argument fixe
Cette macro initialise le parcourt de la liste d’argument variables params, sachant
que identificateur est le nom du dernier argument muet fixe. Celui-ci ne doit pas
être de classe registre, ni de type fonction, sous peine d’aboutir à un
comportement indéterminé.
type_v va_arg (va_list params, type_v) (stdarg.h)
params
Liste d’arguments variables, convenablement initialisée par
va_start et éventuellement modifiée par de précédents appels
à va_arg
type_v
Nom de type (quelconque) correspondant au type attendu
pour l’argument variable courant
Valeur de Valeur de type type_v correspondant à la valeur de
retour l’argument variable courant
Cette macro fournit la valeur de l’argument courant, supposé de type type_v, de la
liste d’arguments variables params, et lui fait désigner l’argument suivant. Le
comportement du programme est indéterminé s’il n’y a plus d’arguments
disponibles ou si type_v ne correspond pas au type de l’argument variable reçu.
void va_end (va_list params) (stdarg.h)
params
Liste d’arguments variables, convenablement initialisée par
va_start et éventuellement modifiée par de précédents appels
à va_arg
Cette macro permet surtout à l’implémentation de gérer convenablement les
échanges d’arguments correspondants à une liste variable. Le comportement du
programme est indéterminé si elle est appelée sur une liste d’arguments variables
params non initialisée par va_start.
2. Transmission d’une liste variable
Considérons les deux fonctions à arguments variables suivantes :
void fct1 (int n, …) ;
int fct2 (double x, int p, …) ;
Supposons que l’on ait besoin, dans fct1, d’appeler fct2 en lui transmettant les
arguments variables reçus par fct1 (correspondants aux points de suspension). En
général, il ne sera pas possible d’expliciter ces arguments comme on le fait dans
l’appel de fct1. En revanche, la norme autorise qu’on transmette en argument un
objet de type va_list. La fonction fct1 peut donc transmettre à fct2 sa liste
d’arguments variables, à condition :
• de modifier l’en-tête de fct2 pour qu’elle dispose d’un argument de type va_list,
à la place d’une liste variable ;
• d’initialiser convenablement la valeur de l’argument effectif fourni à fct2 lors
de son appel, en utilisant va_start.
Cela nous conduit au schéma suivant :
void fct1 (int n, …)
{ int fct2 (double, int, va_list) ; /* déclaration de fct2 */
int p ; double x ;
va_list params ;
va_start (params, n) ;
…..
fct2 (x, p, params) ;
…..
}
int fct2 (double x, int p, va_list params)
{ /* ici, on peut parcourir la liste params par des appels appropriés de va_arg */
}
D’une manière générale, l’objet params peut très bien voir sa valeur modifiée dans
fct1 par un ou plusieurs appels de va_arg, avant d’être transmis en argument
effectif à fct2. Cela raccourcit simplement la liste variable reçue par fct2.
De plus, comme l’objet params reçu par fct2 est déjà initialisé, il n’est plus
nécessaire de recourir à va_start dans cette fonction ; d’ailleurs, un tel appel
n’aurait aucun sens puisque fct2 n’est plus une fonction à arguments variables.
Remarque
Une fonction à arguments variables, c’est-à-dire disposant de points de suspension (…) dans son en-
tête, ne peut pas être appelée avec un argument effectif de type va_list (à moins qu’un tel type
n’apparaisse dans ses arguments fixes). Réciproquement, une fonction possédant un argument de type
va_list doit obligatoirement être appelée avec un argument effectif de ce type.
Exemple
Voici un exemple simple de programme montrant l’emploi d’arguments de type
va_list :
Exemple d’utilisation d’arguments de type va_list
#include <stdio.h>
#include <stdarg.h>
int main()
{ void f1 (int, …) ;
int n=3 ;
int t[3] = {12, 25, 32 } ;
f1(n, t[0], t[1], t[2]) ;
}
void f1 (int n, …)
{ void f2 (int, va_list) ;
void f3 (va_list) ;
int p ;
va_list params ;
va_start(params, n) ;
f2(n, params) ;
f3(params) ;
p = va_arg(params, int) ; /* pour sauter un paramètre */
n = 2 ; f2(n, params) ;
va_end(params) ;
}
void f2 (int n, va_list params)
{ int i, p ;
for (i=0 ; i<n ; i++)
{ p = va_arg(params, int) ;
printf ("f2 - param variable : %d\n", p) ;
}
}
void f3 (va_list params)
{ int i, p ;
for (i=0 ; i<3 ; i++)
{ p = va_arg(params, int) ;
printf ("f3 - param variable : %d\n", p) ;
}
}
f2 - param variable : 12
f2 - param variable : 25
f2 - param variable : 32
f3 - param variable : 12
f3 - param variable : 25
f3 - param variable : 32
f2 - param variable : 25
f2 - param variable : 32
3. Les fonctions vprintf, vfprintf et vsprintf
La section précédente a montré comment transmettre un objet de type va_list en
argument. Dans la bibliothèque standard, il existe une fonction vprintf, voisine de
printf, qui remplace la liste variable (…) par un argument de type va_list :
int printf (char *format, …)
int vprintf (char *format, va_list args)
L’intérêt de vprintf, par rapport à printf, est de pouvoir être appelée à partir d’une
fonction à arguments variables. Voici un cas d’école qui montre comment écrire
une fonction nommée trace qui affiche :
• un message fixe : --- trace ---
• les valeurs d’arguments quelconques suivant un format donné.
Exemple d'utilisation de vprintf
#include <stdio.h>
#include <stdarg.h>
int main()
{ void trace (char *format, …) ;
int n=12 ;
float x=1.25 ;
trace ("bonjour\n") ;
trace ("n = %d, x=%f\n", n, x) ;
n = 25 ;
trace ("n = %d\n", n) ;
}
void trace (char *format, …)
{ va_list params ;
va_start (params, format) ;
printf ("--- trace ---") ;
vprintf (format, params) ;
va_end (params) ;
}
--- trace ---bonjour
--- trace ---n = 12, x=1.250000
--- trace ---n = 25
D’une manière générale, il existe deux autres fonctions comparables à vprintf :
• vfprintf qui correspond à fprintf ;
• fsprintf qui correspond à sprintf.
1. À la section 2, on trouvera un exemple utilisant non plus une sentinelle, mais un paramètre
supplémentaire précisant le nombre d’arguments variables.
21
Communication
avec l’environnement
La plupart du temps, un programme ne s’exécute pas de manière totalement
autonome, mais sous le contrôle d’un ou de plusieurs autres programmes qu’on
regroupe sous le terme générique « environnement ». Ce chapitre étudie les
outils proposés par la norme pour assurer une (légère) communication entre le
programme et cet environnement.
Nous commencerons par montrer qu’il existe une norme restreinte destinée au
cas très particulier des programmes autonomes qui, précisément, ne seront pas
concernés par les outils évoqués. Puis, nous étudierons le mécanisme nommé
généralement « arguments de la ligne de commande » qui permet de fournir des
informations à la fonction main au moment de son lancement. Nous verrons
ensuite comment un programme peut transmettre une information à son
environnement au moment où il se termine. Puis, nous examinerons le cadre
général offert par la norme pour permettre à un programme de connaître
certaines informations relatives à son environnement (getenv) ou pour effectuer ce
que l’on nomme souvent un « appel système » (system).
Enfin, nous verrons comment les possibilités dites de « traitement de signaux »
permettent à une implémentation de mettre en place un mécanisme de gestion
des exceptions et des interruptions.
1. Cas particulier des programmes autonomes
La plupart des programmes s’exécutent sous contrôle d’un environnement.
Souvent, cette notion correspond à celle de système d’exploitation, mais elle
peut prendre un caractère plus général dans le cas d’outils intégrés qui peuvent
alors jouer le rôle d’intermédiaire entre le programme et le système.
L’environnement se trouve alors formé de la réunion de ces outils et du système
d’exploitation.
Quoi qu’il en soit, le système d’exploitation s’avère indispensable dans bon
nombre de circonstances. Les plus flagrantes sont manifestement les entrées-
sorties conversationnelles et la gestion des fichiers et, sans système
d’exploitation, on ne disposerait pas de fonctions standards aussi banales que
printf ou scanf.
Néanmoins, pour tenir compte de l’existence de machines très spécialisées
comme les calculateurs embarqués sur satellite, la norme a prévu une version
restreinte correspondant à ce qu’elle nomme des « programmes autonomes ».
Dans cette norme restreinte, on ne trouve aucune définition (de symbole ou
macro), ni aucune fonction de la bibliothèque standard, à l’exception de ce qui
correspond aux quelques fichiers en-tête suivants :
• float.h et limits.h ;
• stdarg.h (macros pour l’utilisation des fonctions à arguments variables) ;
• stddef (quelques types et synonymes).
La fonction principale, c’est-à-dire celle qui s’exécute en premier lieu, peut
porter un nom autre que main.
Tout ce qui sera dit dans les sections suivantes concernera la norme intégrale et
non pas cette norme restreinte.
2. Les arguments reçus par la fonction main
La norme prévoit qu’un programme exécuté sous contrôle d’un environnement
débute par la fonction nommée main. Celle-ci peut disposer d’arguments lui
permettant de recueillir des informations en provenance de l’environnement.
C’est ce que nous allons examiner ici.
2.1 L’en-tête de la fonction main
Contrairement à ce qui se passe pour toutes les autres fonctions, la fonction main
ne dispose pas véritablement de prototype et elle n’a pas à être déclarée. Son en-
tête ne peut toutefois s’écrire que sous l’une des deux formes suivantes, la
seconde étant indispensable lorsqu’on souhaite récupérer les informations
fournies par l’environnement :
int main (void) /* forme usuelle */
int main (int nbarg, char *argv[]) ;
On rencontre souvent des formes différentes, théoriquement hors norme, mais
acceptées par toutes les implémentations, quitte à provoquer un message
d’avertissement (warning) :
int main() /* théoriquement hors norme, mais généralement accepté, */
/* sans message de compilation */
/* c'est la forme la plus répandue */
main() /* théoriquement hors norme, mais généralement accepté, */
/* avec message d'avertissement */
2.2 Récupération des arguments reçus par la fonction
main
2.2.1 Généralités
Si la fonction main possède l’en-tête approprié, l’environnement lui transmet, au
moment du lancement du programme, un certain nombre d’informations sous
forme de chaînes de caractères. La norme ne fournit aucune précision quant à
l’origine des ces informations ou à leur nature, exception faite du nom du
programme lui-même. En fait, la plupart des environnements permettent à
l’utilisateur de fournir des informations au moment du lancement du programme.
Dans le cas d’un programme lancé par une ligne de commande, ces informations
proviendront souvent de paramètres mentionnés à la suite du nom du
programme.
2.2.2 Récupération des arguments
Les informations communiquées par l’environnement à la fonction main sont
toujours des chaînes de caractères. Elles lui sont transmises sous la forme d’un
tableau de pointeurs sur ces différentes chaînes. Cela permet de limiter à deux le
nombre des arguments de main, dont l’en-tête se présente alors ainsi :
int main (int nbarg, char *argv[])
nbarg
Nombre d’informations de Positif ou nul
type chaîne
argv
Adresse d’un tableau de doit être le nom du
argv[0]
nbarg pointeurs sur des programme ou une chaîne
chaînes vide.
On constate que la norme n’impose pas à l’environnement de communiquer quoi
que ce soit, puisqu’il est permis que la valeur de nbarg soit nulle, ce qui revient à
dire que la fonction main ne reçoit aucune information. En revanche, dès qu’il y a
au moins une information transmise, la première doit correspondre au nom du
programme. Toutefois, l’implémentation peut se contenter de fournir une chaîne
vide.
Remarques
1. Les valeurs de nbarg et de argv ne sont pas considérées comme constantes et elles sont modifiables
comme des arguments véritables. Il en va de même pour les chaînes pointées par argv, bien que ce
genre de choses soit déconseillé.
2. Dans les environnements qui en offrent la possibilité, la redirection des entrées-sorties standards se
fait souvent en introduisant des paramètres appropriés dans la ligne de commande. En général, ces
paramètres sont interceptés par l’environnement et ne sont pas retransmis à la fonction main.
Exemple
Voici un exemple de programme qui récupère les informations transmises par
l’environnement. Il est accompagné de trois exemples d’exécution dans une
implémentation où le lancement du programme se faisait par une ligne de
commande que nous avons alors mentionnée en gras, avant le résultat
correspondant.
Exemple de programme récupérant les arguments de la ligne de commande
#include <stdio.h>
int main(int nbarg, char * argv[])
{ int i ;
printf ("mon nom de programme est : %s\n", argv[0]) ;
if (nbarg>1) for (i=1 ; i<nbarg ; i++)
printf ("argument numero %d : %s\n", i, argv[i]) ;
else printf ("pas d'arguments\n") ;
}
LIGCOM
mon nom de programme est : LIGCOM
pas d'arguments
LIGCOM parametre
mon nom de programme est : LIGCOM
argument numero 1 : parametre
LIGCOM [Link] sortie 25CX9
mon nom de programme est : LIGCOM
argument numero 1 : [Link]
argument numero 2 : sortie
argument numero 3 : 25CX9
3. Terminaison d’un programme
Un programme peut être interrompu d’office par le système d’exploitation en cas
de rencontre d’une situation dite d’exception, par exemple : tentative
d’exécution d’une instruction inexistante, adresse invalide, division par zéro…
On parle dans ce cas de fin anormale ou prématurée.
En dehors de ces situations d’exception, un programme s’achève d’une façon
dite normale, à la rencontre de l’une des situations suivantes :
• appel, depuis n’importe quelle partie du programme, de la fonction standard
exit ;
• rencontre d’une instruction return dans la fonction main ;
• fin naturelle de la fonction main.
Par ailleurs, le programme peut lui-même provoquer une fin anormale en
appelant la fonction abort.
3.1 Les fonctions exit et atexit
L’appel de la fonction exit provoque la fermeture de tous les fichiers encore
ouverts. Les fichiers éventuellement créés par la fonction tmpfile sont détruits1.
Puis, la fonction met fin à l’exécution du programme et elle transmet à
l’environnement la valeur de son unique argument entier qui représente un état
de fin. La norme n’impose rien sur l’usage qui pourra être fait ultérieurement de
cette valeur. En revanche, elle prévoit que, quelle que soit l’implémentation, il
existe deux valeurs prédéfinies (dans stdlib.h) permettant de distinguer deux
sortes de fins normales :
EXIT_SUCCESS /* fin avec succès (généralement 0) */
EXIT_FAILURE /* fin avec échec (généralement non nul) */
Rien n’empêche une implémentation de définir des valeurs supplémentaires
permettant d’affiner cet indicateur de fin. On ne confondra pas une fin normale
avec échec avec une fin anormale, provoquée non plus par appel de exit, mais
par une exception ou un appel de la fonction abort.
Par ailleurs, la fonction atexit permet d’enregistrer les noms de fonctions de son
choix qui seront appelées par atexit, avant l’arrêt de l’exécution. Ces fonctions
seront appelées dans l’ordre inverse de leur enregistrement, avant la fermeture
des fichiers encore ouverts et la destruction des fichiers temporaires (créés par
tmpfile). Les appels de atexit peuvent avoir lieu de n’importe quel point du
programme et à tout moment. Comme on peut s’y attendre, la norme précise que
exit ne doit pas être appelée plus d’une fois par un programme, sous peine
d’aboutir à un comportement indéterminé. Cela signifie qu’il faut éviter de
transmettre l’adresse de exit à atexit, ce qui va de soi !
void exit (int etat) (stdlib.h)
etat
Valeur qui sera transmise à l’environnement pour indiquer la
manière dont le programme s’est achevé.
int atexit (void (*fct)(void)) (stdlib.h)
fct
Fonction sans argument et sans valeur de retour dont
l’adresse sera mémorisée en vue d’être exécutée lors d’une
fin normale (que ce soit par exit, return dans le main ou par fin
naturelle de la fonction main).
Valeur de 0 en cas de déroulement normal, différente de 0, sinon
retour
3.2 L’instruction return dans la fonction main
Rappelons que l’instruction return peut comporter une expression mais que cela
ne constitue pas une obligation, même lorsque l’en-tête de la fonction concernée
prévoit une valeur de retour. Dans le cas de la fonction main, il en va de même et
l’expression en question, lorsqu’elle est présente, doit être d’un type numérique
(sa valeur sera convertie en int).
L’instruction :
return (expression) ;
est équivalente à :
exit (expression) ;
Autrement dit, c’est la valeur de expression qui sera transmise à l’environnement.
Il est donc possible d’employer l’une de ces deux formes :
return (EXIT_SUCCESS) ; /* dans main : fin normale avec succès */
return (EXIT_FAILURE) ; /* dans main : fin normale avec échec */
Quant à l’instruction :
return ;
elle est équivalente à un appel de exit, avec une valeur non précisée par la norme.
Enfin, la fin naturelle de la fonction main est équivalente à l’instruction return
précédente (sans expression).
On notera que dans tous ces cas de fin normale, il y aura bien appel des
différentes fonctions enregistrées par atexit, fermeture des fichiers encore
ouverts et destruction des fichiers temporaires créés par tmpfile.
4. Communication avec l’environnement
La norme propose, sous forme de fonctions standards, deux outils permettant à
un programme :
• de recueillir certaines informations relatives à l’état de l’environnement
(fonction getenv) ;
• d’envoyer une commande à l’environnement (fonction system).
4.1 La fonction getenv
La norme considère que, à un instant donné, l’état de l’environnement est défini,
en totalité ou en partie, par un certain nombre de paramètres de type chaînes de
caractères. Chaque paramètre est lui-même repéré par un nom prédéfini, c’est-à-
dire également une chaîne de caractère. Les noms des paramètres dépendent de
l’implémentation. Il en va de même pour la façon d’agir sur les valeurs de ces
paramètres. En particulier, rien ne dit qu’il existe un lien entre les paramètres
accessibles par getenv et les actions qui peuvent être effectuées par la fonction
system.
La fonction getenv permet donc de connaître l’adresse d’une chaîne
correspondant à la valeur d’un paramètre de nom donné dont on fournit
l’adresse2.
La fonction getenv
char *getenv (const char *ad_nom_par) (stdlib.h)
ad_nom_par
Adresse d’une chaîne de caractère représentant le nom du
paramètre d’environnement dont on souhaite connaître la
valeur.
Valeur de Adresse d’une chaîne de caractères correspondant à la
retour valeur du paramètre d’environnement requis s’il existe, la
valeur NULL sinon
La valeur située à l’adresse reçue en retour ne doit pas être modifiée par le
programme. En revanche, elle peut l’être par l’environnement, suite à un nouvel
appel de getenv. Cela signifie tout simplement que l’implémentation n’est pas
obligée d’effectuer une copie de la valeur du paramètre.
4.2 La fonction system
Cette fonction permet de transmettre à l’environnement une commande
exprimée sous la forme d’une chaîne de caractères dont on lui fournit l’adresse.
Les commandes utilisables ainsi que leur effet dépendent de l’implémentation. Il
se peut qu’aucune commande ne soit accessible. On peut le savoir par un appel
de la fonction system avec une adresse nulle.
La fonction system
int system (const char *ad_commande) (stdlib.h)
ad_commande
Adresse d’une chaîne de caractère représentant la
commande à transmettre à l’environnement, éventuellement
valeur NULL
Valeur de – si ad_commande est nul, la valeur de retour est non nulle si,
retour dans l’environnement, il existe au moins une commande
accessible à la fonction system, sinon elle est nulle ;
– si ad_commande est non nulle, la valeur de retour dépend de
l’implémentation.
5. Les signaux
5.1 Généralités
La norme propose un cadre assez flou permettant à une implémentation qui le
désire de mettre en place un mécanisme de gestion des exceptions et des
interruptions. Il s’agit de ce qu’elle nomme les possibilités de transmission de
signaux. Un signal est repéré par un numéro entier et il est émis (on dit aussi
déclenché ou levé) par un programme ou par un mécanisme. Il provoque un
certain traitement, c’est-à-dire le déroulement d’un certain nombre
d’instructions.
Un signal peut être émis de l’une des manières suivantes :
• par l’environnement, notamment par le mécanisme de détection d’erreurs de la
machine ;
• par le programme lui-même, par un appel de la fonction standard raise ;
• par une action asynchrone (externe au programme), par exemple une
interruption provoquée par un matériel spécialisé.
Pour chaque signal prévu par l’implémentation, il existe un traitement par
défaut. Mais il est possible de demander qu’un traitement particulier soit
effectué, en faisant appel à la fonction signal, à laquelle on fournit le numéro du
signal et l’adresse d’une fonction de son choix. On peut aussi recourir à cette
fonction pour demander qu’un signal soit ignoré. Enfin, la fonction signal peut
très bien être appelée à plusieurs reprises pour un même signal, afin d’en
modifier le traitement, voire de revenir à un traitement par défaut.
La norme prévoit un certain nombre de valeurs prédéfinies de signaux,
correspondant à des situations d’exception données, par exemple la tentative de
division par zéro. Malheureusement, elle n’impose pas à l’environnement de
déclencher le signal correspondant lorsque ladite exception est détectée. En
revanche, ce signal pourra toujours être émis par la fonction raise (bien qu’une
telle action n’ait pas toujours de signification pour certains des signaux
prédéfinis).
5.2 Exemple introductif
Voici un exemple simple dans lequel nous montrons comment, au sein d’un
même programme, nous pouvons choisir le traitement à associer à un signal de
numéro SIGUSR1, à savoir :
• tout d’abord, l’appel d’une fonction nommée ici fsig1 ;
• ensuite, le traitement prévu par défaut.
Notez que si la norme n’impose pas l’existence du symbole SIGUSR1, bon nombre
d’implémentations le propose, accompagné généralement de quelques autres
(SIGUSR2, SIGUSR3…), ce qui permet de déclencher et de traiter des signaux réservés à
l’usage du programmeur.
Par ailleurs, nous provoquons volontairement une division par zéro, ce qui, dans
notre implémentation, déclenchait effectivement le signal de numéro SIGFPE
(Floating Point Exception), et ce bien que la norme ne l’impose pas.
Comme on le verra à la section 5.3.5, la norme ne garantit pas qu’on puisse
appeler une fonction standard dans une fonction de réponse à un signal. Notre
implémentation l’acceptait cependant (du moins dans ce cas), ce qui nous a
permis d’illustrer facilement le déroulement du programme par des appels à
printf. Si l’on souhaitait rester portable, il faudrait recourir à des variables
globales.
Exemple de traitement de signaux
#include <stdio.h>
#include <signal.h> /* pour signal et raise */
#include <stdlib.h> /* pour exit */
int main()
{ void f1(void) ; void f2(void) ;
void fsig1(int) ; void fdiv0 (int) ;
signal (SIGUSR1, fsig1) ; /* si signal SIGUSR1 --> appel de fsig1 */
printf ("*** appel 1 de f1\n") ;
f1() ;
printf ("*** appel 2 de f1\n") ;
f1() ; /* on n''a pas réinstallé la fonction */
/* de traitement de signal SIGUSR1 */
signal (SIGUSR1, SIG_IGN) ; /* si signal SIGUSR1 --> ignore */
printf ("*** appel 3 de f1\n") ;
f1() ;
signal (SIGFPE, fdiv0) ; /* si signal SIGFPE --> appel de fdiv0 */
printf ("*** appel de f2\n") ;
f2() ;
}
void f1 (void)
{ raise (SIGUSR1) ; /* on déclenche le signal SIGUSR1 */
}
void f2 (void)
{ double x=1, y=0, z ;
z = x/y ; /* on provoque une division par zéro, */
} /* ce qui, ici, déclenche le signal SIGFPE */
void fsig1 (int n)
{ printf ("--- appel de fsig1, n = %d\n", n) ;
}
void fdiv0 (int n)
{ printf ("--- appel de fdiv0, n = %d\n", n) ;
exit (EXIT_FAILURE) ;
}
*** appel 1 de f1
--- appel de fsig1, n = 16
*** appel 2 de f1
*** appel 3 de f1
*** appel de f2
--- appel de fdiv0, n = 8
Comme on s’y attendait, le premier déclenchement (dans f1) du signal SIGUSR1
provoque l’appel de la fonction fsig1. En revanche, il n’en va plus de même pour
le déclenchement suivant du même signal. Cela provient de ce que la norme
prévoit que dès qu’on a traité un signal par une fonction de son choix, les
déclenchements ultérieurs de ce même signal ne seront plus « vus » par le
programme. Pour parvenir à traiter à nouveau le signal, il suffit en fait de prévoir
un appel approprié de la fonction signal, à l’intérieur de la fonction de traitement
de signal elle-même. Par exemple, en remplaçant la définition précédente de
fsig1 par celle-ci :
void fsig1 (int n)
{ printf ("--- appel de fsig1, n = %d\n", n) ;
signal (n, fsig1) ; /* le prochain signal de numéro n */
} /* entraînera l'appel de fsig1 */
l’exécution de notre programme se présenterait ainsi :
*** appel 1 de f1
--- appel de fsig1, n = 16
*** appel 2 de f1
--- appel de fsig1, n = 16
*** appel 3 de f1
*** appel de f2
--- appel de fdiv0, n = 8
5.3 La fonction signal
5.3.1 Prototype et rôle
void (*signal (int numsig, void (*fsig)(int))) (int) (signal.h)
numsig
Numéro du signal concerné – la liste des numéros
autorisés est imposée par
l’implémentation ;
– il existe des numéros
prédéfinis (voir section
4.3.3).
fsig
Traitement à associer au
signal :
– soit valeur prédéfinie :
SIG_DFL, SIG_IGN ;
– soit fonction recevant un
int et sans valeur de
retour.
Valeur de – SIG_ERR en cas d’erreur ; Voir section 5.3.2
retour
– la valeur de fsig relative
au dernier appel de signal
pour le signal de numéro
numsig (ou SIG_DFL si signal
n’a jamais été appelée
pour le numéro numsig).
La fonction signal permet donc d’associer un traitement donné à un signal de
numéro donné. Ce traitement est spécifié sous la forme d’une fonction de
prototype :
void fct (int) ;
où l’unique argument représente le numéro du signal. Il peut s’avérer intéressant
lorsqu’on affecte une même fonction au traitement de différents signaux.
Il existe deux valeurs prédéfinies du type voulu :
• SIG_DFL : le traitement par défaut sera associé au signal ;
• SIG_IGN : le signal sera ignoré.
Lorsqu’on spécifie effectivement une fonction de traitement de signal,
l’implémentation peut, auparavant, effectuer un traitement de son choix qui peut,
éventuellement, correspondre au traitement par défaut.
Remarque
Lorsqu’une fonction de traitement de signal se termine naturellement (return ou atteinte de sa
dernière instruction), il y a retour à l’instruction du programme qui a été interrompue par le signal
concerné. C’est ce qu’on attend et c’est ce qui se passait dans nos précédents exemples. Bien entendu,
il n’en ira plus de même en cas d’appel, dans la fonction de traitement de signal, de l’une des fonctions
exit, abort ou longjmp (cette dernière est étudiée au chapitre 26).
5.3.2 Valeur de retour
La fonction signal fournit la valeur prédéfinie SIG_ERR en cas d’erreur. C’est ce qui
se produit généralement avec un numéro de signal non prévu par
l’implémentation. En cas de succès, elle fournit l’adresse de la fonction qui était
prévue auparavant pour le traitement du signal concerné. Il peut s’agir de SIG_DFL
ou de SIG_IGN. Cette particularité peut s’avérer intéressante lorsqu’on souhaite
modifier temporairement la fonction de traitement d’un signal et revenir
ultérieurement au traitement initial, comme dans cet exemple :
void (*tr_init)(int) ;
…..
tr_init = signal (num, fsig) ; /* le signal num sera traité par fsig */
/* tr_init reçoit l'adresse de l'ancien traitement */
…..
signal (num, tr_init) ; /* on revient pour le signal num au traitement initial */
5.3.3 Les numéros de signaux prédéfinis
Une implémentation doit définir les numéros de signaux suivants, avec la
signification correspondante :
Tableau 21.1 : les numéros de signaux prédéfinis
Numéro Signification
SIGABRT
Fin anormale (éventuellement par appel de abort)
SIGFPE
Opération arithmétique incorrecte (division par zéro,
dépassement de capacité…)
SIGILL
Instruction invalide
SIGINT
Réception d’un signal interactif
SIGSEGV
Accès mémoire invalide
SIGTERM
Demande d’arrêt envoyée au programme
Rappelons que, bien que les six valeurs indiquées doivent toujours être définies,
l’implémentation n’est nullement obligée de générer de signal lorsque
l’événement correspondant se produit.
Lors du démarrage d’un programme, le traitement des signaux correspondants
doit être prévu sous l’une des deux formes SIG_IGN ou SIG_DFL, comme si l’on avait
appelé la fonction signal avec l’une de ces deux valeurs pour tous les numéros de
signaux permis.
5.3.4 Contraintes
1. La fonction de traitement d’un signal peut se terminer par return, abort, exit ou
longjmp. Une exception a lieu pour le signal SIGFPE, ainsi que pour tout signal
correspondant à une erreur de calcul. Dans ce cas, le comportement de la
fonction de traitement est indéterminé si elle s’achève par return. Notez que
nous avions tenu compte de cette remarque dans l’exemple de la section 5.2,
puisque nous appelions la fonction exit dans le traitement du signal de
numéro SIGFPE.
2. Lorsqu’un signal a été déclenché par abort (il porte alors le numéro SIGABRT) ou
par raise, la fonction de traitement a un comportement indéterminé si elle
réalise un appel d’une fonction standard. La seule exception acceptée dans ce
cas est un appel à la fonction signal elle-même, à condition qu’on lui
retransmette le numéro de signal reçu en argument.
3. La norme précise que, après le déclenchement d’un signal de numéro num,
l’implémentation commence :
– soit par exécuter l’appel signal (num, SIG_DFL) ;
– soit par mettre en place un mécanisme de blocage des prochains
déclenchements du même signal.
Dans ces conditions, on voit qu’il est souvent préférable de réinstaller
systématiquement la fonction de traitement de signal ; le plus simple est alors
de le faire dans la fonction de traitement elle-même, comme nous l’avons
suggéré dans l’exemple de la section 5.2.
5.4 La fonction raise
int raise (int numsig) (signal.h )
numsig
Numéro du signal à émettre
Valeur de Zéro en cas de succès, non nulle en cas d’erreur
retour
Cette fonction provoque l’émission du signal de numéro numsig.
1. Ces actions ne sont pas nécessairement réalisées en cas de fin anormale.
2. Dans certaines implémentations, il existe une fonction putenv qui joue le rôle inverse de getenv.
22
Les caractères étendus
Généralement, le type char s’avère suffisant pour représenter les différents
caractères utilisés dans une implémentation. Cependant, dans certains pays
comme les pays asiatiques, le nombre de combinaisons offertes par le type char
s’avère notoirement insuffisant. C’est pourquoi la norme laisse toute liberté à
une implémentation pour définir un ou plusieurs jeux de caractères
supplémentaires, dits « jeux de caractères étendus ». À un instant donné, on ne
peut utiliser qu’un seul jeu de caractères étendus, en plus du jeu standard
correspondant au type char. Le choix se fait par la fonction setlocale (catégorie
LC_TYPE) décrite au chapitre 23.
Les caractères d’un jeu étendu sont codés dans un type entier existant défini dans
stddef.h par un synonyme de nom wchar_t. Contrairement à ce qui se produit pour
le type char, toutes les valeurs du type wchar_t ne correspondent pas
nécessairement à un caractère étendu valide.
Pour faciliter l’utilisation d’un jeu de caractères étendus, la norme a prévu qu’à
chaque valeur valide de type wchar_t corresponde une représentation textuelle
nommée « caractère multioctet ». Il s’agit tout simplement d’une suite d’un ou
plusieurs caractères du jeu standard. En définitive, un même caractère étendu
peut se trouver représenté de deux façons différentes : d’une part par son code
interne, de type wchar_t, d’autre part par un caractère multioctet.
Dans ce chapitre, nous commencerons par apporter quelques précisions
concernant le type wchar_t et les contraintes qui sont imposées aux caractères
multioctets. Nous verrons ensuite comment s’écrivent les constantes
correspondantes. Puis, nous présenterons les fonctions permettant d’effectuer les
conversions entre les deux types de représentation d’un caractère étendu, c’est-à-
dire entre code interne et caractère multioctet.
Nous étudierons ensuite ce que sont les chaînes de caractères étendus qui
généralisent simplement la notion de chaîne de caractères au type wchar_t. Elles
disposeront, là encore, de deux représentations qui découleront directement des
deux représentations des caractères étendus. Nous verrons comment écrire les
constantes chaîne correspondantes puis nous présenterons les fonctions
permettant d’effectuer les conversions entre les deux représentations.
1. Le type wchar_t et les caractères multioctets
La norme impose donc à une implémentation de définir, dans stddef.h, un
synonyme wchar_t correspondant à un type entier existant1. Il doit permettre de
représenter n’importe quel caractère des différents jeux de caractères étendus
dont on peut éventuellement disposer dans l’implémentation. Ce type wchar_t doit
être défini même si l’implémentation ne dispose d’aucun jeu de caractères
étendus.
À chaque valeur de type wchar_t représentant un caractère étendu, doit
correspondre une suite unique d’octets, dite caractère multioctet. Le nombre
d’octets peut varier d’un caractère étendu à un autre. Mais l’implémentation doit
en fournir une valeur maximale dans la constante MB_LEN_MAX définie dans limits.h.
Cette valeur ne dépend pas du jeu de caractères étendus utilisé à un instant
donné, ce qui revient à dire qu’elle représente la taille maximale des différents
caractères multioctets correspondant aux différents jeux de caractères étendus
utilisables. Par ailleurs, dans stdlib.h, on trouve une constante MB_CUR_MAX
fournissant le nombre maximal d’octets d’un caractère multioctet du jeu de
caractères étendus en vigueur à un instant donné.
Dans le type wchar_t, les caractères du jeu minimal d’exécution devront posséder
la même valeur2 que celle correspondant à une constante de type char (laquelle
est en fait de type int). Le caractère multioctet correspondant à un caractère du
jeu standard devra être formé d’un seul octet ayant le même code que dans le
type char.
Dans l’interprétation d’un caractère multioctet, l’implémentation reste libre
d’utiliser un mécanisme basé sur des changements d’états. Dans ce cas, il doit
exister un état initial dans lequel tous les caractères du jeu standard conservent
leur signification habituelle ; en revanche, certains caractères peuvent, selon le
contexte, faire passer dans un autre état dans lequel il n’est plus nécessaire que la
contrainte précédente soit satisfaite. Autrement dit, dans un état autre que l’état
initial, un caractère appartenant au jeu minimal d’exécution peut posséder une
signification différente de celle qu’il a dans le type char. Cependant, pour que le
traitement des caractères multioctets ne soit pas trop complexe, la norme impose
que :
• le caractère de code nul possède toujours la même signification, quel que soit
l’état ;
• le caractère de code nul n’apparaisse jamais couplé à d’autres dans un caractère
multioctet ; autrement dit, un caractère multioctet de plus d’un caractère ne
peut jamais comporter de caractère de code nul.
Ces contraintes permettent notamment de reconnaître la fin d’une chaîne de
caractères multioctet, sans qu’il soit nécessaire d’en interpréter un à un les
différents caractères.
2. Notation des constantes du type wchar_t
Par analogie avec les constantes de type char qui se notaient sous la forme ‘x', les
constantes de type wchar_t se notent sous la forme :
L'xxx'
dans laquelle xxx désigne une suite de caractères usuels représentant le caractère
multioctet correspondant.
Chacun de ces caractères usuels peut apparaître sous l’une des trois formes
suivantes :
• imprimable (si elle existe), comme dans :
wchar_t ce1 = L'a4g' ; /* caractère multioctet comportant 3 caractères : */
/* a, 4 et g */
• échappatoire, comme dans :
wchar_t ce2 = L'e\n' /* caractère multioctet comportant 2 caractères : */
/* e et \n */
• octale ou hexadécimale ; on n’oubliera pas que la notation octale est limitée à 3
chiffres, alors que la notation hexadécimale n’est théoriquement3 pas limitée ;
voici un exemple :
wchar_t ce3 = L'p\7t\x4fza' /* caractère multioctet comportant 6 caractères : */
/* p \7 t \x4f z et a */
Par ailleurs, comme le type wchar_t est un type entier, il est toujours possible
d’utiliser un entier de valeur convenable pour représenter directement le code
interne d’un caractère étendu, comme dans :
wchar_t ce4 = 2548 ; /* caractère étendu de code interne 2548 */
/* (à condition qu'il existe) */
Remarques
1. Le type d’une constante de la forme L'xxx' est wchar_t (type entier), alors que celui d’une
constante de la forme ‘x' était int et non char. Voyez ces initialisations :
char c = ‘a' ; /* conversion de ‘a' de type int en char */
wchar_t ce = L'abc' /* aucune conversion : L'abc' est déjà de type wchar_t */
2. De même que, en théorie, une constante caractère pouvait contenir plusieurs caractères, une
constante de type wchar_t peut contenir plusieurs caractères multioctets. Là encore, le résultat
dépendra de l’implémentation.
3. Les caractères multioctets sont utilisables dans les formats des fonctions de la famille de printf.
3. Les fonctions liées aux caractères étendus mblen,
mbtowc et wctomb
3.1 Généralités
On a vu qu’un même caractère étendu peut se coder de deux manières :
• suivant son code interne de type wchar_t ;
• sous forme d’un caractère multioctet, c’est-à-dire d’une suite d’un ou plusieurs
octets.
Pour tenir compte de cette dualité, la norme propose des fonctions de
conversion :
• d’un caractère multioctet en une valeur de type wchar_t ; il s’agit de la fonction
mbtowc (MultiByte To Wide Character) ;
• d’un caractère étendu en un caractère multioctet : il s’agit de la fonction wctomb
(Wide Character To MultiByte).
En outre, il existe une fonction mblen qui permet de déterminer le nombre d’octets
d’un caractère multioctet.
Par ailleurs, la norme impose une certaine cohérence à la représentation des
caractères étendus sous forme de caractères multioctets. En effet, il est
nécessaire que, à partir d’un caractère donné d’une suite de caractères
quelconques, on ne puisse former au maximum qu’un seul caractère multioctet,
quelle que soit sa longueur. Cette contrainte est indispensable pour qu’un texte
donné puisse être interprété sans ambiguïté comme une suite de caractères
multioctets.
Enfin, si l’implémentation fait intervenir un mécanisme de changement d’état
dans l’interprétation des caractères multioctets, cet état peut être affecté par ces
trois fonctions. Généralement, il est utile de provoquer un retour à l’état initial.
Cela peut s’obtenir, de façon quelque peu artificielle, en appelant l’une des trois
fonctions avec une adresse nulle. La valeur de retour permet alors de savoir si
l’implémentation fait ou non intervenir un mécanisme de changement d’état.
3.2 La fonction mblen
Le rôle principal de cette fonction est de déterminer le nombre d’octets à prendre
en compte, à partir de l’adresse ad_cmo, afin d’obtenir un caractère multioctet
valide. On limite l’étendue de la recherche en fournissant un nombre maximal
d’octets à considérer. Accessoirement, cette fonction permet aussi de savoir si le
codage des caractères multioctets fait ou non intervenir un mécanisme de
changement d’état et, le cas échéant, de redonner à ce dernier sa valeur initiale.
int mblen (const char *ad_cmo, size_t nb_oct) (stdlib.h)
ad_cmo
Adresse du premier octet à considérer
nb_oct
Nombre maximal d’octets à considérer
Valeur de – si ad_cmo non nul : nombre d’octets (inférieur ou égal à
retour nb_oct) formant, à partir de ad_cmo un caractère multioctet
valide, ou -1 si ce n’est pas le cas ; on obtient la valeur 0
si ad_cmo pointe sur un caractère de code nul ;
– si ad_cmo est nul : valeur différente de 0 si le codage des
caractères multioctets fait intervenir un mécanisme de
changement d’état qui se trouve alors replacé dans l’état
initial, 0 sinon.
3.3 La fonction mbtowc
Le rôle principal de cette fonction est de déterminer le nombre d’octets
constituant un caractère multioctet et de le convertir en une valeur de type
wchar_t. On limite l’étendue de la recherche en fournissant un nombre maximal
d’octets à prendre en compte mais, de toute façon, on n’en considérera jamais
plus de MB_CUR_MAX. Comme cette fonction doit fournir deux informations, la
première (longueur) l’est en valeur de retour, la seconde est placée à une adresse
fournie en argument. On peut éventuellement n’obtenir que l’information de
longueur en fournissant une adresse nulle comme adresse.
Accessoirement, cette fonction permet aussi de savoir si le codage des caractères
multioctets fait ou non intervenir un mécanisme de changement d’état et, le cas
échéant, de redonner à ce dernier sa valeur initiale.
int mbtowc (wchar_t *ad_car_et, const char *ad_cmo, size_t
nb_oct) (stdlib.h)
ad_car_et
Adresse à laquelle sera rangé le résultat de la conversion du
caractère multioctet valide débutant en ad_cmo, s’il existe et si
ad_cmo et ad_car_et sont tous deux non nuls.
ad_cmo
Adresse du premier octet à considérer
nb_oct
Nombre maximal d’octets à considérer
Valeur de – si ad_cmo non nul : nombre d’octets (inférieur ou égal à
retour nb_car) formant, à partir de ad_cmo un caractère multioctet
valide, ou -1 si ce n’est pas le cas ; fournit la valeur 0 si
ad_cmo pointe sur une chaîne vide (ou, ce qui revient au
même, sur un caractère de code nul) ;
– si ad_cmo est nul : valeur différente de 0 si le codage des
caractères multioctets fait intervenir un mécanisme de
changement d’état qui se trouve alors replacé dans l’état
initial, 0 sinon.
3.4 La fonction wctomb
Le rôle principal de cette fonction est de déterminer le nombre d’octets à utiliser
pour représenter le caractère multioctet correspondant à un caractère étendu
donné et de fournir la suite d’octets correspondante. Là encore, comme cette
fonction doit fournir deux informations, la première (longueur) l’est en valeur de
retour (elle ne sera jamais supérieure à MB_CUR_MAX), la seconde est placée à une
adresse fournie en argument.
Accessoirement, cette fonction permet aussi de savoir si le codage des caractères
multioctets fait ou non intervenir un mécanisme de changement d’état et, le cas
échéant, de redonner à ce dernier sa valeur initiale.
int wctomb (char *ad_cmo, wchar_t car_et) (stdlib.h)
ad_cmo
Adresse du premier octet où sera rangé le caractère
multioctet résultant de la conversion.
car_et
Caractère étendu à convertir
Valeur de – si ad_cmo non nul : nombre d’octets nécessaires à la
retour représentation sous forme d’un caractère multioctet du
caractère étendu figurant dans car_et si celui-ci est valide,
– sinon ;si ad_cmo est nul, valeur différente de 0 si le codage
des caractères multioctets fait intervenir un mécanisme de
changement d’état qui se trouve alors replacé dans l’état
initial, 0 sinon.
4. Les chaînes de caractères étendus
Par convention, une chaîne de caractères usuels est formée de la suite de
caractères s’étendant d’une adresse de début (de type char *) jusqu’au premier
caractère de code nul non compris. Cette convention est élargie à des chaînes de
caractères étendus, en tenant compte des deux façons de représenter de tels
caractères. On peut donc trouver :
• une chaîne de valeurs de type wchar_t, formée de la suite de valeurs s’étendant
d’une adresse donnée (de type wchar_t *) jusqu’à la première valeur (de type
wchar_t) nulle non comprise ;
• une chaîne de caractères multioctets, c’est-à-dire finalement une chaîne usuelle
s’étendant d’un caractère multioctet d’adresse donnée (il s’agit en fait de
l’adresse de son premier octet, de type char *), jusqu’au premier caractère
(usuel) de code nul ; dans les implémentations qui font intervenir un
mécanisme de changement d’état dans l’interprétation des caractères
multioctets, on convient que l’examen d’une telle chaîne commence toujours
dans l’état initial.
On notera bien que la notion de sous-chaîne est facile à appliquer à une chaîne
de caractères étendus utilisant la première représentation sous la forme d’une
suite de valeurs de type wchar_t. En particulier, on pourra ne prendre en compte
qu’une partie d’une telle chaîne en considérant l’adresse de l’un de ses éléments.
En revanche, avec la seconde représentation sous la forme d’une suite de
caractères multioctets, les choses seront beaucoup moins simples :
• la taille d’un caractère multioctet n’est pas nécessairement constante dans une
implémentation ; disposant de l’adresse de début d’une chaîne de caractères
multioctets, il n’est pas facile de trouver l’adresse d’une partie de cette chaîne,
à moins d’analyser un à un les caractères qui la composent (par exemple, avec
mblen) ;
• l’interprétation d’un caractère multioctet à l’intérieur d’une chaîne peut
dépendre d’un état ; dans ces conditions, la connaissance de son adresse n’est
pas suffisante pour l’interpréter correctement, ni même pour en définir la
longueur ;
• une adresse d’un octet quelconque d’une chaîne de caractères multioctets n’est
pas nécessairement l’adresse d’un caractère multioctet valide.
5. Représentation des constantes chaînes de caractères
étendus
Lorsqu’on souhaite introduire dans un programme source une chaîne constante
de valeurs de type wchar_t, on peut recourir à la notation :
L"xxxxx"
Les caractères xxxxx correspondent à une suite de constantes caractères
multioctets tels qu’elles ont été définies au paragraphe 2.
Malgré la notation ainsi utilisée, le compilateur crée en mémoire statique une
suite de valeurs de type wchar_t, terminée par une valeur nulle. Par analogie avec
ce qui se produit pour les constantes chaînes usuelles, le compilateur remplace la
notation L"xxxxx" par l’adresse correspondante, de type wchar_t * (et non char *,
comme cela aurait été le cas si le compilateur avait généré une suite de
caractères multioctets).
En théorie, il n’existe pas de notation permettant de créer une constante chaîne
de caractères étendus, sous la forme d’une suite de caractères multioctets. On
peut y parvenir en utilisant une constante chaîne usuelle. Par exemple,
supposons que dans l’implémentation concernée, les notations suivantes
désignent trois caractères multioctets valides :
^2ax ^1z ^3def
Comparons alors :
wchar_t *ch_e = L"^2ax^1z^3def" ; /* 3 valeurs de type wchar_t + valeur nulle */
char *ch_cmo = "^2ax^1z^3def" ; /* chaîne usuelle de 12 octets + octet nul */
On peut dire que ch_cmo pointe sur la representation multioctets de la chaine de
wchar_t sur laquelle pointe ch_e. Cependant, aucun controle de validite des
caracteres multioctets ne pourra se faire dans le deuxieme cas puisque le
compilateur n’y voit plus qu’une suite de caracteres usuels.
6. Les fonctions liées aux chaînes de caractères
étendus : mbstowcs et wcstombs
Comme on s’y attend, on dispose de fonctions de conversion :
• d’une suite de caractères multioctets en une suite de valeurs de type wchar_t :
mbstowcs ;
• d’une suite de valeurs de type wchar_t en une suite de caractères multioctets :
wcstombs.
Si l’implémentation fait intervenir un mécanisme de changement d’état, toutes
les interprétations de caractères multioctets se font en supposant qu’elles
débutent dans l’état initial. Cela signifie que l’état courant n’intervient pas dans
cette interprétation. Par ailleurs, sa valeur, quelle qu’elle soit, n’est pas modifiée
par ces fonctions.
6.1 La fonction mbstowcs
Le rôle de cette fonction est de convertir une chaîne formée de caractères
multioctets en une chaîne de valeurs de type wchar_t. Si l’implémentation utilise
un mécanisme de changement d’état, tout se passe comme si l’on était dans l’état
initial. On limite le nombre de valeurs de type wchar_t ainsi créées. Si ce nombre
n’est pas atteint, la chaîne de caractères étendus est bien terminée par un zéro de
fin, ce qui n’est pas le cas si la limite est atteinte.
size_t mbstowcs (wchar_t *ad_chet, const char *ad_chmo, size_t
nb_caret) (stdlib.h)
ad_chet
Adresse à laquelle sera rangé la première valeur de type
wchar_t du résultat de la conversion de la chaîne de caractères
multioctets débutant en ad_chmo.
ad_chmo
Adresse du premier octet du premier caractère multioctet de
la chaîne à convertir.
nb_caret
Nombre maximal de valeurs de type wchar_t à créer.
Valeur de Nombre de valeurs de type wchar_t créées à partir de
retour l’adresse ad_chet (0 de fin non compris) si la conversion s’est
déroulée correctement, (size_t)-1 sinon1.
1. Il s’agit de la conversion de la valeur -1 dans le type size_t (non signé).
6.2 La fonction wcstombs
Le rôle de cette fonction est de convertir une chaîne de valeurs de type wchar_t en
une chaîne de caractères multioctets. Si l’implémentation utilise un mécanisme
de changement d’état, tout se passe comme si l’on était dans l’état initial. On
limite le nombre d’octets ainsi créés. Si ce nombre n’est pas atteint, la chaîne de
caractères multioctets est bien terminée par un zéro de fin, ce qui n’est pas le cas
si la limite est atteinte.
size_t wcstombs (char *ad_chmo, const wchar_t * ad_chet, size_t
nb_oct) (stdlib.h)
ad_chmo
Adresse où sera rangé le premier octet du résultat de la
conversion de la chaîne de valeurs de type wchar_t débutant
en ad_chet.
ad_chet
Adresse de la première valeur de type wchar_t de la chaîne à
convertir.
nb_oct
Nombre maximal d’octets à créer.
Valeur de Nombre d’octets créés à partir de l’adresse ad_chmo (0 de fin
retour non compris) si la conversion s’est déroulée correctement,
1
(size_t)-1 sinon .
1. Même remarque que précédemment.
1. La norme ne précise pas son attribut de signe.
2. Mais pas nécessairement au même code, compte tenu des conversions de caractère vers entier qui
peuvent faire apparaître des bits supplémentaires (à zéro ou à un) du côté des poids forts.
3. Certaines implémentations se permettent de limiter d’office le nombre de caractères pris effectivement en
compte dans la notation hexadécimale.
23
Les adaptations locales
La norme du langage C offre un mécanisme général dit de « localisation » qui
offre à une implémentation la possibilité d’adapter le comportement de quelques
fonctions standards à des particularités nationales ou locales, en ce qui
concerne :
• le formatage des nombres tel qu’il est mis en œuvre par les fonctions de lecture
ou d’écriture formatées ;
• le formatage des valeurs monétaires ;
• l’ordre lexicographique des caractères utilisé par une fonction spécifique
nommée strcoll ;
• le choix d’un jeu de caractères étendus.
D’une manière générale, ces possibilités sont assez peu utilisées par les
compilateurs actuels, ce qui justifie leur place dans un chapitre spécifique.
Nous commencerons par présenter ce mécanisme, avant d’étudier en détail les
fonctions localeconv et setlocale qui permettent de le mettre en œuvre.
1. Le mécanisme de localisation
Il existe une localisation standard définie par la norme de façon totalement
indépendante de l’implémentation. Elle est bien sûr utilisée par défaut par les
fonctions de la bibliothèque standard.
La norme autorise une implémentation à définir d’autres localisations, sans
toutefois imposer quoi que ce soit. Pour faciliter les choses, elle a prévu que les
différentes caractéristiques d’une localisation donnée se classent en cinq
catégories. Il est possible de modifier les caractéristiques d’une seule catégorie
ou de toutes les catégories à la fois.
Une localisation donnée est définie par une chaîne de caractères quelconques.
On peut dire qu’il s’agit de son nom mais rien n’empêche une implémentation de
faire de cette chaîne une sorte de description codée des caractéristiques de la
localisation correspondante. Il existe une localisation par défaut nommée « C ».
Elle possède les mêmes caractéristiques dans toutes les implémentations.
Pour modifier les caractéristiques d’une localisation, on fera appel à la fonction
setlocale à laquelle on précisera simplement la catégorie concernée et le nom de
la nouvelle localisation.
Par ailleurs, parmi les cinq catégories évoquées, les deux catégories
correspondant au formatage des valeurs numériques ou monétaires voient leurs
caractéristiques définies par des valeurs de paramètres. Par exemple, on y
trouvera le symbole utilisé comme point décimal (il s’agit du point dans la
localisation standard), le symbole monétaire (inexistant dans la localisation
standard)… Ces valeurs peuvent alors être connues par la fonction localeconv. En
revanche, elles ne pourront pas être modifiées autrement qu’en changeant la
localisation par appel de setlocale.
2. La fonction setlocale
Le rôle principal de cette fonction est de modifier les caractéristiques de
localisation soit globalement, soit seulement pour une catégorie.
Accessoirement, elle permet aussi de connaître le nom de la localisation en
vigueur à un moment donné pour une catégorie donnée.
char * setlocale (int categorie, const char *ad_nom_loc) (locale.h)
categorie
Catégorie concernée : – LC_ALL correspond à une
LC_ALL, LC_COLLATE, LC_TYPE,
modification globale ;
LC_MONETARY, LC_NUMERIC, LC_TIME
– voir signification des
autres symboles au
tableau 23.1.
ad_nom_loc
Pointeur sur le nom de la « C » correspond à la
localisation choisie localisation par défaut.
Valeur de – si ad_nom_loc est non nul :
retour la valeur de ad_nom_loc si le
changement a pu aboutir,
pointeur nul sinon ;
– si ad_nom_loc est nul :
pointeur sur le nom de la
localisation associé à la
categorie concernée.
Le tableau 23.1 indique les différents symboles représentant les cinq catégories
de localisation, ainsi que les opérations qui sont concernées par les
caractéristiques correspondantes. La norme autorise une implémentation à
ajouter de nouvelles catégories.
Tableau 23.1 : les différentes catégories de localisation
Fonctions standards
Nom Remarques
concernées
LC_COLLATE
strcoll et strxfrm Ne concerne pas les
opérateurs de comparaison
de caractères, ni la fonction
strcmp, qui se basent
toujours sur la valeur
numérique du code.
LC_TYPE
– test d’appartenance d’un La définition des espaces
caractère à une catégorie blancs peut intervenir dans
(de la forme isxxxx), à les entrées-sorties
l’exception de isdigit et formatées et les conversions
de isxdigit ; de chaînes en nombres.
– fonctions multioctets.
LC_MONETARY
Aucune – simples informations de
formatage des valeurs
monétaires ;
– accessibles par la fonction
localeconv.
LC_NUMERIC
– entrées-sorties formatées ; – informations de
– fonctions de conversions formatage des nombres :
de chaînes en nombres. point décimal, formes
numériques valides ;
– accessibles par la fonction
localeconv.
LC_TIME strftime
Exemples
Si l’implémentation dispose d’une localisation nommée LOC, on pourra l’imposer
par :
char *ad ;
…..
ad = setlocale (LC_ALL, "LOC") ;
if (ad) /* modification OK */
else /* échec de la modification */
Si l’on souhaite procéder temporairement à une modification des caractéristiques
du seul groupe LC_TYPE et revenir ensuite à la localisation actuelle (pour ce
groupe) quelle qu’elle soit, on pourra procéder ainsi :
char *ad_loc ;
…..
ad_loc = setlocale (LC_TYPE, NULL) /* nom de la localisation actuelle */
/* pour la catégorie LC_TYPE */
…..
setlocale (LC_TYPE, "…") ; /* modification catégorie LC_TYPE */
…..
setlocale (LC_TYPE, ad_loc) ; /* retour caractéristiques initiales */
3. La fonction localeconv
Cette fonction permet de connaître les valeurs des différents paramètres
intervenant, à un moment donné, dans le formatage des nombres ou des valeurs
monétaires.
struct lconv *localeconv (void) (locale.h)
Valeur de Adresse d’une structure de type Description
retour struct lconv contenant les valeurs des détaillée de cette
différents paramètres de formatage structure ci-après
des nombres ou des valeurs
monétaires.
La structure dont on obtient ainsi l’adresse ne doit pas être modifiée par le
programme. En revanche, son contenu peut se trouver modifié par un nouvel
appel de localeconv ou par un appel de setlocale avec LC_ALL, LC_MONETARY ou
LC_NUMERIC, comme valeur du premier argument.
Le tableau 23.2 précise la signification des différents champs de la structure en
question, ainsi que les valeurs correspondantes dans la localisation standard
« C ». On notera que la valeur CHAR_MAX doit être interprétée comme une absence
d’information.
Exemple
Voici un petit exemple de mise en œuvre de la fonction localeconv1 :
Exemple d’utilisation de la fonction localeconv
#include <stdio.h>
#include <locale.h>
int main()
{ struct lconv *ad_s ;
char * ad ;
ad_s = localeconv() ;
printf ("point decimal :%s:\n", ad_s->decimal_point) ;
printf ("separateur groupe mille :%s:\n", ad_s->thousands_sep) ;
printf ("position signe valeur monetaire non neg :%d:\n", ad_s-> p_sign_posn) ;
}
point decimal :.:
separateur groupe mille ::
position signe valeur monetaire non neg :127:
Tableau 23.2 : le contenu de la structure lconv
Valeur
Champ Contenu
standard
char *decimal_point "."
Caractère utilisé comme « point
décimal » dans les nombres
char *thousands_sep ""
Caractère utilisé pour séparer les
groupes de chiffres dans les
nombres, avant le point décimal
char *grouping ""
Chaîne dont chaque élément précise
la taille des groupes de chiffres
précédents.
char *int_curr_symbol ""
Chaîne représentant une
identification internationale des
symboles ci-après
char *currency_symbol ""
Chaîne représentant le symbole
monétaire local
char ""
*mon_decimal_point Caractère utilisé comme « point
décimal » dans les valeurs
monétaires
char ""
*mon_thousands_sep Caractère utilisé pour séparer les
groupes de chiffres dans les valeurs
monétaires, avant le point décimal
char *mon_grouping ""
Chaîne dont chaque élément précise
la taille des groupes de chiffres
précédents
char *positive_sign ""
Chaîne utilisée pour indiquer une
valeur monétaire non négative
char *negative_sign ""
Chaîne utilisée pour indiquer une
valeur monétaire négative
char int_frac_digits CHAR_MAX
Nombre de décimales à afficher
dans une valeur monétaire
internationale
char frac_digits CHAR_MAX
Nombre de décimales à afficher
dans une valeur monétaire locale
char p_cs_precedes CHAR_MAX
1 si le symbole monétaire précède
une valeur monétaire non négative,
0 s’il la suit.
char p_sep_by_space CHAR_MAX
1 si le symbole monétaire est séparé
par un espace d’une valeur non
négative, 0 sinon.
char n_cs_precedes CHAR_MAX
1 si le symbole monétaire précède
une valeur monétaire négative, 0 s’il
la suit.
char n_sep_by_space CHAR_MAX
1 si le symbole monétaire est séparé
par un espace d’une valeur
négative, 0 sinon.
char p_sign_posn CHAR_MAX
Position du signe pour une valeur
monétaire non négative
char n_sign_posn CHAR_MAX
Position du signe pour une valeur
monétaire négative
1. Certaines implémentations peuvent nécessiter l’inclusion de fichiers en-tête supplémentaires ou la
définition de symboles spécifiques.
24
La récursivité
Le langage C autorise la récursivité des appels de fonctions. Autrement dit, une
fonction peut s’appeler elle-même, soit directement, soit par l’intermédiaire
d’une autre fonction. La mise en œuvre de cette récursivité ne pose pas de
problème majeur et, d’ailleurs, nous n’introduirons aucun nouvel outil dans ce
chapitre. En fait, nous nous contenterons d’illustrer la manière dont s’empilent
les appels successifs. Auparavant, nous rappellerons sommairement ce qu’est la
notion de récursivité.
1. Notion de récursivité
La récursivité est une notion générale que l’on rencontre dans bien d’autres
domaines que la programmation. On dit qu’il y a récursivité lorsque la définition
d’une entité fait apparaître cette entité elle-même. C’est ainsi que nous avons eu
l’occasion de constater qu’en C la notion d’instruction devait être définie d’une
manière récursive. Un autre exemple de définition récursive nous est fourni par
la fonction mathématique « factorielle » qui peut se définir ainsi, en supposant
qu’on la nomme f :
f (1) = 1
f(n) = n.f(n-1) pour n>1
La seconde ligne qui définit f(n) pour n>1 comporte à son tour une référence à f.
Il y a bien récursivité. Bien entendu, une telle définition est cohérente et
exploitable car, en l’appliquant un nombre fini de fois, elle permet d’aboutir à
un résultat. Par exemple, pour n=3, on trouvera d’abord :
f(3) = 3.f(2)
puis, en appliquant à nouveau la définition :
f(3) = 3.2.f(1)
Là, c’est la première ligne de notre définition qui intervient en arrêtant en
quelque sorte le processus récursif et qui nous amène à :
f(3) = 3.2.1
Une définition apparemment voisine telle que :
f(1) = 1
f(n) = f(n+1)/n pour n>1
aurait en revanche été inexploitable. Intuitivement, on comprend que cette
définition ne « se termine jamais ». Nous n’aborderons pas ici les aspects
théoriques sous-jacents à ces problèmes de « terminaison d’un algorithme
récursif ».
2. Exemple de fonction récursive
Notre première définition de la fonction f est directement utilisable en C pour
écrire une fonction de calcul de factorielle. Elle nous conduit simplement à ceci :
Exemple de fonction récursive de calcul de factorielle
long fac (int n)
{ if (n>1) return fac(n-1)*n ;
else return 1 ;
}
Pour bien mettre en évidence la manière dont se déroule l’exécution d’une telle
fonction, ajoutons-lui deux instructions d’écriture, l’une à l’entrée dans la
fonction, l’autre à sa sortie. Notez que cela nous oblige alors à effectuer le calcul
du résultat dans une variable locale. En incorporant le tout dans un programme
complet, nous sommes conduit à :
Déroulement de l’exécution d’une fonction récursive
#include <stdio.h>
int main()
{ long fac (int) ;
printf ("valeur de fac(3) : %ld\n", fac(3)) ;
}
long fac (int n)
{ long res ;
printf ("+++ entree dans fac - n = %d\n", n) ;
if (n>1) res = fac(n-1)*n ;
else res = 1 ;
printf ("--- sortie de fac - n = %d, fac = %ld\n", n, res) ;
return res ;
}
+++ entree dans fac - n = 3
+++ entree dans fac - n = 2
+++ entree dans fac - n = 1
--- sortie de fac - n = 1, fac = 1
--- sortie de fac - n = 2, fac = 2
--- sortie de fac - n = 3, fac = 6
valeur de fac(3) : 6
Les messages affichés montrent clairement que l’on est entré à trois reprises
dans fac, sans en sortir. Il y a eu, en quelque sorte, « empilement des appels ». En
même temps, il a été nécessaire de conserver les valeurs des variables internes de
fac avant de procéder à un nouvel appel. C’est ce mécanisme que nous allons
maintenant décrire.
3. L’empilement des appels
Tout d’abord, le programme principal appelle fac avec, en argument, la valeur 3.
Lors de l’entrée dans fac, il y a allocation de mémoire automatique pour1 :
• les arguments effectifs transmis à la fonction, ici n ;
• les variables locales à la fonction fac, ici res.
Si, comme c’est le cas en général, les emplacements automatiques sont gérés
sous la forme d’une pile, le schéma suivant récapitule l’état de la partie
supérieure de cette pile au moment de l’entrée dans la fonction fac :
|_____3_____| n
__________|___________| res
Notez bien que le résultat de la fonction s’est vu allouer une place, mais pas
encore de valeur. L’exécution de la fonction commence alors, provoquant
l’affichage du message :
+++ entree dans fac - n = 3
L’instruction if suivante entraîne l’amorce de l’exécution de l’affectation :
res = fac(n-1)*n ;
Celle-ci provoque un nouvel appel de fac, avec en argument la valeur 2. Il y a
allocation sur la pile des emplacements automatiques nécessaires à ce nouvel
appel, les emplacements relatifs à l’appel précédent n’étant, pour l’instant, pas
libérés. On est conduit à ce schéma :
appel 2 |_____2_____| n
__________|___________| res
appel 1 |_____3_____| n
__________|___________| res
Il y a de nouveau affichage de la valeur de n, puis appel de fac avec l’argument
1 :
appel 3 |_____1_____| n
__________|___________| res
appel 2 |_____2_____| n
__________|___________| res
appel 1 |_____3_____| n
__________|___________| res
Cette troisième fois, après affichage du message d’entrée, l’instruction if
suivante conduit à affecter la valeur 1 à res :
appel 3 |_____1_____| n
__________|_____1_____| res
appel 2 |_____2_____| n
__________|___________| res
appel 1 |___________| n
__________|_____3_____| res
Cette fois, l’exécution de la fonction se poursuit jusqu’au bout, provoquant tout
d’abord l’affichage du message :
--- sortie de fac - n = 1, fac = 1
Puis l’exécution de l’instruction return provoque le retour dans la fonction
appelante avec transmission du résultat2 et libération de l’espace imparti sur la
pile à la fonction appelée. On se retrouve donc dans fac, dans l’instruction qui
avait provoqué ce dernier appel, c’est-à-dire :
res = fac(n-1)*n ;
La valeur de fac(n-1) est maintenant calculée (elle vaut 1). Le produit par n (ici 2)
peut être réalisé, avant d’être affecté à res. La pile se présente ainsi :
appel 2 |_____2_____| n
__________|_____2_____| res
appel 1 |___________| n
__________|_____3_____| res
Là encore, l’exécution de la fonction se poursuit « jusqu’au bout », provoquant
l’affichage du message :
--- sortie de fac - n = 2, fac = 2
Puis l’instruction return provoque le retour dans fac au niveau du premier appel,
avec transmission du résultat (ici 2) et libération de l’espace correspondant sur la
pile. Cette valeur est alors multipliée par n (ici 3) et le résultat est rangé dans res :
appel 1 |_____3_____| n
__________|_____6_____| res
De nouveau, la fonction fac s'execute jusqu'au bout. Elle affiche :
--- sortie de fac - n = 3, fac = 6
Puis l’instruction return provoque le retour dans la fonction appelante (cette fois
main) avec libération de l’espace correspondant sur la pile et transmission du
résultat (ici 6), qui se trouve alors affiché :
valeur de fac(3) : 6
4. Autre exemple de récursivité
L’exemple précédent de calcul de factorielle était relativement trivial et, de plus,
il pouvait se programmer simplement sans recourir à la récursivité ; il suffisait
d’utiliser une simple répétition avec compteur.
D’une manière générale, d’ailleurs, on peut montrer qu’il est toujours possible
de transformer une solution récursive en une solution itérative (c’est-à-dire
formée de répétitions conditionnelles ou non). L’inverse est également vrai.
Toutefois, cette équivalence théorique entre solution itérative et solution
récursive ne préjuge nullement de la complexité relative de chacune d’entre
elles. Nous vous proposons précisément un exemple dans lequel la solution
récursive est particulièrement élégante ; nous vous laissons le soin de rechercher
la solution itérative équivalente qui, quant à elle, n’a rien de trivial.
Nous allons écrire une fonction calculant la valeur de la fonction dite
d’Ackermann. Il s’agit d’une fonction A de deux variables entières positives ou
nulles qui se définit ainsi :
A(m,n) = A(m-1,A(m,n-1)) pour m>0 et n>0
A(0,n) = n+1 pour n>0
A(m,0) = A(m-1,1) pour m>0.
Voici un exemple qui montre qu’on peut appliquer littéralement cette définition
pour programmer la fonction correspondante. Comme précédemment, nous y
avons introduit des instructions supplémentaires de trace de son exécution et
nous l’appelons avec des paramètres de petite valeur afin d’éviter une exécution
trop importante.
Programmation récursive de la fonction d’Ackermann
#include <stdio.h>
int acker (int m, int n)
{
int res ;
printf ("--- entree dans acker m=%d, n=%d\n", m, n) ;
if ( (m<0) || (n<0) )
res = 0 ;
else if (m==0)
res = n+1 ;
else if (n==0)
res =acker(m-1,1) ;
else
res = acker ( m-1, acker(m,n-1) ) ;
printf ("--- sortie de acker m=%d, n=%d, res = %d\n", m, n, res) ;
return res ;
}
int main()
{ int acker (int, int) ;
int m, n ;
printf ("donnez m et n : ") ;
scanf ("%d %d", &m, &n) ;
printf ("acker ( %d,%d) = %d", m, n, acker(m,n) ) ;
}
donnez m et n : 2 2
--- entree dans acker m=2, n=2
--- entree dans acker m=2, n=1
--- entree dans acker m=2, n=0
--- entree dans acker m=1, n=1
--- entree dans acker m=1, n=0
--- entree dans acker m=0, n=1
--- sortie de acker m=0, n=1, res = 2
--- sortie de acker m=1, n=0, res = 2
--- entree dans acker m=0, n=2
--- sortie de acker m=0, n=2, res = 3
--- sortie de acker m=1, n=1, res = 3
--- sortie de acker m=2, n=0, res = 3
--- entree dans acker m=1, n=3
--- entree dans acker m=1, n=2
--- entree dans acker m=1, n=1
--- entree dans acker m=1, n=0
--- entree dans acker m=0, n=1
--- sortie de acker m=0, n=1, res = 2
--- sortie de acker m=1, n=0, res = 2
--- entree dans acker m=0, n=2
--- sortie de acker m=0, n=2, res = 3
--- sortie de acker m=1, n=1, res = 3
--- entree dans acker m=0, n=3
--- sortie de acker m=0, n=3, res = 4
--- sortie de acker m=1, n=2, res = 4
--- entree dans acker m=0, n=4
--- sortie de acker m=0, n=4, res = 5
--- sortie de acker m=1, n=3, res = 5
--- sortie de acker m=2, n=1, res = 5
--- entree dans acker m=1, n=5
--- entree dans acker m=1, n=4
--- entree dans acker m=1, n=3
--- entree dans acker m=1, n=2
--- entree dans acker m=1, n=1
--- entree dans acker m=1, n=0
--- entree dans acker m=0, n=1
--- sortie de acker m=0, n=1, res = 2
--- sortie de acker m=1, n=0, res = 2
--- entree dans acker m=0, n=2
--- sortie de acker m=0, n=2, res = 3
--- sortie de acker m=1, n=1, res = 3
--- entree dans acker m=0, n=3
--- sortie de acker m=0, n=3, res = 4
--- sortie de acker m=1, n=2, res = 4
--- entree dans acker m=0, n=4
--- sortie de acker m=0, n=4, res = 5
--- sortie de acker m=1, n=3, res = 5
--- entree dans acker m=0, n=5
--- sortie de acker m=0, n=5, res = 6
--- sortie de acker m=1, n=4, res = 6
--- entree dans acker m=0, n=6
--- sortie de acker m=0, n=6, res = 7
--- sortie de acker m=1, n=5, res = 7
--- sortie de acker m=2, n=2, res = 7
acker ( 2,2) = 7
1. D’autres informations peuvent, le cas échéant, se trouver rangées sur la pile, notamment : adresse de
retour dans la fonction appelante, valeur de retour, valeurs d’expressions temporaires ; cet aspect n’a
aucune incidence sur la suite.
2. Le mécanisme de transmission de la valeur de retour peut dépendre à la fois de son type et de
l’implémentation.
25
Les branchements non locaux
Le langage C propose un mécanisme assez rustique permettant de réaliser un
« branchement direct » entre deux fonctions, et donc de s’affranchir de
l’enchaînement classique : appel de fonction, retour. Son utilisation la plus
classique réside dans la possibilité, en cas d’événement anormal ou exceptionnel,
de « s’échapper » directement d’une imbrication d’appels de fonctions, sans
devoir repasser par les différents niveaux.
Ce mécanisme se base sur la macro setjmp et sur la fonction longjmp. La première
permet de mémoriser un emplacement dans le programme. La seconde permet,
depuis n’importe quel endroit du programme, de se brancher à l’endroit
mémorisé, en ignorant les éventuels retours de fonctions intermédiaires. Avant
de décrire ces deux macros en détail, nous vous proposons un petit exemple.
1. Exemple introductif
Considérons ces instructions :
#include <setjmp.h>
…..
jmp_buf env ; /* type défini dans setjmp.h pour sauvegarder l'environnement */
/* la variable env doit être accessible aux appels de longjmp */
if (setjmp(env) == 0)
{ /* instructions_1 */ }
else
{ /* instructions_2 */ }
L’appel setjmp(env) permet de sauvegarder dans la variable nommée env :
• l’adresse de cet appel (attention, pas de l’instruction qui suit) ; cette
information sera exploitée ultérieurement lors d’un éventuel appel de la
fonction longjmp ;
• l’environnement courant, c’est-à-dire un certain nombre d’informations qui
seront utiles pour pouvoir reprendre convenablement l’exécution au point
voulu.
Par ailleurs, la macro setjmp fournit toujours une valeur de retour égale à 0 en cas
d’appel direct. Ici, donc, on exécutera les instructions_1.
Si, par la suite, de n’importe quel endroit du programme, on rencontre
l’instruction :
longjmp (env, 1) ;
on provoquera un retour à l’adresse conservée dans env, ainsi qu’une restauration
de l’état courant. Cela signifie qu’on se retrouvera à nouveau dans l’évaluation
de la condition de l’instruction :
if (setjmp(env) == 0)
Mais cette fois, comme l’appel de setjmp s’est fait de façon indirecte, suite à un
branchement depuis longjmp, la norme prévoit que sa valeur de retour sera celle du
second paramètre de longjmp, c’est-à-dire ici 1. Ainsi, on exécutera dans ce cas les
instructions_2.
Bien entendu, il est nécessaire que la variable env soit accessible à la fois depuis
l’appel de setjmp et depuis les différents appels de longjmp dont on souhaite qu’ils
provoquent le retour à l’endroit voulu.
2. La macro setjmp et la fonction longjmp
2.1 Prototypes et rôles
int setjmp (jmp_buf env) (setjmp.h)
env
Tableau, de type jmp_buf, qui servira à sauvegarder
l’environnement et l’adresse à laquelle on reviendra
ultérieurement par longjmp.
Valeur de 0 si l’appel de setjmp est direct, non nul si l’appel s’est fait
retour par l’intermédiaire de longjmp.
void longjmp (jmp_buf env, int etat) (setjmp.h)
env
Tableau qui fournit l’environnement à restaurer et l’adresse
à laquelle on va se brancher.
etat
Si cette valeur est différente de 0, elle sera considérée
comme la valeur de retour de l’appel de setjmp ; si elle est
nulle, tout se passera comme si elle valait 1.
Valeur de Sans véritable signification ici puisque l’appel de longjmp
retour entraîne un branchement et que tout se passe comme si on
avait appelé setjmp.
L’appel de longjmp restaure l’environnement (préalablement sauvegardé par
setjmp), à partir du contenu de la variable env. Puis l’exécution reprend à l’adresse
précédemment conservée, comme si la valeur état était la valeur de retour de
setjmp. Pour éviter un bouclage intempestif, la norme ANSI prévoit que si état
vaut 0, longjmp fournit une valeur de retour égale à 1 et non pas à 0.
Remarque
Le type correspondant à jmp_buf est toujours un tableau, bien que le type exact des éléments ne soit
pas spécifié. Dès lors que les appels de setjmp et de longjmp ne figurent pas dans la même fonction,
on voit qu’il est nécessaire que l’emplacement correspondant ne soit pas de classe automatique. Il peut
s’agir indifféremment d’une variable de classe statique ou d’un emplacement alloué dynamiquement.
2.2 Contraintes d’utilisation
Le mécanisme même utilisé pour ces branchements non locaux fait qu’il est
nécessaire d’opérer une comparaison entre la valeur de retour de setjmp et une
valeur entière. C’est pourquoi la norme impose tout naturellement que l’appel de
setjmp ne puisse se faire que dans les cas suivants :
• il constitue l’expression de contrôle complète d’une boucle, d’un choix ou d’un
switch, comme dans ces exemples :
if (setjmp(env)) …..
while (setjmp(env)) ….
• il apparaît comme opérande d’un opérateur == ou != dont le second opérande est
également entier ; de plus, l’expression de comparaison correspondante
constitue, là encore, l’expression de contrôle complète d’une boucle, d’un
choix ou d’un switch, comme dans :
if (setjmp(env)==2) …..
while (setjmp(env)!=5) ….
• il apparaît comme unique opérande de l’opérande ! et l’expression
correspondante constitue, là encore, l’expression de contrôle complète d’une
boucle, d’un choix ou d’un switch, comme dans :
if (!setjmp(env)) …..
while (!setjmp(env)) ….
• il constitue à lui seul une instruction expression, comme dans :
setjmp(env) ;
Le dernier cas reste d’un intérêt limité, puisque l’instruction en question ne
permet plus de distinguer un appel direct de setjmp d’un appel indirect par longjmp.
On notera que les appels suivants ne sont pas corrects au sens de la norme,
même s’ils sont acceptés dans certaines implémentations :
if (2*setjmp(env)<12) ….. /* incorrect */
if (setjmp(env)<3) ….. /* incorrect */
if (!(res=setjmp(env)) ….. /* incorrect */
26
Les incompatibilités
entre C et C++
A priori, le langage C++ apparaît comme une extension du C ANSI, de sorte
qu’on pourrait légitimement penser que tout programme écrit en C peut être
compilé indifféremment par un compilateur C ou C++ et fournir le même
résultat. Or, bien qu’il s’agisse effectivement du souhait des concepteurs de C++,
un certain nombre d’incompatibilités ont subsisté. Ce chapitre se propose de les
recenser.
Il est intéressant de classer ces incompatibilités en deux catégories. La première
regroupe les incompatibilités qui résultent de la volonté de supprimer de C++
certaines tolérances dangereuses de la norme ANSI. Nous en avons
systématiquement déconseillé l’emploi tout au long de cet ouvrage et elles ne
devraient pas poser de problème particulier au programmeur « raisonnable ». La
seconde catégorie, en revanche, correspond à quelques incompatibilités
incontournables, quel que soit le style de programmation que l’on adopte ; elles
peuvent donc vous amener à modifier un programme C pour le faire compiler en
C++.
Nous examinerons séparément chacune de ces deux catégories que nous
intitulerons : incompatibilités raisonnables et incompatibilités incontournables.
1. Les incompatibilités raisonnables
1.1 Définition d’une fonction
En C, il existe deux formes de définition d’une fonction que nous avons
nommées forme ancienne et forme moderne. Le C++, quant à lui, n’accepte que
la forme moderne, seule conseillée en C.
1.2 Les prototypes en C++
En C 90, lorsque vous utilisez une fonction qui n’a pas été définie auparavant
dans le même fichier source, vous pouvez :
• ne pas la déclarer (on considère alors que sa valeur de retour est de type int),
• la déclarer sous une forme partielle en ne précisant que le type de la valeur de
retour, par exemple :
double fexple() ;
• la déclarer sous une forme complète, dite prototype, par exemple :
double fexple (int, double) ;
En C++, comme en C99 ou en C11, un appel de fonction n’est accepté que si la
fonction concernée a fait l’objet :
• d’une déclaration complète sous forme d’un prototype (on peut toutefois y
trouver des arguments variables) ;
• ou d’une définition préalable dans le même fichier source ; dans ce dernier cas
cependant, comme en C, le prototype reste conseillé, notamment pour éviter
tout problème en cas d’éclatement du fichier source.
1.3 Fonctions sans valeur de retour
En C++, il est indispensable d’utiliser le mot void pour définir (en-tête) ou
déclarer (prototype) une fonction sans valeur de retour, alors que cela n’est que
facultatif en C :
void fct (int, double) ;
On notera bien que la déclaration suivante ne serait pas illégale en C++ mais elle
signifierait que fct fournit une valeur de retour de type int.
fct (int, double) ;
1.4 Compatibilité entre le type void * et les autres
pointeurs
En C, le type « pointeur générique » void * est compatible avec les autres types
de pointeurs, et ce dans les deux sens. Ainsi, avec ces déclarations :
void * gen ;
int * adi ;
ces deux affectations sont légales en C ANSI :
gen = adi ;
adi = gen ;
Elles font intervenir des « conversions implicites », à savoir :
int * à void * pour la première,
void * à int * pour la seconde.
En C++, seule la conversion d’un pointeur quelconque en void * peut être
implicite. Ainsi, avec les déclarations précédentes, seule l’affectation suivante
est acceptée :
gen = adi ;
Bien entendu, il reste toujours possible de faire appel explicitement à la
conversion void * → int * en utilisant l’opérateur de cast :
adi = (int *) gen ;
Remarque
La conversion d’un pointeur de type quelconque en void * revient à ne s’intéresser qu’à l’adresse
correspondant au pointeur, en ignorant son type. En revanche, la conversion inverse de void * en un
pointeur de type donné revient à associer (peut-être arbitrairement !) un type à une adresse.
Manifestement, cette deuxième possibilité est plus dangereuse que la première ; elle peut même
obliger le compilateur à introduire des modifications de l’adresse de départ, dans le seul but de
respecter certaines contraintes d’alignement (liées au type d’arrivée). C’est la raison pour laquelle
cette conversion ne fait plus partie des conversions implicites en C++.
1.5 Les déclarations multiples
En C, il est permis de trouver (à un niveau global) plusieurs déclarations d’une
même variable dans un fichier source. Par exemple, avec :
int n ;
…..
int n = 5 ;
C considère que la première instruction est une simple déclaration, tandis que la
seconde est une définition ; c’est cette dernière qui provoque la réservation de
l’emplacement mémoire pour n.
En C++, les déclarations multiples sont interdites. Toutefois, la justification de
cette interdiction nécessite une certaine connaissance du langage…
1.6 L’instruction goto
En C++, une instruction goto ne peut pas faire sauter une déclaration comportant
un « initialiseur » (par exemple int n = 2), sauf si cette déclaration figure dans un
bloc et que ce bloc est sauté complètement.
1.7 Initialisation de tableaux de caractères
En C++, l’initialisation de tableaux de caractères par une chaîne de même
longueur n’est pas possible. Par exemple, l’instruction :
char t[5] = "hello" ;
provoquera une erreur, due à ce que t n’a pas une dimension suffisante pour
recevoir le caractère (\0) de fin de chaîne.
En C, cette même déclaration serait acceptée et le tableau t se verrait simplement
initialisé avec les 5 caractères h, e, l, l et o, sans caractère de fin de chaîne.
Toutefois, une simple instruction :
printf ("%s", t) ;
affichera alors tous les caractères suivant cette petite chaîne, jusqu’à la rencontre
d’un zéro !
Notez que l’instruction :
char t[] = "hello" ;
convient indifferemment en C et en C++ et qu'elle réserve dans les deux cas un
tableau de six caractères : h, e, l, l, o et \0.
2. Les incompatibilités incontournables
2.1 Fonctions sans arguments
En C++, l’en-tête d’une fonction sans arguments devra obligatoirement s’écrire
avec une « liste vide d’arguments » comme dans :
float fct () /* en-tête C++ d'une fonction sans arguments */
La forme recommandée par la norme ANSI du C sera illégale :
float fct (void) /* en-tête recommandé en C et illégal en C++ */
Les mêmes remarques s’appliqueront aux déclarations de telles fonctions :
float fct () ; /* déclaration C++ d'une fonction sans arguments */
float fct (void) ; /* déclaration recommandée en C et illégale en C++ */
2.2 Le qualifieur const
Bien qu’en C++ la signification de const reste la même qu’en C, un certain
nombre de différences importantes apparaissent, au niveau de la portée du
symbole concerné et de son utilisation dans une expression.
2.2.1 Portée
Lorsque const s’applique à des variables locales automatiques, aucune différence
n’existe entre C et C++, la portée étant limitée au bloc ou à la fonction concernée
par la déclaration. En revanche, lorsque const s’applique à une variable globale,
C++ limite la portée du symbole au fichier source contenant la déclaration
(comme s’il avait reçu l’attribut static) ; C ne fait aucune limitation.
Pourquoi cette différence ? La principale raison réside dans l’idée qu’avec la
règle adoptée par C++, il devient plus facile de remplacer certaines instructions
#define par des déclarations de constantes. Ainsi, là où en C vous procédez de
cette façon :
#define N 8 #define N 3
….. …..
fichier 1 fichier 2
vous pourrez, en C++, procéder ainsi :
const int N = 8 ; const int N = 3 ;
….. …..
fichier 1 fichier 2
En C, vous obtiendriez une erreur au moment de l’édition de liens. Vous pourrez
l’éviter :
• soit en déclarant N static, dans au moins un des deux fichiers (ou, mieux, dans
les deux) :
static const int N = 8 ;
static const int N = 3 ;
Cela serait parfaitement équivalent à ce que fait C++ avec les premières
déclarations ;
• soit, si N avait eu la même valeur dans les deux fichiers, en plaçant, dans le
second fichier :
extern const int N ;
2.2.2 Utilisation dans une expression
En C, un identificateur ayant reçu le qualifieur const ne peut pas apparaître dans
une expression constante alors qu’il le pourra en C++.
Ce point est particulièrement sensible dans les déclarations de tableaux (statiques
ou automatiques) dont les dimensions doivent obligatoirement être des
expressions constantes (même pour les tableaux automatiques, le compilateur
doit connaître la taille à réserver sur la pile !). Ainsi, les instructions :
const int nel = 15 ;
…..
double t1 [nel + 1], t2[2 * nel] [nel] ;
seront acceptées en C++, alors qu’elles sont refusées en C. Elles seront
également acceptées en C99 et (facultativement en C11).
Remarques
1. En toute rigueur, la possibilité que nous venons de décrire ne constitue pas une incompatibilité entre
C et C++ puisqu’il s’agit d’une facilité supplémentaire.
2. D’une manière générale, C++ a été conçu pour limiter au maximum l’emploi des directives du
préprocesseur (on devrait pouvoir se contenter de #include et des directives d’inclusion
conditionnelle). Les modifications apportées au qualifieur const vont effectivement dans ce sens.
2.3 Les constantes de type caractère
En C++, une constante caractère telle que a, z ou \n est de type char, alors qu’elle
est implicitement convertie en int en C ANSI. Les conséquences d’une telle
différence n’apparaissent en fait que dans les possibilités de surdéfinition de
fonctions, spécifiques à C++.
1. Encore faut-il que le programmeur C++ accepte de changer les habitudes qu’il avait dû prendre en C
(faute de pouvoir faire autrement) !
A
La bibliothèque standard C90
La norme ANSI fournit à la fois la description du langage C et le contenu d’une
bibliothèque standard, subdivisée en plusieurs parties que nous nommerons
« sous-bibliothèques ». À chaque sous-bibliothèque est associé un fichier en-tête
comportant essentiellement :
• les prototypes des fonctions correspondantes ;
• les définitions des macros correspondantes ;
• les définitions de certains symboles utiles au bon fonctionnement des fonctions
ou macros de la sous-bibliothèque ; il s’agit en fait soit de types ou de
synonymes définis par typedef, soit de constantes définies par #define.
Ce chapitre fournit une description de ces différents éléments pour C90, en
conservant le découpage en sous-bibliothèques prévu par la norme. Pour chaque
fichier en-tête, nous précisons, dans cet ordre, le nom et le rôle :
• des types ;
• des constantes prédéfinies ;
• des fonctions et des macros à paramètres, lesquelles, rappelons-le, s’utilisent de
la même manière. Naturellement, la définition des macros figure effectivement
dans le fichier en-tête, tandis que celle des fonctions a fait l’objet de
compilation préalable ; les modules objet correspondants figurent dans un ou
plusieurs fichiers objet qui seront consultés par l’éditeur de liens : dans le
fichier en-tête correspondant, on ne trouvera que le prototype.
Pour les macros, la notion de prototype n’existe théoriquement pas. Cependant,
nous fournirons quelque chose de comparable qu’on pourrait éventuellement
nommer la syntaxe de la macro. En général, elle montrera, comme le ferait un
prototype, les types escomptés pour les différents paramètres, même si la notion
de type n’a pas véritablement de signification à ce niveau. Dans quelques cas
cependant, on trouvera non plus des types, mais d’autres sortes d’informations,
par exemple un identificateur pour va_start.
Pour ce qui est des normes C99 et C11, on trouvera des compléments
d’information dans l’annexe B qui leur est consacrée.
1. Généralités
1.1 Les différents fichiers en-tête
Voici, classée par ordre alphabétique, la liste de tous les fichiers en-tête prévus
par la norme. Notez que le contenu des fichiers limits.h et float.h sont décrits aux
sections 3 et 5 du chapitre 3.
Tableau A.1 : les différents fichiers en-tête prévus par la norme
Nom
fichier en- Contenu Voir
tête
<assert.h>
Macros de mise au point section
2
<ctype.h>
Test de catégories de caractères et conversions section
majuscules/minuscules 3
<errno.h>
Gestion des erreurs section
4
<float.h>
Caractéristiques des types flottants section
5 chap.
3
<limits.h>
Caractéristiques des types entiers (et caractère) section
3 chap.
3
<locale.h>
Caractéristiques locales section
5
<math.h>
Fonctions mathématiques section
6
<setjmp.h>
Branchements non locaux section
7
<signal.h>
Traitement de signaux section
8
<stdarg.h>
Gestion d’arguments variables section
9
<stddef.h>
Définitions communes section
10
<stdio.h>
Entrées-sorties section
11
<stdlib.h>
Utilitaires : conversions de chaînes, nombres section
aléatoires, gestion de la mémoire, 12
communication avec l’environnement,
arithmétique entière, caractères étendus, chaînes
de caractères étendus
<string.h>
Manipulation de chaînes et de suites d’octets section
13
<time.h>
Gestion de l’heure et de la date section
14
Remarques
1. La norme n’impose pas que les fichiers en-tête soient de véritables fichiers. Il suffit que
l’implémentation fournisse un mécanisme permettant à la directive #include de retrouver les
informations voulues.
2. Tous les fichiers en-tête standards sont protégés contre les inclusions multiples.
1.2 Redéfinition d’une macro standard par une
fonction
La norme distingue clairement ce qui, dans la bibliothèque standard, doit être
défini sous forme de macros à paramètres de ce qui doit l’être sous forme de
fonctions. Néanmoins, dans le cas où elle requiert une fonction, elle autorise une
implémentation à fournir, en plus, une définition équivalente sous forme de
[Link] ce cas, le même symbole dispose apparemment de deux définitions
et la norme prévoit des règles assez subtiles pour faire la distinction :
• un appel direct au sein d’un programme conduit effectivement à l’appel de la
macro correspondante ;
• un appel par pointeur conduit à l’appel de la fonction car la norme précise bien
que l’opérateur & doit rester applicable et fournir l’adresse de la (vraie)
fonction (une macro n’ayant pas d’adresse…).
Si, pour une raison ou une autre, on souhaite pouvoir appeler la fonction dans
tous les cas, il suffit d’en annuler la définition, à l’aide d’une simple instruction
#undef.
2. Assert.h : macro de mise au point
ASSERT void assert (int exptest)
Si le symbole NDEBUG est défini au moment où le
préprocesseur rencontre la directive #include <assert.h>, la
macro assert sera sans effet et sans valeur. Dans le cas
contraire, la macro asssert introduit une instruction d’arrêt
conditionnel de l’exécution. Plus précisément, si
l’expression exptest vaut 0, il y aura impression, sur la sortie
standard d’erreur, d’un message de la forme :
Assertion failed : exptest, nom_fichier, line xxxx
On y trouve :
– l’expression concernée : exptest ;
– le nom du fichier source concerné, nom_fichier ;
– le numéro de la ligne correspondante du fichier source
xxxx.
Il y aura ensuite appel de la fonction abort qui interrompra
l’exécution du programme. Si l’expression exptest a une
valeur différente de 0, la macro assert ne fera rien.
Notez que le symbole NDEBUG n’est défini dans aucun fichier
en-tête. C’est au programme de le prévoir s’il souhaite
inhiber l’effet des appels de assert.
Cette macro ne peut jamais être redéfinie sous la forme
d’une fonction.
3. Ctype.h : tests de caractères et conversions
majuscules - minuscules
3.1 Les fonctions de test d’appartenance d’un
caractère à une catégorie
Lorsque cela n’est pas mentionné explicitement, le comportement de ces
fonctions peut dépendre de la localisation.
ISALNUM int isalnum (int c)
Fournit une valeur non nulle (vrai) si c est un caractère
alphanumérique et la valeur 0 (faux) dans le cas contraire.
ISALPHA int isalpha (int c)
Fournit une valeur non nulle (vrai) si c est un caractère
alphabétique et la valeur 0 (faux) dans le cas contraire.
ISCNTRL int iscntrl (int c)
Fournit une valeur non nulle (vrai) si c est un caractère de
contrôle et la valeur 0 (faux) dans le cas contraire.
ISDIGIT int isdigit (int c)
Fournit une valeur non nulle (vrai) si c est un chiffre et la
valeur 0 (faux) dans le cas contraire. Cette valeur est
indépendante de la localisation.
ISGRAPH int isgraph (int c)
Fournit une valeur non nulle (vrai) si c est un caractère
imprimable, excepté un espace, et la valeur 0 (faux) dans le
cas contraire.
ISLOWER int islower (int c)
Fournit une valeur non nulle (vrai) si c est une lettre
minuscule et la valeur 0 (faux) dans le cas contraire.
ISPRINT int isprint (int c)
Fournit une valeur non nulle (vrai) si c est un caractère
imprimable, y compris un espace, et la valeur 0 (faux) dans
le cas contraire.
ISPUNCT int ispunct (int c)
Fournit une valeur non nulle (vrai) si c est un caractère de
ponctuation.
ISSPACE int isspace (int c)
Fournit une valeur non nulle (vrai) si c est un espace blanc et
la valeur 0 (faux) dans le cas contraire. Dans la localisation
standard, les espaces blancs sont constitués des cinq
caractères espace, tabulation horizontale (\t), tabulation
verticale (\v), retour chariot (\r) et fin de ligne (\n).
ISUPPER int isupper (int c)
Fournit une valeur non nulle (vrai) si c est une lettre
majuscule et la valeur 0 (faux) dans le cas contraire.
ISXDIGIT int isxdigit (int c)
Fournit une valeur non nulle (vrai) si c est un chiffre
hexadécimal et la valeur 0 (faux) dans le cas contraire. Cette
valeur est indépendante de la localisation.
3.2 Les fonctions de transformation de caractères
TOLOWER int tolower (int c)
Si isupper(c) est vrai, cette fonction fournit en résultat le
caractère correspondant de la catégorie minuscules, s’il
existe. Dans tous les autres cas, le résultat est la valeur de c.
TOUPPER int toupper (int c)
Si islower(c) est vrai, cette fonction fournit en résultat le
caractère correspondant de la catégorie majuscules, s’il
existe. Dans tous les autres cas, le résultat est la valeur de c.
4. Errno.h : gestion des erreurs
4.1 Constantes prédéfinies
EDOM
Valeur correspondant à une erreur de domaine, c’est-à-dire à
un argument dont la valeur est en dehors des limites
permises
ERANGE
Valeur correspondant à une erreur d’échelle, c’est-à-dire à
un résultat dont la capacité dépasse celle du type utilisé
4.2 Macros
ERRNO erno
Représente une lvalue de type int qui peut être utilisée par
certaines fonctions de la bibliothèque standard. Sa valeur est
initialisée à zéro au démarrage du programme. Elle doit être
modifiée comme indiqué par la norme dans quelques rares
cas. En dehors de cela, elle peut être modifiée par n’importe
quelle fonction, même en dehors d’une situation d’erreur.
La norme ne précise pas si errno doit être défini sous forme
d’une macro ou d’un symbole global. Le comportement du
programme est indéterminé si l’on annule la définition de
errno ou si l’on définit un autre identificateur de même nom.
5. Locale.h : caractéristiques locales
5.1 Types prédéfinis
struct lconv
Structure utilisée pour représenter les caractéristiques
locales de formatage des nombres et des valeurs monétaires.
Elle est décrite en détail à la section 3 du chapitre 23
5.2 Constantes prédéfinies
, ,
LC_ALL LC_COLLATE LC_CTYPE , Catégories prédéfinies de localisation. Elles
, ,
LC_MONETARY LC_NUMERIC LC_TIME sont décrites en détail à la section 2 du
chapitre 23.
5.3 Fonctions
SETLOCALE char * setlocale (int catégorie, const char *ad_nom_loc)
Si ad_nom_loc est non nul, cette fonction remplace les
caractéristiques de la catégorie indiquée (LC_ALL pour toute les
catégories) par celle correspondant à la localisation dont le
nom est fourni par la chaîne d’adresse ad_nom_loc. Elle fournit
la valeur ad_nom_loc en retour si l’opération a pu aboutir, la
valeur NULL sinon. Le nom « C » correspond à une
localisation par défaut, totalement indépendante de
l’implémentation.
Si ad_nom_loc est nul, cette fonction fournit en retour un
pointeur sur le nom de la localisation associé actuellement à
la catégorie concernée.
LOCALECONV struct lconv *localeconv (void)
Cette fonction fournit l’adresse d’une structure de type
struct lconv contenant les différents paramètres de
localisation intervenant, à un moment donné, dans le
formatage des nombres et des valeurs monétaires. Cette
structure ne doit pas être modifiée par le programme. En
revanche, son contenu peut se trouver modifié par un nouvel
appel de localeconv ou par un appel de setlocale avec LC_ALL,
LC_MONETARY ou LC_NUMERIC, comme valeur du premier argument.
6. Math.h : fonctions mathématiques
6.1 Constantes prédéfinies
HUGE_VAL
Expression de type double, pas nécessairement représentable
dans le type float, servant à représenter une valeur trop
grande. Dans certaines implémentations, il peut s’agir d’un
motif binaire particulier destiné à représenter l’infini.
6.2 Traitement des conditions d’erreur
6.2.1 Erreur de domaine
Chaque fonction précise le domaine des valeurs permises pour ses arguments
(toujours de type double). Si une fonction est appelée avec un argument dont la
valeur n’appartient pas à ce domaine :
• d’une part la fonction fournit un résultat dépendant de l’implémentation ;
• d’autre part la pseudo variable errno reçoit la valeur EDOM.
On notera que la norme autorise une implémentation à compléter le domaine des
valeurs interdites, d’une manière compatible avec la définition mathématique de
la fonction. Cela a été prévu pour les implémentations disposant d’une
représentation de l’infini, notamment celles qui utilisent les conventions IEEE.
6.2.2 Erreur d’échelle
Si le résultat théorique de la fonction (toujours de type double) est en dépassement
de capacité par rapport à ce type :
• d’une part on obtient en retour l’une des valeurs HUGE_VAL ou -HUGE_VAL ;
• d’autre part la pseudo-variable errno reçoit la valeur ERANGE.
De même, si le résultat théorique de la fonction (toujours de type double) est en
sous-dépassement de capacité par rapport à ce type :
• d’une part on obtient en retour la valeur 0 ;
• d’autre part la pseudo-variable errno peut recevoir ou non, suivant
l’implémentation, la valeur ERANGE.
6.3 Fonctions trigonométriques
ACOS double acos (double x)
Fournit la valeur principale, exprimée en radians, de arc cos
(x), c’est-à-dire une valeur appartenant à l’intervalle [0, p].
Une erreur de domaine a lieu si x n’appartient pas à
l’intervalle [-1, +1].
ASIN double asin (double x)
Fournit la valeur principale, exprimée en radians, de arc sin
(x), c’est-à-dire une valeur appartenant à l’intervalle [-p/2,
+p/2]. Une erreur de domaine a lieu si x n’appartient pas à
l’intervalle [-1, +1].
ATAN double atan (double x)
Fournit la valeur principale, exprimée en radians, de arc tg
(x), c’est-à-dire une valeur appartenant à l’intervalle [-π/2,
+π/2].
ATAN2 double atan2 (double y, double x)
Fournit la valeur principale, exprimée en radians, de arc tg
(y/x), c’est-à-dire une valeur appartenant à l’intevalle [-π,
+π]. Une erreur de domaine a lieu si les deux arguments
sont nuls.
COS double cos (double x)
Fournit la valeur de cos (x), la valeur de x étant exprimée en
radians.
SIN double sin (double x)
Fournit la valeur de sin (x), la valeur de x étant exprimée en
radians.
TAN double tan (double x)
Fournit la valeur de tg (x), la valeur de x étant exprimée en
radians.
6.4 Fonctions hyperboliques
SINH double sinh (double x)
Fournit la valeur de sh(x). Une erreur d’échelle a lieu si la
valeur absolue de x est trop grande.
COSH double cosh (double x)
Fournit la valeur de ch(x). Une erreur d’échelle a lieu si la
valeur absolue de x est trop grande.
TANH double tanh (double x)
Fournit la valeur de th(x) = sh(x) / ch(x).
6.5 Fonctions exponentielle et logarithme
EXP double exp (double x)
Fournit la valeur de exp(x). Une erreur d’échelle a lieu si la
valeur absolue de x est trop grande.
FREXP double frexp (double x, int *exp)
Détermine une représentation flottante normalisée de x, sous
la forme :
m.2e
La valeur de la mantisse m, appartenant à l’intervalle [1/2, 1],
est fournie en retour et celle de e est rangée à l’adresse
indiquée par exp. Si x vaut 0, m et e valent 0.
LDEXP double ldexp (double x, int exp)
Fournit la valeur de :
x.2exp
Une erreur d’échelle peut apparaître.
LOG double log (double x)
Fournit la valeur de Ln(x) (ou Log(x)), logarithme népérien
(ou naturel) de x. Une erreur de domaine apparaît si x est
négatif. Une erreur d’échelle apparaît si x vaut zéro.
LOG10 double log10 (double x)
Fournit la valeur de log(x), logarithme à base 10 de x. Une
erreur de domaine apparaît si x est négatif. Une erreur
d’échelle apparaît si x vaut zéro.
MODF double modf (double x, d ouble * adr_entier)
Décompose la valeur de x en une partie entière qu’elle range
à l’adresse adr_entier et une partie décimale qu’elle fournit
comme valeur de retour. Ces deux valeurs (partie entière et
partie décimale) ont même signe que x.
6.6 Fonctions puissance
POW double pow (double x, double y)
Fournit la valeur de x . Une erreur de domaine apparaît dans
y
l’une des deux circonstances suivantes :
– x est négatif et y ne correspond pas à une valeur entière,
– le résultat théorique n’est pas représentable, alors que x est
nul et que y est inférieur ou égal à zéro.
Une erreur d’échelle peut apparaître.
SQRT double sqrt (double x)
Fournit la racine carrée arithmétique (valeur non négative)
de x. Une erreur de domaine apparaît si x est négatif.
6.7 Autres fonctions
CEIL double ceil (double x)
Fournit (sous la forme d’un double) le plus petit entier qui ne
soit pas inférieur à x.
FABS double fabs (double x)
Fournit la valeur absolue de x.
FLOOR double floor (double x)
Fournit (sous la forme d’un double) le plus grand entier qui
ne soit pas supérieur à x.
FMOD double fmod (double x, double y)
Fournit le reste de la division réelle x/y (du signe de x). Plus
précisément, si y est non nul, on obtient la valeur de x - i *
y, où i est un entier déterminé de telle façon que le résultat
soit du signe de x et de valeur absolue inférieure à celle de y.
Si y est nul, il peut y avoir ou non erreur de domaine,
suivant l’implémentation (dans certaines, on obtient
simplement 0).
7. Setjmp.h : branchements non locaux
7.1 Types prédéfinis
jmp_buf
Tableau permettant de sauvegarder l’état de l’environnement
et une adresse de retour, pour assurer le bon fonctionnement
de setjmp et longjmp.
7.2 Fonctions et macros
SETJMP int setjmp (jmp_buf env)
Cette macro sauvegarde l’environnement actuel et l’adresse
d’appel dans la variable env. Fournit 0 comme valeur de
retour en cas d’appel direct et une valeur non nulle lorsque
l’appel s’est fait par l’intermédiaire de longjmp. La norme
laisse la liberté à l’implémentation de définir setjmp comme
une macro ou comme un identificateur global. Si le
programme annule la définition de setjmp ou s’il définit un
autre identificateur de même nom, le comportement est
indéterminé.
LONGJMP void longjmp (jmp_buf env, int etat)
Restaure l’environnement (préalablement sauvegardé par
setjmp), à partir du contenu de la variable env. Reprend
l’exécution à l’adresse précédemment conservée, comme si
la valeur etat était la valeur de retour de setjmp. Si l’on
appelle longjmp avec 0 comme valeur de etat, longjmp « force »
une valeur de retour égale à 1.
8. Signal.h : traitement de signaux
8.1 Types prédéfinis
sig_atomic_t
Type entité atomique
8.2 Constantes prédéfinies
SIG_DFL
Pour indiquer le traitement par défaut d’un signal
SIG_IGN
Pour indiquer qu’on ignore un signal.
SIG_ERR
Pour la valeur de retour de la fonction signal en cas d’erreur
SIGABRT
Fin anormale (éventuellement par appel de abort)
SIGFPE
Opération arithmétique incorrecte (division par zéro,
dépassement de capacité…)
SIGILL
Instruction invalide
SIGINT
Réception d’un signal interactif
SIGSEGV
Accès mémoire invalide
SIGTERM
Demande d’arrêt envoyée au programme
8.3 Fonctions de traitement de signaux
SIGNAL void (* signal (int numsig, void (* fsig) (int))) (int)
Cette fonction associe le traitement fsig au signal de numéro
numsig. La fonction spécifiée par fsig peut être n’importe
quelle fonction « ordinaire » et éventuellement l’une des
deux valeurs suivantes :
– SIG_IGN : le signal correspondant sera alors simplement
ignoré ;
– SIG_DFL : le traitement prévu par défaut pour ce signal sera
utilisé.
Si la fonction spécifiée (fsig) se termine « normalement »
(c’est-à-dire, autrement que par exit ou par un branchement
direct par longjmp), il y aura retour à l’instruction de
programme qui a été interrompue par le signal concerné.
Cette fonction fournit en retour la valeur SIG_ERR en cas
d’échec. En cas de succès, elle fournit la valeur de fsig
relative au dernier appel de signal pour le signal de numéro
numsig (ou la valeur SIG_DFL s’il n’y a jamais eu d’appel de
signal avec le numéro numsig).
RAISE int raise (int numsig)
Déclenche le signal de numéro numsig.
9. Stdarg.h : gestion d’arguments variables
9.1 Types prédéfinis
va_list
Utilisé pour représenter un objet de type liste d’arguments
variables
9.2 Macros
VA_START va_start (va_list param, derpar)
Initialise le parcours d’une liste d’arguments variables en
faisant désigner à param le premier des arguments variables
passés à la fonction. derpar doit être le nom du dernier
argument fixe. Cette macro ne peut jamais être redéfinie
sous la forme d’une fonction.
VA_ARG type_v va_arg (va_list param, type_v)
Le développement de cette macro fournit une expression
ayant le type type_v et la valeur de l’argument courant de la
liste d’arguments variables. La valeur de param sera modifiée
de façon que le prochain appel de va_arg fournisse bien
l’argument suivant. Cette macro ne peut jamais être
redéfinie sous la forme d’une fonction.
VA_END void va_end (va_list param)
Cette macro doit être appelée pour une liste d’arguments
variables ayant fait l’objet d’une initialisation par va_start,
avant d’avoir quitté la fonction concernée.
10. Stddef.h : définitions communes
Ce fichier contient les définitions de symboles, de types et de macros les plus
usuelles. Certaines figurent également dans d’autres fichiers en-tête.
10.1 Types prédéfinis
ptrdiff_t
Type entier utilisé pour la différence de deux pointeurs
size_t
Type entier non signé correspondant au résultat fourni par
sizeof.
wchar_t
Type entier permettant de représenter n’importe quel
caractère des différents jeux étendus qu’on est susceptible
de sélectionner par la fonction setlocale
10.2 Constantes prédéfinies
NULL
Pointeur nul (dépend de l’implémentation).
10.3 Macros prédéfinies
OFFSETOF size_t offsetof (type_s, nom_de_membre)
Fournit la valeur du nombre d’octets séparant le début de la
structure de type type_s de son membre de nom nom_de_membre
(qui ne doit pas être un champ de bits).
11. Stdio.h : entrées-sorties
11.1 Types prédéfinis
size_t
Type entier non signé correspondant au résultat fourni par
sizeof.
FILE
Type d’un objet contenant toutes les informations
nécessaires à la gestion d’un ficher. On y trouve notamment
la valeur d’un pointeur dans un fichier, l’adresse du tampon
associé (s’il existe), des indicateurs : erreur, fin de fichier.
fpos_t
Type d’un objet utilisé pour représenter un pointeur dans un
fichier
11.2 Constantes prédéfinies
NULL
Pointeur nul (dépend de l’implémentation).
_IOFBF, _IOLBF, _IONBF
Les valeurs entières à utiliser comme troisième
argument de la fonction setvbuf
BUFSIZ
Taille du tampon utilisé par la fonction setbuf
EOF
Valeur entière renvoyée par certaines fonctions
pour indiquer que la fin de fichier à été atteinte.
Il s’agit souvent de -1, mais cette valeur n’est
pas imposée par la norme
FOPEN_MAX
Nombre maximal de fichiers qui peuvent être
ouverts simultanément dans l’implémentation1
FILENAME_MAX
Taille nécessaire pour un tableau de caractères
susceptible d’accueillir n’importe quel nom de
fichier de l’implémentation
L_tmpnam
Taille nécessaire pour un tableau de caractères
susceptible d’accueillir n’importe quel nom de
fichier fournit par la fonction tmpnam
SEEK_CUR, SEEK_END,
SEEK_SET
Les valeurs entières à utiliser comme troisième
argument de la fonction fseek
TMP_MAX
Nombre minimal de noms uniques et différents
qu’est susceptible de générer la fonction tmpnam
dans l’implémentation ; ce nombre ne peut être
inférieur à 25.
stderr
Pointeur de type FILE *, associé à la sortie
d’erreur standard
stdin
Pointeur de type FILE *, associé à l’entrée
standard
stdout
Pointeur de type FILE *, associé à la sortie
standard
1. On peut supposer qu’il s’agit des fichiers ouverts par un même
programme, bien que la norme ne soit pas explicite sur ce point.
11.3 Fonctions d’opérations sur les fichiers
REMOVE int remove (const char *nom_fichier)
Supprime le fichier dont le nom est fourni par la chaîne
située à l’adresse nom_fichier. Fournit la valeur 0 si
l’opération a réussi et une valeur non nulle si elle a échoué.
Lorsque le fichier à supprimer est ouvert, le comportement
de cette fonction dépend de l’implémentation.
RENAME int rename (const char *ancien_nom, const char *nouveau_nom)
Modifie le nom d’un fichier. L’ancien nom (chaîne
d’adresse ancien_nom) est remplacé par le nouveau nom
(chaîne d’adresse nouveau_nom). Fournit la valeur 0 si
l’opération a réussi et une valeur non nulle si elle a échoué.
La norme précise comme raisons possibles d’un tel échec le
cas où le fichier concerné est ouvert et celui où le
changement de nom nécessite la recopie du fichier. En cas
d’échec, le fichier conserve son ancien nom.
S’il existe déjà un fichier de nom nouveau_nom, le
comportement de cette fonction dépend de
l’implémentation.
TMPFILE FILE *tmpfile (void)
Crée un fichier temporaire, ouvert en mode wb+ et fournit
dans tmpfile l’adresse du flux correspondant ; cette valeur est
nulle dans le cas où l’opération a échoué. Le fichier en
question sera automatiquement effacé lorsqu’il sera fermé
ou quand le programme se terminera.
TMPNAM char *tmpnam (char *nom_temp)
Fabrique automatiquement un nom de fichier, tout en
assurant qu’il n’existe pas déjà de fichier de même nom
(attention, elle ne crée pas le fichier correspondant !). Si
nom_temp n’est pas nul, tmpnam place ce nom à l’adresse nom_temp
(il faut y prévoir au moins L_tmpnam caractères) et fournit
également l’adresse nom_temp comme valeur de retour. Si
nom_temp est un pointeur nul, la fonction alloue
automatiquement un emplacement pour y ranger le nom de
fichier et en fournit également l’adresse comme valeur de
retour. Cette fonction engendre un nom différent à chaque
appel, à concurrence de TMP_MAX noms différents.
11.4 Fonctions d’accès aux fichiers
FCLOSE int fclose (FILE *flux)
Vide éventuellement le tampon associé au flux concerné,
désalloue l’espace mémoire attribué à ce tampon (lorsque
cette allocation a été automatique) et ferme le fichier
correspondant. Fournit la valeur EOF en cas d’erreur et la
valeur 0 dans le cas contraire.
FFLUSH int fflush (FILE *flux)
Si flux est non nul et associé à un fichier ouvert en écriture
(w, wb, a, ab) ou en mise à jour (+ dans le mode d’ouverture), à
condition que la dernière opération n’ait pas été une lecture,
cette fonction vide le tampon associé au flux. Elle fournit
alors la valeur EOF en cas d’erreur et la valeur 0 dans le cas
contraire. Si flux est associé à un fichier ouvert en lecture (r
ou rb), l’effet de fflush n’est pas défini.
Si flux est nul, cette fonction ferme tous les fichiers ouverts
en écriture ou en mise à jour. On notera que close (comme
exit) effectue le même travail que fflush qui ne sera donc
employée que dans des cas très particuliers.
FOPEN FILE * fopen (const char *nom_fichier, const char *mode)
Ouvre, suivant le mode indiqué, le fichier dont le nom est
fourni par la chaîne d’adresse nom_fichier. Fournit un flux en
retour (pointeur sur une structure de type prédéfini FILE), ou
un pointeur nul si l’ouverture a échoué.
Les valeurs possibles de mode sont décrites en détail à la
section 8 du chapitre 13.
FREOPEN FILE * freopen (const char *nom_fichier, const char *mode, FILE
*flux)
Ferme le fichier actuellement associé à flux (pointeur sur
une structure FILE), puis ouvre le fichier de nom nom_fichier,
en l’associant au même flux. La signification de mode est celle
présentée pour la fonction fopen à la section 8 du chapitre 13.
Fournit, en retour, la valeur de flux, ou un pointeur nul si
l’ouverture a échoué (un échec éventuel de la fermeture de
l’ancien fichier n’a aucune incidence sur cette valeur de
retour).
La fonction freopen est surtout employée pour « connecter »
un fichier particulier à l’un des flux prédéfinis stdin, stdout
ou stderr.
SETBUF void setbuf (FILE *flux, char *tampon)
Si tampon est un pointeur nul, cette fonction supprime
l’utilisation d’un tampon pour le fichier associé au flux
mentionné. Si tampon n’est pas nul, cette fonction permet,
comme setvbuf, de forcer le système à utiliser un tampon
d’adresse tampon.
Plus précisément, l’appel :
setbuf (flux, tampon) ;
est équivalent à l’appel :
setvbuf (flux, tampon, _IOFBF, BUFSIZ)
SETVBUF int setvbuf (FILE *flux, char *tampon, int mode, size_t taille)
Cette fonction force le système à utiliser, pour le fichier
associé à flux, un tampon d’adresse tampon (il faut y prévoir
alors taille caractères), au lieu d’un tampon alloué
automatiquement. Elle doit être appelée après l’ouverture du
fichier et avant toute autre opération portant sur ce fichier.
Les valeurs possibles de mode sont :
_IOFBF (full buffering) : utilisation complète du tampon ;
_IOLBF (line buffering) : en entrée, le tampon est utilisé
complètement ; en sortie, il est vidé à chaque fois qu’un
caractère de fin de ligne est reçu ;
_IONBF (no buffering) : pas d’utilisation de tampon.
Si tampon est un pointeur nul, la fonction allouera
automatiquement un tampon de taille voulue (taille). Notez
qu’on se retrouve alors dans une situation équivalente à
celle où l’on ne fait pas appel à setvbuf, avec cette différence
qu’on contrôle la taille du tampon.
Cette fonction fournit une valeur nulle en cas de succès et
une valeur non nulle en cas d’erreur (requête impossible à
satisfaire ou mode incorrect).
11.5 Fonctions d’écriture formatée
FPRINTF int fprintf (FILE *flux, const char *format, …)
Convertit les valeurs éventuellement mentionnées dans la
liste d’arguments variables (…) en fonction du format
spécifié, puis écrit le résultat dans le flux indiqué. Fournit le
nombre de caractères effectivement écrits ou une valeur
négative en cas d’erreur.
La description détaillée du format se trouve à la section 4 du
chapitre 9.
PRINTF int printf (const char *format, …)
Convertit les valeurs éventuellement mentionnées dans la
liste d’arguments (…) en fonction du format spécifié, puis écrit
le résultat sur la sortie standard (stdout). Fournit le nombre
de caractères effectivement écrits ou une valeur négative en
cas d’erreur.
Notez que :
printf (format, …) ;
est équivalent à :
fprintf (stdout, format, …) ;
La description détaillée du format se trouve à la section 4 du
chapitre 9.
SPRINTF int sprintf (char *chaine, const char *format, …)
Convertit les valeurs éventuellement mentionnées dans la
liste d’arguments (…) en fonction du format spécifié et place le
résultat dans la chaîne d’adresse ch, en le complétant par un
caractère nul. Fournit le nombre de caractères effectivement
écrits (sans tenir compte du caractère de fin) ou une valeur
négative en cas d’erreur. On peut dire que sprintf travaille
comme printf, avec cette seule différence que les
informations produites sont transférées dans une chaîne au
lieu de l’être sur la sortie standard.
S’il y a recouvrement entre les objets d’adresse format et
chaîne (autrement dit si l’on tente d’écrire dans le format), le
comportement est indéterminé.
La description détaillée du format se trouve à la section 4 du
chapitre 9.
VFPRINTF int vfprintf (FILE *flux, const char *format, va_list arg)
Fonctionne comme fprintf, avec cette seule différence que
les expressions concernées sont fournies par la liste variable
arg qui doit avoir été initialisée, dans la fonction appelante,
par un appel de va_start, éventuellement complété par des
appels de va_arg. La fonction vfprintf n’appelle pas va_end.
VPRINTF int vprintf (const char *format, va_list arg)
Fonctionne comme printf, avec cette seule différence que les
expressions concernées sont fournies par la liste variable arg
qui doit avoir été initialisée, dans la fonction appelante, par
un appel de va_start, éventuellement complété par des appels
de va_arg. La fonction vprintf n’appelle pas va_end.
VSPRINTF int vsprintf (char *chaine, const char *format, va_list arg)
Fonctionne comme sprintf, avec cette seule différence que
les expressions concernées sont fournies par la liste variable
arg qui doit avoir été initialisée, dans la fonction appelante,
par un appel de va_start, éventuellement complété par des
appels de va_arg. La fonction vsprintf n’appelle pas va_end.
11.6 Fonctions de lecture formatée
FSCANF int fscanf (FILE *flux, const char *format, …)
Lit des caractères sur le flux spécifié, les convertit en tenant
compte du format indiqué et affecte les valeurs obtenues aux
différentes adresses mentionnées dans la liste d’arguments
(…). Fournit le nombre de valeurs lues convenablement ou la
valeur EOF si une erreur s’est produite ou qu’une fin de
fichier a été rencontrée avant qu’une conversion n’ait pu
être tentée (on a pu sauter des espaces blancs).
La description détaillée du format se trouve à la section 8 du
chapitre 9.
SCANF int scanf (const char *format, …)
Lit des caractères sur l’entrée standard (stdin), les convertit
en tenant compte du format indiqué et affecte les valeurs
obtenues aux différentes variables de la liste d’arguments
(…). Fournit le nombre de valeurs lues convenablement (et
affectées à une variable de la liste) ou la valeur EOF si une
erreur s’est produite ou qu’une fin de fichier a été
rencontrée avant qu’une conversion n’ait pu être tentée (on a
pu sauter des espaces blancs).
Notez que :
scanf (format, …)
est équivalent à :
fscanf (stdin, format, …)
La description détaillée du format se trouve à la section 8 du
chapitre 9.
SSCANF int sscanf (char *chaine, const char *format, …)
Lit des caractères dans la chaîne d’adresse chaine, les
convertit en tenant compte du format indiqué et affecte les
valeurs obtenues aux différentes variables de la liste
d’arguments (…). Fournit le nombre de valeurs lues (et
affectées à une variable de la liste) ou la valeur EOF si une
erreur s’est produite ou que la fin de chaîne a été rencontrée
avant qu’une conversion n’ait pu être tentée (on a pu sauter
des espaces blancs).
En cas de recouvrement entre les objets d’adresse format et
chaîne (autrement dit si l’on tente de lire dans le format), le
comportement est indéterminé.
La description détaillée du format se trouve à la section 8 du
chapitre 9.
11.7 Fonctions d’entrées-sorties de caractères
FGETC int fgetc (FILE *flux)
Lit le caractère courant du flux indiqué s’il en existe encore
un. Dans le cas contraire (fin de fichier atteinte), positionne
l’indicateur de fin de fichier. La valeur de retour fournie par
cette fonction est :
– le résultat de la conversion en int du caractère c (considéré
comme unsigned char) si l’on n’était pas en fin de fichier ;
– la valeur EOF si la fin de fichier était atteinte ou en cas
d’erreur ; dans ce dernier cas, l’indicateur d’erreur du flux
est activé.
FGETS char * fgets (char *chaine, int n, FILE *flux)
Lit au maximum n-1 caractères sur le flux mentionné, en les
rangeant dans la chaîne d’adresse chaîne, en complétant le
tout par un caractère de fin de chaîne (\0). Cette fonction
s’interrompt éventuellement dans les cas suivants :
– rencontre d’un caractère (\n) de fin de ligne qui est alors
recopié en mémoire1, juste avant le caractère \0 ;
– détection d’une fin de fichier : l’indicateur de fin de
fichier est alors positionné ; si aucun caractère n’a encore
été lu alors, le contenu de chaine reste inchangé ;
– situation d’erreur : l’indicateur d’erreur est positionné ; le
contenu de l’emplacement d’adresse chaîne est alors
indéfini.
Cette fonction fournit en retour l’adresse chaîne lorsque
l’opération s’est bien déroulée, le pointeur NULL si une
éventuelle erreur a eu lieu ou si une fin de fichier a été
rencontrée, sans qu’aucun caractère ait été trouvé.
FPUTC int fputc (int c, FILE *flux)
Écrit sur le flux mentionné la valeur de c, après l’avoir
convertie en unsigned char. Fournit la valeur du caractère écrit
(qui peut donc être différente de celle du caractère reçu) ou
la valeur EOF en cas d’erreur (l’indicateur d’erreur étant alors
FPUTS int fputs (const char *chaine, FILE *flux)
Écrit la chaîne d’adresse chaine sur le flux mentionné (sans
caractère de fin de chaîne \0). Fournit une valeur non
négative lorsque le fonctionnement a été correct et la valeur
EOF en cas d’erreur.
GETC int getc (FILE *flux)
Macro effectuant la même chose que la fonction fgetc.
GETCHAR int getchar (void)
Macro définie de façon à ce que l’expression :
getchar ()
soit équivalente à l’expression :
fgetc (stdin)
GETS char *gets (char *chaine)
Lit des caractères sur l’entrée standard (stdin) en les
rangeant à partir de l’adresse chaine, et en les complétant par
un caractère de fin de chaîne (\0). Cette fonction
s’interrompt dans les cas suivants :
– rencontre d’un caractère de fin de ligne (\n) qui n’est alors
pas recopié dans chaine2 (mais qui est consommé),
– détection d’une fin de fichier : l’indicateur de fin de
fichier est alors positionné ; si aucun caractère n’a encore
été lu alors, le contenu de chaine reste inchangé,
– situation d’erreur : l’indicateur d’erreur est positionné ; le
contenu de l’emplacement d’adresse chaine est alors
indéfini.
Cette fonction fournit en retour l’adresse chaine si l’opération
s’est bien déroulée, le pointeur NULL si une éventuelle erreur a
eu lieu ou si une fin de fichier a été rencontrée sans
qu’aucun caractère n’ait été trouvé.
PUTC int putc (int c, FILE *flux)
Macro effectuant la même chose que la fonction fputc.
PUTCHAR int putchar (int c)
Macro définie de façon que l’expression :
putchar (c)
soit équivalente à l’expression :
fputc (c, stdout)
PUTS int puts (const char *chaine)
Écrit sur l’unité standard de sortie (stdout) la chaîne
d’adresse chaine, suivie d’un caractère de fin de ligne \n (le
caractère de fin de chaîne \0 n’est pas écrit). Fournit EOF en
cas d’erreur et une valeur non négative dans le cas contraire.
UNGETC int ungetc (int c, FILE *flux)
Place le caractère c (après conversion en unsigned char) sur le
flux spécifié, de manière qu’une lecture ultérieure sur ce flux
fournisse à nouveau ce caractère c, à condition toutefois
qu’il n’y ait pas eu, entre temps, de modification du pointeur
de fichier correspondant par fseek, fsetpos ou rewind. En aucun
cas le contenu physique du fichier effectivement associé au
flux n’est réellement modifié (le caractère ainsi renvoyé
dans le flux étant simplement conservé temporairement en
mémoire).
La norme n’impose pas à une implémentation d’accepter
plusieurs appels consécutifs de cette fonction (sans lectures
intermédiaires). Trop d’appels consécutifs peuvent donc
conduire à une erreur ou, plus probablement, à l’oubli des
caractères replacés dans le flux.
Lorsqu’un appel à ungetc a aboutit, l’indicateur de fin de
fichier est réinitialisé (à faux), ce qui permettra à une
éventuelle future lecture d’aboutir.
Par ailleurs, la norme impose que la valeur du pointeur de
fichier retrouve sa valeur antérieure, après que tous les
caractères renvoyés dans le flux ont été relus. Entre les deux
(c’est-à-dire entre le premier renvoi de caractère en un
emplacement donné et la dernière relecture), la valeur de ce
pointeur de fichier est :
– indéterminée dans le cas d’un fichier de type texte ;
– décrémentée de un à chaque appel de ungetc, dans le cas
d’un fichier de type binaire.
Cette fonction fournit la valeur EOF en cas d’erreur et la
valeur de c dans le cas contraire.
Si c (de type int) vaut EOF, l’opération échoue et le flux reste
inchangé. Il s’agit d’une protection que la norme a prévue
pour éviter qu’on puisse introduire une marque de fin de
fichier dans les implémentations où celle-ci est
représentable par un caractère.
1. Contrairement à gets qui ne le recopie pas.
2. Contrairement à fgets qui le recopie.
11.8 Fonctions d’entrées-sorties sans formatage
FREAD size_t fread (void *adr, size_t taille, size_t nblocs, FILE *flux)
Lit, sur le flux spécifié, au maximum nblocs de taille octets
chacun et les range à partir de l’adresse adr. Fournit le
nombre de blocs réellement lus. Lorsqu’un bloc n’a pas pu
être lu en entier (erreur ou fin de fichier), sa valeur est
indéterminée. Le pointeur de fichier n’est pas défini en cas
d’erreur.
Si size ou nblocs vaut zéro, la fonction fournit la valeur 0. Le
contenu d’adresse adr reste inchangé, ainsi que l’état du flux.
FWRITE size_t fwrite (const void *adr, size_t taille, size_t nblocs, FILE
*flux)
Écrit, sur le flux spécifié, nblocs blocs de taille octets chacun,
à partir de l’adresse adr. Fournit le nombre de blocs
réellement écrits. Le pointeur de fichier n’est pas défini en
cas d’erreur.
11.9 Fonctions agissant sur le pointeur de fichier
FGETPOS int fgetpos (FILE *flux, fpos_t *pos)
Place, à l’adresse pos, la valeur courante du pointeur de
fichier relatif au flux spécifié. Le type fpos_t dépend de
l’implémentation. En général, on n’utilise pas directement
une telle information. On se contente simplement de la
retransmettre à la fonction fsetpos en vue de se replacer à
l’emplacement mémorisé par fgetpos.
Cette fonction fournit la valeur 0 en cas de succès. En cas
d’erreur, elle fournit une valeur non nulle et elle agit sur le
contenu de la pseudo variable errno (en y plaçant une valeur
définie par l’implémentation).
FSEEK int fseek (FILE *flux, long deplacement, int origine)
Place le pointeur de fichier relatif au flux concerné à un
endroit défini comme étant situé à déplacement octets de
« l’origine » spécifiée par origine :
origine = SEEK_SET correspond au début du fichier ;
origine = SEEK_CUR correspond à la position actuelle du
pointeur de fichier ;
origine = SEEK_END correspond à la fin du fichier.
Dans le cas des fichiers binaires, il est possible que SEEK_END
ne soit pas utilisable. Dans le cas des fichiers de type texte,
les seules possibilités autorisées sont l’une des deux
suivantes :
– deplacement = 0 ;
– deplacement a la valeur fournie par ftell et origine = SEEK_SET.
Après un appel correct à fseek, l’indicateur de fin de fichier
est remis à zéro et les effets d’éventuels appels préalables à
ungetc sont annulés.
La fonction renvoie théoriquement une valeur nulle lorsque
la requête formulée a pu être satisfaite et une valeur non
nulle dans le cas contraire. En pratique, ceci est peu
exploitable.
FSETPOS int fsetpos (FILE *flux, const fpos_t *pos)
Place le pointeur de fichier du flux concerné à
l’emplacement défini par la valeur *pos. Le type fpos_t
dépend de l’implémentation. En général, on ne définit pas
directement une telle valeur : on utilise une valeur obtenue
par un appel préalable à fgetpos.
Cette fonction fournit la valeur 0 en cas de succès ; en cas
d’erreur, elle fournit une valeur non nulle et elle agit sur le
contenu de la pseudo variable errno (en y plaçant une valeur
définie par l’implémentation).
FTELL long ftell (FILE *flux)
Fournit la position courante du pointeur de fichier relatif au
flux indiqué :
– pour un fichier binaire, cette valeur correspond
exactement au nombre d’octets se trouvant entre la
position du pointeur de fichier et le début du fichier ;
– pour un fichier de type texte, la signification de cette
valeur n’est pas imposée par la norme ; en général, elle
correspondra également au nombre d’octets se trouvant
entre la position du pointeur de fichier et le début du
fichier ; cependant, comme dans un tel fichier, certains
caractères (fin de ligne notamment) peuvent être
représentés par plusieurs octets, cette valeur ne sera plus
facile à interpréter.
Dans tous les cas (fichier texte ou fichier binaire), cette
valeur sera directement transmissible à la fonction fseek
(avec origine = SEEK_SET) en vue de replacer le pointeur de
fichier à une position préalablement mémorisée par un appel
de ftell.
La fonction fournit la valeur -1 en cas d’erreur et elle agit
sur le contenu de la pseudo variable errno (en y plaçant une
valeur positive définie par l’implémentation).
REWIND void rewind (FILE *flux)
Place le pointeur de fichier relatif au flux mentionné à son
début, en réinitialisant les indicateurs d’erreur et de fin de
fichier. Les éventuels appels à ungetc sont annulés. Notez
que :
rewind (fich) ;
est équivalent à :
fseek (fich, 0L, SEEK_SET) ; clearerr (fich) ;
11.10 Fonctions de gestion des erreurs d’entrée-sortie
CLEARERR void clearerr (FILE *flux)
Désactive (remet à zéro) les indicateurs d’erreur et de fin de
fichier du flux indiqué.
FEOF int feof (FILE *flux)
Fournit une valeur non nulle si l’indicateur de fin de fichier
du flux indiqué est activé et la valeur 0 dans le cas contraire.
FERROR int ferror (FILE *flux)
Fournit une valeur non nulle si l’indicateur d’erreur du flux
mentionné est activé et la valeur 0 dans le cas contraire.
PERROR void perror (const char *chaine)
Envoie, sur l’unité standard d’erreur (stderr) :
– le contenu de la chaîne d’adresse chaine, lorsque cette
dernière est non nulle, un caractère deux-points (:) et un
espace ;
– à la suite, un message (dépendant de l’implémentation)
relatif à l’erreur dont le numéro figure actuellement dans
la pseudo variable errno. Ce message est le même que celui
qui serait obtenu par un appel de la fonction strerror, avec
l’argument errno. Si ch est non nul :
perror (ch) ;
est équivalent soit à :
fprintf (stderr, "%s: %s\n", ch, "…..") ;
où ….. désigne le message d’erreur en question, soit encore à
(dans certaines implémentations, le caractère \n dans le
format est superflu) :
fprintf (stderr, "%s: %s\n", ch, strerror(errno)) ;
12. Stdlib.h : utilitaires
12.1 Types prédéfinis
size_t
Type non signé correspondant au résultat fourni par sizeof
wchar_t
Type entier permettant de représenter n’importe quel
caractère des différents jeux étendus qu’on est susceptible
de sélectionner par la fonction setlocale
div_t
Structure utilisée pour la valeur de retour de la fonction div
ldiv_t
Structure utilisée pour la valeur de retour de la fonction ldiv
12.2 Constantes prédéfinies
NULL
Pointeur nul (dépend de l’implémentation).
EXIT_FAILURE
Expression entière pouvant être utilisée comme argument de
la fonction exit pour indiquer un retour anormal.
EXIT_SUCCESS
Expression entière pouvant être utilisée comme argument de
la fonction exit pour indiquer un retour normal
RAND_MAX
Plus grande valeur entière fournie par la fonction rand.
MB_CUR_MAX
Nombre maximal d’octets dans un caractère multioctets
compte tenu du choix actuel de localisation ; sa valeur ne
peut être supérieure à MB_LEN_MAX, définie dans limits.h.
12.3 Fonctions de conversion de chaîne
ATOF double atof (const char *chaine)
Fournit le résultat de la conversion en double du début de la
chaîne d’adresse chaine. En fait, l’appel :
atof (ch)
fournit le même résultat que l’appel suivant de la fonction
strtod :
strtod (ch, (char**)NULL)
En revanche, la norme ne précise pas quel doit être le
comportement de cette fonction en cas d’erreur.
ATOI int atoi (const char *chaine)
Fournit le résultat de la conversion en int du début de la
chaîne d’adresse chaine. En fait, l’appel :
atoi (ch)
fournit le même résultat que l’appel suivant de la fonction
strtol (notez la conversion explicite en int) :
(int) strtol (ch, (char **)NULL, 10)
En revanche, la norme ne précise pas quel doit être le
comportement de cette fonction en cas d’erreur.
ATOL long atol (const char *chaine)
Fournit le résultat de la conversion en long du début de la
chaîne d’adresse chaine. En fait, l’appel :
atol (ch)
fournit le même résultat que l’appel :
strtol (ch, (char **)NULL, 10)
En revanche, la norme ne précise pas quel doit être le
comportement de cette fonction en cas d’erreur.
STRTOD double strtod (const char *chaine, char **carinv)
Effectue la conversion du début de la chaîne d’adresse chaine
en une valeur numérique. Cette fonction ignore les éventuels
espaces blancs de début et utilise les caractères suivants
pour fabriquer une valeur numérique de type double. Elle
accepte les mêmes suites de caractères que les codes de
format %f ou %e de scanf ou de sscanf. Avec les localisations
autres que la localisation standard « C », on peut trouver
d’autres formes légales.
Le premier caractère « invalide » (ou le caractère de fin de
chaîne) arrête l’exploration.
La fonction fournit en retour le résultat de la conversion
sauf lorsqu’aucun caractère n’est exploitable, auquel cas elle
fournit la valeur zéro. Si le résultat obtenu dépasse la
capacité d’un double, la fonction fournit la valeur HUGE_VAL
(définie dans stdlib.h), assortie du signe correct. Si le
résultat obtenu est trop petit (sous-dépasssement de
capacité), la fonction fournit la valeur 0. Dans les deux cas,
la variable errno reçoit la valeur prédéfinie ERANGE.
Par ailleurs, si carinv a une valeur non nulle, on obtient dans
*carinv :
– l’adresse du premier caractère invalide rencontré
(éventuellement celle du zéro de fin de chaîne) ;
– l’adresse chaine, si aucun caractère n’a pu être exploité et
ce, même si la chaîne commence par des séparateurs.
STRTOL long strtol (const char *chaine, char **carinv, int base)
Effectue la conversion du début de la chaîne d’adresse chaine
en une valeur numérique de type long. Cette fonction ignore
les éventuels espaces blancs de début et utilise les caractères
suivants pour fabriquer une valeur numérique de type long.
Elle attend un signe facultatif suivi de chiffres
correspondant à la base fournie en argument. Avec d’autres
localisations que la localisation standard (« C »), on peut
éventuellement trouver d’autres formes légales.
Si la valeur de base est comprise entre 2 et 36, le nombre à
convertir est supposé écrit dans cette base (les chiffres, au-
delà de 9, sont notés A, B, C, … jusqu’à Z pour trente-cinq).
Lorsque la valeur de base est 16, le nombre peut commencer
éventuellement par 0x ou 0X. Si la valeur de base est égale à 0,
le nombre à convertir peut être écrit dans l’une des bases 8,
10 ou 16, suivant la manière dont il commence : base 8 s’il
commence par 0 (et que le caractère suivant n’est pas x ou
X), base 16 s’il commence par 0x ou 0X, base 10 dans les
autres cas.
Dans tous les cas, le premier caractère « invalide » (ou le
caractère de fin de chaîne) arrête l’exploration.
La fonction fournit en retour le résultat de la conversion
sauf lorsqu’aucun caractère n’est exploitable, auquel cas elle
fournit la valeur zéro. Si le résultat obtenu dépasse la
capacité d’un long, la fonction fournit l’une des valeurs
prédéfinies LONG_MAX (plus grand positif) ou LONG_MIN (plus petit
négatif). En outre, la pseudo variable errno reçoit alors la
valeur prédéfinie ERANGE.
Par ailleurs, si carinv a initialement une valeur différente de
NULL, on obtient dans *carinv :
– l’adresse du premier caractère invalide rencontré
(éventuellement il peut s’agir du zéro de fin de chaîne) ;
– l’adresse chaîne, si aucun caractère n’a pu être exploité et
ce, même si la chaîne commence par des séparateurs.
STRTOUL unsigned long strtoul (const char *chaine, char **carinv, int base)
Cette fonction travaille de manière comparable à strtol, avec
les deux différences suivantes :
– le résultat fourni est de type unsigned long ;
– en cas de dépassement de capacité, la valeur fournie est
ULONG_MAX.
En revanche, le signe moins reste autorisé.
12.4 Fonctions de génération de séquences de nombres
pseudo aléatoires
RAND int rand (void)
Fournit un nombre entier aléatoire (en fait « pseudo
aléatoire »), compris dans l’intervalle [0, RAND_MAX]. La valeur
prédéfinie RAND_MAX est au moins égale à 32767.
SRAND void srand (unsigned int graine)
Modifie la « graine » utilisée par le « générateur de nombres
pseudo aléatoires » de rand. Pour une valeur de graine
donnée, la suite des valeurs obtenues par des appels
successifs à rand est toujours la même. Par défaut, cette
graine a la valeur 1, autrement dit, si rand est appelée avant
tout appel de srand, tout se passera comme si l’on avait
préalablement appelé srand (1). …/…
À titre indicatif, la norme propose une façon d’écrire ces
deux fonctions rand et srand :
static unsigned long int nbre=1 ;
int rand (void) /* suppose que RAND_MAX vaut 32767 */
{ nbre = nbre * 1103515245 + 12345 ;
return (unsigned int) (nbre/65536) % 32768 ;
}
void srand (unsigned int graine)
{ nbre = graine ;
}
12.5 Fonctions de gestion de la mémoire
CALLOC void * calloc (size_t nb_blocs, size_t taille)
Alloue l’emplacement nécessaire à nb_blocs consécutifs de
taille octets chacun et initialise chaque octet à zéro. Fournit
l’adresse correspondante lorsque l’allocation a réussi ou un
pointeur nul dans le cas contraire.
Si l’une au moins des valeurs de nb_blocs ou taille est nulle,
le comportement de la fonction dépend de l’implémentation.
En pratique, on obtient en résultat soit l’adresse d’un bloc de
taille nulle, soit un pointeur nul.
MALLOC void * malloc (size_t taille)
Alloue un emplacement de taille octets sans l’initialiser et
fournit l’adresse correspondante lorsque l’allocation a réussi
ou un pointeur nul dans le cas contraire.
Si la valeur de taille est nulle, le comportement de la
fonction dépend de l’implémentation. En pratique, on
obtient en résultat soit l’adresse d’un bloc de taille nulle,
soit un pointeur nul.
REALLOC void realloc (void * adr, size_t taille)
Si adr est non nul et correspond à une adresse fournie
préalablement par malloc, calloc ou realloc et qui n’a pas été
libérée par free, cette fonction modifie la taille de la zone
d’adresse adr. Ici, taille représente la nouvelle taille
souhaitée, en octets. Lorsque la nouvelle taille est supérieure
à l’ancienne, le contenu de l’ancienne zone est conservé (il a
pu éventuellement être alors recopié). Dans le cas où la
nouvelle taille est inférieure à l’ancienne, seul le début de
l’ancienne zone est conservé. Si la valeur de taille est nulle
(adr étant non nul) :
realloc (adr, 0) ;
joue le même rôle que :
free (adr) ;
Si adr est nul, cette fonction se comporte comme malloc ;
l’appel :
realloc (NULL, taille) ;
est équivalent à :
malloc (taille) ;
Si adr ne correspond pas à l’adresse d’une zone
préalablement allouée par malloc, calloc ou realloc, ou si la
zone d’adresse adr a déjà été libérée par free, le
comportement du programme est indéterminé. …÷…
La fonction realloc fournit l’adresse de la nouvelle zone ou
un pointeur nul dans le cas où la nouvelle allocation a
échoué (le contenu de la zone reste alors inchangé).
FREE void free (void * adr)
Si adr est non nul et correspond à une adresse fournie
préalablement par malloc, calloc ou realloc, cette fonction
libère la mémoire d’adresse adr.
Si adr est nul, cette fonction ne fait rien.
Si adr ne correspond pas à l’adresse d’une zone
préalablement allouée par malloc, calloc ou realloc, ou si la
zone d’adresse adr a déjà été libérée par free, le
comportement du programme est indéterminé.
12.6 Fonctions de communication avec
l’environnement
ABORT void abort (void)
Entraîne une fin anormale de l’exécution du programme.
L’appel de abort est équivalent à :
raise (SIGABRT)
La norme n’impose pas la fermeture des fichiers ouverts et
la suppression des fichiers temporaires. Ce point dépend
donc de l’implémentation.
EXIT void exit (int etat)
Termine l’exécution du programme. Cette fonction ferme
tout d’abord les fichiers ouverts en vidant les tampons,
détruit les fichiers temporaires créés par tmpfile. Elle appelle
ensuite les « fonctions de sortie » dans l’ordre inverse de
leur enregistrement par atexit. Enfin, elle rend le contrôle au
système, en lui fournissant la valeur etat. La manière dont
cette valeur est effectivement interprétée dépend de
l’implémentation. Toutefois, la valeur EXIT_SUCCESS (en
général, 0) est considérée comme une fin normale avec
succès.
ATEXIT int atexit (void (* fonct) (void) )
Enregistre la fonction pointée par fonct comme une fonction
de sortie, en fournissant une valeur nulle lorsque
l’enregistrement a pu se faire et une valeur non nulle dans le
cas contraire. Plusieurs fonctions de sortie peuvent être ainsi
enregistrées. Elles seront appelées, lors de la fin normale du
programme, dans l’ordre inverse de leur enregistrement.
SYSTEM int system (const char *ad_commande)
Permet de « passer » la commande contenue dans la chaîne
d’adresse ad_commande au système d’exploitation. La valeur
fournie par cette fonction dépend de l’implémentation. Si
ad_commande a la valeur NULL, cette fonction fournit une valeur
non nulle s’il existe au moins une commande accessible
dans l’environnement concerné.
GETENV char * getenv (const char *ad_nom_par)
Fournit la chaîne associée au paramètre d’environnement
ayant le nom indiqué par ad_nom_par. Les noms concernés et
les réponses fournies par cette fonction dépendent de
l’implémentation.
12.7 Fonctions de tri et de recherche
BSEARCH void * bsearch (const void * cle, const void * table, size_t nelem,
size_t taille, int (*fcompare) (const void *, const void *) )
Cette fonction effectue une « recherche dichotomique » d’un
l’élément de valeur donnée (*cle) dans un tableau d’adresse
table, formé de nelem éléments de taille octets (leur type peut
être absolument quelconque). Pour réaliser son travail, la
fonction bsearch sera amenée à effectuer différentes
comparaisons entre les éléments du tableau, en appelant la
fonction fcompare. Celle-ci devra fournir :
– une valeur négative si son premier argument arrive avant
le second (au sens d’un ordre qu’on peut définir
librement) ;
– une valeur nulle si les deux éléments sont égaux ;
– une valeur positive si son premier argument arrive après le
second.
Il n’est pas nécessaire que le tableau soit complètement
ordonné suivant l’ordre induit par la fonction fcompare (bien
sûr, il peut l’être). Il suffit en fait qu’on y trouve :
– d’abord tous les éléments qui apparaissent inférieurs à
*cle ;
– ensuite tous les éléments qui apparaissent égaux à *cle ;
– enfin tous les éléments qui apparaissent supérieurs à *cle.
La fonction bsearch fournit l’adresse de l’un des éléments
égaux à *cle s’il en existe au moins un (il ne s’agit pas
nécessairement du premier), et le pointeur nul s’il n’en
existe aucun.
QSORT void qsort (void * table, size_t nelem, size_t taille, int
(*fcompare) (const void *, const void *) )
Réalise un « tri rapide » (algorithme quick sort) du tableau
d’adresse table, comportant nelem éléments de taille octets
(leur type peut être absolument quelconque). Pour réaliser
son travail, la fonction qsort sera amenée à effectuer
différentes comparaisons entre les éléments du tableau, en
appelant la fonction fcompare. Celle-ci devra fournir :
– une valeur négative si son premier argument arrive avant
le second (au sens d’un ordre qu’on peut définir
librement) ;
– une valeur nulle si les deux éléments sont égaux ;
– une valeur positive si son premier argument arrive après le
second.
Le tri ainsi réalisé n’est pas stable, c’est-à-dire que l’ordre
initial des éléments égaux n’est pas nécessairement
conservé.
12.8 Fonctions liées à l’arithmétique entière
ABS int abs (int n)
Fournit la valeur absolue de n.
LABS long abs (long n)
Fournit la valeur absolue de n.
DIV div_t div (int num, int denom)
Fournit un résultat de type prédéfini div_t (structure
comportant deux champs de type int nommés quot et rem)
correspondant au quotient et au reste de la division entière
de num par denom.
LDIV ldiv_t ldiv (long num, long denom)
Fournit un résultat de type prédéfini ldiv_t (structure
comportant deux champs de type long nommés quot et rem)
correspondant au quotient et au reste de la division entière
de num par denom.
12.9 Fonctions liées aux caractères étendus
MBLEN int mblen (const char *ad_cmo, size_t nb_oct)
Si ad_cmo est non nul, cette fonction fournit le nombre
d’octets, inférieur ou égal à nb_oct, formant, à partir de ad_cmo
un caractère multioctet valide ou -1 si ce n’est pas le cas ;
fournit la valeur 0 si ad_cmo pointe sur un caractère de code
nul. Si l’implémentation utilise un mécanisme de
changement d’état dans le codage des caractères multioctets,
il peut être modifié.
Si ad_cmo est nul, la fonction fournit une valeur non nulle si le
codage des caractères multioctets fait intervenir un
mécanisme de changement d’état en lui redonnant alors sa
valeur initiale, 0 sinon.
MBTOWC int mbtowc (wchar_t *ad_car_et, const char *ad_cmo, size_t nb_oct)
Si ad_cmo est non nul, cette fonction détermine le nombre
d’octets, inférieur ou égal à nb_car, formant, à partir de ad_cmo,
un caractère multioctets valide ; fournit ce nombre ou la
valeur -1 s’il n’existe pas de caractère valide. Si le caractère
est valide et si ad_car_et est non nul, cette fonction en
effectue la conversion en un code interne de type wchar_t et
range le résultat à l’adresse ad_car_et. Si l’implémentation
utilise un mécanisme de changement d’état dans le codage
des caractères multioctets, il peut être modifié.
Si ad_cmo est nul, la fonction fournit une valeur non nulle si le
codage des caractères multioctets fait intervenir un
mécanisme de changement d’état en lui redonnant alors sa
valeur initiale, 0 sinon.
WCTOMB int wctomb (char *ad_cmo, wchar_t car_et) (stdlib.h)
Si ad_cmo est non nul, cette fonction fournit le nombre
d’octets nécessaires à la représentation sous la forme d’un
caractère multioctet du caractère étendu figurant dans car_et
si celui-ci est valide, la valeur -1 sinon. Si l’implémentation
utilise un mécanisme de changement d’état dans le codage
des caractères multioctets, il peut être modifié.
Si ad_cmo est nul, la fonction fournit une valeur non nulle si le
codage des caractères multioctets fait intervenir un
mécanisme de changement d’état en lui redonnant alors sa
valeur initiale, 0 sinon.
12.10 Fonctions liées aux chaînes de caractères
étendus
MBSTOWCS size_t mbstowcs (wchar_t *ad_chet, const char *ad_chmo, size_t
nb_caret)
Cette fonction convertit la chaîne de caractères multi-octets
d’adresse ad_chmo en une chaîne de valeurs de type wchar_t
qu’elle range à partir de l’adresse ad_chet, sans dépasser
nb_caret valeurs. Si ce nombre n’est pas atteint, on trouvera
un zéro de fin. La fonction fournit en retour le nombre de
valeurs de type wchar_t introduites en mémoire (zéro de fin
non compris) si la conversion s’est déroulée
convenablement, la valeur (size_t)-1 sinon.
Si l’implémentation utilise un mécanisme de changement
d’état dans le codage des caractères multioctets, il n’est pas
modifié.
WCSTOMBS size_t wcstombs (char *ad_chmo, const wchar_t * ad_chet, size_t
nb_oct)
Cette fonction convertit la chaîne de valeurs de type wchar_t,
d’adresse ad_chet, en une chaîne de caractères multioctets
qu’elle range à l’adresse ad_chmo, sans dépasser nb_oct octets.
Si ce nombre n’est pas atteint, on trouvera un zéro de fin. La
fonction fournit en retour le nombre d’octets introduits en
mémoire (zéro de fin non compris) si la conversion s’est
déroulée convenablement, la valeur (size_t)-1 sinon.
Si l’implémentation utilise un mécanisme de changement
d’état dans le codage des caractères multioctets, il n’est pas
modifié.
13. String.h : manipulations de suites de caractères
La norme range dans la même sous-bibliothèque les fonctions de traitement de
chaînes de caractères (terminées par un caractère de code nul) et les fonctions
destinées simplement à manipuler des suites d’octets, pour lesquelles la notion
de caractère de fin n’existe plus.
13.1 Types prédéfinis
size_t
Type non signé correspondant au résultat fourni par sizeof
13.2 Constantes prédéfinies
NULL
pointeur nul (dépend de l’implémentation)
13.3 Fonctions de copie
MEMCPY void *memcpy (void *but, const void *source, size_t longueur)
Copie longueur octets depuis l’adresse source à l’emplacement
d’adresse but. Fournit en retour l’adresse but. Si les deux
zones (de même longueur) d’adresse but et source ont des
parties communes, le comportement du programme est
indéterminé. Dans ce cas, on peut recourir à la fonction
memmove.
MEMMOVE void *memmove (void *but, const void *source, size_t longueur)
Fonctionne comme memcpy, en acceptant que la source et le but
se chevauchent.
STRCPY char *strcpy (char *but, const char *source)
Copie la chaîne d’adresse source à l’emplacement d’adresse
but (y compris le \0 de fin) et fournit en retour l’adresse but.
Si les deux chaînes d’adresse but et source ont des parties
communes, le comportement du programme est
indéterminé.
STRNCPY char *strncpy (char *but, const char *source, size_t longueur)
Copie au maximum longueur caractères de la chaîne
d’adresse source à l’emplacement d’adresse but en
complétant éventuellement par des caractères de code nul si
cette longueur maximale n’est pas atteinte. Si cette longueur
est atteinte, aucun caractère de fin de chaîne ne figurera
dans la chaîne d’adresse but. Fournit en retour l’adresse but.
Si les deux chaînes d’adresse but et source ont des parties
communes, le comportement du programme est
indéterminé.
13.4 Fonctions de concaténation
STRCAT char *strcat (char *but, const char *source)
Recopie la chaîne d’adresse source, avec son zéro de fin, à la
fin de la chaîne d’adresse but, c’est-à-dire à partir de son
zéro de fin qui se trouve donc remplacé par le premier
caractère de la chaîne d’adresse source (sauf lorsque cette
dernière est de longueur nulle, auquel cas la chaîne
d’adresse but est inchangée). Fournit en retour l’adresse but.
Si les deux chaînes d’adresse but et source ont des parties
communes, le comportement du programme est
indéterminé.
STRNCAT char *strncat (char *but, const char *source, size_t longueur)
Recopie au maximum longueur caractères de la chaîne
d’adresse source à la fin de la chaîne d’adresse but, c’est-à-
dire à partir de son zéro de fin qui se trouve donc remplacé
par le premier caractère de la chaîne d’adresse source (sauf
lorsque cette dernière est de longueur nulle ou lorsque
longueur vaut 0, auquel cas la chaîne d’adresse but est
inchangée). Dans tous les cas, un caractère de code nul est
ajouté à la fin de la chaîne d’adresse but. Fournit en retour
l’adresse but. Si les deux chaînes d’adresse but et source ont
des parties communes, le comportement du programme est
indéterminé.
13.5 Fonctions de comparaison
MEMCMP int memcmp (const void *zone1, const void *zone2, size_t longueur)
Compare longueur octets commençant à l’adresse zone1 avec
le même nombre d’octets commençant à l’adresse zone2. La
valeur est déterminée suivant les mêmes règles que celles
utilisées par strcmp.
STRCMP int strcmp (const char *chaine1, const char *chaine2)
Compare les chaînes situées aux adresses chaine1 et chaine2,
en se basant sur la valeur du code de leurs caractères,
considérés comme unsigned char, et fournit :
– une valeur négative si chaîne d’adresse chaine1 < chaîne
d’adresse chaine2 ;
– une valeur positive si chaîne d’adresse chaine1 > chaîne
d’adresse chaine2 ;
– zéro si chaîne d’adresse chaine1 = chaîne d’adresse chaine2.
STRCOLL int strcoll (const char *chaine1, const char *chaine2)
Compare les chaînes situées aux adresses chaine1 et chaine2,
en utilisant un ordre défini par les caractéristiques de
localisation courante de la catégorie LC_COLLATE.
Elle fournit :
– une valeur négative si chaîne d’adresse chaine1 < chaîne
d’adresse chaine2 ;
– une valeur positive si chaîne d’adresse chaine1 > chaîne
d’adresse chaine2 ;
– zéro si chaîne d’adresse chaine1 = chaîne d’adresse chaine2.
STRNCMP int strncmp (const char *chaine1, const char *chaine2, size_t
longueur)
Travaille comme strcmp, en limitant la comparaison à un
maximum de longueur caractères pour chacune des deux
chaînes (les caractères au-delà du zéro de fin n’étant jamais
comparés).
STRXFRM size_t strxfrm (char *but, const char *source, size_t longueur)
Transforme la chaîne d’adresse source, de telle manière que
la comparaison par strcmp de deux chaînes transformées par
strxfrm fournisse le même résultat que la comparaison par
strcoll des chaînes d’origine. Le résultat de la
transformation, suivi d’un zéro de fin, est placé à l’adresse
but si la longueur indiquée est suffisante ; dans le cas
contraire, le contenu de l’emplacement d’adresse but est
indéterminé. Si la chaîne d’adresse source et la zone
d’adresse but ont des parties communes (sur leurs longueur
premiers octets), le comportement du programme est
indéterminé.
Cette fonction fournit la longueur de la chaîne ainsi
transformée, y compris dans le cas où cette dernière ne peut
tenir dans la longueur impartie. Il est ainsi possible de
déterminer la longueur de la « transformée » d’une chaîne
donnée, par exemple avant d’allouer un emplacement de
taille adéquate. Lorsque le paramètre longueur a pour valeur
0, il est possible de fournir un pointeur nul pour chaîne1.
Voici, par exemple, comment obtenir la taille nécessaire
pour accueillir la transformée d’une chaîne d’adresse ch,
avant d’opérer la transformation proprement dite :
int taille ;
char * adres ;
…..
taille = strxfrm (NULL, ch, 0) + 1 ; /* +1 pour le 0 de fin */
adres = malloc (taille) ;
strxfrm (adres, ch, taille) ;
13.6 Fonctions de recherche
MEMCHR void *memchr (const void *zone, int c, size_t longueur)
Fournit l’adresse de la première occurrence de la valeur c
(convertie en unsigned char) dans les longueur octets d’adresse
zone, le pointeur nul si ce caractère n’y figure pas ou si
longueur est nulle.
STRCHR char *strchr (const char chaine, int c) ;
Fournit un pointeur sur la première occurrence du caractère
résultant de la conversion de c en char, dans la chaîne
d’adresse chaine, ou un pointeur nul si ce caractère n’y figure
pas. Le caractère de fin de chaîne participe à la recherche, ce
qui signifie qu’en recherchant un caractère de code nul dans
une chaîne, on obtient simplement l’adresse de son zéro de
fin.
STRCSPN size_t strcspn (const char *chaine1, const char *chaine2)
Fournit la longueur du « segment initial » de chaine1 formé
entièrement de caractères n’appartenant pas à chaine2.
STRPBRK char *strpbrk (const char *chaine1, const char *chaine2) ;
Fournit un pointeur sur la première occurrence dans la
chaîne d’adresse chaine1 de l’un des caractères de la chaîne
d’adresse chaine2 ou un pointeur nul si aucun de ces
caractères n’y figure.
STRRCHR char *strrchr (const char *chaine, int c) ;
Fournit un pointeur sur la dernière occurrence dans la chaîne
d’adresse chaine du caractère corrrespondant à la conversion
de la valeur c en char ou un pointeur nul si ce caractère n’y
figure pas. Le caractère de fin de chaîne participe à la
recherche, ce qui signifie qu’en recherchant un caractère de
code nul dans une chaîne, on obtient simplement l’adresse
de son zéro de fin.
STRSPN size_t strspn (const char *chaine1, const char *chaine2) ;
Fournit la longueur du « segment initial » de la chaîne
d’adresse chaine1 formé entièrement de caractères
appartenant à la chaîne d’adresse chaine2.
STRSTR char *strstr (const char *chaine1, const char *chaine2) ;
Recherche la première occurrence, si elle existe, dans la
chaîne d’adresse chaine1 de la chaîne d’adresse chaine2 (le
zéro de fin ne participe pas à la recherche). Fournit l’adresse
de la chaîne ainsi localisée si elle existe ou un pointeur nul
dans le cas contraire. Si chaine2 est une chaîne vide, la
fonction fournit l’adresse chaine1.
STRTOK char *strtok (char *chaine, const char *delimiteurs) ;
Cette fonction permet « d’éclater » la chaîne d’adresse chaine
en différentes sous-chaînes obtenues en considérant comme
délimiteurs les différents caractères de la chaîne delimiteurs.
Plus précisément, le premier appel de strtok fournit l’adresse
de la première sous-chaîne, c’est-à-dire l’adresse du premier
caractère différent de ceux de la chaîne delimiteurs, après
avoir remplacé le premier délimiteur trouvé au-delà de ce
caractère (c’est-à-dire le premier caractère appartenant à
delimiteurs) par un caractère nul. Les appels suivants de
strtok (tant qu’ils concernent la même chaîne) doivent se
faire avec un pointeur nul comme adresse de chaine ; ils
fonctionnent de manière analogue au premier appel, en
commençant leur recherche à partir du caractère suivant le
dernier caractère nul placé. On obtient un pointeur nul
lorsqu’aucun caractère différent des caractères de delimiteurs
n’est trouvé (ce qui peut éventuellement se produire au
premier appel). Il n’est pas nécessaire que la chaîne
d’adresse delimiteurs soit la même pour tous les appels
relatifs à une même chaîne.
13.7 Fonctions diverses
MEMSET void *memset (void *zone, int c, size_t longueur)
Copie longueur fois le caractère obtenu par conversion en
unsigned char de la valeur c, à partir de l’adresse zone. Fournit
en retour l’adresse zone.
STRERROR char *strerror (int num_erreur)
Fournit un pointeur sur une chaîne de caractères
correspondant à un message d’erreur (dépendant de
l’implémentation) relatif à l’erreur de numéro num_erreur.
Cette chaîne ne doit pas être modifiée par le programme ; en
revanche, elle peut l’être par un nouvel appel à strerror, ce
qui signifie que, dans certains cas, on pourra être amené à la
recopier au sein du programme pour en assurer la pérennité.
STRLEN size_t strlen (const char *chaine)
Fournit la longueur de la chaîne d’adresse chaine.
14. Time.h : gestion de l’heure et de la date
Ces fonctions manipulent des temps constitués de la réunion d’une date et d’une
heure. La plupart d’entre elles se réfèrent à ce qu’on nomme un « temps
calendaire », dans lequel la date correspond au calendrier grégorien. Certaines
fonctions se réfèrent à un « temps local » qui n’est rien d’autre que le temps
calendaire adapté à une zone géographique donnée, dépendant alors de
l’implémentation. Enfin, quelques fonctions se réfèrent à un temps local qui tient
compte des décalages horaires saisonniers (heure d’hiver et heure d’été, par
exemple).
14.1 Types prédéfinis
size_t
Type entier non signé correspondant au résultat fourni par
sizeof
time_t
Type entier servant à représenter une durée suivant un
codage dépendant de l’implémentation
clock_t
Type entier servant à représenter un temps suivant un
codage dépendant de l’implémentation
struct tm
Structure servant à représenter un temps courant (date +
heure) et comportant les champs suivants :
int tm_sec : nombre de secondes (0, 59)
int tm_min : nombre de minutes (0, 59)
int tm_hour : heure (0, 23)
int tm_mday : jour du mois (1, 31)
int tm_mon : numéro de mois (0, 11)
int tm_year : année (1900 = 0)
int tm_wday : numéro du jour dans la semaine (0 = dimanche)
int tm_yday : numéro du jour dans l’année (0, 365)
int tm_isdst : indicateur précisant si l’heure est soumise à un
« décalage solaire local » (valeur positive = oui, 0 = non,
valeur négative = information non disponible).
14.2 Constantes prédéfinies
NULL
Pointeur nul (dépend de l’implémentation)
CLOCKS_PER_SEC
Sert à définir l’unité de temps et représente le nombre
d’unités contenues dans une seconde.
14.3 Fonctions de manipulation de temps
CLOCK clock_t clok (void)
Fournit une approximation du temps utilisé par le
programme depuis le début de son exécution ou la valeur
(clock_t)-1 si cette information n’est pas disponible. L’unité
est définie par CLOCK_PER_SEC, nombre d’unités contenues dans
une seconde.
DIFFTIME double difftime (time_t instant2, time_t instant1)
Fournit la différence (en secondes) entre les deux temps
calendaires instant1 et instant2 (en secondes).
MKTIME time_t mktime (struct tm * adr)
Cette fonction fournit le résultat de la conversion du temps
local figurant dans la structure d’adresse adr en un temps
calendaire exprimé sous la même forme (dépendant de
l’implémentation) que celle utilisée par la fonction time. Les
valeurs figurant dans les champs tm_wday et tm_yday sont
ignorées. Il n’est pas nécessaire que les valeurs des autres
champs respectent les contraintes prévues ci-dessus à la
section 14.1 (par exemple, il est équivalent de considérer le
jour 32 du mois 0, ou le jour 2 du mois 1…). En cas de
succès, les valeurs des champs tm_wday et tm_yday sont
renseignées de façon appropriée ; celles des autres champs
sont ajustées, de manière à respecter les contraintes prévues.
Cette fonction fournit la valeur (time_t)-1 si la conversion
n’a pu aboutir.
TIME time_t time (time_t * adr)
Fournit une approximation de la valeur du temps calendaire
(date+heure) courant, exprimé suivant un codage dépendant
de l’implémentation ou la valeur (time_t)-1 si cette
information n’est pas disponible. De plus, si adr est non nul,
la valeur de retour est également rangée à l’adresse adr.
14.4 Fonctions de conversion
ASCTIME
char * asctime (const struct tm * adr)
Traduit le temps (date + heure) figurant dans la structure
d’adresse adr sous la forme d’une chaîne de caractères
l’exprimant « en clair ». Par exemple, pour le vendredi 16
janvier 1998, 16h 19 mn 15 s, on obtiendra la chaîne
suivante (terminée par une fin de ligne notée ici ) :
Fri Jan 16 [Link] 1998
Si les valeurs des champs de la structure ne respectent pas
les contraintes prévues à la section 14.1, le comportement
du programme est indéterminé (en pratique, on obtient des
valeurs fantaisistes).
Les abréviations utilisées pour les noms de mois et de jour
sont imposées par la norme et exprimées sur trois caractères
(Mon, Tue, Wed, Thu, Fri, Sat, Sun pour les jours et Jan, Feb, Mar, Apr,
May, Jun, Jul, Aug, Sep, Oct, Nov, Dec pour les mois).
La fonction fournit en résultat un pointeur sur cette chaîne
dont l’emplacement est de classe statique et dont la valeur
peut se trouver modifiée, suite à un nouvel appel à asctime.
CTIME
char * ctime (const time_t * adr)
Joue le même rôle que :
asctime (localtime(adr))
GMTIME
struct tm * gmtime (const time_t * adr)
Convertit le temps calendaire (date + heure) figurant dans la
structure de type tm et d’adresse adr en un « temps
universel » (TU), exprimé de la même manière, c’est-à-dire
dans une structure de type tm.
La fonction fournit un pointeur sur la structure contenant le
résultat de la conversion ; son emplacement est de classe
statique mais sa valeur peut être modifiée par un nouvel
appel à gmtime. On obtient un pointeur nul si le temps TU
n’est pas accessible dans l’implémentation.
LOCALTIME struct tm * localtime (const time_t * adr)
Convertit un temps calendaire (date + heure) figurant dans
la structure de type tm et d’adresse adr en un temps local,
exprimé de la même manière, c’est-à-dire dans une structure
de type tm, en tenant compte du fuseau horaire et des
décalages solaires locaux éventuels.
La fonction fournit un pointeur sur la structure contenant le
résultat de la conversion ; son emplacement est de classe
statique mais sa valeur peut être modifiée par un nouvel
appel à localtime.
STRFTIME
size_t strftime (char *ad_ch, size_t lgmax, const char *format,
const struct tm *ad_t)
Cette fonction permet d’effectuer un formatage de tout ou
partie des informations contenues dans une structure de type
struct tm. La chaîne d’adresse format contient des codes de
format, commençant par % et utilisant l’un des codes décrits
ci-après. Les autres caractères sont recopiés tels quels. Si le
résultat du formatage est de taille strictement inférieure à
lgmax caractères, il est rangé à l’adresse ad_ch, complété par
un zéro de fin, et la fonction renvoie cette taille. Dans le cas
contraire, la fonction renvoie une valeur nulle et le contenu
de l’adresse ad_ch est indéterminé.
Voici la liste des codes utilisables :
%a abréviation locale du jour de la semaine
%A nom local du jour de la semaine
%b abréviation locale du nom de mois
%B nom local du mois
%c date et heure dans un format local
%d jour du mois (1-31)
%H heure (0-23)
%I heure (0-12) (attention, il s’agit de i majuscule)
%j jour de l’année (1-366)
%m numéro de mois (1-12)
%M minutes (0-59)
%p indication complémentaire pour une heure exprimée sur
12 heures (souvent AM/PM)
%S secondes (0-59)
%U semaine de l’année (0-53, le premier dimanche étant le
premier jour de la semaine 1)
%w numéro du jour de la semaine (0-6, dimanche = 0)
%W semaine de l’année (0-53, le premier lundi étant le
premier jour de la semaine 1)
%x date sous forme locale
%X heure sous forme locale
%y année, sans numéro de siècle (00-99)
%Y année, avec numéro de siècle
%Z abréviation représentant la zone locale (rien si
l’information n’est pas disponible)
%% caractère %
B
Les normes C99 et C11
L’ISO a publié deux extensions de la norme du langage C : la première en 1999
(référence ISO/IEC 9899:1999), plus connue sous l’acronyme C99, et la seconde
en 2011 (référence ISO/IEC 9899:2011), plus connue sous l’acronyme C11.
Elles sont loin d’être implémentées entièrement par tous les compilateurs. C’est
ce qui justifie la présence de cette annexe qui en présente la plupart des
nouveautés. Notez que, sauf mention contraire, les apports de la norme C99 se
retrouvent dans la C11.
1. Contraintes supplémentaires (C99)
La norme C99 supprime quelques tolérances subsistant dans le C90.
1.1 Type de retour d’une fonction
En C99, le type de retour d’une fonction doit toujours être mentionné, alors
qu’en C90, l’absence de type est interprétée comme int.
1.2 Déclaration implicite d’une fonction
En C90, il est possible d’utiliser une fonction sans qu’elle ait été déclarée ou
définie préalablement dans le même fichier source. Dans ce cas, tout se passe
comme si la fonction avait été déclarée partiellement de cette manière (la
fonction se nommant ici f) :
int f ()
En C99, ceci n’est plus permis. Toute fonction utilisée dans un fichier source et
non définie préalablement dans ce même fichier, doit être déclarée. En revanche,
rien n’est imposé concernant la déclaration de ses arguments puisqu’une liste
vide correspond toujours à des arguments de types indéfinis.
1.3 Instruction return
En C90, il est possible d’introduire une instruction return sans expression, alors
que la fonction doit fournir un résultat. Cette tolérance n’est plus admise en C99.
2. Division d’entiers (C99)
En C90, il subsiste une ambiguïté pour les opérateurs / et % lorsque l’un au moins
de leurs opérandes est négatif (voir section 2.1.2 au chapitre 4). La norme C99
tranche entre les deux possibilités en imposant que la troncature du quotient ait
toujours lieu vers zéro (en cas d’ambiguïté, c’est la valeur de plus petite valeur
absolue qui est retenue). Voici quelques exemples :
• 11/3 vaut 3, en C99 comme en C90 ;
• -11/3 vaut -3 en C99, -3 ou -4 en C90 ;
• 11/-3 vaut -3 en C99, -3 ou -4 en C90 ;
• -11/-3 vaut 3 en C99, 3 ou 4 en C90 ;
• 11%3 vaut 2 en C99 comme en C90 ;
• -11%3 vaut -2 en C99, -2 ou 1 en C90 ;
• 11%-3 vaut 2 en C99, 2 ou -1 en C90 ;
• -11%-3 vaut -2 en C99, -2 ou 1 en C90.
3. Emplacement des déclarations (C99)
En C90, les déclarations doivent figurer au début d’un bloc, avant les premières
instructions exécutables. Cette contrainte n’existe plus en C99 où une variable
doit simplement être déclarée avant d’être utilisée, comme dans cet exemple :
{ int n ;
n =….
int p=12, q ;
q = n + 2*p ;
…..
}
Par ailleurs, C99 permet de déclarer une variable utilisée comme compteur d’une
boucle for, au sein même de la boucle, ce qui permet de libérer l’espace
correspondant dès la sortie de la boucle. En voici un exemple :
for (int i=0 ; i<12 ; i++)
{ …..
}
D’une manière générale, la nouvelle syntaxe de l’instruction for prévoit que sa
première partie (initialisation) puisse être non seulement une liste d’expressions,
mais aussi une déclaration. Cette dernière peut éventuellement porter sur une ou
plusieurs variables de même type, comme dans :
for (int i=0,j=3 ; i<12 ; i++) { ….. }
qui remplace alors :
int i, j ;
…..
for (i=0,j=3 ; i<12 ; i++) { ….. }
Dans le premier cas, les emplacements de i et de j seront libérés en sortie de
boucle.
En revanche, il ne sera pas possible d’initialiser deux variables de types
différents en les déclarant dans l’instruction for :
for (int i=0, double x=0. ; ….. ) { ….. } /* incorrect */
Il vous faudra en déclarer au moins une à l’intérieur.
4. Commentaires de fin de ligne (C99)
C99 autorise les commentaires de fin de ligne, comme C++ ou Java. Ils sont
introduits par // et tout ce qui suit ces deux caractères jusqu’à la fin de la ligne
est considéré comme un commentaire. Cette nouvelle possibilité n’apporte qu’un
surcroît de confort et de sécurité ; en effet, une ligne telle que :
printf ("bonjour\n") ; // formule de politesse
peut toujours être écrite ainsi :
printf ("bonjour\n") ; /* formule de politesse */
Vous pouvez mêler (volontairement ou non !) les commentaires libres et les
commentaires de fin de ligne. Dans ce cas, notez que, dans :
/* partie1 // partie2 */ partie3
le commentaire « ouvert » par /* ne se termine qu’au prochain */ ; donc partie1 et
partie2 sont des commentaires, tandis que partie3 est considéré comme
appartenant aux instructions. De même, dans :
partie1 // partie2 /* partie3 */ partie4
le commentaire introduit par // s’étend jusqu’à la fin de la ligne. Il concerne
donc partie2, partie3 et partie4.
On notera que le commentaire de fin de ligne constitue l’un des deux cas où la
fin de ligne joue un rôle significatif. L’autre cas concerne les directives destinées
au préprocesseur (il ne concerne donc pas la compilation proprement dite).
5. Tableaux de dimension variable (C99, facultatif en
C11)
En C90, la dimension d’un tableau ne pouvait être qu’une expression constante.
En C99, il est possible d’utiliser une expression quelconque, pour peu que sa
valeur soit calculable à l’exécution lorsque l’on rencontre l’instruction de
déclaration correspondante. Nous distinguerons deux cas suivant que cette
dimension dite variable apparaît dans une déclaration ou dans un en-tête de
fonction.
5.1 Dans les déclarations
En C99, la dimension d’un tableau peut être indiquée sous la forme d’une
expression entière quelconque (et non plus seulement sous la forme d’une
expression constante). En voici un exemple :
main()
{ …..
int n, p ;
…..
scanf (…, &n) ;
float ti [n] ;
double td [2*n + p] ;
…..
}
Les tableaux ti et td verront leurs emplacements alloués dynamiquement lors de
l’exécution et ils seront libérés à la sortie du bloc correspondant (ici, il s’agit du
corps du main).
Voici un autre exemple où la dimension fait intervenir des valeurs fournies en
argument d’une fonction :
void f (int n, int q)
{ ….
int t[2*n+q] ;
…..
}
5.2 Dans les en-têtes de fonctions et leurs prototypes
En C99, la dimension d’un tableau fourni en argument peut être définie à partir
des valeurs de un ou plusieurs autres arguments, comme dans ces exemples :
void f (int n, double t[n])
{ ….. }
void g (int n, int p, double t[n*p])
{ ….. }
Il est nécessaire que les arguments utilisés dans la dimension du tableau
apparaissent avant le tableau lui-même.
Les prototypes de f et de g pourront se présenter sous la forme :
void f (int n, double t[n]) ;
void g (int n, int p, double t[n+p]) ;
ou sous la forme :
void f (int n, double t[*]) ;
void g (int n, int p, double t[*]) ;
ou encore :
void f (int , double t[*]) ;
void g (int , int , double t[*]) ;
En combinant cette possibilité avec la précédente (dimension variable dans une
déclaration), on peut réaliser des constructions telles que :
void f (int n, double t[n])
{ …..
int td [n] ; /* ici td sera un tableau */
/* de même dimension que t */
…..
}
6. Nouveaux types (C99)
6.1 Nouveau type entier long long (C99)
Le nouveau type entier long long et sa variante non signée unsigned long long ont
surtout été prévus pour tenir compte de l’apparition des machines à 64 bits.
Leurs limites effectives sont précisées dans les constantes suivantes du fichier
en-tête limits.h :
• LLONG_MIN : valeur minimale de long long (au moins -9 223 372 036 854 775 807) ;
• LLONG_MAX : valeur maximale de long long (au moins 9 223 372 036 854 775 807) ;
• ULLONG_MAX : valeur maximale de unsigned long long (au moins 18 446 744 073 709
551 615).
Les constantes de ce type se notent avec le suffixe LL (ou ll) ou ULL (ou ull).
Pour exploiter ce type dans les opérations d’entrées-sorties formatées, on utilise
le modificateur ll comme dans %lld pour un affichage d’un long long sous forme
décimale.
6.2 Types entiers étendus (C99)
La taille effective d’un type entier dépend de l’environnement. C99 a introduit
de nouveaux types entiers, permettant un meilleur contrôle sur le nombre de bits
effectivement utilisés.
Plus précisément, le fichier en-tête stdint.h contient les définitions de nouveaux
types entiers. Certains sont facultatifs pour une implémentation donnée et sont
donc d’un intérêt limité :
• entiers de taille exacte, à savoir :
– int8, int16, int32 et int64 pour les entiers signés ;
– uint8, uint16, uint32 et uint64 pour les entiers non signés ;
• entiers pouvant contenir n’importe quel pointeur : intptr_t et uintptr_t.
Les autres types sont obligatoires en C99 :
• entiers codés sur un minimum de N bits, N pouvant représenter 8, 16, 32 ou
64 : leur nom est de la forme int_leastN_t pour les entiers signés (par exemple,
int_least32_t avec N = 32) et de la forme uint_leastN_t pour les entiers non signés
(par exemple, uint_least64_t) ;
• entiers codés sur un minimum de N bits (comme les précédents), mais offrant
de plus les opérations les plus rapides possibles ; leur nom est de la forme
int_fastN_t pour les entiers signés (par exemple, int_fast16_t) et uint_fastN_t pour
les entiers non signés (par exemple, uint_fast32_t) ;
• entiers de taille maximale, susceptibles de représenter n’importe quelle valeur
d’un type signé (intmax_t) ou non signé (uintmax_t).
Enfin, le fichier stdint.h contient également des macros à utiliser pour le
formatage de ces entiers dans les entrées-sorties formatées. Par exemple, pour
afficher un entier nr de type int_fast16_t, on procédera ainsi :
int_fast16_t nr ;
printf (" valeur de nr = %12" PRIdFAST16), nr) ;
D’une manière générale, ces macros voient leur nom formé de PRI (print), suivi
d’une spécification de format (o, u, x ou X) et d’une spécification de type de la
forme LEASTN, FASTN, MAX ou PTR, N représentant le nombre de bits correspondant au
type. Elles fournissent seulement le code de conversion et les éventuels
modificateurs (h, l, L, ll…) correspondant au type réellement utilisé dans
l’implémentation ; il ne faut pas oublier de leur concaténer le caractère % ainsi
que les éventuels indicateurs de gabarit (12 dans notre exemple) et de précision.
Pour la lecture formatée, on utilisera le même mécanisme, les macros étant cette
fois préfixées par SCN (scan).
6.3 Nouveaux types flottants (C99)
Le type float_t désigne le type flottant le plus rapide dont la taille est supérieure
ou égale au type float. De même, le type double_t désigne le type flottant le plus
rapide dont la taille est supérieure ou égale au type double.
6.4 Le type booléen (C99)
C99 a introduit le type booléen, sous le nom bool. Une variable de ce type ne peut
prendre que l’une des deux valeurs : vrai (notée true) et faux (notée false).
Or, bon nombre de programmes écrits en C90 simulaient souvent un tel type à
l’aide de constantes entières prédéfinies (valant généralement 0 et 1) nommées
fréquemment true et false, et ce type lui-même était également souvent défini par
une énumération nommée bool. Pour que ces programmes restent compatibles, la
norme C99 a prévu que le type bool ne serait disponible que moyennant
l’inclusion d’un fichier en-tête nommé stdbool.h contenant précisément les
définitions du type bool et des deux constantes true et false. On peut alors écrire
des constructions de ce genre :
#include <stdbool.h> /* pour disposer du type bool */
….
bool ok = false ;
…..
if (…) ok = true ;
…..
if (ok) …..
On notera que, contrairement à ce qui se passe dans d’autres langages, il ne
s’agit pas d’un vrai type booléen, mais simplement d’une simulation par des
valeurs entières. En particulier, rien n’interdit d’attribuer une valeur entière
quelconque à une variable déclarée de type bool. On retrouve en fait les
difficultés inhérentes au type enum.
Pour être exhaustifs, signalons que :
• il existe un type nommé _Bool dont les variables peuvent prendre l’une des
valeurs 0 ou 1 et qui peut être utilisé sans incorporer le fichier stdbool.h (en fait,
il sert de base à la construction du type bool défini dans ce fichier) ;
• le fichier stdbool.h contient une macro constante __bool_true_false_are_defined
valant 1.
6.5 Les types complexes (C99, facultatif en C11)
C99 a introduit de nouveaux types permettant de manipuler des nombres
complexes. Là encore, comme pour le type booléen, pour préserver la
compatibilité de programmes C90 ayant défini leur propre type sous le nom
complex, il a été prévu que ces types ne seraient disponibles que moyennant
l’inclusion d’un fichier en-tête nommé complex.h. On dispose alors de trois types,
se différenciant par la taille utilisée pour représenter les deux composantes
(partie entière et partie imaginaire) du nombre :
• float complex : composantes de type float ;
• double complex : composantes de type double ;
• long double complex : composantes de type long double.
La constante I, définie sous forme d’une macro, permet de donner une valeur à
une variable de type complex, en précisant ses composantes :
#include <complex.h> /* pour disposer du type complex */
float complex c1, c2 ;
float x, y ;
…..
c1 = x + I * y ;
c2 = 1.0 + 2.0 * I ;
Les types complexes disposent des opérations arithmétiques usuelles (+, -, * et /).
L’accès aux parties réelles et imaginaires d’un complexe se fait à l’aide des
fonctions :
• crealf : partie réelle d’un complexe de type float complex ;
• creal : partie réelle d’un complexe de type double complex ;
• creall : partie réelle d’un complexe de type long double complex ;
• cimagf : partie imaginaire d’un complexe de type float complex ;
• cimag : partie imaginaire d’un complexe de type double complex ;
• cimagl : partie imaginaire d’un complexe de type long double complex ;
D’autres fonctions ont été introduites (nous verrons qu’il en existe des « versions
génériques » :
• détermination du module d’un nombre complexe : fonctions cabsf, cabs, cabsl ;
• détermination de l’argument d’un nombre complexe : fonctions cargf, carg,
cargl ;
• puissance complexe : cpowf, cpow, cpowl ;
• détermination du complexe conjugué d’un complexe donné : conjf, conj, conjl ;
• détermination de la projection sur la sphère de Riemann d’un complexe donné :
cprojf, cproj, cprojl ;
• exponentielle complexe : cexpf, cexp, cexpl ;
• logarithme complexe : clogf, clog, clogl ;
• racine carrée complexe : csqrtf, csqrt, csqrtl ;
• fonctions trigonométriques complexes : csinf, csin, csinl, ccosf, ccos, ccosl, ctanf,
ctan, ctanl, casinf, casin, casinl, cacosf, cacos, cacosl, catanf, catan, catanl ;
• fonctions trigonométriques hyperboliques complexes : csinhf, csinh, csinhl,
ccoshf, ccosh, ccoshl, ctanhf, ctanh, ctanhl, casinhf, casinh, casinhl, cacoshf, cacosh,
cacoshl, catanhf, catanh, catanhl.
Par ailleurs, il existe trois types permettant de représenter des nombres
imaginaires purs : float imaginary, double imaginary et long double imaginary.
Il faut signaler également qu’il existe deux types nommés Complex et Imaginay qui
sont utilisables sans qu’il soit nécessaire d’incorporer le fichier complex.h.
Enfin, comme on le verra plus loin, il est possible de bénéficier de versions
génériques de toutes ces fonctions relatives aux complexes.
En C11, la variable STDC_NO_COMPLEX_est définie lorsque ces fonctionnalités sont
absentes.
7. Nouvelles fonctions mathématiques (C99)
Outre les fonctions introduites pour le type complex, présentées précédemment, les
apports de C99 peuvent se classer en trois catégories :
• généralisation aux types float et long double des fonctions mathématiques
prévues en C90 pour le seul type double ;
• nouvelles fonctions ;
• introduction de fonctions génériques permettant d’utiliser un seul nom pour les
trois types flottants et, éventuellement, pour les types complexes.
7.1 Généralisation aux trois types flottants (C99)
Toutes les fonctions mathématiques de nom xxxx (concernant le type double),
prévues dans math.h se voient complétées de xxxxf pour le type float et de xxxxl
pour le type long double. Par exemple, la fonction atan (type double) est complétée
par atanf (type float) et atanl (type long double).
7.2 Nouvelles fonctions (C99)
• Les fonctions abs et labs sont complétées par llabs pour le type long long
(prototypes dans stdlib.h) et par imaxabs pour le type intmax_t (prototype dans
inttypes.h).
• Détermination du maximum de deux flottants : fmaxf, fmax, fmaxl.
• Détermination du minimum de deux flottants : fminf, fmin, fminl.
• Calcul de racines cubiques : cbrt (type double), dbrtf (type float) et cbrtl (type
long double).
• Approximation entière de quotients de flottants : la fonction fmod de C90 est
complétée par fmodf et fmodl. De plus, C99 introduit les nouvelles fonctions
remainderf, remainder et remainderl qui effectuent un arrondi au plus proche.
• Troncature : la fonction floor de C90 est complétée par floorf et floorl. Celles-ci
fournissent un flottant, arrondi par défaut de la valeur fournie en argument. De
plus, deux nouvelles fonctions sont introduites :
– trunc (truncf et truncl) qui fournit un arrondi par excès ;
– ceil (ceilf et ceill) qui fournit l’entier le plus proche en allant vers 0 (arrondi
par excès pour les valeurs négatives, et par défaut pour les valeurs
positives).
• Calcul de l’entier le plus proche d’un flottant donné : certaines fonctions
fournissent un résultant flottant (roundf, round et roundl) ; d’autres fournissent un
résultat entier de type long (lroundf, lround et lroundl) ou de type long long
(llroundf, llround et llroundl). Dans tous les cas, la valeur « centrale » est
arrondie vers le flottant de plus grande valeur absolue.
• Calcul d’arrondi de flottant, en utilisant le mode d’arrondi défini par
l’environnement de calcul flottant (présenté plus loin) :
– nearbyintf, nearbyint et nearbyintl fournissent un résultat de type flottant ;
– rintf, rint et rintl fournissent, comme les fonctions précédentes, un résultat
de type flottant, mais en provoquant une exception lorsque l’argument ne
correspond pas à une valeur entière exacte ;
– lrintf, lrint et lrintl fournissent un résultat de type long ;
– llrintf, llrint et llrintl fournissent un résultat de type long long.
Principales nouvelles fonctions liées aux logarithmes :
• exp2f, exp2 et exp2l : puissance réelle de 2 ;
• log2f, log2 et log2l : logarithme à base 2 ;
• log1pf, log1p et log1pl : valeur de log(1+x) ;
• tgammaf, tgamma et tgammal : valeur de la fonction Gamma ;
• lgammaf, lgamma et lgammal : logarithme népérien de la fonction Gamma.
Pour les rares implémentations qui n’utilisent pas la base 2 dans la représentation
des flottants, la fonction ldexp se voit complétée par :
• scalbnf, scalbn et scalbnl qui fournissent la valeur de l’expression [Link], b étant la
base de représentation des flottants (fournie dans FLT_RADIX), les valeurs de m et n
étant fournies en argument (type int pour n) ;
• scalblnf, scalbln et scalblnl qui fournissent la valeur de l’expression [Link], b étant
la base de représentation des flottants (fournie dans FLT_RADIX), les valeurs de m
et n étant fournies en argument (type long pour n) ;
Toutes ces fonctions disposent également de versions génériques, comme décrit
ci-après.
La macro fpclassify fournit un résultat permettant de savoir si un flottant donné
est dans un format normalisé (FP_NORMAL), non normalisé (FP_SUBNORMAL) ou s’il
contient une valeur spéciale : FP_INFINITE, FP_NAN ou FP_ZERO. La macro signbit
fournit la valeur du bit de signe d’un flottant. Les macros isinf, isnan, isfinite et
isnormal permettent de savoir si un flottant contient une valeur infinie, la valeur
NaN, une valeur finie ou s’il est normalisé. Enfin, la macro signbit permet de
connaître la valeur du bit de signe d’un flottant.
7.3 Fonctions mathématiques génériques (C99)
En C90, la plupart des fonctions mathématiques ne sont définies que pour le type
double. Nous avons vu que C99 les généralise aux deux autres types flottants float
et long double. De plus, C99 en a prévu une version générique. Il devient alors
possible d’utiliser un seul nom de fonction, indépendant du type flottant, le choix
de la fonction voulue étant effectué suivant le type de l’argument. Pour ce faire,
il suffit d’inclure le fichier en-tête tgmath.h.
Cette généricité concerne alors :
• toutes les fonctions mathématiques disposant d’arguments flottants (à
l’exception des fonctions fmodf, fmod et fmodl) ;
• les fonctions complexes ; par exemple, on pourra utiliser cabs aussi bien pour
un float complex (au lieu de cabsf) que pour un double complex (au lieu de cabs) ou
un long double complex (au lieu de cabsl).
• certaines fonctions communes aux réels et aux complexes, telles que cos, cosh,
sin, sinh, tan, tanh, acos, acosh, asin, asinh, atan, atanh, exp, log et sqrt. Par exemple,
on pourra utiliser sqrt, indifféremment pour un type flottant quelconque ou un
type complexe quelconque.
On trouve aussi des fonctions génériques de comparaison des deux flottants
fournis en arguments, nommées isgreater, isgreaterequal, isless, islessequal
islessgreater qui fournissent un entier de type int (0 ou 1) ; elles fonctionnent
comme les opérateurs correspondants de comparaison, avec cette différence
qu’elles ne lèvent pas d’exception lorsque l’un de leurs arguments vaut NaN (voir
plus loin le paragraphe consacré aux calculs flottants). En outre, la fonction
nommée isunordered fournit l’entier 1 si l’un au moins de ses deux arguments vaut
NaN.
Par ailleurs, on trouve des macros génériques nommées isfinite, isinf, isnan et
isnormal qui permettent de savoir si le flottant fourni en argument est fini, infini,
s’il vaut NaN ou s’il est normalisé.
On notera que les fonctions modfl, modf et modfl ne disposent pas de version
générique.
8. Les fonctions en ligne (C99)
Le code exécutable correspondant à une fonction est généré une seule fois par le
compilateur. Néanmoins, pour chaque appel de cette fonction, le compilateur
doit prévoir non seulement le branchement au code exécutable correspondant,
mais également les instructions utiles pour établir la communication entre le
programme appelant et la fonction, notamment :
• sauvegarde de l’état courant (valeurs de certains registres de la machine, par
exemple) ;
• allocation d’espace sur la pile et copie des valeurs des arguments ;
• branchement à la fonction ;
• recopie de la valeur de retour ;
• restauration de l’état courant et retour dans le programme appelant.
Dans le cas de petites fonctions, ces différentes instructions de service peuvent
représenter un pourcentage important du temps d’exécution total de la fonction.
Lorsque l’efficacité du code devenait un critère important, C90 ne laissait pas
d’autre possibilité que de transformer la fonction en une macro, avec tous les
risques que cela comportait (effets de bords notamment). C99 permet de
demander au compilateur que les instructions d’une fonction soient générées
totalement à chaque appel. Il suffit d’utiliser le mot inline dans son en-tête
comme l’exemple suivant. Il s’agit d’une fonction nommée norme qui a pour but
de calculer la norme d’un vecteur à trois composantes qu’on lui fournit en
arguments :
#include <stdio.h>
inline double norme (double vec[3])
{ double s = 0 ;
for (int i=0 ; i<3 ; i++) s += vec[i] * vec[i] ;
return sqrt (s) ;
}
main()
{ double v1[3], v2[3] ;
double nv1, nv2 ;
…..
nv1 = norme (v1) ;
…..
nv2 = norme (v2) ;
…..
}
On notera que, en raison de sa nature même, une fonction en ligne doit être
définie dans le même fichier source que celui où on l’utilise. Elle ne peut plus
être compilée séparément. Il n’est plus nécessaire de déclarer une telle fonction
(sauf si elle est utilisée avant d’être définie). Ainsi, on ne trouve pas dans notre
exemple de déclaration telle que :
double norme (double) ;
Cette absence de compilation séparée constitue une contrepartie notable aux
avantages offerts par la fonction en ligne. En effet, pour qu’une même fonction
puisse être partagée par différents programmes, il faudra la placer dans un fichier
en-tête séparé (à moins de la recopier, ce qui reste peu conseillé).
Enfin, on notera que la déclaration inline ne constitue qu’une demande effectuée
auprès du compilateur qui peut éventuellement ne pas en tenir en compte et faire
de la fonction une fonction ordinaire.
9. Les caractères étendus (C99) et Unicode (C11)
La norme C90 a déjà introduit un type caractère étendu nommé wchar_t (voir
chapitre 22). La norme C99 en a élargi les fonctionnalités, en intégrant un additif
publié en 1994 (ISO/IEC 9899/COR1 :1994). Désormais, on dispose
notamment :
• d’un nouveau type entier wint_t qui sera utilisé lors des promotions numériques
d’un caractère (de type wchar_t) en entier (wint_t) ;
• de la généralisation aux caractères étendus des fonctions de test d’appartenance
à une catégorie (comme iswidigit) ou de conversions de casse (towupper et
towlower), ainsi que de nouvelles fonctions de test de catégories (wctype et
iswctype) ;
• de nouvelles fonctions de manipulations de chaînes de caractères étendus :
toutes les fonctions préfixées par str (chaînes de char) disposent de leur
équivalent préfixé par wcs (chaînes de wchar_t).
• de nouvelles fonctions d’entrées-sorties formatées : wprintf, wscanf, fwprintf et
fwscanf. Pour lire ou écrire des chaînes de caractères étendus, on utilisera les
mêmes codes de format que pour les chaînes de caractères usuels mais le
format lui-même devra être préfixé par u.
Par ailleurs, la norme C11 introduit la possibilité de créer directement des
constantes chaînes en UTF-8 (suffixe u8), en UTF-16 (suffixe u) ou en UTF-32
(suffixe U), ainsi que les types char16_t et char32_t pour représenter des caractères
codés respectivement en UTF-16 ou UTF-32 (UTF-8 utilisant naturellement le
type char).
10. Les pointeurs restreints (C99)
Lorsque l’on est certain qu’un pointeur donné est le seul à permettre l’accès à un
emplacement mémoire, il est possible de le préciser au compilateur à l’aide du
mot restrict. Cela peut lui permettre d’effectuer certaines optimisations. À titre
d’exemple, voici le nouveau prototype de la fonction memcpy en C99 :
void *memcpy (void * restrict dst, const void *restrict src,
size_t size) ;
// Dans la fonction, on est certain que les octets adressés
// par dst ne le seront par aucun autre pointeur.
// La même remarque s'applique à src.
11. La directive #pragma (C99)
Cette directive est déjà présente en C90, mais sous une forme générale, offrant
simplement à une implémentation donnée un mécanisme de transmission
d’informations au compilateur, sous la forme :
#pragma texte
Sans interdire l’utilisation de directives spécifiques à un environnement donné,
la norme C99 introduit trois directives standards se présentant sous la forme :
#pragma fonction etat
avec :
• fonction pouvant prendre l’une des valeurs suivantes :
– FP_CONTRACT : optimisation des expressions flottantes ;
– FENV_ACCESS : accès du programme à l’environnement de calcul flottant ;
– CX_LIMITED_RANGE : limitations concernant les valeurs complexes ;
• etat pouvant prendre l’une des trois valeurs suivantes :
– ON : activation de la fonction indiquée ;
– OFF : désactivation de la fonction indiquée ;
– DEFAULT : retour à l’état par défaut pour la fonction indiquée.
12. Les calculs flottants (C99)
En matière de calculs flottants, les apports de C99 concernent la préconisation de
la norme IEEE 754, le choix du mode d’arrondi, la gestion des situations
d’exception et la manipulation de l’environnement de calcul.
12.1 La norme IEEE 754
Pour représenter les flottants, la norme C99 préconise, sans l’imposer, d’utiliser
la norme IEEE 754 (ou ISO/IEC 60559). Outre l’unicité de la représentation,
cette norme prévoit des motifs binaires particuliers permettant de noter une
valeur infinie ou une valeur non représentable (par exemple, le résultat de la
division de 0 par 0). Ces valeurs figurent dans le fichier en-tête math.h sous les
noms FP_INFINITE et FP_NAN (Not A Number). En outre, on doit pouvoir distinguer
deux valeurs nulles (l’une de signe +, l’autre de signe -).
Lorsqu’une implémentation respecte cette norme IEEE, la constante macro
__STDC_IEE_559__ est définie.
12.2 Choix du mode d’arrondi
En C90, le mode d’arrondi est défini par l’implémentation et il peut être
simplement connu en examinant la valeur de FLT_ROUNDS (définie dans float.h). C99
permet de choisir le mode d’arrondi parmi les possibilités suivantes, représentées
par des constantes du fichier fenv.h :
• FE_TONEAREST : arrondi au plus proche (mode par défaut) ;
• FE_UPWARD : arrondi à la valeur immédiatement supérieure ;
• FE_DOWNWARD : arrondi à la valeur immédiatement inférieure ;
• FE_TOWARDZERO : arrondi vers le haut pour les valeurs négatives, vers le bas pour
les valeurs positives.
Le mode par défaut (arrondi au plus proche) peut être modifié par la fonction
fsetround. Le mode utilisé à un moment donné peut être connu par la fonction
fgetround. Leurs prototypes figurent dans fenv.h. Il faut noter que la valeur de
FLT_ROUNDS est modifiée en conséquence.
12.3 Gestion des situations d’exception
Le comportement prévu par C90 en cas d’exception dans les calculs flottants est
décrit à la section 2.2 du chapitre 4. En C99, ces exceptions conduisent au
positionnement d’un drapeau qui peut être testé par de nouvelles fonctions
appropriées.
Les valeurs des drapeaux sont définies par les constantes suivantes, dans fenv.h :
• FE_DIVBYZERO : division par zéro (le résultat est infini) ;
• FE_OVERFLOW : dépassement de capacité ;
• FE_UNDERFLOW : sous-dépassement de capacité ;
• FE_INEXACT : résultat non exact ;
• FE_INVALID : opération dont le résultat est indéfini (par exemple, quotient de deux
valeurs nulles).
Ces drapeaux peuvent être testés, positionnés ou supprimés (mise à zéro) à l’aide
des fonctions fetestexcept, feraiseexcept et feclearexcept, respectivement, auxquelles
on fournit en argument la valeur d’un drapeau ou celle de plusieurs drapeaux
(combinées par l’opérateur binaire |). Il est également possible de sauvegarder
l’état des drapeaux à un instant donné à l’aide de fesetexceptflag et de le restaurer
ultérieurement à l’aide de fegetexceptflag.
En cas d’exception, le système peut aussi envoyer un signal, mais cela n’est pas
imposé par la norme C99.
12.4 Manipulation de l’ensemble de l’environnement
de calcul flottant
C99 permet également de connaître l’ensemble des paramètres de calcul flottant,
c’est-à-dire à la fois le choix du mode d’arrondi et les drapeaux d’exception. On
utilise alors la fonction fgetenv. L’ensemble de paramètres ainsi obtenu peut être
restauré ultérieurement à l’aide de la fonction fesetenv.
13. Structures incomplètes (C99)
C99 permet de déclarer une structure dont le dernier élément (et uniquement
celui-ci) est un tableau dont on ne précise pas la dimension, comme dans :
sruct stincomp
{ int n ;
int tv [] ; // dimension non précisée ici
}
Cette possibilité exploite le fait que tv est en fait un pointeur sur des int. Elle ne
peut être utilisée que conjointement à l’allocation dynamique des emplacements
de type stincomp, en précisant exactement la taille nécessaire. On notera que des
expressions telles que sizeof(stincomp) ou sizeof(s), où s serait un objet de type
stincomp, fournissent une valeur ne tenant pas compte de tv.
De plus, une telle structure incomplète ne peut pas apparaître à son tour dans un
tableau ou dans une autre structure.
14. Structures anonymes (C11)
En C90, dans la déclaration d’une structure ou d’une union, on peut omettre le
nom de modèle lorsque ce dernier n’est pas utile pour la suite. En C11, on peut
omettre à la fois le nom de modèle et le nom de la variable correspondante
lorsqu’ils ne sont pas nécessaires. On parle alors de structure ou d’union
« anonyme ». Cette facilité peut s’avérer utile dans le cas d’imbrication de
structures et/ou d’unions. En voici un exemple :
struct st1
{ union // union anonyme
{ struct {int n ; double d ; } partie1 ; // structure de nom partie1
struct { long q ; float x ; } ; // structure anonyme
} ;
} ;
struct st1 s ;
s.partie1.n = 1 ; // OK
s.n = 1 ; // erreur : la structure incluant n n'est pas anonyme
s.q = 1 ;
15. Expressions fonctionnelles génériques (C11)
Il est possible de faire correspondre un appel de fonction à un argument, une
expression dépendant du type de cet argument, en employant le mot-clé _Generic.
Cela peut s’avérer intéressant pour définir des macros génériques. À titre
indicatif, voici comment on pourrait définir la fonction générique cbrt (déjà
prévue en C99) si elle n’existait pas :
#define cbrt(X) _Generic((X), long double : cbrtl, \
default : cbrt, \
float : cbrtf ) (X)
16. Gestion des contraintes d’alignement (C11)
Beaucoup d’implémentations imposent des contraintes d’alignement aux
variables en fonction de leur type, afin d’optimiser les performances. Par
exemple, un flottant de 4 octets pourra voir son adresse alignée sur un multiple
de 4 octets. Certains environnements permettent en outre d’intervenir sur ces
contraintes, par exemple pour optimiser certaines opérations. C11 normalise ces
possibilités, en fournissant le moyen d’imposer à une variable une contrainte
d’alignement plus restrictive que celle que lui attribuerait l’environnement. Par
exemple, avec :
char _Alignas (long) c ;
on imposera à la variable c d’être alignée sur une frontière correspondant à un
long.
De plus, une nouvelle fonction aligned_alloc a été introduite pour allouer un
emplacement en imposant une contrainte d’alignement fournie en argument.
17. Fonctions vérifiant le débordement mémoire (C11
facultatif)
Beaucoup de fonctions de gestion des chaînes de C90 placent une suite de
caractères en mémoire, sans qu’il soit possible de s’assurer que l’on ne déborde
pas de l’emplacement initialement prévu. C’est le cas de gets, strcat ou strcpy.
Pour ces deux dernières fonctions, il existe une version plus sûre (strncat ou
strncpy, respectivement) qui permet de limiter le nombre de caractères introduits
mais avec toutefois le risque de ne pas trouver de caractère de fin de chaîne.
La norme C11 propose (de façon facultative) un ensemble de fonctions « de
substitution » créées initialement par Microsoft pour lutter contre des problèmes
de sécurité connus. Chacune de ces fonctions porte le nom de la fonction
substituée, suffixé par _s, et son prototype figure dans le même fichier en-tête
que celui de la fonction d’origine.
C’est ainsi qu’on trouve gets_s (voir chapitres 10 et 17), strcpy_s, strcat_s, memcpy_s
et memmove_s qui permettent de limiter le nombre de caractères introduits ; strncpy_s
et strncat_s, qui assurent la présence d’un zéro de fin (ou d’une chaîne vide s’il y
a débordement) ; et enfin strlen_s qui permet de fixer une longueur maximale de
la zone considérée (pour éviter des erreurs d’adressage).
Mais cette bibliothèque comporte beaucoup d’autres fonctions « sécurisées »
remplaçant les fonctions de la famille scanf, printf, open ainsi que qsort, bsearch ou
encore wctomb et mbstowcs.
Lorsque ces fonctions sont implémentées, la variable __STDC_LIB_EXT1 est définie et
vaut 1.
18. Les threads (C11 facultatif)
La norme C11 prévoit (de façon facultative) un ensemble de fonctions
(<threads.h>) permettant de mettre en œuvre la « programmation concurrente » :
plusieurs threads peuvent s’exécuter simultanément (machines multiprocesseurs)
ou en apparente simultanéité. Un autre ensemble de fonctions (<stdatomic.h>)
permet de gérer les accès multiples à un même objet. L’absence de ces
fonctionnalités est signalée par la définition des variables __STDC_NO_THREADS et/ou
__STDC_NO_ATOMICS.
Une nouvelle fonction de terminaison de programme quick_exitest également
prévue, utile lorsqu’il est impossible de terminer simultanément plusieurs
threads.
19. Autres extensions de C99
La macro va_copy (prototype dans stdarg.h) permet de recopier un objet de type
va_list.
En C90, les fonctions pouvaient disposer d’un nombre variable d’arguments. En
C99, ce mécanisme est étendu aux macros. Il utilise les mêmes notations et les
mêmes macros va_start et va_end. Il faut simplement utiliser le type __va_list au
lieu du type va_list pour représenter une liste d’arguments.
Pour tenir compte du fait que certains caractères spéciaux ne sont pas présents
sur tous les claviers, C99 propose des « séquences d’échappement » : les suites
de deux caractères <:,:>, <%, %> et %: sont respectivement remplacées par [, ], {, }
et #.
20. Autres extensions de C11
Le mot-clé _Noreturn s’utilise de cette manière :
__Noreturn void fct (….) ;
Il autorise le compilateur à effecteur certaines optimisations tenant compte de ce
que la fonction fct ne renvoie aucun résultat.
Les assertions statiques se présentent sous la forme :
_Static_assert (expression_entière_constante, constante_chaîne) ;
Le compilateur vérifie que l’expression mentionnée est non nulle. Dans le cas
contraire, il affiche un message contenant le texte correspondant.
La fonction fopen dispose de nouveaux modes d’ouverture dits « exclusifs »
qu’on choisit en ajoutant x à la fin du mode (wx, wbx, w+x, wb+x, w+bx). Dans ce cas,
l’ouverture échoue si le fichier existe déjà ou s’il ne peut être créé. Sinon, le
fichier est créé avec un accès exclusif au programme ou au thread concerné (à
condition, toutefois, que l’environnement sache gérer cette possibilité !).
Index
Symboles
_Bool 821
__bool_true_false_are_defined 821
__DATE__ 660
__FILE__ 660
_Generic 830
_IOFBF (stdio.h) 790
_IOLBF (stdio.h) 790
_IONBF(stdio.h) 790
__LINE__ 660
_Noreturn 831
_Static_assert 831
__STDC__ 660
__STDC_LIB_EXT1 830
__STDC_NO_ATOMICS 831
__STDC_NO_THREADS 831
__TIME__ 660
__va_list (stdarg.h) 831
#define (directive) 637
#elif (directive) 664
#endif (directive) 662
#error (directive) 678
#ifndef (directive) 663
#include (directive) 672
#line (directive) 678
#pragma 827
#pragma (directive) 679
#undef (directive) 655
A
abort (stdlib.h) 738, 804
abs (stdlib.h) 805
accès
aux variables 331
direct 543, 551, 593, 595, 598
indexé 603
séquentiel 543, 551
acos (math.h ) 785
adaptations locales 755
additif, opérateur~ 66
adresse
d’une structure 498, 503
d’une union 498, 503
opérateur d’~ 235
affectation
de pointeurs 239, 242
de structures 501
d’unions 502
opérateur d’~ 104
Alignas 830
aligned_alloc 830
allocation dynamique 613
American National Standard Institute (ANSI) 4
appel d’une macro 643
argument
const 263, 280
conversion d’ ~ 88
conversion d’un ~ 279
déclaration 263, 690
de la fonction main 736, 737
de type fonction 341
de type structure 505
de type tableau 289
de type union 505
fonction sans~ 264
mécanisme de transmission 284
portée 325
sous-tableau 293
tableau 198
tableau de tableau 293, 299
variable 279, 725
volatile 263, 280
arithmétique, opérateur~ 65
arrangement
d’un tableau 198, 202
arrêt prématuré 385
asctime (time.h) 813
asin (math.h) 785
assert (assert.h) 781
assert.h (en-tête) 781
assertion statique 831
associativité 64
atan2 (math.h) 785
atan (math.h) 785
atexit (stdlib.h) 804
atof (stdlib.h) 475, 800
atoi (stdlib.h) 475, 801
atol (stdlib.h) 475, 801
auto 331
B
bibliothèque standard 779
binaire, opérateur 64
bloc 137
bool 820
boucle
définie 168
indéfinie 169
infinie 158, 162, 168
branchement
à l’intérieur d’un bloc 140, 181
instructions de~ 170, 172, 180
non local 769
break (instruction) 170
bsearch (stdlib.h) 805
BUFSIZ (stdio.h) 790
byte 12
C
calloc (stdlib.h) 620, 803
caractère 12
alphabétique 782
alphanumérique 782
catégories 709
code d’un~ 38
# dans une directive 632
de contrôle 782
de ponctuation 782
d’exécution 16
espace blanc 782
étendu 747
graphique 782
hexadécimal 783
imposé dans les données 394
imprimable 40, 782
invalide 384, 387, 472
majuscule 782
minuscule 782
non signé 37
signé 37
source 16
transformation de~ 715, 783
type 37
cast
opérateur de~ 110
pour les pointeurs 249
catégories
de caractères 709
de localisation 757
de tokens 24
d’expressions constantes 130
d’instructions exécutables 134
d’opérateurs 65
ceil (math.h) 787
chaîne
comparaison 452
concaténation 446
constante~ 408, 410
conversion d’un nombre en une~ 475
conversion en un nombre 464
copie 439, 443
création 417
de caractères étendus 752
éclatement 461
écriture 424, 425
entrée-sortie standard 423
initialisation 418
lecture 426, 429
longueur 438
modification 418, 419, 420, 421
recherche dans une~ 454
recherche d’un préfixe 459
utilisation 421, 422
champ 488
classe de mémorisation 489
constant 488
de bits
anonyme 520
déclarateur 687
déclaration 522
initialisation 521
espace de noms 494
manipulation 500
volatile 488
char16_t 826
char32_t 826
CHAR_BIT (limits.h) 45
CHAR_MAX (limits.h) 45
CHAR_MIN (limits.h) 45
choix
instruction de~ 141
multiple (instructions de~) 147
classe
d’allocation 12
automatique 326, 331
des variables 331
des variables globales 321
des variables locales 326
registre 330
statique 321, 328
de mémorisation 684
d’un champ 489
d’une fonction 263, 268
d’une structure 491
d’une union 491
d’un pointeur 219
d’un tableau 192
clavier, lecture 699
clearerr (stdio.h) 799
CLOCKS_PER_SEC (time.h) 812
clock (time.h) 812
clock_t (time.h) 811
codage
d’un caractère 38
d’un entier 29
d’un flottant 46
code
de conversion 349, 373, 378, 400
de format 349, 369, 378, 397
colonne d’un tableau 202
commentaire 22, 817
communication avec l’environnement 735, 740
commutativité 68
comparaison
de chaînes 452
de pointeurs 246, 342
de pointeurs sur des champs 503
de structures 504
d’une suite d’octets 478
opérateurs de~ 89
compilateur 9
compilation 9, 631
conditionnelle 661, 664
séparée 259
complément à deux 30
complex 821
concaténation
de chaînes 446
des chaînes adjacentes 410
conditionnel, opérateur~ 119
const 57
argument 263, 280
pour les pointeurs 219, 221, 239
pour les structures 491
pour les tableaux 193
pour les unions 491
valeur de retour 265
constante
caractère 40
chaîne 408, 410
chaîne de caractères étendus 753
décimale 33
d’énumération 528
dépassement de capacité 36
de type wchar_t 749
entière 33
expression~ 128, 130
flottante 51, 52
continue
instruction 172
contrainte d’alignement 830
contrôle
instruction de~ 134
conventions IEEE 71, 73
conversationnel, mode 346
conversion
code de~ 373, 397, 400
combinaisons de~ 86
d’ajustement de type 64, 74, 75
de chaîne en nombre 464
de nombre en chaîne 475
d’entier en pointeur 252
de pointeur en entier 252
d’un argument 88, 279
d’un pointeur 249
en chaîne (opérateur de~) 650
implicite 64, 74, 87
induite par return 270
mixte 78
numérique 74, 113, 118
promotions numériques 64, 74, 80
copie d’une suite d’octets 476
cosh (math.h) 786
cos (math.h) 786
ctime (time.h) 813
ctype.h (en-tête) 782
CX_LIMITED_RANGE 827
D
DBL_DIG (float.h) 53
DBL_EPSILON (float.h) 54
DBL_MANT_DIG (float.h) 53
DBL_MAX_10_EXP (float.h) 54
DBL_MAX_EXP (float.h) 53
DBL_MAX (float.h) 54
DBL_MIN_10_EXP (float.h) 53
DBL_MIN (float.h) 54
débordement d’indice 198, 202
décalage, opérateurs de~ 97
déclarateur 682, 689
de champ de bits 687
de forme fonction 265, 689
de forme pointeur 216, 689
de forme tableau 189, 203, 689
de membre 686, 687
déclaration 681, 816
anticipée 492
classe de mémorisation 684
d’argument 690
de champ de bits 522
définition 315
de membre 686, 687
de pointeur 214
de structure 486, 490
des variables globales 313
schéma conseillé 318
de synonyme 530
de tableau 187, 203
d’une énumération 526
d’une fonction 273
d’union 486, 490
écriture de~ 695
initialisation 56, 204
initialiseur 684, 686
interprétation 692
partielle d’une fonction 274
partielle d’une structure 492
partielle d’une union 492
qualifieur 683
spécificateur
de type énumération 689
de type structure 686
de type union 688
syntaXE générale 684
type de base 54
defined (opérateur) 664
définition
déclaration~ 315
de macro 634
de structure 486
de symbole 634
d’une fonction 277, 691
d’une variable globale 314
d’union 486
potentielle 316
dépassement de capacité 36, 52, 70, 71
différé (mode) 346
difftime (time.h) 812
dimension d’un tableau 190, 818
directive 9, 631
catégories 633
conditionnelle (imbrication) 670
de compilation conditionnelle 661
#elif 664
#endif 662
#error 678
et caractère # 632
et substitution 646
#if 666
#ifndef 663
#include 672
#line 678
#pragma 679, 827
#undef 655
# (vide) 677
division par zéro 71, 73
div (stdlib.h) 805
div_t (stdlib.h) 800
DLB_MIN_EXP (float.h) 53
double 50
double complex 821
double_t 820
do… while (instruction) 155
drapeau 369
- 360
# 364
+ 365
0 366
espace 366
E
écriture
dans un fichier 561, 568, 573, 579, 586
de déclarations 695
édition de liens 320
EDOM (errno.h) 783
effet de bord 657, 720
else 141
enregistrement 542, 545
logique 544
physique 544
en-tête 260
de la fonction main 736
d’une fonction 262, 727
fichier~ 282
entier
choix d’un~ 32
limites 29
non signé 28
représentation 29
signé 28
type 28, 819
entrée-sortie 699
binaire 542, 547, 556
de chaînes 423
formatée 347, 542, 547, 568
standard 346
enum 523
énumération 523
déclaration 526
espace de noms 527
spécificateur de type~ 689
environnement, communication avec l’~ 735, 740
EOF (stdio.h) 791
ERANGE (errno.h) 783
erreur
dans le traitement des fichiers 552, 600
d’échelle 785
de domaine 785
errno (errno.h) 554, 783
errno.h (en-tête) 783
espace blanc 20, 782
dans les données 390, 398
espace de noms
de champ 494
de type 495
et énumérations 527
espilon machine 50
étiquette 180
EXIT_FAILURE (stdlib.h) 739, 800
exit (stdlib.h) 804
EXIT_SUCCESS (stdlib.h) 800
expansion 640
des paramètres de macro 643, 652
exp (math.h) 786
exposant 47
expression
constante 128, 130
constante pour le préprocesseur 667
instruction~ 136
lvalue 105
extension d’un fichier 608
extern 315
pour les fonctions 268
F
fabs (math.h) 787
fclose (stdio.h) 792
FE_DIVBYZERO (fenv.h) 828
FE_DOWNWARD (fenv.h) 828
FE_INEXACT (fenv.h) 828
FE_INVALID (fenv.h) 828
FENV_ACCESS 827
feof (stdio.h) 554, 572, 799
FE_OVERFLOW (fenv.h) 828
fermeture (d’un fichier) 558
ferror (stdio.h) 553, 799
FE_TONEAREST (fenv.h) 828
FE_TOWARDZERO (fenv.h) 828
FE_UNDERFLOW (fenv.h) 828
FE_UPWARD (fenv.h) 828
fflush (stdio.h) 792
fgetc (stdio.h) 795
fgetpos (stdio.h) 605, 798
fgets (stdio.h) 431, 582, 795
fichier
accès direct 543, 551, 593, 595, 598
accès indexé 603
accès séquentiel 543, 551
binaire 547
découpage en lignes 548
détection des erreurs 600
de type texte 347, 548, 549
écriture 561, 568, 573, 579, 586
enregistrement 542, 545
en-tête 282, 780
extension 608
fermeture 558, 792
fin de~ 554, 572
formaté 547
lecture 564, 568, 575, 582
mise à jour 599
mode d’ouverture 549, 606
opération binaire 549
opération formatée 549
opération mixte 549, 585
ouverture 605
rembobinage 799
renommer un~ 791
réouverture 792
source 8
suppression 791
taille 597
tampon 551, 792, 793
temporaire 791
traitement des erreurs 552
FILENAME_MAX(stdio.h) 791
FILE (stdio.h) 790
fin
anormale d’un programme 738
de fichier 554, 572
normale d’un programme 738
float 50
float complex 821
float.h (en-tête) 53
float_t 820
floor (math.h) 787
flottant
codage d’un~ 46
limites 49, 51
type 46
FLT_DIG (float.h) 53
FLT_EPSILON (float.h) 54
FLT_MANT_DIG (float.h) 53
FLT_MAX_10_EXP (float.h) 54
FLT_MAX_EXP (float.h) 53
FLT_MAX (float.h) 54
FLT_MIN_10_EXP (float.h) 53
FLT_MIN_EXP (float.h) 53
FLT_MIN (float.h) 54
FLT_RADIX (float.h) 53
FLT_ROUNDS (float.h) 53
flux 545
prédéfini 609
fmod (math.h) 787
fonction
à arguments variables 279, 725
argument 262, 263, 284
classe de mémorisation 268
déclarateur de forme~ 689
déclaration 273
déclaration partielle 274
définition 261, 277, 691
en argument 341
en ligne 825
en-tête 260, 262, 727
mathématique générique 824
nom de type~ 283
pointeur sur une~ 332
prototype 727
récursive 761
redéclaration 276
sans argument 264
structure en argument d’une~ 505
synonyme de type~ 538
union en argument d’une~ 505
valeur de retour 260, 264
FOPEN_MAX (stdio.h) 791
fopen (stdio.h) 606, 792
forçage d’alignement (d’un champ de bits) 520
for (instruction) 162
format 349, 378
code de~ 369, 378, 397
libre 21
formatage 347
FP_CONTRACT 827
fpos_t (stdio.h) 790
fprintf (stdio.h) 369, 425, 573, 793
fputc (stdio.h) 796
fputs (stdio.h) 579, 796
fread (stdio.h) 564, 797
free (stdlib.h) 620, 804
freopen (stdio.h) 609, 792
frexp (math.h) 786
fscanf (stdio.h) 389, 429, 575, 794
fseek (stdio.h) 595, 798
fsetpos (stdio.h) 605, 798
ftell (stdio.h) 597, 798
fwprintf 826
fwrite (stdio.h) 561, 797
fwscanf 826
G
gabarit 355, 370, 392, 399
paramètre de~ 357
par défaut 356, 371
variable 360
générique (pointeur) 243
gestion dynamique 611
getchar (stdio.h) 404, 796
getc (stdio.h) 589, 796
getenv (stdlib.h) 740, 804
gets_s 700, 702
gets_s (stdio.h) 427, 430, 830
gets (stdio.h) 426, 796
gmtime (time.h) 813
go to (instruction) 180
H
hexadécimale, notation 33, 42
historique du langage 7
HUGE_VAL (math.h) 784
I
identificateur 19
externe 20
identification
d’un paramètre de macro 641
d’un symbole 641
IEEE (conventions) 71, 73, 827
if (instruction) 141
imbrication
de directives conditionnelles 670
de #include 675
des définitions de synonymes 534
de structures 508
inclusion
de fichier source 672
multiple 676
incompatibilités entre C et C++ 773
incrémentation, opérateur d’~ 104
indice 195, 200
INF 71
information
formatage d’une~ 347
justification 360
initialisation
dans une déclaration 56
d’une chaîne 418
d’une structure 511
d’une suite d’octets 478
d’une union 511
d’une variable globale 322
d’une variable locale 326
d’un tableau 204
d’un tableau de caractères 208, 776
initialiseur 684, 686
de tableau 206
inline 825
instruction
break 170
classification d’une~ 134
composée 137
continue 172
de choix 141
de choix multiple 147
de contrôle 134
de déclaration 54
do… while 155
exécutable 133
expression 136
for 162
go to 180
if 141
return 269
switch 147
typedef 532
while 158
int 28
int8 820
int16 820
int32 820
int64 820
interaction 346
interprétation de déclarations 692
INT_MAX (limits.h) 45
INT_MIN (limits.h) 45
intptr 820
isalnum (ctype.h) 710, 782
isalpha (ctype.h) 710, 782
isdigit (ctype.h) 782
isgraph (ctype.h) 782
islower (ctype.h) 782
isnctrl (ctype.h) 782
isprint (ctype.h) 782
ispunct (ctype.h) 710, 782
isspace (ctype.h) 710, 782
isupper (ctype.h) 782
isxdigit (ctype.h) 710, 783
itération
instruction d’~ 135
J
jeu
de caractères d’exécution 16
de caractères source 16
jmp_buf (setjmp.h) , 771
justification 360
K
Kernighan 7
L
labs (stdlib.h) 805
LC_ALL (locale.h) 784
LC_COLLATE (locale.h) 757, 784
LC_CTYPE (locale.h) 784
LC_MONETARY (locale.h) 757, 784
LC_NUMERIC (locale.h) 757
lconv (locale.h) 784
lconv (structure) 758
LC_TIME (locale.h) 757, 784
LC_TYPE (locale.h) 757
LDBL_DIG (float.h) 53
LDBL_EPSILON (float.h) 54
LDBL_MANT_DIG (float.h) 53
LDBL_MAX_10_EXP (float.h) 54
LDBL_MAX_EXP (float.h) 53
LDBL_MAX (float.h) 54
LDBL_MIN_10_EXP (float.h) 53
LDBL_MIN_EXP (float.h) 53
LDBL_MIN (float.h) 54
ldexp (math.h) 786
ldiv (stdlib.h) 806
ldiv_t (stdlib.h) 800
lecture
au clavier 699
dans un fichier 564, 568, 575, 582
ligne d’un tableau 202
limites
des entiers 29
des flottants 49
limits.h (en-tête) 45
liste
chaînée 629
de déclaration d’arguments 690
de remplacement 637
variable 730
LLONG_MAX 819
LLONG_MIN 819
localeconv (locale.h) 758, 784
locale.h (en-tête) 784
localisation 709, 755
catégories 757
standard 712
localtime (time.h) 813
log10 (math.h) 787
logique, opérateur~ 93
log (math.h) 786
long 28
long double 50
long double complex 821
longjmp (setjmp.h) 788
long long 819
LONG_MAX (limits.h) 45
LONG_MIN (limits.h) 45
longueur d’une chaîne 438
L_tmpnam (stdio.h) 791
lvalue 105, 196, 230
M
macro
absence de typage 657
appel 643
définition de~ 634
expansion 640
expansion des paramètres 652
priorité dans les~ 656
main
en-tête de~ 736
return dans~ 739
malloc (stdlib.h) 618, 803
manipulation de bits, opérateurs de~ 97
mantisse 46
math.h (en-tête) 784
MB_CUR_MAX (stdlib.h) 748, 800
MB_LEN_MAX (limits.h) 45, 748
mblen (stdlib.h) 750, 806
mbstowcs (stdlib.h) 754, 806
mbtowc (stdlib.h) 751, 806
membre, déclaration 686, 687
memchr (string.h) 479, 809
memcmp (string.h) 478, 808
memcpy_s (string.h) 830
memcpy (string.h) 477, 807
memmove_s (string.h) 830
memmove (string.h) 477, 807
memset (string.h) 478, 810
mise à jour (d’un fichier) 599
mise au point 671
mktime (time.h) 812
mode
+ 606
a 606
b 606
conversationnel 346
différé 346
d’ouverture d’un fichier 549, 606
r 606
t 609, 831
w 606
modèle de structure 483, 485
modf (math.h) 787
modificateur
h 368, 373, 399
l 373, 399
L 373, 399
module 256
objet 9
mot-clé 20
multiplicatif, opérateur~ 66
N
NaN 71
nom
de type 111
de type fonction 283
de type pointeur 223
de type tableau 194
notation
décimale 33, 51
exponentielle 51
hexadécimale 33, 42
octale 33, 42
NULL 237, 241
O
objet 10
automatique 611
dynamique 611
statique 611
octale, notation 33, 42
octet 12
de remplissage 499
offsetof (stddef.h) 790
opérateur
^ 98
- 66, 225, 226
-= 109
-> 504
, 122
! 94
!= 90, 248
? 119
[] 228
* 66, 213
*= 109
/ 67
/= 109
& 98
&& 94
&= 109
# 650
## 654
% 67
%= 109
+ 66, 225
+= 109
< 90
<< 98
<<= 109
<= 90
= 106, 108
== 90
> 90
>= 90, 247
>> 98
>>= 109
| 98
|= 109
|| 94
~ 98
~= 109
additif 66
arithmétique 65
associativité 64
binaire 64
catégories d’un~ 65
commutatif 68
conditionnel 119
d’affectation 104
de cast 110, 253
defined 664
de manipulation de bits 97
d’incrémentation 104
logique 93
multiplicatif 66
pluralité d’un~ 64
priorité des ~ 63
priorité d’un~ 68, 127
puissance 66
relationnel 90
séquentiel 122
sizeof 124, 197
tableau récapitulatif des~ 127
ternaire 64
unaire 64
opération
binaire 549, 556
formatée 549, 568
mixte 549, 585
ouverture d’un fichier 605
P
paramètre
* 399
de gabarit 370, 399
de précision 372
drapeau 369
identification d’un ~ de macro 641
modificateur 373, 399
substitution d’un ~ de macro 645
parenthèses 63
perror (stdio.h) 799
pile 612
pluralité des opérateurs 64
pointeur 212
addition d’un entier à un~ 225
affectation de ~ 242
affectation de~ 239
classe de mémorisation 219
comparaison de~ 246, 342
const 219, 221, 239
conversion de~ 249
déclarateur de forme~ 216, 689
déclaration 214
de pointeur 218
de type void * 241
générique 243
lien avec tableau 226
nom de type 223
NULL 237, 241
opérateur -- 242
opérateur ++ 242
propriétés arithmétiques 224, 232
qualifieur 219
restreint 826
soustraction de~ 226, 235
soustraction d’un entier à un~ 225
sur des champs 503
sur des structures 511
sur des tableaux 218
sur une fonction 332
void * 243
volatile 220, 222, 239
portée
des arguments muets 325
des variables 331
des variables globales 319
des variables locales 324
pow (math.h) 787
précision 49
paramètre de~ 358, 360, 367, 372
par défaut 372
variable 360
préfixe, recherche d’un~ 459
préprocesseur 9, 631
expression constante 667
prétraitement 9, 631
printf (stdio.h) 350, 369, 425, 793
priorité
dans les macros 656
des opérateurs 63, 68, 127
procédure 257
programme
autonome 735
exécutable 9
source 8
terminaison 738
promotions numériques 64, 80
prototype 273, 727
ptrdiff_t (stddef.h) 235, 790
putchar (stdio.h) 376, 796
putc (stdio.h) 796
puts (stdio.h) 424, 797
Q
qsort (stdlib.h) 805
qualifieur 57, 683
et conversions de pointeurs 251
et expression pointeur 225
et typedef 536
pour les champs 488
pour les pointeurs 219
quick_exit (stdlib.h) 831
R
raise (signal.h) 746, 789
RAND_MAX (stdlib.h) 800
rand (stdlib.h) 802
realloc (stdlib.h) 623, 803
recopie d’une suite d’octets 476
récursivité 761
redéclaration
d’une fonction 276
d’une variable glogale 314
redéfinition d’une macro standard 781
register 330
pour les fonctions 263
pour les structures 491
pour les tableaux 193
pour les unions 491
relationnels, opérateurs~ 89
rémanente (variable) 321
remove (stdio.h) 791
rename (stdio.h) 791
répétition
définie 168
indéfinie 169
représentation
d’un caractère 38
d’une constante d’énumération 528
d’un entier 29
d’une structure 495
d’une union 495
d’un flottant 46
en complément à deux 30
restrict 826
return 269
conversions induites par~ 270
dans main 739
réversibilité, conditions 548
rewind (stdio.h) 799
Ritchie 7
S
saut, instruction de~ 135
scanf (stdio.h) 379, 389, 429, 794
SCHAR_MAX (limits.h) 45
SCHAR_MIN (limits.h) 45
SEEK_CUR (stdio.h) 791
SEEK_END (stdio.h) 595, 791
SEEK_SET (stdio.h) 791
sélection, instruction de~ 135
séparateur 20
séquence
d’échappement 41, 831
trigraphe 17
setbuf (stdio.h) 793
setjmp.h (en-tête) 788
setjmp (setjmp.h) 770, 788
setlocale (locale.h) 756, 784
setvbuf (stdio.h) 793
short 28
SHRT_MAX (limits.h) 45
SHRT_MIN (limits.h) 45
SIGABRT (signal.h) 788
sig_atomic_t (signal.h) 788
SIG_DFL (signal.h) 744, 788
SIG_ERR (signal.h) 788
SIGFPE (signal.h) 788
SIG_IGN (signal.h) 744, 788
SIGILL (signal.h) 788
SIGINT (signal.h) 788
signal 741
numéros prédéfinis 745
signal.h (en-tête) 788
signal (signal.h) 744, 789
signed 28
SIGSEGV (signal.h) 788
SIGTERM (signal.h) 788
sinh (math.h) 786
sin (math.h) 786
sizeof 124
pour une structure 499
pour une union 499
pour un tableau 197
size_t (type) 437
sous-dépassement de capacité 52, 72
sous-programme 257
sous-tableau 293
spécificateur de type 682
énumération 689
structure 686
union 688
sprintf (stdio.h) 369, 794
sqrt (math.h) 787
srand (stdlib.h) 802
sscanf (stdio.h) 795
static 327, 328
pour les fonctions 268
pour les pointeurs 219
pour les tableaux 192
stdatomic (en-tête) 831
stdbool.h 821
stddef (en-tête) 790
stderr (stdio.h) 609, 791
stdin (stdio.h) 609, 791
stdio.h (en-tête) 790
stdout (stdio.h) 609, 791
strcat_s (string.h) 830
strcat (string.h) 446, 808
strchr (string.h ) 455, 809
strcmp (string.h) 453, 808
strcoll (string.h) 809
strcpy_s (string.h) 830
strcpy (string.h) 439, 807
strcspn (string.h) 460, 810
strerror (string.h) 811
strftime (time.h) 814
string.h (en-tête) 807
strlen_s (string.h) 830
strlen (string.h) 438, 811
strncat_s (string.h) 830
strncat (string.h) 449, 808
strncmp (string.h) 454, 809
strncpy (string.h) 443, 808
strpbrk (string.h) 458, 810
strrchr (string.h) 455, 810
strspn (string.h) 459, 810
strstr (string.h) 458, 810
strtod (stdlib.h) 466, 801
strtok (string.h) 461, 810
strtol (stdlib.h) 802
strtol (string.h) 469
strtoul (stdlib.h) 469, 802
struct 482
structure 482
adresse 498, 503
affectation 501
anonyme 829
champ 488
comparaison de~ 504
const 491
déclaration 486, 490
de structures 509
de tableaux 508
en argument 505
en valeur de retour 505
imbrication 508
incomplète 829
initialisation 511
lconv 758
modèle 483, 485
représentation 495
sizeof 499
spécificateur de type~ 686
tableau de~ 510
volatile 491
strxfrm (string.h) 809
substitution
dans les constantes 645
dans une directive 646
d’un paramètre de macro 645
d’un symbole 645
switch (instruction) 147
symbole
absence de typage 657
définition de~ 634
expansion 640
identification 641
prédéfini 660
substitution d’un~ 645
synonyme 530
de type fonction 538
system (stdlib.h) 741, 804
T
tableau
accès par pointeur 203
à plusieurs indices 200
arrangement 198, 202
classe de mémorisation 192
colonne d’un~ 202
const 193
débordement d’indice 198, 202
de caractères 208, 776
déclarateur de forme~ 203, 689
déclaration 187, 203
de structures 510
de tableau 200
de tableau en argument 299
de taille variable 626, 627
dimension 190, 296, 818
en argument 198, 289
indice 195, 198, 202
initialisation 204
initialiseur de~ 206
lien avec pointeur 226
ligne d’un~ 202
nom de type 194
pointeur sur des~ 218
register 193
sizeof 197
static 192
structure de~ 508
volatile 193
taille d’un fichier 597
tampon 383, 397, 551, 792
alimentation du~ 383
utilisation des caractères du~ 384
tanh (math.h) 786
tan (math.h) 786
tas 612
terminaison d’un programme 738
ternaire, opérateur 64
test de caractères, fonctions de~ 710
texte, fichier de type~ 347, 548, 549
threads.h (en-tête) 831
time.h (en-tête) 811
time (time.h) 812
time_t (time.h) 811
tmpfile (stdio.h) 791
TMP_MAX (stdio.h) 791
tmpnam (stdio.h) 792
tm (time.h) 811
token 23, 632
catégories 24
tolower (ctype.h) 715, 783
toupper (ctype.h) 715, 783
transformation de caractères 715
transmission
d’une adresse 287
par valeur 284
trigraphe, séquence 17
type
booléen 820
caractère 37
complexe 821
de base 27
des objets dynamiques 616
d’un champ de bits 520
d'une constante caractère 43
d’une constante entière 34, 36
d’une constante flottante 52
d’un objet 11
entier 28
énumération 523
espace des noms de~ 495
flottant 46
jmp_buf 771
long long 819
ptrdiff_t 235
size_t 437, 617
structure 482
union 484
void * 241, 243
wchar_t 748
typedef 530
U
u8 (suffixe) 826
UCHAR_MAX (limits.h) 45
uint8 820
uint16 820
uint32 820
uint64 820
UINT_MAX (limits.h) 45
uintptr_t 820
ULLONG_MAX 819
ULONG_MAX (limits.h) 45
unaire, opérateur 64
ungetc (stdio.h) 797
union 484
adresse 498, 503
affectation 502
champ 488
const 491
déclaration 486, 490
en argument 505
en valeur de retour 505
initialisation 511
représentation 495
sizeof 499
spécificateur de type~ 688
volatile 491
unsigned 28
USHRT_MAX (limits.h) 45
u (suffixe) 826
U (suffixe) 826
UTF-8 826
UTF-16 826
UTF-32 826
V
va_arg (stdarg.h) 729, 789
va_copy (stdarg.h) 831
va_end (stdarg.h) 727, 729, 789
valeur de retour 260, 264, 287
déclaration 265
des fonctions de gestion de fichier 555
structure 505
union 505
validation 383
va_list (stdarg.h) 726, 789
variable 10
accès 331
classe d’allocation 331
globale 287, 309, 310, 718
cachée 313
déclaration 313
définition 314
et édition de liens 320
initialisation 322
portée 319
redéclaration 314
locale 324
classe d’allocation 326
initialisation 326
portée 324
portée 331
rémanente 321
statique 328
structure 490
union 490
va_start (stdarg.h) 729, 789
vfprintf (stdio.h) 369, 794
vide (directive) 677
void 264
volatile 58
argument 263, 280
pour les pointeurs 220, 222, 239
pour les structures 491
pour les tableaux 193
pour les unions 491
valeur de retour 265
vprintf (stdio.h) 369, 794
vsprintf (stdio.h) 369, 732, 794
W
wchar_t (stddef.h) 748, 790
wchar_t (stdlib.h) 800
wcstombs (stdlib.h) 754, 807
wctomb (stdlib.h) 752, 806
while (instruction) 158
wint_t 826
wprintf 826
wscanf 826
Z
zéro, suppression des~ 363
Pour suivre toutes les nouveautés numériques du Groupe Eyrolles, retrouvez-nous sur Twitter et Facebook
@ebookEyrolles
EbooksEyrolles
Et retrouvez toutes les nouveautés papier sur
@Eyrolles
Eyrolles