Préambule : différences avec le langage C

Ce cours s'adresse aux personnes ayant une bonne connaissance du langage C ainsi que des notions concernant le modèle objet. Il présente les notions de base du langage C++ ; une série d'exercices lui est associée dans la partie travaux pratiques.

Dans un premier temps, et bien que la syntaxe du C++ reprenne dans les grandes lignes celle du C, nous allons étudier quelques unes des principales différences.

Les commentaires en C++

Il y a deux types de commentaires en C++ : les commentaires mono ligne et les blocs de commentaire. Les commentaires mono ligne commencent par les symboles // et permettent de placer en commentaire la fin d'une ligne comme le montre le fragment de code suivant :

int i; // fin de la ligne en commentaire

Les commentaires mono ligne s'avèrent particulièrement utiles et agréables à utiliser … au point que l'on aurait ensuite tendance à vouloir les incorporer dans du code C ! ce qui est naturellement interdit mais néanmoins autorisé par certains compilateurs peu scrupuleux.

Les blocs de commentaires sont eux compatibles avec le langage C (ils en sont directement issus) et permettent de banaliser de plus larges portions de code en le plaçant entre les paires /* et */ comme le montre le fragment de code suivant :

/* code sur 
plusieurs lignes 
en commentaire */ 

Ce type de commentaire a toutefois une particularité fâcheuse : ils ne peuvent être imbriqués, ce qui peut conduire à des erreurs de compilation lorsque, par mégarde on veut mettre en commentaire une portion de code qui contient déjà un tel commentaire !

Considérez, par exemple, le code éronné suivant où l'on tente d'imbriquer deux commentaires :

/* debut de commentaire externe


... texte en commentaire


/* debut de commentaire imbrique


...


Fin de commentaire imbrique ... et fin effective du commentaire externe*/


... texte compilé alors qu'on souhaitait le voir placé en commentaire


Fin souhaitée du commentaire externe*/

Placez vous dorénavant dans la peau du compilateur C++ (c'est un peu étriqué, j'en conviens). Vous voyez le début du premier bloc de commentaire, vous savez donc que, dorénavant, tout le code avant un signe */ doit être considéré comme étant mis en commentaire. Aussi, vous ne détecterez pas la présence de la balise /* indiquant le début d'un commentaire mais vous arrêterez au premier signe */ présent. Aussi, toute la partie de code placée entre les deux balises */ n'est pas considérée comme étant mise en commentaire, ce qui, dans le meilleur des cas se traduira par une erreur de compilation éveillant vos soupçons, et, dans le pire des cas, par un programme avec un comportement erronné du fait de lignes de code non prévues.

Aussi, il faut utiliser autant que possible les commentaires mono ligne qui, eux, peuvent être imbriqués sans danger dans un bloc de commentaire !

Signalons pour finir, une astuce (un peu moisie) qui utilise le préprocesseur de macros du C++ :

#if 0


code en commentaire


#endif

Le bloc de code compris entre #if et #endif ne sera compilé que si l'expression suivant immédiatement #if est différente de 0. Comme ici, nous plaçons directement 0 après #if nous sommes certains que ce bloc ne sera pas compilé. Ce genre de technique n'est décrit ici qu'à but documentaire et doit être absolument proscrit ! Vous pouvez néanmoins le trouver dans de nombreux codes déjà existants.

Les types références

Revenons sur les fonctions du langage C. Contrairement à la plupart des langages de programmation modernes, ce dernier n'admet qu'un seul mode de passage des paramètres : le passage par valeur. Supposons désormais qu'un sous programme ait besoin de modifier la valeur de l'un de ses paramètres. Comme seul le passage par valeur est supporté, il ne reste plus qu'à passer un pointeur sur l'objet à modifier, ce qui s'avère peu pratique et source d'erreurs. Le C++ autorise quand à lui le passage par référence, à l'instar du Pascal ou de l'ADA. Examinons le code permettant d'échanger le contenu de deux variables entières en C puis en C++ :

/* En C d'abord */
void swapEntiers(int *a, int *b)
{

  int c = *a;


  *a = *b;
  *b = c;
}


