Travaux pratiques

Table des matières

true
int main(void), return, compilateur, cc, gcc, ./a.out, cc -o
Fonctions
appel, opérateur (), argument, paramètre, prototype, cc -Wall
false
portabilité, macro, #define, #include, cc -E, exit
Commentaires et espaces blancs
/* */, //, #if 0, #endif
hello, world
printf, stdio.h, sortie standard stdout, chaîne de caractères littérale, séquence d'échappement, \n
printf
sortie formatée, %d, %c, %s, %f, caractère littéral, 'A'
Opérateurs de calcul
+, -, *, /, %, ~, <<, >>, &, |, ^, <, <=, >, >=, ==, !=, !, &&, ||, ?:, ,
Variable
déclaration, type, int, char, unsigned, float, double, initialisation, =, visibilité, static
Opérateurs d'assignation
++, --, +=, -= *=, /=, %=, &=, |=, ^=, <<=, >>=
Copie
condition, boucles while, do, for, break, continue, getchar, entrée standard
133t
switch, case, default, if, else
Tableau
[], initialisation, chaîne de caractères, '\0'
Pointeur
adresse, &, *, NULL, conversion tableau-pointeur, const
Ligne de commande
int main(int argc, char *argv[])
Mémoire dynamique
malloc, realloc, free, sizeof, forçage de type (type), size_t, typedef, void*
Structure
struct, ., ->, fonction UNIX
Étapes de compilation
pré-processeur, compilateur, assembleur, éditeur de liens
Compilation séparée
fichiers objets, #include "file", ifndef

true

Ce premier programme correspond au programme UNIX true qui ne fait rien et termine avec succès (selon man true) :

int main(void)
{
  return 0;
}

Théorie

Description du source de true

int main(void)

La fonction main a une liste de paramètres vide et retourne un nombre entier. La paire de parenthèses suivant main indique qu'il s'agit d'une fonction.

{

La définition de toute fonction est entourée d'une paire d'accolades { et (voir plus bas) }.

return 0;

Le mot-clé return fait que la fonction retourne immédiatement le nombre entier 0. Cette ligne est une instruction, close par le point-virgule.

}

} clôt la définition de la fonction.

Un programme C qui n'est pas destiné à un environnement particulier doit au minimum définir la fonction main. En effet, toutes les instructions sont dans des fonctions et la première fonction exécutée par un programme est main.

L'environnement (un shell interactif, un script, make...) qui lance notre programme récupère son statut de sortie. La valeur retournée par main est le statut de sortie, 0 signifiant « terminé avec succès.»

Compilateur

C est un langage compilé. Il faut donc utiliser un compilateur pour construire un fichier exécutable en langage machine à partir des fichiers sources en C. Sur UNIX le compilateur C est la commande cc (C Compiler) ou gcc pour utiliser les outils GNU (cc est gcc sur Linux).

Construction du programme true
fichier sourcecommandeexécutable produit
true.c cc true.c a.out

Fichier texte décrivant notre programme en langage C.

Commande traduisant notre programme du C en langage machine.

Fichier exécutable produit par le compilateur, nommé a.out (pour Assembler Output) par défaut.

L'option -o de cc spécifie le nom du fichier à produire, par exemple cc true.c -o true pour produire true au lieu de a.out.

