3SQ1020 - Fondements des STIC - Programmation en langage évolué (C/C++)

Table des matières

Solution avec Docker

Sous MacOS ou Linux

Sous Windows


Introduction

Utilisation de l'environnement de programmation

  • Demandez la création d'un environnement Développement C/C++ (code-server) sur MyDocker.
  • Copiez le mot de passe en utilisant le bouton à droite dans le champ.
  • Cliquez sur Connexion à l'interface.
  • Collez le mot de passe quand celui-ci vous est demandé, puis cliquez sur SUBMIT.
  • L'icône le plus en en haut à gauche (celui avec 3 traits horizontaux) vous donne accès à des menus ; choisissez Terminal -> New Terminal pour obtenir un shell (un interpréteur de commandes).
  • Pour créer un fichier, utilisez le premier icône à droite de WORKSPACE et nommez le avec une extension cpp (par exemple, exo1.cpp), même si vous allez surtout écrire du C.
  • Tapez votre code ; pour le compiler, cliquez dans l'onglet TERMINAL et tapez c++ exo1.cpp (en supposant que votre fichier se nomme exo1.cpp) ; s'il y a des erreurs dans votre code, les messages seront affichés ici.
  • Pour exécutez votre code (après une compilation sans erreur), tapez dans l'onglet TERMINAL ./a.out ; la lecture et l'affichage réalisés par votre programme se feront dans cet onglet.

Vos fichiers sont conservés d'une session à l'autre.

Pédagogie

Cette initiation est une suite d'explications et de petits exercices destinés à vous présenter et à vous faire utiliser les différents éléments du langage qui vous seront nécessaires pour la suite de votre formation. Vous devez vous astreindre à faire tous les exercices demandés, même si vous considérez que la réponse à certains est évidente. N'hésitez pas non plus à essayer les petits exemples de code qui vous sont donnés (dans ces exemples, les instructions ne sont pas systématiquement montrées à l'intérieur d'une fonction, il faut les y mettre, par exemple dans main, pour les essayer).

Il y a beaucoup d'explications (surtout dans la première partie), il n'est pas nécessaire de tout mémoriser à la première lecture (mais il ne faut pas hésiter à y revenir par la suite).

Chaque mot ou partie de phrase souligné est un lien vers une page d'explication ou d'aide.

Il est souhaitable de travailler de manière individuelle même si les discussions entre étudiants sont bien évidemment possibles, et même souhaitées. N'hésitez pas aussi à solliciter l'encadrant de votre groupe pour une explication, un conseil, une aide ou tout autre demande.

Ne rester pas bloqué plus de 5mn sur un exercice, demandez de l'aide !

Pré-requis

Cette initiation est destinée à des étudiants ayant des notions de base en programmation avec Python. En particulier, les concepts suivants sont supposés connus :

  • écriture d'une expression en utilisant les opérateurs arithmétiques classiques, notation infixée (l'opérateur est au milieu de ses 2 opérandes), priorité des opérateurs, utilisation des parenthèses, évaluation d'une expression permettant d'obtenir sa valeur ;
  • types simples : booléen, entier, flottant, chaîne de caractère ;
  • variable, affectation de la valeur d'une expression dans une variable ;
  • structures de contrôle habituelles : conditionnelle (if), alternative (if .. else ..), boucles (for et/ou while) ;
  • découpage d'un programme en plusieurs fonctions, appel de fonction, passage d'arguments, retour de valeur.

D'où vient le langage C