int main(int argc, char *argv[])
{
  int i=5;
  int j=6;


  swapEntiers(&i, &j);


  return 0;
}

On ne peut pas dire que l'utilisation des "&" soit très intuitive ...

// Et maintenant en C++
void swapEntiers(int &a, int &b)
{
  int c=a;


  a = b;
  b = c;
}




int main(int, char **)
{
  int i=5;
  int j=6;


  swapEntiers(i,j);


  return 0;
}

Le code est beaucoup plus intuitif et moins sujet aux erreurs. La syntaxe est néanmoins trompeuse car le signe de reference "&" est le même que celui de la prise d'adresse. Il faut comprendre une référence comme étant un alias pour une variable. Examinons le code suivant :

#include <iostream>


int main(int, char **)
{
  int  i=5;
  int  j=10;
  int &r=i;




  cout << i << endl; // Affiche 5
  cout << r << endl; // Affiche 5


  r++;


  cout << r << endl; // Affiche 6
  cout << i << endl; // Affiche 6


  r=j;
  r++;


  cout << r << endl; // Affiche 11
  cout << j << endl; // Affiche 10
  cout << i << endl; // Affiche 11 !
  
  return 0;
}

L'on voit que toute manipulation sur r est immédiatement transmise sur i. En outre, l'affectation sur r ne change pas la variable à laquelle r fait référence mais bien la valeur de la variable référencée ! Après l'instruction r=j, c'est i qui se voit affecter la valeur de j et non pas r qui pointe sur j comme le prouve le code ci-dessus.

Avant de terminer avec les références, signalons de suite une différence fondamentale entre les pointeurs et les références : une référence est toujours associée à une variable. Il ne peut y avoir de référence nulle.

En outre, ce fragment de code utilise deux nouvelles fonctionnalités du C++ :

  1. Une fonctionnalité du langage lui même : les arguments de fonction anonymes
  2. Une partie de la bibliothèque : les nouvelles primitives d'entrées sorties

Les paramètres avec valeurs par défaut

Imaginez une fonction où certains paramètres sont appelés la plupart du temps avec la même valeur. Il serait agréable de n'avoir à spécifier ces arguments que lorsque leur valeur diffère du défaut. C'est ce que vous propose les valeurs par défaut.

Ceux-ci ne doivent être spécifiés que lors de la déclaration de la fonction et ne doivent pas être rappelés lors de définition de la fonction.

Supposons, par exemple, que l'on écrive une fonction destinée à afficher une fenêtre à l'écran. Comme le montre le prototype suivant, deux paramètres sont nécessaires :

int ShowWindow(Window *p, unsigned int modeOuverture);
Window *p
Le pointeur vers la fenêtre
unsigned int modeOuverture
L'état initial de la fenêtre lorsque celle-ci va être affichée. Codé numériquement, ce paramètre peut avoir les valeurs suffisantes :
Valeur Signification Fréquence
SHOW_MAXIMIZED Ouverture en plein écran rare
SHOW_MINIMIZED Ouverture iconifiée rare
SHOW_CREATION Ouverture à la taille prévue lors de la création la pluspart du temps

Conférons dorénavant la valeur par défaut au second paramètre, nous obtenons le prototype :

int ShowWindow(Window *p, unsigned int modeOuverture=SHOW_CREATION);

La définition ne devra pas répéter cette valeur par défaut :

int ShowWindow(Window *p, unsigned int modeOuverture=SHOW_CREATION)
{
  // On ne rappelle pas la valeur par défaut dans la définition
  // Code omis pour simplification (moi aussi je me prends pour Microsoft)
}

Nous montrons ici 3 appels différents de cette fonction, le premier utilise la valeur par défaut, le second et le troisième non :

int main(int, char **)
{
  Window *p;




  ShowWindow(p); // Ouvre la fenêtre	avec la valeur par défaut
                 // du second paramètre : taille de création


  ShowWindow(p, SHOW_MAXIMIZED); // Ouvre la fenêtre en mode plein
                                 // écran => nécessaire d'utiliser 
                                 // le second paramètre


  ShowWindow(p, SHOW_CREATION); // Rien ne vous interdit de spécifier
                                // la valeur par défaut


  return 0;
}