Exercices

  1. Vérifier le fonctionnement de la commande standard true (lancer true && echo $? dans bash).

  2. Créer le fichier source C true.c (l'extension .c est obligatoire). En tapant le C, prendre notamment garde aux lettres minuscules, parenthèses, accolades et point-virgule.

  3. Construire le programme avec la commande cc true.c en corrigeant les éventuelles erreurs détectées par le compilateur. Vérifier que a.out a été produit.

  4. Lancer a.out et vérifier son statut de sortie.

  5. Si on nomme notre programme exécutable true au lieu de a.out, à quoi faut-il faire attention en le lançant ?

Corrigés

  1. $ true && echo $?
    0
    $ 
    
  2. Après édition :

    $ cat true.c
    int main(void)
    {
      return 0;
    }
    $
    
  3. $ cc true.c
    $ ls -l *
    -rwxrwxr-x    1 marc     marc         4684 oct  7 22:40 a.out
    -rw-rw-r--    1 marc     marc           31 oct  7 21:49 true.c
    $ 
    

    La taille de a.out peut varier. Le site A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux décrit le contenu des milliers d'octets de a.out.

  4. Dans bash on peut vérifier le succès et afficher le statut de sortie ainsi :

    $ ./a.out && echo $?
    0
    $ 
    
  5. Il faut faire attention à ne lancer ni la commande true du shell (voir help true dans bash) ni le programme true du système (voir man true). Pour être sûr de lancer notre true il faut donner son chemin, comme ./true depuis le répertoire courant.

    On se fait aussi facilement piéger en appelant son programme test...

Subtilités

void main

On tombe souvent sur des programmes définissant main ainsi :

void main(void) {}

Cela signifie que main ne retourne rien. Un certain nombre de compilateurs (notamment sur Windows) le supportent. Mais dans tous les standards C et C++, main retourne un int.

Conseil d'achat de livre

Le livre The C Programming Language, Second edition de Brian W. Kernighan et Dennis M. Ritchie décrit extrêmement bien C en moins de 300 pages, y compris des exemples avec UNIX.

Pour le reste, la fréquence élevée de l'erreur void main en fait un excellent critère d'achat de livres sur C ou C++. Si celui-ci utilise void main il faut s'attendre à des exemples non standards, non portables, voire complètement erronés.

main(){}

Il s'agit d'une syntaxe obsolète du K&R C. L'important est qu'elle équivaut à int main(){}.

Fonctions

On peut définir et utiliser autant de fonctions qu'on veut :

int somme(int x, int y) { return x + y; }
int produit(int a, int b) { return a * b; }
int happy_end(void) { return produit(2, somme(-3, 3)); }
int main(void) { return happy_end(); }

Théorie

Appel de fonction

happy_end() est une expression, qui signifie « appeler la fonction happy_end », qui a le type de happy_end (soit int) et qui a la valeur retournée par happy_end (soit 0). L'opérateur d'appel de fonction est () ; il n'est pas facultatif, même vide.

L'expression produit(2, somme(-3, 3)) appelle les fonctions somme puis produit. Elle se lit comme une expression mathématique. Elle vaut 0, soit 2 * 0 soit 2 * (-3 + 3). -3 et 3 sont les arguments passés à somme pour initialiser les paramètres x et y. 2 et 0 (retourné par somme(-3,3)) sont ensuite passés à produit pour initialiser a et b.

Dans cet exemple les fonctions sont définies avant d'être appelées. Ainsi le compilateur sait exactement à quoi il a affaire en compilant les appels. C'est simple et propre.

Prototype

int somme(int, int);
int produit(int, int);
int happy_end(void);

int main(void) { return  happy_end(); }
int happy_end(void) { return produit(2, somme(-3,3)); }
int somme(int x, int y) { return x + y; }
int produit(int a, int b) { return a * b; }

La façon propre d'appeler une fonction avant sa définition (voire même sans connaître sa définition) est de précéder les appels des prototypes des fonctions appelées. Un prototype donne à l'appelant tout ce dont il a besoin de savoir : le type retourné, le nom de la fonction et les types des paramètres.

Dans les projets non triviaux, l'usage des prototypes est systématique pour utiliser des fonctions réparties dans plusieurs fichiers sources et bibliothèques (voir chapitre Compilation séparée).

On n'écrit en principe pas le prototype de main car on n'appelle généralement pas main.

Une fonction qui ne retourne aucune valeur (appelée procédure dans les autres langages) retourne void.

Exercices

  1. void f1(void) {}
    void f2(void) { f1(); }
    void f3(void) { f2(); f1(); }
    int main(void){ f3(); f2(); return 0; }
    

    Les fonctions appelées sont dans l'ordre : main, f3... à compléter.

  2. Que fait ce programme ?

    void f(void) { f(); }
    int main(void) { f(); return 0; }
    
  3. Si on remplace main par Main le compilateur se plaint de l'absence de main mais ne dit rien à propos de Main. Pourquoi ?

Corrigés

  1. main, f3, f2, f1, f1, f2, f1.

  2. main appelle f puis f s'appelle elle-même indéfiniment. Comme un appel consomme de la mémoire, le programme se plante après avoir tout consommé.

  3. Définir une fonction Main est tout à fait permis, elle n'a simplement rien à voir avec main. L'absence de main empêche toutefois de créer un programme exécutable.

Subtilités

Déclaration implicite de fonction

En C (mais pas en C++) on peut aussi appeler une fonction sans prototype préalable :

int main(void) { return happy_end(); }
int happy_end(void) { return 0; }

GCC compile ceci sans avertissement, à moins d'utiliser l'option -Wall. Mais c'est une survivance du K&R C à éviter. Elle crée en effet une déclaration implicite de la fonction appelée avec des implications inattendues quant aux types des paramètres.

Exercice

Soit le fichier ex4.c :

int main(void) { f(); g(); h(); return 0; }
void f(void) {}
g() {}
int h(void) {return 0;}

Pourquoi la fonction f cause-t-elle un avertissement de gcc ex4.c et pas g ni h ?

Corrigé

Les appels f();g();h(); créent des déclarations implicites des fonctions. Or une fonction est déclarée implicitement comme retournant un int et non void comme le fait f, d'où l'avertissement.

À noter que la définition g() {} utilise la syntaxe obsolète du int implicite de K&R C. Étant équivalente à int g() {} elle correspond à la déclaration implicite.

On ne devrait pas être confronté à ce genre de problème dans un programme bien écrit. Mais un oubli de prototype ou une erreur de frappe cause souvent ce genre d'avertissement.

false

Voici une version particulièrement portable de false :

#include <stdlib.h>
int main(void)
{
  return EXIT_FAILURE;
}

Théorie

Portabilité

C permet de retourner un entier quelconque de main. Sur UNIX il suffit de retourner 1 pour signaler l'échec du programme. Mais sur les autres systèmes comme Windows ou VMS ? Les standards C ou C++ garantissent la signification de seulement trois valeurs : 0 (succès), EXIT_SUCCESS (succès) et EXIT_FAILURE (échec).

Macro (#define)

EXIT_SUCCESS et EXIT_FAILURE sont des macros qui doivent (d'après les standards) être définies par le fichier include stdlib.h. Par exemple :

#define EXIT_SUCCESS 0
#define EXIT_FAILURE 1

La définition de macro #define EXIT_FAILURE 1 définit que chaque occurrence de l'identificateur EXIT_FAILURE doit être remplacée par le texte 1.

Pré-processeur

La première étape de compilation d'un fichier source C consiste à le passer à un pré-processeur. Il ne fait essentiellement que des substitutions de texte. Il interprète chaque ligne commençant par # comme étant une commande. La commande finit à la fin de la ligne. Contrairement aux instructions C, il n'y a donc pas besoin d'un ; pour marquer la fin d'une commande du pré-processeur.

#include

La commande #include<stdlib.h> est substituée par le texte du fichier stdlib.h. Autrement dit, le fichier est inclus. Les caractères < et > indiquent de chercher stdlib.h dans des répertoires prédéfinis (comme /usr/include). Si on utilise le délimiteur " à la place, alors le fichier est recherché dans le répertoire du fichier pré-processé.

stdlib.h est un fichier include standard. Les fichiers includes doivent aussi être en C pour pouvoir être inclus dans nos sources. Dans les fichiers includes standards chaque fabricant de compilateur définit des macros comme EXIT_FAILURE avec les valeurs idoines pour le système (UNIX, Windows, VMS...)

Exercices

  1. Que se passe-t-il si on compile false en utilisant EXIT_FAILURE sans inclure stdlib.h ?
  2. Le pré-processeur ne fait que modifier le source avant de le donner à la prochaine étape de compilation. Utiliser la commande cc -E false.c pour observer la sortie du pré-processeur.
  3. L'abus de macros cause souvent des bugs difficiles à trouver. Par exemple la commande cc ex3.c échoue à cause d'un petit bug, lequel ?

    ex3.cex3_1ex3_2
    #include "ex3_1"
    #include "ex3_2"
    
    #define MAIN int main ( void )
    #define FAUX return EXIT_FAILURE
    #include <stdlib.h>
    
    MAIN{ FAUX }
    

Corrigés

  1. EXIT_FAILURE ne représente rien de connu pour le compilateur, donc la compilation échoue.

  2. On voit que notre source est précédé de centaines de lignes qui correpondent au contenu de stdlib.h et des fichiers inclus par ce dernier. À noter que les sources incluses sont aussi modifiées par le pré-processeur. On voit enfin que EXIT_FAILURE a été remplacé par un nombre.

    Ce source transformé est ce que reçoit le compilateur proprement dit. Après le pré-processeur, les messages d'erreur du compilateur se rapportent à ce source transformé.

  3. Si on utilise l'option -E on voit qu'il manque un ; à la fin de l'instruction return.

Subtilités

exit

Souvent un problème fatal apparaît dans une autre fonction que main. Un moyen pratique de terminer immédiatement le programme est alors d'appeler la fonction standard exit :

#include <stdlib.h>

void autre(void) { exit(EXIT_FAILURE); }

int main(void)
{
  autre();
  return 0;
}

Souvent la valeur -1 est utilisée comme statut de sortie, mais la documentation de la libc n'autorise que des valeurs entre 0 et 255. On peut vérifier qu'une valeur hors de ces limites est tronquée sur 8 bits. La valeur conventionnelle sur les systèmes POSIX comme Linux est 1.

Commentaires et espaces blancs

Un bon programme a des commentaires :

/*
 * true
 */
int main(void)
{
  return 0;    /* succès */
}

Théorie

Un commentaire C commence par /* et fini par */. Ce qui est pratique, c'est qu'un commentaire peut couvrir plusieurs lignes. Ce qui est moins pratique, c'est qu'un commentaire ne peut pas contenir un autre commentaire car la première occurrence de */ stoppe tout.

Pour commenter des lignes contenant des commentaires, on peut heureusement utiliser une commande du pré-processeur :

int f(){}
#if 0
/* Tout ceci est supprimé par le pré-processeur
 * jusqu'au #endif correspondant au #if.
 */
void f(){} /* autre définition dont on ne veut plus */
#endif

On peut encore noter que de nombreux compilateurs supportent aussi les commentaires C++ (et maintenant C99) :

// Ceci est un commentaire C++ (et C99)
             // Il commence par // et se termine tout
             // seul à la fin de la ligne.
/*// Il peut être inclu dans un commentaire C (pratique).*/
// Mais ce n'est pas du C (ni ANSI C ni K&R C) !

Pour séparer les mots d'un programme C, on peut indistinctement utiliser des espaces, des tabulations, des retours à la ligne ou des commentaires. Ils équivalent tous à ce qu'on appelle un espace blanc. On peut même tout coller tant que cela n'introduit pas d'ambiguïté.

Exercices

Soit le programme :

#include <stdlib.h>
int main(void)
{
  return EXIT_FAILURE;
}
  1. Coller au maximum les mots.
  2. Mettre sous forme de retour à la ligne un maximum d'espaces blancs.

Corrigés

  1. Le pré-processeur C est sensible aux lignes alors il faut garder le retour à la ligne après la commande d'inclusion.

    #include<stdlib.h>
    int main(void){return EXIT_FAILURE;}
    
  2. # include <stdlib.h>
    int
    main
    (
    void
    )
    {
    return
    EXIT_FAILURE
    ;
    }
    

hello, world

Voici le fameux programme hello, world (qui termine avec succès) :

#include <stdio.h>
int main(void)
{
	printf("hello, world\n");
	return 0;
}
hello, world

Théorie

stdio.h (pour STanDard Input/Output) contient le prototype de printf. C'est une fonction de la bibliothèque standard de C. Elle est donc documentée dans le manuel, qu'on peut consulter avec man 3 printf (ou info libc qui donne toute la documentation de la GNU C Library).

La fonction printf est compilée dans la libc. Il s'agit d'une bibliothèque partagée (fichier .so). Par défaut GCC sur Linux utilise les fonctions standards des bibliothèques partagées, ce qui rend nos exécutables bien plus petits tout en optimisant l'utilisation des ressources. La commande ldd exe permet de lister les bibliothèques partagées utilisées par exe.

Sortie standard

printf sort sur la sortie standard (appelée stdout) la chaîne de caractères Hello, world\n.

Le concept de sortie standard est très abstrait. Si nous lançons simplement ./hello c'est la console. Si nous lançons ./hello>dump.txt c'est le fichier dump.txt. Si nous lançons ./hello|wc c'est le programme wc. Dans un script CGI le serveur Web redirige la sortie standard sur lui-même pour en faire la page Web.

Chaîne de caractères littérale

Une chaîne de caractères littérale est délimitée par des guillemets ". Une chaîne doit tenir sur une ligne. Depuis ANSI C on peut toutefois écrire une chaîne en plusieurs morceaux séparés par des espaces blancs. On pourrait donc écrire "hello" "," " world\n".

Dans la chaîne "hello, world\n", \n est une séquence d'échappement qui représente le caractère de retour à la ligne. On ne pourrait pas mettre le retour à la ligne tel quel dans une chaîne qui doit tenir sur une seule ligne. Les séquences utiles sont :

séquencecaractère
\nnewline
\thorizontal tab
séquencecaractère
\\\
\''
séquencecaractère
\""
\ooocaractère de code octal ooo

Exercices

  1. Supprimer le retour à la ligne de hello, world, lancer le programme et observer.

  2. Modifier hello, world pour qu'il affiche :

    hello, world
    Il a écrit :
    	"'lut \-)"
    

    À noter une espace insécable (caractère ISO-8859-1 de code décimal 160) avant les deux-points et une tabulation horizontale avant le premier double guillemet. Il peut être utile de rediriger la sortie standard sur un fichier pour l'enregistrer et la vérifier.

  3. Si au lieu de printf("hello, world\n") on compile printf(123), que se passe-t-il ? En quoi l'inclusion de stdio.h change-t-elle quelque-chose ?

Corrigés

  1. $ ./a.out
    hello, world$ 
    

    Il manque un retour à la ligne avant la nouvelle invite.

  2. Solution compacte :

    printf("hello, world\nIl a écrit\240:\n\t\"'lut \\-)\"\n");

    Solution plus jolie :

    printf("hello, world\n"
           "Il a écrit\240:\n"
           "\t\"'lut \\-)\"\n");
    

    Il n'est pas garanti que la console affiche correctement les caractères non ASCII comme '\240' (160 en base 10).

  3. Grâce au prototype contenu dans stdio.h, le compilateur voit qu'il y a un problème avec printf(123) et nous en averti :

    $ cc hello.c -o hello
    hello.c: Dans la fonction « main »:
    hello.c:5: AVERTISSEMENT: passage de arg 1 de « printf » transforme en pointeur un entier sans transtypage
    $ 
    

    Il ne s'agit cependant que d'un avertissement, donc un programme hello est tout de même produit, mais il plante :

    $ ./hello
    Erreur de segmentation
    $ 
    

    Sans l'inclusion de stdio.h le compilateur ne voit plus le prototype de printf, donc nous ne sommes pas averti. Mais le programme produit plante tout autant.

printf

On peut aussi afficher des nombres et des caractères avec printf :

#include <stdio.h>
int main(void)
{
  printf("%d plus %d font %d.\n", 2, 2, 2+2);
  printf("Le cube de %g est %g.\n", 0.125, 0.125 * 0.125 * 0.125);
  printf("Le code du caractère '%c' est %d (%o octal, %x hexadécimal).\n",
         'A', 'A', 'A', 'A');
  printf("Le code du caractère '%c' est %d (%o octal, %x hexadécimal).\n",
	 123, 123, 123, 123);
  printf("%s, %s\n", "hello", "world!");
  return 0;
}
2 plus 2 font 4.
Le cube de 0.125 est 0.00195312.
Le code du caractère 'A' est 65 (101 octal, 41 hexadécimal).
Le code du caractère '{' est 123 (173 octal, 7b hexadécimal).
hello, world!

Théorie

Chaîne de formatage

La fonction printf a la particularité d'accepter de 1 à N paramètres. Le premier est une chaîne de formatage. Elle peut contenir N-1 spécifications de conversion (%truc) qui doit correspondre à un des N-1 paramètres suivants. Les conversions les plus utiles sont :

caractèreconversion
dentier décimal
uentier décimal non signé
oentier octal non signé
x ou Xentier hexadécimal non signé
ccaractère du code entier
caractèreconversion
fnombre à virgule [-]mmm.ddd
e ou Enombre à virgule [-]m.dddddd[e ou E]±xx
g ou Gcomme f, ou e (ou E) si f manque de précision
schaîne de caractères
%le caractère %

Il existe encore quelques autres lettres de conversion. Mais surtout il existe des possibilités de variations d'alignement, de précision ou de remplissage. La documentation de printf donne tous les détails.

Caractère littéral

Il faut remarquer que pour le compilateur, le littéral 'A' est un nombre entier, qui vaut le code du caractère A. Il n'existe donc pas de conversion d'entier en caractère en C. Il n'y a que des entiers, dont on peut afficher le caractère de code correspondant.

Exercices

  1. Le programme suivant utilise des possibilités de formatage documentées, que sort-il ?

    #include <stdio.h>
    int main(void)
    {
    #define PI 3.1415926535
      printf("%f %.3f %010f %+10.10f\n", PI, PI, PI, PI);
      return 0;
    }
    
  2. Que vaut 'A'+'A' ?

Corrigés

  1. 3.141593 3.142 003.141593 +3.1415926535
  2. 65 + 65 soit 130.

Opérateurs de calcul

Voici un exemple d'utilisation de chaque opérateur de calcul.

arithmétiquesur bitscomparaisonlogiqueautre
+11
-1-1
2 + 46
2 - 4-2
2 * 48
2 / 40
2 / 4.00.5
123 % 103
~00xffffffff
1 << 24
4 >> 21
5 & 31
5 | 37
5 ^ 36
3 < 30
3 <= 31
3 > 30
3 >= 31
3 == 31
3 != 30
!01
!1230
8 && 41
8 && 00
8 || 01
0 || 00
1 ? 2 : 32
0 ? 2 : 33
4 , 77

Le complément sur les opérateurs détaille l'usage et la précédence de chaque opérateur.

Théorie

Précédence

Les opérateurs ont la même précédence qu'en mathématiques et on peut utiliser des parenthèses pour en forcer une autre. Ainsi 3*4-10/(5-3)==7 vaut 1.

Arithmétique et promotion de type

Dans une opération artihmétique, si les deux opérandes n'ont pas la même précision, alors l'opérande de basse précision est automatiquement promue en haute précision, puis l'opération a lieu en haute précision. Donc 2/4.0 est d'abord promu en 2.0/4.0 puis le calcul est fait en nombres à virgule, d'où le résultat de 0.5 au lieu de 0 en division entière (quotient).

Notation hexadécimale

Pour les opérations sur les bits, la notation hexadécimale est généralement plus lisible. Ainsi ~0 inverse tous les bits, ce qui sur 32 bits donne 11111111111111111111111111111111 en binaire, 4294967295 en décimal ou FFFFFFFF en hexadécimal. Le préfixe 0x permet d'utiliser la notation hexadécimale en C.

Exercices

  1. Que vaut -1 - -1 ?
  2. Que vaut 4 == 3 || 4 != 3 ?
  3. Que vaut (100 / 11) * 11 ?
  4. Que vaut (100 / 11) * 11 + 100 % 11 ?
  5. Que vaut 3 == 2 ? 1 : 0 ?
  6. Que vaut 4 / 0 ?
  7. Que vaut 0 && 4 / 0 ?
  8. Que vaut (0x16 | 0x7) == (0x16 | 0x7) ?
  9. Que vaut 0x16 | 0x7 == 0x16 | 0x7 ?
  10. Que vaut 1.74e20 - 7.2e19 ?

Corrigés

  1. 0
  2. 1
  3. 99
  4. 100
  5. 0
  6. non défini (plantage)
  7. 0
  8. 0x1
  9. 0x17
  10. 1.02e+20

Subtilités

Ordre d'évaluation

Les opérandes peuvent être évaluées dans n'importe quel ordre : dans 2*3+4*5 on ne sait pas quel produit est calculé en premier.

Seuls les opérateurs &&, ||, ?: et , introduisent un point de séquencement. Autrement dit, l'opérande de droite est évaluée après celle de gauche et tous ses effets de bord. En plus l'opérande de droite n'est évaluée qu'en cas de nécessité. Donc on a :

f() + g();  /* On ne sait pas quelle fonction est appelée en premier. */
f() || g(); /* f est appelée et seulement si elle retourne 0, g est appelée. */
f() , g();  /* f est appelée, puis g. */

Variable

Une variable sert à stocker une valeur pendant l'exécution :

int main(void)
{
  int x =3;
  short y;

  y = x;
  return x - y;  /* retourne 0 */
}

Théorie

Déclaration

Toute variable doit être déclarée avant d'être utilisée. Pour ceci on donne son type et son nom. On peut aussi l'initialiser.

Type

En principe on utilise le type int pour stocker un nombre entier et double pour un nombre à virgule. On utilise souvent les types suivants :

typeusage
charpour stocker un caractère
unsigned charoctet non signé, pour accéder à des données binaires
shortentier de 16 bits
intentier efficace (32 bits sur i386)
longentier long, pour portabilité sur des systèmes ou int est plus court
floatnombre à virgule court (imprécis) mais efficace
doublenombre à virgule

Assignation

Assigner une valeur à une variable signifie qu'on stocke une valeur dans une variable. L'opérateur d'assignation est =. Les types de différentes précisions forcent parfois une conversion de la valeur lors d'initialisation ou d'assignation :

double f = 1.1;           /* f == 1.1  */
int i = 1.1;              /* i == 1    */
unsigned char u = 1.1;    /* u == 1    */
u = 0xffff;               /* u == 0xff */
i = u = 0x1234;           /* u == 0x12 et i == 0x12 */

À noter qu'une assignation est une expression, qui vaut ce qui est enregistré dans la variable, après l'assignation, d'où la possibilité d'écrire i=u=0x1234.

Classes de stockage

Toutes les variables ne sont pas créées avec la même durée de vie et visibilité :

int n = 0;

void plus_un(int n)
{
  printf("plus_un = %d\n", n = n+1);
}

void compteur_global(void)
{
  printf("compteur global = %d\n", n = n+1);
}

void compteur(void)
{
  static int n = 0;
  printf("compteur = %d\n", n = n+1);
}

void un(void)
{
  int n = 0;
  printf("un = %d\n", n = n+1);
}

Si plusieurs variables ont le même nom alors celle qui est visible masque les autres.

Variable globale

Créée et initialisée avant même que main commence, une variable globale existe en exemplaire unique aussi longtemps que le programme. Elle est visible après sa déclaration.

Paramètre formel

Un paramètre formel est créé lorsque l'exécution entre dans la fonction et est détruit à la sortie de la fonction. Il est initialisé par l'argument correspondant passé par la fonction appelante. Il n'est visible que par sa fonction.

Variable statique

Une variable statique est créée et initialisée (0 par défaut) lorsque l'exécution passe la première fois par là. Les passages suivants ne créent et n'initialisent plus la variable qui existe déjà. Une fois créée, elle existe jusqu'à la fin du programme. Elle est visible de sa déclaration à la fin de son bloc. Les variables statiques sont peu utilisées en pratique.

Variable locale (automatique)

Une variable locale est créée (et éventuellement initialisée, sinon sa valeur est indéfinie) lorsque l'exécution passe par là. Elle est détruite lorsque l'exécution sort de son bloc. Elle est visible de sa déclaration à la fin de son bloc. Les variables locales sont les plus utilisées en pratique. On les appelle parfois «automatique», en opposition à «statique».

Bloc

En C les variables déclarées dans un bloc (statiques ou automatiques) doivent précéder les instructions :

{
  déclarations;
  instructions;
  {
    déclarations;
    instructions;
  }
  instructions;
}

Exercices

  1. int i; double d; char c;
    i = d = c = 'A';
    

    Est-ce que i égale d et d égale c après ces 3 assignations ?

  2. Avec les fonctions ci-dessus, qu'est-ce que le programme suivant affiche ?

    int main(void)
    {
      plus_un(0);
      compteur_global();
      compteur();
      un();
    
      plus_un(-8);
      compteur_global();
      compteur();
      un();
    
      n = 123;
      plus_un(0);
      compteur_global();
      compteur();
      un();
      return 0;
    }
    

Corrigés

  1. Oui, i==d && d==c vaut 1.

  2. plus_un = 1
    compteur global = 1
    compteur = 1
    un = 1
    plus_un = -7
    compteur global = 2
    compteur = 2
    un = 1
    plus_un = 1
    compteur global = 124
    compteur = 3
    un = 1
    

Subtilité

Par « int est un type efficace » j'entends que les opérations sont plus rapides dans ce type. Pour int, il faut particulièrement noter que c'est la précision minimale avec laquelle une opération est faite. Si par exemple on additionne deux opérandes short, les deux sont promues en int avant l'opération, dont le résultat est aussi de type int, d'où inefficacité.

Tous les types entiers ont une version unsigned, notamment utile pour manipuler les bits. Le complément sur les types de base donne des définitions plus formelles de tous les types de base.

Exercice

Quel avertissement peut causer la compilation de cette fonction, et pourquoi ?

short somme(short x, short y) { return x + y; }

Corrigé

Certains compilateur émettent un avertissement car l'expression x + y produit un résultat de type int, qui est converti en short, d'où risque de perte de précision, pour être retourné.

Opérateurs d'assignation

Il existe des opérateurs d'assignation dont la seule utilité est de rendre les expressions plus lisibles. Par exemple si int x=0; alors :

expressionvaleur de l'expressionvaleur assignée à x
++x11
x++01
--x-1-1
x--0-1

Si int x=2; alors :

expressionvaleur assignée et de l'expression
x += 35
x -= 3-1
x *= 36
x /= 30
x %= 32
expressionvaleur assignée et de l'expression
x &= 32
x ^= 31
x |= 33
x <<= 316
x >>= 30

Théorie

On a l'équivalence suivante :
expr1 op= expr2
équivaut à
expr1 = (expr1) op (expr2)
sauf que expr1 n'est évaluée qu'une fois.

Exercices

Soit int i=0; :

  1. Que vaut i++ + 1 ?
  2. Que vaut i++, i+1 ?
  3. Que vaut i += 6 + i ?
  4. Trouver l'intrus : i=i+i, i*=2, i+=i, i^=i, i<<=1.

Corrigés

  1. 1
  2. 2
  3. 6
  4. i^=i est la seule expression qui ne double pas i.

Subtilités

Il ne faut pas assigner deux fois la même variable dans une expression. Il ne faut même pas l'utiliser une variable assignée pour calculer autre chose que la valeur à assigner. En effet, à moins d'un point de séquencement intermédiaire (introduit par les opérateurs &&, ||, ?: ou ,), le résultat est indéterminé :

i = i++;      /* BUG */
f(i, ++i);    /* BUG */
++i * i;      /* BUG */
--i && 3/i;   /* OK, grâce à && */

Copie

Ce programme copie telle quelle l'entrée standard sur la sortie standard.

#include <stdio.h>

int main(void)
{
  int c;

  c = getchar();
  while  (c != EOF) {
    putchar(c);
    c = getchar();
  }
  return 0;
}
$ echo "hello, world" | ./a.out 
hello, world
$

Théorie

getchar

getchar() est déclaré dans stdio.h et retourne le caractère (en fait un octet) lu sur l'entrée standard. L'entrée standard est le pendant de la sortie standard, par défaut c'est le clavier, un fichier avec ./a.out <fichier ou la sortie d'un autre programme avec ls | ./a.out. S'il n'y a plus rien à lire, alors getchar() retourne l'entier EOF. putchar(c) écrit le caractère c sur la sortie standard.

Condition

En C, 0 signifie «faux», tout autre nombre signifie «vrai». Les opérateurs logiques et de comparatifs retournent 0 et 1 pour signifier «faux» et «vrai», donc il n'y a aucun soucis à avoir.

Il n'y a pas de type booléen en C. Le type bool ayant les valeurs true et false a été ajouté en C++.

Boucles

Il existe 3 syntaxes de boucle. La boucle est exécutée tant que l'expression de condition est vraie :

/* boucle while */
while (condition) {
  instructions;
}

/* boucle do while */
do {
  instructions;
} while (condition);

/* boucle for */
for (initialisation; condition; post-itération) {
  instructions;
}

Les accolades sont optionnelles s'il n'y a qu'une instruction dans la boucle. Formellement, les accolades forment un bloc qui est une instruction composée. Et une boucle a toujours exactement une instruction (qui peut être composée).

Les boucles avec while bouclent tant que la condition est vraie.

La boucle for est la plus complexe. L'initialisation est une expression évaluée exactement une fois, avant de commencer la boucle. L'expression de condition est évaluée avant chaque entrée dans la boucle, comme pour une boucle while. Enfin l'expression post-itération est évaluée à la fin de chaque itération.

Les trois expressions de la boucle for sont optionnelles. On utilise souvent la syntaxe for(;;) lorsqu'on veut une boucle infinie.

Il existe 2 instructions pour contrôler une boucle de l'intérieur :

break;    /* sort de la boucle */
continue; /* va directement après la dernière instruction de la boucle */

À l'intérieur de plusieurs boucles imbriquées, ces instructions ne s'appliquent qu'à la boucle la plus imbriquée.

Exercices

  1. Combien de fois la boucle for(i=0;i<3;i++); est-elle exécutée ?
  2. Combien de fois la boucle for(i=3;i;i--); est-elle exécutée ?
  3. Combien de fois la boucle for(;;)break; est-elle exécutée ?
  4. Combien de fois la boucle for(i=2;i;--i){continue;i=0;} est-elle exécutée ?
  5. Récrire le programme de copie en utilisant une boucle for au lieu d'une boucle while.
  6. Récrire le programme de copie avec une boucle while, mais en appelant getchar() en un seul endroit. Il est conseillé d'utiliser le fait que c = getchar() retourne la valeur lue.

Corrigés

  1. 3 fois (avec i valant 0, 1 puis 2). À noter que cette boucle contient seulement une instruction vide : ;.
  2. 3 fois (avec i valant 3, 2 puis 1). On constate que l'expression i suffit.
  3. C'est une boucle infinie mais elle est interrompue dès la première exécution à cause du break;.
  4. 2 fois (i valant 2 puis 1) car l'instruction i=0; n'est jamais exécutée à cause du continue; précédent.
  5. int c;
    for (c = getchar(); c != EOF; c = getchar())
      putchar(c);
    return 0;
    

    Comme il n'y a plus que l'instruction putchar(c); dans la boucle, on peut se passer des accolades.

  6. int c;
    while ((c = getchar()) != EOF)
      putchar(c);
    return 0;
    

    La précédence de l'opérateur = étant inférieure à celle de !=, les parenthèses sont nécessaires. Autrement c aurait été assigné avec le résultat de l'inégalité (soit 0 ou 1).

133t

Ce programme copie l'entrée sur la sortie en remplaçant le caractère «a» par «@», le «e» par «3» et le «l» par «1» :

#include <stdio.h>

int main(void)
{
  int c;
  while ((c = getchar()) != EOF) {
    switch (c) {
    case 'a':
      putchar('@');
      break;
    case 'e':
      putchar('3');
      break;
    case 'l':
      putchar('1');
      break;
    default:
      putchar(c);
    }
  }
  return 0;
}
$ echo "hello, world" | ./a.out 
h311o, wor1d
$ 

Théorie

switch case default

Le switch (expression) évalue l'expression qu'on donne puis saute directement au label (étiquette) case de même valeur. En pratique les accolades sont toujours employées même si formellement il s'agit d'exactement une instruction pouvant être composée.

Si aucun label case ne correspond, on peut récupérer le cas avec un default. S'il n'y a pas de default, tout est sauté. Pour sortir d'un switch avant la fin il faut utiliser l'instruction break;, comme pour les boucles. Sinon on exécute tous les case suivant celui où on est arrivé !

if else

if (condition) {
  instructions;
}

Comme pour les boucles, la condition est une expression et les accolades sont optionnelles s'il n'y a qu'une instruction. On peut donner une alternative avec un else :

if (condition) {
  instructions1;
} else {
  instructions2;
}

Exercice

  1. Modifier le programme d'exemple pour utiliser des if else au lieu de switch case default.

  2. Récrire le programme pour que les «A», «E» et «L» soient également transformés en «@», «3» et «1».

  3. Ce programme calcule et sort le même résultat qu'un programme UNIX standard, lequel ?

    #include <stdio.h>
    
    int main(void)
    {
      int c;
      int nl = 0, w = 0, t = 0, in_word = 0;
    
      while ((c = getchar()) != EOF) {
        switch (c) {
        case '\n':
          ++nl;
          /* pas de break */
        case ' ': case '\r': case '\t': case '\v':
          in_word = 0;
          break;
        default:
          if (!in_word) {
    	in_word = 1;
    	++w;
          }
        }
        ++t;
      }
      printf("% 7d % 7d % 7d\n", nl, w, t);
      return 0;
    }
    

Corrigés

  1.     if (c == 'a')
          putchar('@');
        else if (c == 'e')
          putchar('3');
        else if (c == 'l')
          putchar('1');
        else
          putchar(c);
    
  2. On peut par exemple regrouper les labels en utilisant la propriété qu'il faut un break; pour les séparer :

        switch (c) {
        case 'a':
        case 'A':
          putchar('@');
          break;
        case 'e':
        case 'E':
          putchar('3');
          break;
        case 'l':
        case 'L':
          putchar('1');
          break;
        default:
          putchar(c);
        }
    

    Une autre solution plus légère utilise la fonction standard tolower qui retourne la minuscule d'une lettre :

    #include <stdio.h>
    #include <ctype.h>  /* contient le prototype de tolower */
    /*...*/
        switch (tolower(c)) {
        case 'a':
          putchar('@');
          break;
        case 'e':
          putchar('3');
          break;
        case 'l':
          putchar('1');
          break;
        default:
          putchar(c);
        }
    
  3. ./a.out <fichier affiche la même chose que wc <fichier.

Tableau

Ce programme affiche à l'envers les lignes reçues sur l'entrée standard.

#include <stdio.h>
#include <stdlib.h>
#define LIGNE_MAX 80

int main(void)
{
  int c;
  char ligne[LIGNE_MAX];
  int m = 0;

  while ((c = getchar()) != EOF) {
    if (c == '\n') {
      /* affiche la ligne à partir des derniers caractères */
      while (m > 0)
	putchar(ligne[--m]);
      putchar(c);
    }
    else if (m < LIGNE_MAX)   /* enregistre le caractère */
      ligne[m++] = c;
    else {                     /* erreur, ligne trop longue */
      fprintf(stderr, "ligne trop longue\n");
      return EXIT_FAILURE;
    }
  }
  return 0;
}
$ echo "hello, world" | ./a.out 
dlrow ,olleh
$ echo "hello, world" | ./a.out | ./a.out
hello, world
$ 

Théorie

Le principe est de mémoriser dans le tableau ligne tous les caractères de la ligne avant de les afficher en commençant par le dernier. char ligne[LIGNE_MAX] déclare un tableau de LIGNE_MAX éléments de type char. En pratique cela revient à avoir 80 variables de type char côte à côte en mémoire.

La syntaxe la plus naturelle pour nommer un élément de tableau est nom_du_tableau[indice]. Les indices d'un tableau commencent toujours à partir de zéro. Donc en l'occurrence on a :

ligne[0]ligne[1]ligne[2]...ligne[LIGNE_MAX-2]ligne[LIGNE_MAX-1]

Une limitation de notre programme est que le tableau à une taille limitée. Si on essaie d'écrire dans ligne[TAILLE_MAX] où encore plus loin, alors le programme va planter, car en fait on écrase ce qui suit le tableau dans la RAM ! Malheureusement la dimension des tableaux est fixe. On quitte après avoir affiché une erreur sur stderr si on tombe sur une ligne trop longue.

On pourrait utiliser un tableau plus grand, mais essentiellement cela ne fait que repousser le problème en consommant beaucoup de mémoire généralement inutilisée. Ce problème ne peut pas être résolu proprement avec un tableau de taille fixe.

Implémentation des chaînes de caractères

Une chaîne de caractères comme "hello, world\n" est en fait un tableau de caractères. Voici donc une nouvelle version de hello, world :

#include <stdio.h>
int main(void)
{
  char h[] = "hello, world\n";
  printf(h);
  return 0;
}

On constate que la taille du tableau h est automatiquement déduite de la taille de la chaîne initialisatrice. Il faut bien noter qu'il s'agit d'une initialisation et non d'une assignation. Il n'est plus possible d'écrire h="hello, world\n" par la suite.

Il n'existe aucun opérateur traitant un tableau dans son ensemble. Un tel opérateur serait inefficace car il devrait potentiellement traiter des milliers de données. Or l'inefficacité va à l'encontre de C. Voici donc le moyen classique de copier le tableau t1 dans le tableau t2 :

int i, t1[TAILLE], t2[TAILLE];
/* ... */
for (i = 0; i < TAILLE; i++)
  t2[i] = t1[i];

Pour être vraiment précis, une chaîne de caractères C est en fait une suite de caractères en mémoire terminée par le caractère nul (zéro). On peut donc initialiser le tableau h ainsi, ça revient au même :

#include <stdio.h>
int main(void)
{
  char h[] = {'h','e','l','l','o',',',' ','w','o','r','l','d','\n','\0'};
  printf(h);
  return 0;
}

La syntaxe type t[]={expr0,expr1,exprN}; est la syntaxe normale d'initialisation de tableau.

Le caractère '\0' n'est pas affiché, il est utilisé pour détecter la fin de la chaîne. En effet, printf, comme toutes les fonctions qui reçoivent des chaînes, ne connaît pas la taille de la chaîne qui lui est donnée, mais elle sait qu'il y a un caractère nul à la fin. Et si suite à une erreur il n'y en a pas, le programme plante.

Exercice

  1. Qu'affiche printf("hello, w\0orld\n") ?

  2. Après l'initialisation int t[] = {1, 6, 9}; que vaut t[1] ?

    Après l'instruction t[t[0]] = t[2];, quelles sont les valeurs dans le tableau ?

  3. Que fait ce morceau de programme ?

    for (i = 0; i < 3; i++)
      t[i] = i;
    
  4. Qu'affiche ce morceau de programme ?

    char text[] = "ABC";
    int i;
    for (i = 0; text[i]; ++i);
    printf("%d", i);
    

Corrigés

  1. hello, w
  2. 6.
    {1, 9, 9}.
  3. Il assigne aux 3 premiers éléments du tableau t les valeurs 0, 1 et 2.
  4. 3, c'est-à-dire la longueur de la chaîne "ABC", sans compter le caractère nul final.

Pointeur

#include <stdio.h>
int main(void)
{
  char h[] = "hello\n";
  char * p;

  for (p = &h[0]; *p != '\n'; ++p)
    printf(p);
  return 0;
}
$ ./a.out
hello
ello
llo
lo
o
$ 

Théorie

Adresse

Pour comprendre ce qu'est un pointeur, il faut savoir comment fonctionne la mémoire vive d'un ordinateur. La mémoire vive est constituée de bytes numérotés. On appelle adresse le numéro d'un byte. Dans tous les ordinateurs modernes, un byte contient 8 bits, soit un octet. En C, le type char a la taille d'un byte, donc en pratique d'un octet.

Dans l'exemple qui suit, le tableau h se trouve dans les octets d'adresses 2 à 8.

adresse012345678910...
contenu220'h''e''l''l''o''\n''\0'123'a'

Les autres adresses contiennent d'autres variables, des instructions, n'importe quoi... Typiquement, dans un ordinateur 32 bits, les adresses font 32 bits, ce qui permet d'adressé un maximum théorique de 4294967296 octets (4 Go).

Pointeur

char * p; déclare (sans l'initialiser) que la variable p est un pointeur sur un char. Cela signifie que p contient l'adresse d'un char. On dit que p pointe sur un char.

L'opérateur unaire & donne l'adresse de l'objet auquel il est appliqué. En l'occurrence l'expression &h[0] donne l'adresse de h[0], soit 2 dans l'exemple précédent. Donc p vaudrait 2 après p = &h[0]. Il faut cependant noter qu'un pointeur n'est pas un entier comme int, même si en C (pas en C++) la conversion se fait automatiquement.

L'opérateur unaire * retourne l'objet pointé. Ainsi *p est le caractère pointé par p. Donc si p vaut 2 dans l'exemple précédent, alors *p est h[0], soit l'octet à l'adresse 2.

Arithmétique de pointeur

Dans le programme précédent, p vaut d'abord &h[0]. L'incrémentation ++p fait que p vaut ensuite &h[1], puis &h[2] jusqu'à &h[7]. Alors la boucle se termine car h[7], donc *p, contient '\n'.

Ajouter ou soustraire 1 à un pointeur le fait par définition pointer sur l'élément suivant, respectivement précédent du tableau. Si on ne pointe pas dans un tableau alors l'opération est indéfinie.

Par définition la différence de deux pointeurs retourne le nombre d'éléments de tableau qui les séparent. La différence ne retourne un résultat défini que si les deux pointeurs pointent dans le même tableau.

L'addition de pointeurs n'existe pas car elle n'a pas de sens.

On peut aussi comparer des pointeurs avec les mêmes opérateurs que pour les entiers (==, <, ...)

Il faut encore noter que l'opérateur [] que nous utilisons pour indexer le tableau s'applique en fait sur des pointeurs (voir le sous-chapitre de conversion de tableau en pointeur).

Pointeur nul

0 est par définition le pointeur nul. C'est une adresse impossible où il est garanti que rien ne se trouve jamais. Déréférencer (*p) un pointeur nul plante le programme. La macro NULL est aussi souvent utilisée pour être plus explicite, mais ça revient au même, comme on le voit dans l'exemple suivant :

int *p1 = 0, *p2 = NULL;
if (p1 || p2) printf("impossible\n");
if (p1 != 0 || p2 != 0) printf("impossible\n");
if (pi != NULL || p2 != NULL) printf("impossible\n");
if (p1 != p2) printf("impossible\n");

Typiquement les fonctions qui retournent une adresse retournent le pointeur nul en cas d'échec.

Conversion de tableau en pointeur

printf(p) signifie qu'on donne à printf l'adresse du premier caractère à afficher. printf attend comme premier paramètre un const char*. Le const signifie simplement que printf ne va pas profiter de savoir où est notre chaîne pour la modifier (pointeur sur un caractère constant). Le char* est bien le type de p, donc tout est normal.

Mais alors comment se fait-il que l'on ait pu écrire printf(h) avec h de type char[] ? Et même comment se fait-il qu'on puisse écrire printf("hello\n") sachant que "hello\n" est de type char[7] ?

La subtilité est qu'un tableau se converti tout seul en un pointeur sur son premier élément lorsqu'on l'utilise dans un contexte où un pointeur est attendu. Donc printf(h) signifie implicitement printf(&h[0]) et printf("hello\n") signifie printf(&"hello\n"[0]).

Les expressions mélangeant tableaux et pointeurs sont très courantes dans les programmes C. La première raison est qu'il n'est pas possible de donner un tableau à une fonction (il y a toujours conversion en pointeur). La seconde raison est que les chaînes de caractères sont très utilisées et sont des tableaux.

Exercices

  1. Si l'on remplace for(p=&h[0]; *p!='\n' ;++p) par for(p=&h[0]; *p ;++p), que se passe-t-il ?
  2. Si l'on remplace for(p=&h[0]; *p!='\n' ;++p) par for(p=h; *p!='\n' ;++p), que se passe-t-il ?
  3. Qu'affiche le morceau de programme suivant ?

    char t[] = "hello\n";
    char * p = t;
    while (*p++);
    printf("%u\n", p-t);
    
  4. Qu'affiche le morceau de programme suivant ? Combien de bytes occupe le tableau ?

    int t[] = {'h','e','l','l','o','\n',0};
    int * p = t;
    while (*p++);
    printf("%u\n", p-t);
    

Corrigés

  1. Le programme affiche une ligne de plus, qui contient uniquement le retour à la ligne. Ensuite *p pointe sur '\0', soit 0, soit «faux», donc le test échoue et la boucle est terminée.
  2. C'est exactement la même chose grâce à la conversion automatique de tableau en pointeur.
  3. 7, c'est-à-dire la longueur de la chaîne "hello\n" avec le caratère nul final. L'expression *p++ est particulièrement illisible mais (malheureusement) commune. Elle équivaut à *(p++). C'est-à-dire qu'elle retourne l'élément pointé par p puis incrémente p. Donc la boucle while(*p++); incrémente le pointeur p tant qu'il pointe sur une valeur non nulle. La boucle s'arrête donc lorsque p pointe sur le caractère nul suivant le '\n'.
  4. 7. 7*(32/8)=28 bytes, sur une architecture avec des int de 32 bits et des char de 8 bits.

Subtilités

Assigner à un pointeur un entier différent de 0 est fortement découragé, cause un avertissement et même une erreur en C++. C'est ce que l'on a fait dans les exercices de hello, world en écrivant printf(123), et le programme a planté car il n'avait pas le droit d'accéder à l'adresse 123. En fait donner une adresse numériquement n'a de sens que pour programmer directement le matériel. Or système comme UNIX ne laisse les utilisateurs programmer directement le matériel...

Formellement la différence de pointeurs est de type ptrdiff_t. Sur i386 c'est un typedef de unsigned, d'où la spécification de conversion %u dans les exercices. Mais ce n'est pas portable puisque ça dépend du typedef de ptrdiff_t. Plus portable mais inélégant serait de forcer une conversion dans le plus grand type entier, unsigned long en C.

C++ offre une solution vraiment élégante avec les fonctions surchargées.

Ligne de commande

Voici un programme qui affiche une liste numérotée des mots de la ligne de commande utilisée pour lancer le programme.

#include <stdio.h>
int main(int argc, char * argv[])
{
  int i;
  for (i = 0; i < argc; ++i) {
    printf("%d: %s\n", i, argv[i]);
  }
  return 0;
}

Il existe deux définitions de main répondant aux standards C(++) : int main(void) et int main(int argc, char *argv[]). Les noms argc et argv sont conventionnels. argc indique combien de paramètres la ligne de commande comporte. argv pointe sur ces paramètres. Sur UNIX le premier paramètre est en principe le nom du programme.

La notation char *argv[] est trompeuse car elle est en fait équivalente à char * * argv. En effet, en tant que paramètre de fonction, var[] est synonyme de *var. D'ailleurs en dehors de main on n'utilise pratiquement jamais la notation [] pour les paramètres d'une fonction.

argv argv pointe donc sur un tableau de pointeurs qui pointent chacun sur un paramètre de la ligne de commande.

Exercice

  1. Écrire un programme tel que ./a.out 1000 x affiche 1000 fois la lettre x. La fonction standard atoi est utile pour convertir une chaîne en int.

Corrigé

  1. Une proposition de solution est dans les répertoires de sources.

Mémoire dynamique

Pour pouvoir inverser des lignes de toute taille (tenant en RAM) il faut pouvoir s'allouer la mémoire à mesure des besoins.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
  int c;
  char * ligne;
  size_t longueur = 1, m = 0;

  ligne = (char*)malloc(longueur * sizeof(char));
  if (!ligne) {
    fprintf(stderr, "Pas assez de RAM\n");
    return EXIT_FAILURE;
  }
  while ((c = getchar()) != EOF) {
    if (c == '\n') {
      while (m > 0)
        putchar(ligne[--m]);
      putchar(c);
    }
    else if (m < longueur)
      ligne[m++] = c;
    else {
      longueur = longueur * 1.1 + 1;
      if (!(ligne = (char*)realloc(ligne, longueur * sizeof(char)))) {
        fprintf(stderr, "Pas assez de RAM\n");
	free(ligne);
	return EXIT_FAILURE;
      }
      ligne[m++] = c;
    }
  }
  free(ligne);
  return 0;
}

Théorie

malloc, realloc, free, sizeof

Ce programme utilise les fonctions standards malloc, realloc et free à la place d'un tableau pour allouer, redimensionner et libérer la mémoire. Le pointeur ligne pointe sur le premier char de mémoire allouée.

malloc alloue le nombre de bytes demandé. On multiplie donc le nombre d'éléments voulu (longueur) par la taille en bytes de l'élément donnée par l'opérateur sizeof. En l'occurrence on pourrait s'en passer car par définition sizeof(char) vaut 1 (mais ça fait une illustration).

realloc change la taille de la mémoire allouée à l'adresse donnée par ligne. Ici on s'en sert pour augmenter de 10% la mémoire allouée.

free libère toute la mémoire allouée à l'adresse donnée par ligne.

size_t et typedef

La longueur et l'indice m ont été déclaré de type size_t car on pourrait théoriquement avoir besoin de tellement de mémoire qu'un entier de type int déborderait. size_t est en fait le synonyme d'un type de base définit dans stdlib.h. Il est d'usage en C de définir des synonymes pour des types ayant de telles contraintes de taille. On utilise le mot-clé typedef pour cela. En l'occurrence mon stdlib.h définit :

typedef unsigned int size_t;

Forçage de type (type), void*

Les fonctions de très bas niveau comme malloc servent à allouer de la mémoire en vrac, dans laquelle on peut mettre des char, des int, des double**, peu importe. Dans notre cas on veut y mettre des char donc nous assignons le résultat de malloc à un char*. Si on avait voulu y mettre des double** on aurait assigné le résultat de malloc à un double***.

Le problème est que malloc ne retourne pas un char* (ni un double***) ; il retourne un pointeur universel de type void*. Nous utilisons l'opération (char*) pour forcer le pointeur de type void* en char*.

En fait en C un void* se convertit tout seul en tout autre pointeur, le forçage (char*) n'est donc qu'illustratif. On trouve cependant de nombreux programmes utilisant ce forçage inutile en C. On peut arguer que c'est devenu obligatoire en Standard C++, cependant malloc est fort peu utile en C++.

Structure

Une structure est une collection de variables nommées. Quelques exemples :

/* Une coordonnée (x,y) */
struct coord { double x; double y; };

/* Un tirage de lotto */
struct tirage { int numeros[6]; int complementaire; }

/* Informations sur un fichier retournées par la fonction UNIX
   int stat(const char *file_name, struct stat *buf); */
struct stat {
  dev_t         st_dev;      /* device */
  ino_t         st_ino;      /* inode */
  mode_t        st_mode;     /* protection */
  nlink_t       st_nlink;    /* number of hard links */
  uid_t         st_uid;      /* user ID of owner */
  gid_t         st_gid;      /* group ID of owner */
  dev_t         st_rdev;     /* device type (if inode device) */
  off_t         st_size;     /* total size, in bytes */
  unsigned long st_blksize;  /* blocksize for filesystem I/O */
  unsigned long st_blocks;   /* number of blocks allocated */
  time_t        st_atime;    /* time of last access */
  time_t        st_mtime;    /* time of last modification */
  time_t        st_ctime;    /* time of last change */
};

Exemple d'usage avec la fonction UNIX stat.

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char * argv[])
{
  int c;

  for (c = 1; c < argc; ++c) {
    struct stat file_infos;

    if (stat(argv[c], &file_infos) == 0) {
      printf("Informations sur %s :\n", argv[c]);
      printf("  taille de %lu octets ;\n", (unsigned long)file_infos.st_size);
      printf("  soit %lu octets sur disque ;\n", (unsigned long)file_infos.st_blocks * 512);
      printf("  bits de protection %o.\n", file_infos.st_mode & 0777);
   }
  }
  return 0;
}

Théorie

Opérateur .

On utilise l'opérateur . pour accéder à un membre de structure. L'obligation de répéter le mot-clé struct à chaque déclaration étant assez lourde, on utilise souvent un typedef pour raccourcir, comme dans :

typedef struct { double x; double y; } coord_t;
coord_t un_point = { 1.0, 3.4 };

On voit qu'on peut initialiser une structure comme un tableau. Il faut noter que l'assignation est définie pour les structures.

Opérateur ->

Généralement les fonctions ont comme paramètre un pointeur sur une structure plutôt que la structure elle-mêmes. Il y a deux raisons à cela. D'abord en K&R C c'était la seule solution. Ensuite il est plus efficace de passer un simple pointeur que toute une structure.

/* Retourne un pointeur sur la structure du plus grand fichier */
struct stat * plus_grand(const struct stat * rhs,
                         const struct stat * lhs)
{
  return rhs->st_size > lhs->st_size ? rhs : lhs;
}

On remarque l'usage de l'opérateur -> qui est en fait un raccourci d'écriture. On a l'équivalence suivante : (*a).b est équivalent à a->b. On aurait pu écrire la fonction compare_stat ainsi :

struct stat * plus_grand(struct stat * rhs,
                         struct stat * lhs)
{
  return (*rhs).st_size > (*lhs).st_size ? rhs : lhs;
}

Exercice

Il est possible d'écrire une version du programme d'inversion de chaînes qui utilise une liste de blocs alloués au besoin. Une telle version n'a pas besoin de realloc. Cette exercice est long.

Corrigés

Il y a deux sources implémentant cette solution. Le second optimise un peu le premier en allouant des blocs de plus en plus grands.

Étapes de compilation

Jusqu'à présent nous avons utilisé le compilateur pour directement créer un fichier exécutable à partir d'un fichier source. Mais la construction d'un fichier exécutable passe par plusieurs étapes :

  1. le pré-processeur change le texte du source par inclusion, remplacement de définition et macros
  2. la compilation analyse le source et produit un fichier assembleur
  3. l'assembleur produit un fichier objet en langage machine
  4. l'éditeur de lien lie les fonctions et variables globales des divers fichiers objets pour produire un exécutable.

Exercices

  1. Voir ce que produit cc -M hello.c.
  2. Voir ce que produit cc -E hello.c.
  3. Voir ce que produit cc -S hello.c.
  4. Voir ce que produit cc -c hello.c.

Corrigés

  1. La liste des fichiers dont hello.c dépend, sous une forme utilisable par l'outil make. C'est en fait la liste récursive des inclusions.
  2. La sortie du pré-processeur tel que le reçoit le compilateur est affichée. C'est très utile pour voir ce qui a été inclus et comment les définitions et les macros ont transformés le source.
  3. Un fichier hello.s contenant la traduction en assembleur du source C. Surtout utile aux passionnés d'optimisation.
  4. Un fichier hello.o en langage machine, mais pas encore exécutable. C'est la forme intermédiaire la plus aboutie de la compilation, très utile pour recompiler partiellement des gros projets.

Compilation séparée

global.h
#ifndef GLOBAL_H
#define GLOBAL_H
typedef int INT32;
#endif
part1.h
#ifndef PART1_H
#define PART1_H

#include "global.h"

void print1(INT32);

#endif
part2.h
#ifndef PART2_H
#define PART2_H

#include "global.h"

void print2(INT32);

#endif
part.c
#include "part1.h"
#include "part2.h"

int main(void)
{
  print1(22);
  print2(33);
  return 0;
}
part1.c
#include "part1.h"
#include <stdio.h>

void print1(INT32 a)
{
  printf("part1 %d\n", a);
}
part2.c
#include "part2.h"
#include <stdio.h>

void print2(INT32 a)
{
  printf("part2 %d\n", a);
}

Théorie

Un projet classique a plusieurs centaines de fonctions, normalement réparties dans plusieurs fichiers sources. Ici il y a 3 fichiers sources .c et 3 fichiers include .h.

En général on a un fichier.h par fichier.c. Le fichier.h contient tous les prototypes, typedef, #define et autres déclarations qui peuvent être utiles aux autre_fichier.c. Ici part.c n'a pas son .h car il n'est utile à aucun autre fichier. global.h en revanche est nécessaire à tous les autres fichiers puisqu'il définit INT32.

part.c inclu part1.h et part2.h afin d'avoir les prototypes des fonctions print1 et print2 utilisée par main. Tout comme pour stdio.h et printf, cette inclusion n'est pas obligatoire, mais permet au compilateur de mieux vérifier les appels print1(22) et print2(33) faits dans main.

En outre chaque fichier.c inclus son fichier.h car cela permet un contrôle de cohérence entre les prototypes du .h et la définition dans le .c.

#include "fichier.h"

On a utilisé des guillemets " au lieu de < et > autour des noms de nos fichiers include. Cela indique au compilateur de les chercher en partant du répertoire du fichier .c.

Si à la place nous avions utilisé < et > aussi pour nos fichiers, nous aurions dû ajouter le répertoire courant à la liste des répertoires contenant des include standards avec l'option de compilation -I..

Il est courant de devoir utiliser l'option -I lorsqu'on utilise des include d'une bibliothèque non standard.

#ifndef MACHIN_H

Les commandes du pré-processeur #ifndef MACHIN_H, #define MACHIN_H et #endif sont un bricolage inévitable pour régler les problèmes d'inclusion multiple. Ici global.h est inclus deux fois depuis part.c, une fois à travers part1.h et une fois à travers part2.h. Si le compilateur tombait deux fois sur typedef int INT32; ce serait une erreur.

Pour éviter ce problème, lors de la première inclusion le pré-processeur définit le symbole GLOBAL_H. Ainsi lors des inclusions suivantes, tout ce qui est compris entre le #ifndef GLOBAL_H et le #endif est substitué par un espace blanc.

cc -c

Pour compiler séparément un fichier.c on utilise la commande cc -c fichier.c. Ceci produit un fichier objet fichier.o. Une fois tous les fichiers .o obtenus, il ne reste plus qu'à les lier pour obtenir l'exécutable final (cc *.o).

Bibliothèques

Lorsqu'on utilise une fonction de bibliothèque autre que la libc, il faut donner le nom de cette bibliothèque à l'éditeur de liens pour qu'il puisse y chercher la fonction. Par exemple les fonctions mathématiques standards (cos, sin, ...) se trouvent dans la libm. Il faut donner l'option -lm lors de l'édition de liens.

Exercices

  1. Produire un exécutable avec ce fichier pi.c :

    #include <math.h>
    #include <stdio.h>
    int main(void)
    {
      printf("Pi vaut %.10f.\n", acos(-1));
      return 0;
    }
    

Corrigés

  1. cc pi.c -lm

© 2002, Marc Mongenet