Le langage de programmation C a été inventé au début des années 1970 dans le but d'écrire de manière portable le système d'exploitation Unix. C'est un langage de bas niveau (accès facile au matériel) destiné initialement à la programmation système, mais qui reste l'un des langages les plus utilisés (voir par exemple l'index Tiobe), en particulier car il permet d'écrire des programmes performants, et dont la syntaxe se retrouve en partie dans de nombreux langages dérivés plus ou moins compatibles (C++, C#, Java, PHP, Javascript…).

Quelques caractéristiques du langage C (et différences avec Python)

  • Le langage C est un langage compilé : contrairement à Python qui peut exécuter directement du code source, il faut commencer par une phase de compilation (traduction du code source en langage machine exécutable par le processeur) avant de pouvoir exécuter un programme.
  • Le langage C est un langage typé statiquement : cela signifie que toute variable (et paramètre...) doit avoir un type explicite lors de sa définition, et que les valeurs affectées à cette variable doivent être compatibles (c'est-à-dire du même type ou d'un type qui peut être converti) avec le type de la variable, sinon une erreur est signalée par le compilateur.
  • La ligne n'est pas un élément de structuration d'un programme C : une instruction peut-être sur une ou plusieurs lignes, il peut y avoir plusieurs instructions sur une même ligne, c'est le caractère  ;  qui est utilisé pour signaler la fin d'une instruction.
  • De même, l'indentation est ignorée par le compilateur : un bloc (délimité par les accolades  {  et  } ) est utilisé pour grouper des instructions ; les blocs peuvent bien sûr être imbriqués.
  • Pour autant, la lisibilité d'un programme est un critère de qualité primordial ; on respectera donc une indentation cohérente, et on ne cherchera surtout pas à utiliser le minimum possible de lignes.
  • Le caractère retour à la ligne est, au même titre qu'un espace ou une tabulation, un caractère blanc qui sert de séparateur dans le langage ; un objectif de lisibilité doit là aussi présider à leurs utilisations.
  • Les commentaires, qui participent aussi à faciliter la compréhension d'un programme, existent sous deux formes :
    • un commentaire qui débute par  /*  se termine après le premier  */  rencontré, il peut donc être sur plusieurs lignes ;
    • un commentaire qui débute par  //  se termine à la fin de la ligne.

1. Première partie

1.1. Premier programme

Traditionnellement, le premier programme qui est montré quand on présente un langage de programmation est un programme qui affiche Hello world!, le voici donc en langage C :

#include <stdio.h>

int main(void) {
  printf("Hello world!\n");
  return 0;
}

Examinons chaque ligne l'une après l'autre.

Module

#include <stdio.h>

Le langage C ne supporte pas directement le concept de module (tel qu'il existe par exemple en Python, avec le mot clef import).

Pour avoir l'équivalent en C, à savoir la possibilité d'avoir un programme constitué de plusieurs fichiers ainsi que celle d'utiliser des bibliothèques existantes, on procède de la manière suivante :

  • les services offerts par un module (les fonctions dans le cas du langage C, ainsi que les types de données utilisés par ces fonctions) sont déclarés dans un fichier d'entête (extension .h, le h provenant du mot anglais header) ;
  • le code source des fonctions de ce module figure dans un ou plusieurs fichiers d'implémentation (extension .c) ;
  • Un programmeur souhaitant utiliser les services de ce module doit avoir accès au fichier d'entête correspondant, mais n'a besoin que de la version compilée du code source ;
  • pour accéder aux services du module, le fichier d'entête doit être vu par le compilateur avant l'utilisation des fonctions disponibles dans le programme : ce mécanisme d'inclusion n'est pas réalisé par le compilateur, mais par un autre outil, appelé préprocesseur, qui est exécuté automatiquement avant la compilation proprement dite ;
  • ce préprocesseur se charge de toutes les lignes commençant par le caractère  # , le mot clef  include  lui indique de remplacer la ligne courante par le contenu du fichier stdio.h ;

On est bien ici sur une structure à base de lignes car il ne s'agit pas d'instructions du langage C mais de directives du préprocesseur.

Vous verrez d'autres lignes commençant par ce caractère # mais avec d'autres mots clefs comme ifndef, define, else, endif... : ces autres directives ne seront pas expliquées dans ce cours (sauf un peu define), vous pouvez a priori les ignorer.

  • ce fichier stdio.h contient la déclaration des fonctions d'entrée-sortie (io) de la bibliothèque standard C (std) ;
  • les chevrons ( <  et  > ) sont utilisés car il s'agit d'un fichier d'entête standard ; pour un fichier d'entête correspondant à un module utilisateur, les guillemets ( " ) sont utilisés.

Fonction principale

int main(void) {

Tout programme C doit contenir une et une seule fonction nommée  main  pour qu'il soit, après compilation, exécutable.

Cette ligne correspond donc au début de la définition de cette fonction, la fin de cette définition correspond à la fin du bloc qui est ouvert sur cette ligne par le caractère  { .

 int  est un type natif en C (c'est un entier signé, les autres types natifs seront présentés plus loin), il est utilisé ici pour qualifier le type de la valeur de retour de la fonction  main .

Après le nom de la fonction, on trouve comme en Python des parenthèses qui délimitent la liste des paramètres de la fonction ; ici,  void  indique que c'est une fonction sans paramètre (plus précisément : les arguments de  main  sont ignorés dans ce programme).

Affichage

  printf("Hello world!\n");

 printf  est une fonction de la bibliothèque standard C permettant l'affichage sur la sortie standard, c'est l'équivalent de la fonction print de Python. L'argument donné à cette fonction est la chaîne de caractères Hello world!\n (les chaînes de caractères sont délimités par des guillemets  " ), le dernier caractère \n est un retour à la ligne.

Notez le  ;  qui termine l'instruction d'appel de la fonction  printf .

Retour de fonction

  return 0;

L'instruction  return , comme en Python, termine l'exécution de la fonction courante, la valeur de retour est indiquée à la suite du mot clef. Comme il s'agit ici de la fonction principale  main , il s'agit donc aussi de la fin du programme. La valeur de retour est transmise au système d'exploitation qui interprète le 0 comme un code signalant la bonne exécution du programme.

On retrouve là aussi un  ;  à la fin de l'instruction  return .

Exécution

Créez un fichier comme indiquer au début du sujet, copiez/collez le code donné ci-dessus, compilez le et exécutez le ; cela doit se traduire par l'affichage de la chaîne de caractères Hello World.

Modifiez la chaîne de caractères passée en argument à  printf  et vérifiez que cette nouvelle chaîne est bien affichée après une nouvelle compilation/exécution.


Vans l'onglet TERMINAL, vous pouvez rappeler les dernières commandes tapées à l'aide des flèches ↑ et ↓ (et se déplacer dans la commande pour la modifier avec → et ←)



SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  printf("Bonjour le monde !\n");
  return 0;
}


1.2. Variables

Définition de variable

Comme indiqué en introduction, le langage C est typé statiquement, il faut donc indiquer explicitement le type d'une variable.

Par exemple, pour définir une variable temperature de type  int , on écrira :

  int temperature;

S'il s'agit d'une variable globale (voir ci-dessous), sa valeur initiale sera de 0 ; sinon, sa valeur sera quelconque, ce qui n'est pas une bonne pratique. La syntaxe suivante permet d'initialiser une variable au moment de sa définition :

  int temperature = 20;

Modification d'une variable

Une fois qu'une variable est définie, il est possible de modifier sa valeur par une affectation :

  temperature = 25;

La partie droite de l'affectation (techniquement on parle de rvalue) est une expression quelconque, on aurait donc pu écrire à la place et pour le même résultat :

  temperature = temperature + 5;

Affichage d'une variable

La fonction  printf  peut être utilisée pour afficher la valeur d'une variable ainsi que le montre cet exemple :

  printf("La température est de %d\n", temperature);

C'est le %d dans la chaîne de caractères qui indique à  printf  qu'il faut aller chercher l'argument après cette chaîne pour l'afficher en tant qu'entier. Il est possible d'afficher plusieurs variables en ajoutant d'autres %d :

  printf("La température est de %d °C, soit %d °F\n", temperature, temperature * 9 / 5 + 32);

Il faut utiliser d'autres caractères que le d pour afficher des valeurs qui ne sont pas des  int , la description précise de cette chaîne de format est disponible sur cette page.

Soit le programme suivant (un  double  est un réel stocké avec une représentation flottante):

#include <stdio.h>

int main()
{
    double nombre = 1234.567890123456789;
    printf("nombre = %f\n", nombre);
}

Si vous exécuter ce programme, vous obtenez :

 nombre = 1234.567890

En utilisant la documentation de la fonction  printf  disponible sur cette page, ajouter les instructions d'affichage afin que le résultat soit :

 nombre = 1234.567890
 nombre = +1234.567890
 nombre =          1234.567890
 nombre = 1234.56789012345689
 nombre = 1.234568e+03
 nombre = 1.23456789012345689E+03


SOLUTION POSSIBLE

#include <stdio.h>

int main()
{
    double value = 1234.567890123456789;
    printf("value = %f\n", value);

    printf("value = %+f\n", value);
    printf("value = %20f\n", value);
    printf("value = %19.14f\n", value);

    printf("value = %e\n", value);
    printf("value = %.17E\n", value);
}


Lecture d'une variable

C'est la fonction  scanf  qui est utilisée pour lire une donnée entrée par un utilisateur ; cette fonction utilise aussi une chaîne de format pour spécifier le type de la donnée à lire :

  int nombre = 0;
  scanf("%d", &nombre);

Ici, la fonction  scanf  stockera dans la variable nombre la valeur tapée au clavier par l'utilisateur ; le %d indique l'a aussi que c'est un entier qui doit être vu (la description exhaustive de la chaîne de format de  scanf  figure sur cette page).

On verra plus loin quel est le rôle de ce caractère & devant le nom de la variable, il est important de ne pas l'oublier.


Écrire un programme qui demande à l'utilisateur un nombre et affiche ce nombre et son carré. Voici un exemple de l'exécution attendue (le nombre 12 est donné par l'utilisateur) :

 Donner un nombre : 12
 Le carré de 12 est 144


Contrairement à la fonction input de Python, la fonction  scanf  ne fait aucun affichage : il faut donc utiliser  printf  pour afficher Donner un nombre : .



SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  int nombre = 0;
  printf("Donner un nombre : ");
  scanf("%d", &nombre);
  printf("Le carré de %d est %d\n", nombre, nombre * nombre);
  return 0;
}


1.3. Les types du langage C (partie 1)

Types entiers natifs

Contrairement à Python qui n'a qu'une seule sorte d'entiers, sans limite de représentation (sauf en terme de mémoire utilisée), le langage C a plusieurs sortes d'entiers qui diffèrent par leur taille en mémoire, et donc leur intervalle de représentation possible des valeurs.

Nous avons déjà vu le type  int  : c'est un entier signé ; même si le standard C ne spécifie pas formellement la représentation des données, on peut considérer que, sauf exception, c'est un entier codé en complément à 2 sur 32 bits, donc qu'il peut représenter des valeurs entre -231 et 231-1 (de -2 147 483 648 à 2 147 483 647).

Il existe aussi une version non signée avec la même taille en mémoire, unsigned int , qui peut donc représenter des valeurs entre 0 et 232-1 (4 294 967 295).

Le plus petit entier manipulable en C et un  signed char , correspondant en général à un octet (8 bits), de -128 à +127 ; sa version  unsigned char  code un nombre entre 0 et 255. Le type  char  est un caractère sur 8 bits.

Les autres sortes d'entiers sont les  short  (habituellement 16 bits) et les  long  (habituellement 64 bits ; si ce n'est pas le cas, il y a aussi les  long long ) ; ils existent aussi en version  unsigned .

Afin de remédier à cette incertitude potentielle sur cette représentation des entiers, le standard C99 a introduit les types  int8_t ,  uint8_t ,  int16_t ... qui n'existent que si l'implémentation correspondante (par exemple :  int32_t  est un entier en complément à 2 sur 32 bits) existe. Ces définitions sont dans le fichier d'entête stdint.h

Les littéraux entiers sont par défaut des  int  exprimés en base 10. On peut utiliser le suffixe  u  ou  U  pour un  unsigned , et le suffixe  l  ou  L  pour un  long . Le préfixe  0x  permet d'utiliser la base 16 (en utilisant les lettres a à f, minuscules ou majuscules, comme chiffres supplémentaires), le préfixe  0  indique l'utilisation de la base 8.

Les littéraux caractères sont entourés de l'apostrophe  '  : 'a', '0', '\n'

Historiquement, il n'y avait pas de booléen en C : une valeur entière de 0 correspondait à false, les autres valeurs étant donc toutes true.

Le standard C99 a introduit le type natif  _Bool , et l'utilisation de  bool ,  true  et  false  est possible en incluant le fichier stdbool.h. Afin de respecter la compatibilité avec les précédents standards, un entier reste cependant convertible implicitement en booléen.

C++ supporte depuis le début nativement  bool ,  true  et  false .

Types flottants natifs

Les nombres réels sont classiquement représentés en virgule flottante selon le standard IEEE 754 ; les 3 sortes sont  float  (32 bits),  double  (64 bits) et  long double  (≥ 64 bits).

Les littéraux flottants sont des  double  (suffixe  f  pour un  float ), ils utilisent le point décimal ( . ), un exposant (puissance de 10) peut être indiqué après la lettre  e  ou  E  :

  double pi = 314.159e-2;

Pointeurs

Le langage C est connu comme permettant un accès facile à l'architecture matérielle (langage de bas niveau). Un des principaux points qui justifie cette caractéristique est la possibilité d'accéder à l'adresse en mémoire d'une variable. Comme C reste un langage typé statiquement, le type d'une adresse prends en compte le type de la valeur contenue à l'adresse accédée : ainsi, l'adresse d'un  int  est un type différent de l'adresse d'un  double  par exemple. Pour éviter cette ambiguïté avec le concept matériel d'adresse mémoire, le terme pointeur est utilisé : un pointeur est l'adresse typée d'une donnée en mémoire (ou d'un registre d'un coupleur d'entrée/sortie...).

Pour déclarer une variable nommée ptr de type pointeur (sur un entier pour cet exemple), la syntaxe suivante est utilisée :

  int * ptr;

Le caractère  *  n'est bien sûr pas l'opérateur de multiplication, mais les règles de syntaxe du langage font qu'il ne peut par y voir d'ambiguïté.

Pour affecter une valeur dans une telle variable, il faut donc une expression de type adresse d'entier. L'une des solutions les plus simple est d'utiliser l'opérateur  &  sur une variable qui permet d'obtenir l'adresse en mémoire de cette variable (cet opérateur a déjà été utilisé pour lire des données avec  scanf ).

Ainsi, avec une variable

  int nombre = 0;

on peut donner comme valeur à notre variable ptr l'adresse de la variable nombre ainsi :

  ptr = &nombre;

On dit que ptr pointe sur nombre. L'opérateur inverse de  &  (adresse de) est l'opérateur  *  (indirection). Avec cette même variable ptr, je peux donc écrire :

  *ptr = 22;

Cette instruction ne modifie donc pas ptr, mais la donnée pointée par ptr, à savoir nombre actuellement (il est bien sûr possible de modifier la variable ptr en la faisant pointer sur un autre entier). L'instruction précédente est donc strictement équivalente à

  nombre = 22;

Il est parfois nécessaire d'indiquer qu'une variable de type pointeur ne pointe sur rien (on parle de pointeur invalide, ou de pointeur nul par abus de langage) : on utilise la constante  NULL  dans ce cas :

  ptr = NULL;

Tout accès à la zone mémoire pointée par un pointeur contenant  NULL  est interdit et provoque en général une erreur à l'exécution. Si vous essayez sur MyDocker, par exemple, vous obtenez l'erreur suivante à l'exécution : zsh: segmentation fault (core dumped) ./a.out.

Modifiez le précédent programme (affichage d'un nombre et de son carré) en ajoutant une variable mémorisant l'adresse de votre variable qui stocke le nombre, et en utilisant ce pointeur dans les appels à  scanf  et  printf .



SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  int nombre = 0;
  int *ptr = &nombre;
  printf("Donner un nombre : ");
  scanf("%d", ptr);
  printf("Le carré de %d est %d\n", *ptr, *ptr * *ptr);
  return 0;
}


Tableaux

Un tableau est une suite contiguë de données d'un même type. L'exemple suivant définit un tableau, nommé tab, de 10 entiers :

int tab[10];

Chaque élément de ce tableau est une variable de type  int  accessible via un indice ; le premier indice valide est 0, le dernier est donc le nombre d'éléments moins 1.

  tab[0] = 9;    // Premier élément du tableau
  tab[9] = 0;    // Dernier élément du tableau

En C, il n'y a pas de vérification de la validité d'un indice à l'exécution ; ainsi

  tab[-1] = 10;
  tab[10] = -1;

seront des instructions acceptées par le compilateur (tout au plus, un avertissement sera donné) ; à l'exécution, il se peut qu'aucune erreur ne soit déclenchée par ces affectations, mais il est fort probable qu'un défaut se produira plus tard et il sera alors très difficile de remonter à la cause de ce défaut.

Chaque élément du tableau à sa propre adresse, il est donc possible d'y accéder :

  int * ptr = &tab[5];    // Pointeur sur le 6ème élément
  *ptr = 3;               // équivalent à tab[5] = 3;

À noter cette particularité du langage C : le nom du tableau est converti implicitement en un pointeur vers son premier élément. Ainsi, ces deux instructions sont strictement équivalentes :

  ptr = &tab[0];          // Pointeur sur le premier élément
  ptr = tab;              // idem

Une conséquence importante de cette particularité est que l'affectation entre tableaux n'est pas possible.



La proximité des notions de tableau et de pointeur va plus loin : un pointeur sur un élément d'un tableau indique une position dans ce tableau ; il est donc légitime de vouloir calculer une distance entre 2 positions :

  int * ptr3 = &tab[3];      // Position du 4ème élément
  int * ptr7 = &tab[7];      // Position du 8ème élément
  int quatre = ptr7 - ptr3;  // distance (en nombre d'éléments) entre le 4ème et le 8ème éléments

De même qu'il est légitime d'ajouter une distance à une position :

  ptr7 = ptr3 + 4;      // Toujours position du 8ème élément
  ptr3 = ptr7 - 4;      // Toujours position du 4ème élément

On comprendra par contre que la somme de 2 positions (l'addition de 2 pointeurs) n'a pas de sens. Enfin, un pointeur sur un élément (le premier ou un autre) peut être utilisé comme s'il s'agissait d'un nom de tableau :

  ptr = tab + 9;          // Pointeur sur le dernier élément
  ptr[-9] = 0;            // Modification du premier élément

Ces possibilités ne doivent bien sûr être utilisées que dans des cas où elles sont justifiées, car la lisibilité d'un tel code ne va pas de soi (au moins quand on est débutant).

Cette arithmétique sur les pointeurs peut vous être utile dans le prochain exercice.

Nous avons utilisé ici des tableaux de taille fixe, connue du compilateur (il est possible de faire des tableaux dont la taille n'est connue qu'à l'exécution, mais nous n'aborderons pas cet aspect dans ce cours). Cette taille sera certainement réutilisée dans le reste du code (voir par exemple les boucles plus loin), il est donc souhaitable de la nommer afin d'améliorer la lisibilité et pour que tout changement de cette taille ne nécessite la modification que d'une seule ligne de code. Le langage C n'offre pas cette possibilité d'avoir des constantes nommées, il est d'usage d'utiliser le préprocesseur avec une autre directive que celle déjà vue :  define . Il est aussi d'usage de n'utiliser que des majuscules (le caractère  _  est aussi possible) dans le nom d'une telle constante.

// Il ne s'agit pas d'une instruction du langage C, mais d'une directive du préprocesseur
// Il ne faut donc pas mettre de point-virgule !!
#define TAILLE 10

int tab[TAILLE];

Il est possible d'initialiser un tableau au moment de sa définition avec la syntaxe suivante :

// Il n'est pas nécessaire d'indiquer la taille dans ce cas
int tab[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

Chaînes de caractères

Les chaînes de caractères sont simplement des tableaux de caractères ( char ), mais avec une sentinelle (caractère dont le code est 0, il s'écrit '\0') pour indiquer la fin de la chaîne de caractères qui peut être plus courte que le tableau lui-même.

// Ce tableau aura une taille de 8 car un caractère '\0' est ajouté à la fin
char bonjour[] = "Bonjour";
// On peut donc aussi utiliser cette syntaxe pour définir une chaîne de caractères.
char * hello = "hello";
// Mais bonjour est un nom de tableau (non modifiable) alors que hello est une
// variable de type pointeur sur caractère (modifiable)

Pour manipuler ces chaînes de caractères, il existe toute un ensemble de fonctions dans la bibliothèque standard, vous trouverez la liste et les descriptions à partir de cette page.

Voici celles qui pourraient vous être utiles :

  • strlen permet d'obtenir la longueur d'une chaîne de caractères (la sentinelle '\0' n'est pas comptée)
  • strcpy permet de recopier une chaîne de caractères (la sentinelle '\0' est aussi recopiée), et sa variante strncpy
char source[] = "Bonjour";
// Le tableau de destination doit être assez grand, sinon un comportement inattendu peut se produire
char destination[20];
// L'ordre des arguments peut sembler bizarre, mais c'est le même ordre qu'une instruction d'affectation
strcpy(destination, source);
  • strcat permet de concaténer deux chaînes de caractères, et sa variante strncat
  • strcmp permet de comparer deux chaînes de caractères, et sa variante strncmp
  • strchr permet de trouver dans une chaîne de caractères le début d'une sous-chaîne commençant par un caractère donné
  • strstr permet de trouver dans une chaîne de caractères le début d'une sous-chaîne commençant par une chaîne de caractères donnée

Écrire un programme qui demande à l'utilisateur une chaîne de caractères contenant un -, et affiche la longueur de sa partie gauche (avant le caractère -) et de sa partie droite (après le caractère -). Voici un exemple de l'exécution attendue (on supposera que le caractère - est toujours présent) :

 Donner une chaîne contenant un - (maximum 20 caractères) : abcd-ef
 longueur de la partie gauche : 4
 longueur de la partie droite : 2

Et un autre :

 Donner une chaîne contenant un - (maximum 20 caractères) : -
 longueur de la partie gauche : 0
 longueur de la partie droite : 0

SOLUTION POSSIBLE

#include <stdio.h>
#include <string.h>

int main()
{
  printf("Donner une chaîne contenant un - (maximum 20 caractères) : ");
  char chaine[21];
  scanf("%s", chaine);
  char * minus = strchr(chaine, '-');
  int long_gauche = minus - chaine;
  printf("longueur de la partie gauche : %d\n", long_gauche);
  int long_droite = strlen(minus + 1);
  printf("longueur de la partie droite : %d\n", long_droite);
}


Il existe aussi des fonctions permettant de convertir une chaîne de caractères représentant un nombre en nombre : atoi, atof...

Pour faire l'inverse (convertir un nombre en chaîne de caractères), on utilise une variante de  printf , sprintf, qui utilise le même principe que  printf  (une chaîne de format), mais, au lieu d'afficher le résultat à l'écran, le stocke dans un tableau de caractères.

Écrire un programme qui demande à l'utilisateur un nombre flottant en utilisant  scanf , l'affiche avec  printf , convertit son double en chaîne de caractères avec  sprintf  et affiche cette chaîne de caractères, et enfin convertit cette chaîne en flottant avec  atof  et affiche ce flottant. Voici un exemple de l'exécution attendue (le nombre 1234.567890 est donné par l'utilisateur) :

 Donner un nombre : 1234.567890
 nombre = 1234.567890
 chaine du double : 2469.135780
 après atof : 2469.135780

SOLUTION POSSIBLE

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

int main()
{
  printf("Donner un nombre : ");
  double nombre;
  scanf("%lf", &nombre);
  printf("nombre = %f\n", nombre);
  char chaine[50];
  sprintf(chaine, "%f", nombre * 2);
  printf("chaine du double : %s\n", chaine);
  nombre = atof(chaine);
  printf("après atof : %f\n", nombre);
}


1.4. Les opérateurs, les expressions

Liste des opérateurs

Le langage C est assez semblable à Python concernant l'utilisation des opérateurs et l'écriture des expressions :

  • L'affectation utilise l'opérateur  = .
  • Les opérateurs binaires s'utilisent avec une notation infixée (opérateur au milieu des opérandes).
  • Les opérateurs arithmétiques binaires sont  + ,  - ,  * ,  /  (division entière si les 2 opérandes sont entiers, sinon division flottante),  %  (modulo) ; il n'y a pas d'opérateur d'exponentiation.
  •  * ,  /  et  %  sont prioritaires sur  +  et  - , on utilise les parenthèses  (  et  )  pour imposer un autre ordre d'association.
  • Comme en Python, il y a des opérateurs qui interviennent au niveau de la représentation en bits des entiers :  &  (et),  |  (ou),  ^  (ou exclusif),  <<  et  >>  (décalages),  ~  (complément à 1, opérateur unaire préfixé).
  • Les opérateurs de comparaison sont  == ,  != ,  < ,  <= ,  > ,  >= .
  • Les opérateurs booléens sont  &&  (et logique),  ||  (ou logique) et  !  (non, opérateur unaire préfixé).
  • Il existe des opérateurs d'affectation combinée avec une opération arithmétique ; ainsi, i += 2; est équivalent à i = i + 2;.
  • Il existe aussi des opérateurs d'incrémentation et de décrémentation pré et post-fixés :
  int i = 5;
  int j = ++i;    // pré-incrémentation de i,  affectation dans j (i == 6 et j == 6 après exécution)
  int k = i--;    // affectation dans k, post-décrémentation de i (i == 5 et k == 6 après exécution)
  • Enfin, il existe un opérateur ternaire  ?:  réalisant un  if  ..  else  :
  int nombre = 0;
  scanf("%d", &nombre);
  char * parite = (nombre % 2 == 0) ? "pair" : "impair";
  printf("%d est un nombre %s\n", nombre, parite);

Comme indiqué ci-dessus, et contrairement à Python, il n'y a qu'un seul opérateur de division en C :  / . Si ses deux opérandes sont des entiers, alors une division entière est effectuée :

  int i = 10;
  int j = 4;
  double d = i / j;    // d contiendra 2.0

Pour forcer une division de flottants, il faut convertir au moins l'un des deux opérandes en flottant (le second le sera alors, voir ci-dessous la section sur les conversions) :

  int i = 10;
  int j = 4;
  double d = (double) i / j;    // d contiendra 2.5

Note : la conversion est prioritaire sur la division, c'est pour cela qu'il n'est pas nécessaire d'écrire double d = ((double) i) / j;.

Opérateurs de manipulation de bits

Quand on fait de la programmation de bas niveau, on a souvent besoin de manipuler des entiers non pour leur valeur, mais pour leur représentation en binaire : quels sont les bits à 0 et ceux à 1.

Les opérateurs qui travaillent au niveau de la représentation en bits sont :

  •  &  : effectue le et bit à bit de ses 2 opérandes
    • exemple : 0x67 & 0xAE = 01100111 & 10101110 (en binaire) = 00100110 = 0x26
  •  |  : effectue le ou bit à bit de ses 2 opérandes
    • exemple : 0x26 | 0x45 = 00100110 & 01000101 (en binaire) = 01100111 = 0x67
  •  ^  : effectue le ou exclusif bit à bit de ses 2 opérandes
    • exemple : 0xB7 ^ 0x7C = 10110111 ^ 01111100 (en binaire) = 11001011 = 0xCB
  •  ~  : effectue le complément à 1 de son opérande
    • exemple : ~0x5C = ~01011100 (en binaire) = 10100011 = 0xA3
  •  <<  : effectue un décalage à gauche du premier opérande d'un nombre de bits correspondant au second opérande (en insérant des 0)
    • exemple : 0x12 << 3 = 00010010 << 3 (en binaire) = 10010000 = 0x90
  •  >>  : effectue un décalage à droite du premier opérande d'un nombre de bits correspondant au second opérande (en insérant le bit de poids fort)
    • exemple : 0x5A >> 3 = 01011010 >> 3 (en binaire) = 00001011 = 0x0B

À titre d'illustration, on peut imaginer une carte processeur disposant d'un port de sortie de 16 bits, ces bits servant à commander 16 lignes distinctes, par exemple pour allumer 16 leds.

Vous avez comme objectif d'allumer un led d'un numéro donné (entre 0 et 15). Ajouter l'instruction qui manque dans le code ci-dessous.

#include <stdio.h>

int main(void) {
  // Valeur à écrire dans le port de sortie pour allumer la première led (de numéro 0)
  unsigned short value = 1;
  // 11 est un exemple, votre code doit marcher quelque soit la valeur de numero entre 0 et 15
  int numero = 11;            // On veut ici allumer la 12ème led (de numéro 11)
  // Ajouter l'instruction pour mettre dans une variable output la bonne valeur

  // 12ème bit à 1 en binaire : 0000 1000 0000 0000
  // Soit en hexadecimal      :    0    8    0    0
  printf("%X\n", output);      // Affiche 800
}

SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  // Valeur à écrire dans le port de sortie pour allumer la première led (de numéro 0)
  unsigned short value = 1;
  // 11 est un exemple, votre code doit marcher quelque soit la valeur de numero entre 0 et 15
  int numero = 11;            // On veut ici allumer la 12ème led (de numéro 11)
  // Ajouter l'instruction pour mettre dans une variable output la bonne valeur
  int output = value << numero;

  // 12ème bit à 1 en binaire : 0000 1000 0000 0000
  // Soit en hexadecimal      :    0    8    0    0
  printf("%X\n", output);      // Affiche 800
}


On veut maintenant allumer aussi le 6ème led (numéro 5). Ajouter les instructions correspondantes (en commençant par affecter 5 à la variable numero) à la fin de  main  (le second affichage de output doit produire 820).


SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  // Valeur à écrire dans le port de sortie pour allumer la première led (de numéro 0)
  unsigned short value = 1;
  // 11 est un exemple, votre code doit marcher quelque soit la valeur de numero entre 0 et 15
  int numero = 11;            // On veut ici allumer la 12ème led (de numéro 11)
  // Ajouter l'instruction pour mettre dans une variable output la bonne valeur
  int output = value << numero;
  // 12ème bit à 1 en binaire : 0000 1000 0000 0000
  // Soit en hexadecimal      :    0    8    0    0
  printf("%X\n", output);      // Affiche 800

  numero = 5;
  // Pour mettre un bit à 1, il faut utiliser | avec le masque
  output = output | (value << numero);
  // 6ème et 12ème bits à 1 en binaire : 0000 1000 0010 0000
  // Soit en hexadecimal               :    0    8    2    0
  printf("%X\n", output);      // Affiche 820
}


On veut maintenant éteindre le 12ème led (après avoir allumé le 6ème). Ajouter les instructions correspondantes à la fin de  main  (le troisième affichage de output doit produire 20).


SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  // Valeur à écrire dans le port de sortie pour allumer la première led (de numéro 0)
  unsigned short value = 1;
  // C'est un exemple, votre code doit marcher quelque soit la valeur de numero de 0 à 15
  int numero = 11;            // On veut ici allumer la 12ème led (de numéro 11)
  // Ajouter l'instruction pour mettre dans une variable output la bonne valeur
  int output = value << numero;
  printf("%X\n", output);      // Affiche 800

  numero = 5;
  // Pour mettre un bit à 1, il faut utiliser | avec le masque
  output = output | (value << numero);
  // 6ème et 12ème bits à 1 en binaire : 0000 1000 0010 0000
  // Soit en hexadecimal               :    0    8    2    0
  printf("%X\n", output);      // Affiche 820

  numero = 11;
  // Pour mettre un bit à 0, il faut utiliser & avec le masque inversé
  output = output & ~(value << numero);

  // 6ème bit à 1 en binaire : 0000 0000 0010 0000
  // Soit en hexadecimal     :    0    8    2    0
  printf("%X\n", output);      // Affiche 20
}

Si vous souhaitez approfondir ce sujet d'examen et de manipulation de bits dans un mot, voici deux exemples plus avancés :



Conversion

Quand les deux opérandes ne sont pas du même type, le plus petit est converti vers le type du plus grand (un  int  est plus grand qu'un  short , un  float  est plus grand qu'un  long ).

Ces conversions implicites sont aussi réalisées dans l' autre sens de manière silencieuse (pas de message du compilateur) :

  float f = 2.5;
  int i = f;       // i vaudra 2 (suppression de la partie décimale)

Pour faire une conversion explicite (c'est nécessaire dans certains cas), on n'utilise la syntaxe suivante :

  int i = (int) f;       // la conversion est ici explicite et visible

2. Seconde partie

2.1. Les structures de contrôle

Conditionnelle, alternative

L'instruction conditionnelle utilise le mot clef  if , la condition doit être entre parenthèse, elle est suivie soit d'une seule instruction dépendante, soit d'un bloc :

  int nombre = 0;
  scanf("%d", &nombre);
  if (nombre < 0) nombre = -nombre;

L'alternative est introduite par le mot clef  else  :

  if (nombre % 2 == 0) {
    // utilisation d'un bloc même si ce n'est pas nécessaire ici
    printf("Ce nombre est pair\n");
  }
  else {
    printf("Ce nombre est impair\n");
  }

Pour des traitements dépendants de valeurs connues, on peut utiliser une instruction  switch  :

  char * mois = NULL;    // Contiendra le nom du mois sous forme de chaîne de caractères
  // La variable entière m est supposée contenir le numéro du mois entre 1 et 12
  switch (m) {

  // Le break est indispensable, sinon l'exécution continue au cas suivant
  case 1 : mois = "janvier";   break;
  case 2 : mois = "février";   break;
  case 3 : mois = "mars";      break;
  // Cas
 4 à 11 non montrés dans cet exemple
  case 12 : mois = "décembre"; break;
  // Quand aucun cas ne correspond à la valeur de m
  default : mois = "invalide";

  }

Boucles

La boucle  while  itère tant que la condition est vraie :

  int nombre = 0;
  scanf("%d", &nombre);
  int non_pair = nombre;
  while (non_pair % 2 == 0) {
    non_pair = non_pair / 2;
  }
  printf("Le plus grand diviseur non pair de %d est %d\n", nombre, non_pair);

La boucle  do ... while  s'exécute au moins une fois :

  unsigned int nombre;
  do {
    printf("Donner un nombre entre 0 et 100 : ");
    // Si l'entrée ne commence pas par un chiffre, nombre n'est pas modifié
    nombre = 101;
    scanf("%u", &nombre);
    // Ignorer jusqu'à la fin de ligne
    while (getchar() != '\n') /* nothing */;
  } while (nombre > 100);

Enfin, la boucle  for  est construite avec une instruction d'initialisation, la condition pour continuer à itérer et une instruction exécutée après chaque itération :

  int tab[TAILLE];
  for (int i = 0; i < TAILLE; i = i + 1) {
    tab[i] = i * i;
  }

L'instruction  break  dans une boucle permet de forcer la fin de la boucle courante, alors que  continue  force le passage à l'itération suivante.

Écrire un programme qui affiche la suite de Syracuse d'un nombre donné par l'utilisateur. Voici un exemple de l'exécution attendue (le nombre 14 est donné par l'utilisateur) :

 Donner un nombre : 14
 La suite de Syracuse de 14 est 14, 7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1.

SOLUTION POSSIBLE

#include <stdio.h>

int main(void) {
  int nombre = 0;
  printf("Donner un nombre : ");
  scanf("%d", &nombre);
  printf("La suite de Syracuse de %d est %d", nombre, nombre);
  while (nombre != 1) {
    if (nombre % 2 == 0) nombre = nombre / 2;
    else nombre = nombre * 3 + 1;
    printf(", %d", nombre);
  }
  printf(".\n");
  return 0;
}



2.2. Les fonctions

Un programme C est constitué d'un ensemble de fonctions ; la fonction principale est, on l'a vu,  main , mais pour un programme non trivial, il est indispensable de le structurer en plusieurs fonctions.

Définition de fonction

Une fonction C est identifiée par un nom, unique dans le programme, zéro ou plusieurs paramètres typés et un type de retour : on nomme signature de la fonction cet ensemble de caractéristiques.

Voici un exemple très simple de définition d'une fonction qui reçoit un argument entier et retourne son carré :

int carre(int val) {
  return val * val;
}

Le premier  int  est le type de la valeur retournée (on utilisera  void  pour une procédure, une fonction qui ne retourne pas de valeur).

On trouve ensuite le nom de la fonction (carre ici), et, entre parenthèses, la liste des paramètres (un seul ici ; s'il y en a plusieurs, on utilise la  ,  pour les séparer ; s'il n'y en a aucun, soit on ne met rien, soit on utilise le mot clef  void  comme on peut le voir dans les exemples de  main ).

Chaque paramètre a un type et un nom (en C, il n'y a que des arguments positionnels, pas d'argument par mot clef).

Cette partie de la définition de la fonction constitue sa signature (formellement, le nom des paramètres ne fait pas partie de la signature, et un changement de l'un de ces noms ne change pas la signature).

Après cette signature, on trouve le corps de la fonction qui est un bloc d'instructions ; les accolades sont obligatoires même s'il n'y a qu'une seule instruction comme dans l'exemple donné.

L'utilisation de cette fonction se fait naturellement par un appel où la valeur des arguments sera donnée entre parenthèses après le nom de la fonction appelée ; le résultat de cet appel est la valeur de l'évaluation de cette expression d'appel de fonction :

#include <stdio.h>

int main(void) {
  int nombre = 0;
  printf("Donner un nombre : ");
  scanf("%d", &nombre);
  printf("Le carre de %d est %d\n", nombre, carre(nombre));
  return 0;
}

Une fonction ne peut être appelée que si elle a été auparavant vue par le compilateur : techniquement, on dit que la fonction doit avoir été déclarée avant d'être utilisée. Une définition de fonction est aussi une déclaration de cette même fonction, une première solution est donc de définir systématiquement les fonctions en amont de leur utilisation. Cette solution ne permet pas le découpage d'un programme en plusieurs fichiers, c'est pour cela qu'il existe une syntaxe permettant de déclarer une fonction sans la définir ; il suffit pour cela de faire suivre la signature d'un  ;  :

// Déclaration de la fonction carre
int carre(int val);

On comprend ainsi mieux le rôle des fichiers d'entête en C : ils contiennent les déclarations des fonctions proposées par le module (ainsi que la définition des types nécessaires à ces modules, voir plus loin). Par exemple, stdio.h va contenir les déclarations de  printf ,  scanf ...

Les définitions de ces fonctions figurent en général sous forme compilées dans un fichier bibliothèque (classiquement nommé libc pour la bibliothèque standard C).

Une étape complémentaire après la compilation (exécutée automatiquement, comme le préprocesseur), nommée édition de liens, se charge de faire les liens entre l'appel d'une fonction de la bibliothèque et sa définition compilée.



Modifier l'exercice précédent pour que le calcul du terme suivant de la suite de Syracuse soit réalisé par une fonction de signature :

int syracuse_suivant(int val);

SOLUTION POSSIBLE

#include <stdio.h>

int syracuse_suivant(int val) {
  if (val % 2 == 0) return val / 2;
  return val * 3 + 1;
}

int main(void) {
  int nombre = 0;
  printf("Donner un nombre : ");
  scanf("%d", &nombre);
  printf("La suite de Syracuse de %d est %d", nombre, nombre);
  while (nombre != 1) {
    nombre = syracuse_suivant(nombre);
    printf(", %d", nombre);
  }
  printf(".\n");
  return 0;
}



Variables locales, globales

Il est possible de définir des variables à l'intérieur de la définition d'une fonction (de manière générale, à l'intérieur d'un bloc) ; cette autre définition de carre est donc parfaitement valide et conduit au même résultat :

int carre(int val) {
  int res = val * val;
  return res;
}

res est une variable locale, créée à chaque appel de cette fonction carre, uniquement visible et accessible, après sa définition, dans le bloc de la fonction ; cette variable locale cesse d'exister dès que l'exécution de cette fonction est terminée. Si un deuxième appel à cette fonction est réalisé, une nouvelle variable locale res sera créée, sans aucun lien, en particulier au niveau de sa valeur, avec la précédente.

Une variable locale n'est pas initialisée par l'environnement d'exécution.

Il est aussi possible de définir des variables en dehors de toute fonction, au niveau principal : on parle de variables globales, elles sont créées au début du programme, détruites à la fin, et accessibles par toutes les fonctions.

Une variable globale est initialisée à 0 par l'environnement d'exécution.

Les variables globales sont des points de fragilité des programmes : comme elles peuvent être accèdées et modifiées par toutes les fonctions, il est parfois très difficile de déterminer la cause d'une valeur incorrecte. On essayera donc au maximum d'utiliser le passage d'arguments et le retour de valeur pour s'échanger des données entre plusieurs fonctions.

Passage d'arguments

Notre fonction carre donnée en exemple a un paramètre nommé val ; techniquement, ce paramètre se comporte comme une variable locale : il est créé à chaque appel, initialisé par l'argument correspondant dans l'appel et n'est accessible que dans la fonction ; toute modification de sa valeur sera perdue après l'exécution de la fonction. Par exemple, avec cette autre version de la fonction carre :

int carre(int val) {
  val = val * val;
  return val;
}

la modification de val ne sera que locale et ne modifiera pas la variable passée en argument lors de l'appel :

  int nombre = 5;
  int res = carre(nombre);  // Après cet appel, nombre vaudra toujours 5
}

On parle de passage par valeur, le paramètre val est une copie de l'argument nombre.

Il est parfois nécessaire qu'une fonction puisse modifier l'un des arguments reçus, ou retourner plusieurs informations alors qu'il n'y a qu'une seule valeur de retour possible en C (contrairement à Python). La solution passe par l'utilisation de pointeurs : la fonction appelante passera comme argument l'adresse de l'une de ses variables locales (on parle donc de passage par adresse), la fonction appelée recevra donc un pointeur sur cette variable et pourra la modifier.

Nous avons utilisé, sans explication jusqu'à présent, le caractère  &  devant le nom d'une variable quand on veut lire une valeur donnée par un utilisateur avec la fonction  scanf  : pour que cette fonction  scanf  puisse affecter une valeur à la variable que nous lui donnons, il faut utiliser un passage par adresse !

Prenons l'exemple d'une fonction qui décompose un nombre en une puissance de 2 et le diviseur impair restant ; une définition possible est :

// La valeur retournée est le plus grand diviseur impair,
// la puissance de 2 est affectée à la variable pointée par puiss_ptr
int decompose_diviseur_impair_et_puissance_2(int val, int * puiss_ptr) {
  *puiss_ptr = 0;
  while (val % 2 == 0) {
    ++*puiss_ptr;       // Comprenez-vous ce que fait cette instruction ?
    val /= 2;
  }
  return val;
}

Écrire un programme qui demande un nombre à l'utilisateur et affiche la décomposition donnée par cette fonction. Voici un exemple de l'exécution attendue (le nombre -2144 est donné par l'utilisateur) :

 Donner un nombre : -2144
 2144 = -67 * 2 ^ 5

SOLUTION POSSIBLE

int main(void) {
  int nombre = 0;
  printf("Donner un nombre : ");
  scanf("%d", &nombre);
  int puissance = 0;
  int diviseur_impair = decompose_diviseur_impair_et_puissance_2(nombre, &puissance);
  printf("%d = %d * 2 ^ %d\n", nombre, diviseur_impair, puissance);
  return 0;
}



Cas des tableaux

Dans le cas d'un tableau passé comme argument, on va se trouver face à la particularité du langage C mentionné précédemment : le nom du tableau va être converti automatiquement en pointeur sur son premier élément.

Soit une fonction qui calcule la somme d'un tableau d'entiers. L'utilisation souhaitée est :

  int tab1[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  int som1 = somme(tab1);       // tab1 est converti en &tab1[0]

La signature de somme devrait dont être :

  int somme(int * tab);

La difficulté qui se pose ici est que cette fonction somme doit connaître le nombre d'éléments du tableau pour faire son calcul, mais le pointeur reçu ne donne aucune information à ce sujet. Par exemple, un second appel à cette fonction peut être :

  int tab2[] = { 1, 2, 3, 4, 5 };
  int som2 = somme(tab2);

La solution à ce problème passe par l'ajout d'un second paramètre à la fonction somme qui contiendra le nombre d'éléments :

  int somme(int * tab, int nb_elem);

Quand on doit écrire en C une fonction qui reçoit un tableau en argument, il faut en général ajouter un autre paramètre qui donne le nombre d'éléments de ce tableau.

Dans le cas des chaînes de caractères (qui sont des tableaux de caractères) cette difficulté est contournée par l'utilisation de la sentinelle '\0' déjà mentionnée.

2.3. Les types du langage C (partie 2)

Agrégat

C99 a introduit le support des nombres complexes, mais supposons que cette version du standard ne soit pas disponible dans l'environnement utilisé et que l'on souhaite pour autant manipuler des complexes.

Une première idée est d'utiliser un tableau de 2 flottants. Une première difficulté est amenée par cette conversion implicite d'un tableau en pointeur sur son premier élément : il ne sera pas possible de faire des affectations entre complexes de manière naturelle (avec l'opérateur  = ). il ne sera pas non plus possible de faire facilement une fonction qui retourne un nouveau complexe puisque tout tableau créé comme variable locale est détruit à la sortie de la fonction...

Le langage C permet de créer des nouveaux types (des types utilisateurs, par opposition aux types natifs) en indiquant la structure d'une valeur de ce type. Comme les éléments de cette structure peuvent avoir des types différents, chaque élément est identifié par un nom. Cette construction, qui existe dans de nombreux langages de programmation, s'appelle un agrégat, mais le terme de structure est couramment utilisé par référence au mot clef  struct  utilisé en C.

Le type Complexe peut par exemple être défini ainsi :

struct Complexe {
  double real;
  double imag;
};

Une variable c1 de type Complexe sera définie ainsi :

  struct Complexe c1;

L'accès aux éléments (ou attributs) d'une telle variable fait intervenir l'opérateur  .  :

  c1.real = 2.0;
  c1.imag = -4.0;

L'affectation est possible avec  =  (mais la comparaison avec  ==  ne l'est pas) :

  struct Complexe c2 = c1;

Enfin, ces variables seront bien passées par valeur comme argument, et une fonction pourra retourner une valeur de ce type sans problème.

Il est possible d'avoir des pointeurs sur de telles variables :

  struct Complexe * pc1 = &c1;

Pour accéder à un attribut via un pointeur, la priorité des opérateurs impose l'utilisation de parenthèses :

  (*pc1).real = -1.0;

Ce besoin est si courant qu'un opérateur spécifique,  -> , a été ajouté au langage :

  pc1->real = -1.0;

En reprenant la définition donnée du type Complexe, écrire un programme qui demande à un utilisateur la partie réelle et la partie imaginaire d'un complexe et qui affiche son carré. Une fonction ayant comme signature :

  struct Complexe carre_complexe(struct Complexe c);

devra être définie. Voici un exemple de l'exécution attendue (les nombres 2 et 5 sont donnés par l'utilisateur) :

 Donner la partie réelle : 2
 Donner la partie imaginaire : 5
 Le carré de 2 + i5 est -21 + i20

SOLUTION POSSIBLE

#include <stdio.h>

struct Complexe {
  double real;
  double imag;
};

struct Complexe carre_complexe(struct Complexe c) {
  struct Complexe res;
  res.real = c.real * c.real - c.imag * c.imag;
  res.imag = 2 * c.real * c.imag;
  return res;
}

int main(void) {
  struct Complexe c;
  printf("Donner la partie réelle : ");
  scanf("%lg", &c.real);
  printf("Donner la partie imaginaire : ");
  scanf("%lg", &c.imag);
  struct Complexe c2 = carre_complexe(c);
  printf("Le carré de %g + i%g est %g + i%g\n", c.real, c.imag, c2.real, c2.imag);
  return 0;
}



En C, il n'est pas possible d'avoir des fonctions ayant le même nom même si le type des arguments diffère, c'est par contre autorisé en C++ et notre fonction qui calcule le carré d'un Complexe pourrait avoir comme signature :

  // Le mot clef struct n'est plus nécessaire en C++
  Complexe carre(Complexe c);

même si la fonction

  int carre(int val);

existe déjà.

Autres types utilisateurs

Il existe aussi en C les énumérations (mot clef  enum ) et les unions (mot clef  union ) qui ne sont pas présentés ici car non nécessaires pour la suite.

2.4. Un peu de C++

Le langage C++ est un sur-ensemble du langage C : tout programme correct en C est aussi correct en C++. Le langage C++ ajoute au langage C en particulier deux aspects : l'approche objet et la généricité. Nous examinerons ici seule une petite partie des ajouts de l'approche objet, celle qui vous sera nécessaire pour la suite.

L'objectif de cette partie n'est pas de vous apprendre à définir des classes C++, mais à utiliser des classes qui vous sont fournies. Ces classes seront plus ou moins documentées, mais vous aurez au minimum accès aux fichiers d'entête qui contiennent les définitions de ces classes : vous devez donc être capable de lire un tel fichier et d'en déduire ce qu'il vous est possible de faire.

Classe

Une classe combine en une seule entité des attributs mémorisant un état et des méthodes permettant d'accéder et de modifier cet état. L'idée générale, en reprenant l'exemple du type Complexe vu juste avant, est la suivante : au lieu d'avoir une fonction carre_complexe qui reçoit un complexe en argument et accède à son état pour calculer son carré, on ajoute une méthode carre, sans argument, à notre type Complexe, méthode qui offre ce service. En tant que méthode de la classe Complexe, elle a naturellement accès à la représentation d'un complexe, à son état. L'utilisateur de cette classe n'a pas besoin, lui, de connaitre cette représentation, la façon dont l'état d'un complexe est mémorisé, il a juste besoin de savoir qu'il existe un service carre qui permet d'en obtenir le carré. Si on décide de modifier la représentation de cette classe Complexe, par exemple en utilisant une représentation polaire plus adaptée aux calculs effectués, le code utilisateur n'a pas besoin d'être modifié ; la méthode carre devra l'être, mais elle fait partie de la classe Complexe, contrairement à une fonction carre_complexe.

Le langage C offrant déjà les agrégats pour mémoriser un état, C++ à ajouté la possibilité d'y définir des comportements. Ainsi, la version C de notre type Complexe avec son unique comportement fonctionnel :

struct Complexe {
  double real;
  double imag;
};

struct Complexe carre_complexe(struct Complexe c);

devient en C++ :

struct Complexe {
  double real;
  double imag;

  // Le mot clef struct n'est plus nécessaire en C++
  Complexe carre();  // Définition de la méthode non montrée
};

Un utilisateur accèdera à ce service carre() de manière naturelle en utilisant le  .  (ou la  ->  s'il a un pointeur) :

  Complexe c2 = c1.carre();    // Appel de la méthode carre sur l'objet c1

Accesseur (getter)

Pour l'instant, un utilisateur qui souhaite connaitre la partie réelle d'un complexe doit continuer à accéder directement à l'attribut real.

  double r = c1.real;

Si un choix de représentation polaire est fait, ce code utilisateur ne sera plus valide. pour éviter ce problème, il est courant d'écrire des accesseurs (getter en anglais) aux attributs :

struct Complexe {
  double real;
  double imag;

  double getReal() { return real; }
  double getImag() { return imag; }
  Complexe carre();
};

Ainsi, si un passage à une représentation polaire est effectué, il faudra ré-écrire le code de ces accesseurs (la notion de partie réelle d'un complexe existe quelle que soit la représentation choisie), mais pas le code utilisateur.

Protection

Pour autant, rien n'interdit pour l'instant l'accès direct aux attributs. Pour remédier à ce point, C++ a introduit des mécanismes de protection qui permettent de différencier ce qui est public, accessible à tout le monde, de ce qui est privé, uniquement accessible aux méthodes de la classe courante. Par ailleurs, un nouveau mot clef,  class , permet d'insister sur le fait qu'il ne s'agit plus de simple agrégats du langage C, mais de vrai classes C++. Ainsi, la définition ci-dessus sera plus certainement vue sous la forme :

class Complexe {
public:
  double getReal() { return real; }
  double getImag() { return imag; }

  Complexe carre();
private:
  double real;
  double imag;
};

Ce qu'il faut retenir en voyant cette définition, c'est qu'avec une variable de type Complexe (un objet Complexe, une instance de Complexe), ce qu'il est possible de faire est d'appeler les méthodes getReal(), getImag() et carre(), c'est tout (le reste est privé).

Initialisation

Une variable locale, que son type soit natif ou utilisateur, n'est pas initialisée par défaut (en C++ comme en C). Une variable de type Complexe aura donc dans cette situation des valeurs quelconques pour ses attributs real et imag. Ces attributs étants privés, l'utilisateur ne peut pas les initialiser ! Il faut donc prévoir aussi des méthodes publiques permettant de fixer les valeurs de ces attributs (des setters en anglais).

Ce n'est pas forcément un problème critique pour un tel type (sous réserve que l'utilisateur utilise bien ces méthodes pour initialiser son complexe !). Mais des objets qui représentent l'accès à des dispositifs physiques (des capteurs, des actuateurs…) ne peuvent exister que s'ils sont liés à ces dispositifs.

C++ permet donc de définir des méthodes spécifiques chargées d'initialiser un objet lors de sa création ; ces méthodes s'appellent des constructeurs. Un constructeur est une méthode sans type de retour indiqué, dont le nom est celui de la classe et qui peut avoir 0 ou plusieurs paramètres : il peut donc y avoir plusieurs constructeurs s'ils diffèrent par leur signature. Dès qu'au moins un constructeur existe, son utilisation est obligatoire.

Sur l'exemple de la classe Complexe, si celle-ci se présente sous la forme :

class Complexe {
public:
  // Un seul constructeur ici
  Complexe(double re, double im);
  // Reste non montré
};

Alors il ne sera plus possible de créer une variable de type Complexe sans donner des arguments au constructeur, le compilateur signalera une erreur :

  // Erreur : pas d'argument
  //Complexe c1;
  // Erreur : un seul argument
  //Complexe c2(1);
  // OK : deux arguments
  Complexe c3(1, 1);

Valeur par défaut des arguments

Toujours pour notre classe Complexe, il semble raisonnable qu'une valeur d'initialisation par défaut (pour notre exemple c1 ci-dessus) soit (0, 0). Il serait possible de proposer un autre constructeur, sans paramètre, qui prendrait en compte ce cas. Une autre solution, permise en C++, est de donner une valeur par défaut aux paramètres d'une fonction (comme en Python). Ainsi, cette nouvelle définition :

class Complexe {
public:
  // Un seul constructeur avec des valeurs par défaut
  Complexe(double re = 0.0, double im = 0.0);
  // Reste non montré
};

Permet les écritures suivantes :

  // OK : équivalent à Complexe c1(0.0, 0.0);
  Complexe c1;
  // OK : équivalent à Complexe c2(1.0, 0.0);
  Complexe c2(1);
  // OK : deux arguments
  Complexe c3(1, 1);

Opérateurs de conversion

Un constructeur avec un argument est considéré comme un opérateur de conversion d'une valeur du type de l'argument vers un objet de la classe considérée ; par exemple, notre classe Complexe possède un tel constructeur (le second argument prenant sa valeur par défaut), donc l'instruction :

  c1 = 1;

est acceptée et exécutée ainsi : un complexe temporaire est créé par appel du constructeur avec (1, 0) comme arguments, ce temporaire est ensuite recopié dans c1.

C++ permet aussi de surcharger les opérateurs du langage, c'est à dire de leur donner une définition particulière quand au moins l'un des opérandes est un objet. Il n'est pas question ici de faire une présentation exhaustive de cette possibilité, mais les documentations et exemples de code que vous allez consulter dans l'environnement MBED-OS en font usage, c'est pourquoi il est nécessaire d'aborder ce sujet.

Soit maintenant l'instruction suivante :

  c1 = "3 + 5i";

Cette instruction n'est pas acceptée par le compilateur car une chaîne de caractères n'est pas convertible en flottant. Si nous souhaitons que l'instruction précédente conduise à donner (3, 5) comme valeur à c1, nous devons définir l'affectation à un Complexe quand l'argument est une chaîne de caractères. Ceci peut se faire en définissant une méthode particulière de nom  operator=  :

class Complexe {
public:
  void operator=(char * c) {
    // Code non montré
  }
  // Reste non montré
};

Ainsi, l'instruction précédente sera comprise comme (et elle peut être écrite ainsi de manière explicite) :

  c1.operator=("3 + 5i");

Enfin, on peut vouloir faire l'inverse, convertir un complexe dans un autre type ; par exemple, l'instruction

  double r = c1;

est refusée pour l'instant. Pour retrouver dans la variable r la partie réelle de notre complexe c1, il faut définir une méthode de conversion d'un Complexe vers un  double , ce qui se fait ainsi :

class Complexe {
public:
  operator double() {
    return real;
  }
  // Reste non montré
};

Grace à cette méthode, l'instruction précédente sera comprise comme (et elle peut être écrite ainsi de manière explicite) :

  double r = c1.operator double();

Exemple dans MBED-OS

Vous trouverez dans cet environnement une classe DigitalOut dont voici une version simplifiée de la description :

class DigitalOut {
public:
  // Create a DigitalOut connected to the specified pin
  DigitalOut(PinName pin);
  // Set the output, represented as 0 or 1 (int)
  void write(int value);
  // Read the output setting, represented as 0 or 1 (int)
  int read();
  // An operator shorthand for write()
  DigitalOut & operator=(int value);
  // An operator shorthand for read()
  operator int();
};

On trouve donc un constructeur prenant un PinName comme argument, une méthode write(int) pour écrire une valeur (0 ou 1) dans la sortie, une méthode read() pour connaître son état actuel, et deux méthodes permettant une utilisation plus concise des deux premières (vous pouvez ignorer le type de retour de la méthode  operator= ).

Le code suivant vous est ensuite montré comme exemple :

DigitalOut led(LED1);

int main() {
  while (1) {
    led = !led;
    wait(0.2);
  }
}

Ré-écrire l'instruction

    led = !led;
  1. de manière explicite avec les méthodes  operator 
  2. de manière explicite avec les méthodes read() et write(int)

SOLUTION POSSIBLE

    led.operator=(!led.operator int());
    led.write(!led.read());