Le nombre des paramètres avec des valeurs par défaut n'est pas limité. En fait, tous les paramètres peuvent prendre une valeur par défaut. Toutefois, seuls les derniers paramètres de la fonction peuvent avoir une valeur par défaut. Par exemple, le code suivant est illégal :

void f1(int i, double angle=0.0, double longueur);

De même, considérez la fonction suivante :

void f2(double angle=0.0, double longueur=50.0);

Soudain, vous avez envie d'appeler f2 avec la valeur par défaut pour angle, mais pas pour longueur, l'appel suivant est illégal :

f2(,35.0);

Vous ne pouvez omettre que les valeurs des derniers paramètres. Sans vouloir entrer dans les détails, il vous faut savoir que cela tient au mécanisme de passage des paramètres sur la pile lors d'appel des fonctions !

Alors, une proposition pour la future norme du C++ ? il serait sans doute intéressant de proposer un mécanisme d'appel par nommage des paramètres permettant de placer n'importe où les paramètres avec valeur par défaut.

Par exemple, on pourrait appeler la fonction f1 et f2 comme suit (avec la syntaxe de l'ADA) :

f1( longueur => 12.0, i=> 3);
f2( longueur => 35.0);

Les arguments de fonction anonymes (application à la fonction main)

Afin de comprendre l'intérêt des arguments de fonction anonymes, commençons par les mettre en usage sur un exemple simple.

A l'instar du C, le programme principal s'inscrit dans une fonction nommée main. Toutefois, en C++, seuls deux prototypes sont autorisés par la norme ANSI :

  1. int main(int argc, char *argv[])
  2. int main(int argc, char *argv[], char *env[])

Rappelons pour la bonne bouche les significations de ces arguments dont les noms ne sont absolument pas imposés par la norme, mais sont devenus un standard de facto:

argc
Nombre d'arguments passés sur la ligne de commande, à l'exception du nom du programme lui même
argv
Tableau de chaînes de caractères regroupant les arguments de la ligne de commande. argv[0] contient le nom du programme lui même ; argv[1] à argv[argc-1] étant quand à eux associés aux arguments proprement dits, s'ils existent.
env
Tableau de chaînes de caractères contenant le nom et la valeur des différentes variables d'environnement sous la forme nom=valeur. La fin du tableau est indiquée par un pointeur nul
retour
L'entier renvoyé correspond au statut de terminaison du programme. Dans le cas où le programme se termine normalement il doit renvoyer 0. Toute autre valeur étant typiquement associée à un code d'erreur dépendant de l'application.
#include <iostream>
using namespace std; // Pour eviter de prefixer avec std::
                     // les variables et constantes 
                     // d'entrees / sorties


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


  // argv[0] est le nom de l'executable
  
  cout << "Nom d'invocation de l'executable " << argv[0] << endl;


  // Examen de la ligne de commande


  if (argc == 1)
  {
    cout << "Pas d'arguments de ligne de commande" << endl;
  }
  else
  {
    cout << "Vous avez passe " << argc << " arguments" << endl;
    for (int compteur=1; 
         compteur < argc ; 
         compteur++)
    {
      cout << "  Parametre de numero " << compteur << " a pour valeur : " << argv[compteur] << endl;
    }
  }




  // Passage en revue des variables d'environnement


  cout << "Examen de l'environnement d'execution" << endl;


  char **exaEnv=env;


  // On s'arrete des que l'on trouve le pointeur nul
  // de fin d'environnement
  while (*exaEnv)
  {
    cout << *exaEnv << endl;
    exaEnv++;
  }


  return 0; // Le programme s'est termine normalement
}

Ce programme très simple permet d'afficher la liste des arguments de la ligne de commande et les variables d'environnement. La seule difficulté provient de l'utilisation de la nouvelle bibliothèque d'entrées / sorties qui sera examinée dans un prochain chapitre. Pour l'heure, il vous suffit de savoir que :

cout << expression;

Affiche expression à l'écran et que les différents appels de l'opérateur << sont empilables. Finalement, la constante endl signifie "saut de ligne".

C'est très beau tout ça, mais une fois de plus, je me suis éloigné de mon objectif : les arguments (ou paramètres) anonymes. Comme vous l'aurez remarqué, la fonction main prend obligatoirement 2 paramètres qui sont argc et argv qui devront donc impérativement apparaître dans la déclaration de votre fonction main que vous en ayez besoin ou non. Toutefois, il est navrant de recevoir un warning de votre compilateur indiquant que vous n'utilisez pas des paramètres qui vous ont été imposés. Aussi, il est possible d'indiquer au compilateur que le paramètre va être effectivement passé par l'appelant mais qu'il ne vous intéresse pas en laissant son nom en blanc.

Ainsi, si vous n'avez pas besoin des arguments de main, vous pourrez l'écrire ainsi :

int main(int , char **)

Le type reste présent (sa taille est importante pour la gestion de la pile) mais, comme il est anonyme, le compilateur ne pourra pas vous insulter pour non utilisation !

Le cas de main est un peu excessif mais vous voudrez régulièrement utiliser des paramètres anonymes, par exemple, dans les cas suivants :

Un point essentiel : si les paramètres à valeur par défaut doivent impérativement être les derniers, cette restriction ne s'applique absolument pas aux paramètres anonymes, ainsi une fonction pourrait très bien être définie ainsi :

int fonction(int a, char *b, int /* param anonyme */, double d)
{
}

Hormis le cas extrème de main, je recommande de toujours documenter la raison de l'anonymat d'un paramètre. Cela évitera à l'utilisateur de votre fonction de se poser trop de questions.

Surcharge des fonctions

La surcharge est un mécanisme qui permet de donner différentes signatures d'arguments à une même fonction. Plus succinctement peut être, vous pouvez nommer de la même façon des fonctions de prototypes différents.

L'intérêt est de pouvoir nommer de la même manière des fonctions réalisant la même opération à partir de paramètres différents. Supposons, par exemple, que vous travailliez sur un système de gestion d'interface utilisateur à base de fenêtres. Chaque fenêtre est repérée par 2 systèmes différents :

  1. Un identificateur numérique
  2. le nom : une chaîne de caractères

Votre but est d'écrire 2 fonctions qui permettent de récupérer un pointeur sur une fenêtre en utilisant soit l'identificateur numérique, soit le nom. Avec un système sans surcharge, il vous faudrait utiliser 2 noms de fonctions différents, par exemple :

Window *GetWindowIdent(unsigned long identificateurNumerique);
Window *GetWindowName(const char *nom);

Avec le mécanisme de surcharge, vous pouvez utilisez le même nom :

Window *GetWindow(unsigned long identificateurNumerique);
Window *GetWindow(const char *nom);

Comment le compilateur fait-il pour s'y retrouver ? C'est en fait très simple : le nom « interne » de la fonction contient la liste des paramètres. De la sorte, à la compilation, en consultant la liste des paramètres effectifs, le compilateur établit quelle est la forme de la fonction à appeler.

Une limitation à la surcharge : il est impossible d'avoir des fonctions qui ne diffèrent que par leur type de retour. En effet, le compilateur ne peut pas les distinguer lorsque l'on omet de récupérer la valeur retournée.

Les constantes du C++

Joie ! contrairement au C, il y a de vraies constantes en C++ ! Vous vous en souvenez surement, en C, lorsque l'on voulait utiliser une constante, il fallait utiliser le préprocesseur. Par exemple, pour définir une constante entière égale à 5 et nommée TAILLE, il fallait déclarer :

#define TAILLE 5

.. et ne surtout pas oublier de ne PAS mettre de ";" à la fin ! Maintenant cette époque est révolue car nous disposons de vraies constantes. Par exemple, pour spécifier la taille d'un tableau, vous pouvez faire :

const int TAILLE=5;
int tab[TAILLE];

Bien entendu, il vous est possible de définir n'importe quel type de données constantes :

const double PI=3.0; // Largement suffisant pour la plupart des applications (si si !)
const double EXP_1=2.718; 
const char MOI[]="Ours Blanc des Carpathes";

De fait, je ne veux plus jamais voir de #define pour définir des constantes … vous avez suivi au fond ?

En parlant de constantes, êtes vous surs de bien savoir comment l'on utiliser les pointeurs constants ? par exemple, la différence entre un const char * et char const *

Le tableau suivant exprime les différences subtiles sur ces 2 notions en indiquant si des opérations de modification (par exemple, l'application de l'opérateur ++) sont possibles sur le pointeur ou l'objet pointé :

Déclaration « Décodage » Pointeur
modifiable ?
(p++)
Valeur pointée modifiable ?
( (*p)++ )
const int *p p est un pointeur sur entier constant oui non
int const *p p est un pointeur constant sur un entier non oui
const int * const p p est un pointeur constant sur un entier constant non non

Donc, par extension, const char * est un type très utilisé car il s'agit d'un pointeur vers une chaîne de caractère constante ! c'est donc le type de choix lorsque vous souhaitez passer une chaîne de caractères à une fonction qui n'est pas sensée la modifier !

Les nouvelles primitives d'entrées / sorties

Oubliés les odieux printf et scanf, voici venu le temps des fonctionnalités d'entrées / sorties orientées objet. Ces dernières reposent sur la notion de flux.

Les flux standard

Trois objets de type flux sont proposés en standard. Le tableau suivant récapitule leurs caractéristiques principales :

Objet Flux Périphérique "C" Description
cout stdout Flux associé à la sortie standard (écran)
cin stdin Flux associé à l'entrée standard (clavier)
cerr stderr Flux associé à la sortie des erreurs (écran)

Leur utilisation se fait principalement au travers des opérateurs de redirection en sortie "<<" et en entrée ">>". Ainsi, l'écriture à l'écran d'une expression se traduit par le code suivant :

cout << expression;

Sans entrer dans les détails, il vous faut savoir que cela sous entend la surcharge de l'opérateur << pour le type de donnée correspondant à expression. Ce travail a été réalisé pour la plupart des types atomiques du C++. Il vous appartiendra de le faire pour vos propres classes, comme nous le verrons un peu plus tard.

Point intéressant : les opérateurs << sont prévus pour être chaînés. C'est à dire que vous pouvez les cumuler sur la même ligne de code, par exemple, le fragment de code suivant affiche le contenu des variables entières i et j séparées par un espace et suivies d'un saut de ligne. Vous noterez au passage l'utilisation de la constante symbolique endl plutôt que du caractère dépendant de l'architecture.

int i;
int j;
cout << i << " " << j << endl;

Il est possible d'utiliser de spécifier des formats d'affichage à la manière des fameux "%6.2f" du C. Pour cela, il faut utiliser des manipulateurs de flux. L'exemple suivant créé l'équivalent d'un printf("6.2f",r);

#include <iostream>
#include <iomanip>
using namespace std;


int main(int, char **)
{
  double r=36.145278;
  int    i=5;
  cout << setw(6) << setprecision(2) << r << endl;
  cout << i << " " << i++ << endl;
  cout << i << endl;
  return 0;
}

La deuxième ligne est encore plus intéressante. En effet, l'on pourrait s'attendre a ce que le résultat produit ressemble à :

 36.14
5 5
6

et nous obtenons :

 36.14
6 5
6

Ce comportement s'explique aisément de la manière suivante : Un opérateur est en fait une fonction. Donc, l'appel d'opérateurs se transforme en appel de fonction et au vu se leur associativité, les appels sont réalisés de la droite vers la gauche. Ainsi, les expressions que vous affichez sont présentées dans l'ordre que vous avez demandé mais elles ont été évaluées dans l'ordre inverse.

Les concepts mettant en jeu des objets vous seront exposés dans les chapitres traitant de la surcharge des opérateurs.