Tutoriel C++ : de Java à C++

Eric Lecolinet - Télécom ParisTech (ENST) - Dpt. INFRES

Liens utiles

Prérequis

Ce cours/TP nécessite une connaissance "raisonnable" des langages C et Java.

Introduction

Historiquement, C++ se situe entre le langage C et le langage Java. A l'origine, C++ était une extension objet du langage C (Bjarne Stroustrup, années 80), d'où son nom. Les premiers compilateurs C++ n'étaient d'ailleurs que des précompilateurs donc le code résultant était ensuite traité par un compilateur C (raison pour laquelle certains prétendent que C++ devrait s'appeler ++C ;-). Depuis, le langage C++ s'est largement émancipé de son ancêtre, mais il reste néanmoins compatible avec lui (un programme C "propre" est censé être compilable par un compilateur C++).

Cette compatibilité offre certains avantages (syntaxe bien connue du C, possibilité de passer facilement du C au C++, d'intégrer des parties de code en C et d'autres en C++ dans un même programme, etc.). Elle est également cause de certains inconvénients (syntaxe bien connue pour être illisible, choix historiques malencontreux qui peuvent être à l'origine de quelques chausse-trapes, etc.)

A son tour, Java s'est largement inspiré du C++ (ainsi que d'autres langages comme par exemple Ada 95). De fait, Java peut (approximativement) être vu comme une simplication de la partie objet de C++. Inversement, même si c'est historiquement faux, C++ peut être vu comme une sorte de "mélange" de Java et du langage C. Avec de plus, certaines fonctionnalités évoluées (mais non triviales) comme les templates ou l'héritage multiple qui n'existent ni en C ni en Java.

Le fait que C++ soit à la fois un langage objet et un langage procédural le rend quelque peu complexe et parfois difficile à appréhender (par quel "fil" tirer pour apprendre ce langage ?) Nous concentrerons la suite du cours sur les aspects typiquement "objet" de C++ en comparant systématiquement aux fonctionnalités équivalentes (lorsqu'elles existent) de Java. Vous serez peut-être frappé par la similitude entre la partie objet de C++ et Java. Celle-ci ne doit pas faire oublier l'existence de différences importantes, concernant en particulier:

On notera enfin:

Dernières remarques avant d'entrer dans le vif du sujet :

1. Des objets et des classes

Le code ci-après décrit une classe Circle censée modéliser un cercle dans le plan. Première différence avec Java, il faut comme en langage C, séparer les déclarations (à mettre dans un fichier header) des définitions (à mettre dans le fichier de code source proprement dit).

Par convention (il en existe d'autres), nous donnerons l'extension .hh aux headers et .cc aux fichiers sources. Il doit normalement y avoir correspondance entre eux (à chaque .cc correspond un (au moins un) .hh qui contient les déclarations correspondantes). Il est possible de déclarer plusieurs classes dans un même fichier (mais nul n'est besoin de regrouper des classes qui n'auraient rien à voir !)

Fichier circle.hh

class Circle {
  static const float PI;       // #1
  int x, y;                    // #2
  unsigned int radius;

public:                        // #3  !NE PAS OUBLIER LE : 
  Circle();                    // #4
  Circle(int x, int y, unsigned int r = 10);  // #4 & #5

  static float getPI();        // #6

  virtual int getX() const;    // #7
  virtual int getY() const;
  virtual unsigned int getRadius() const;
  virtual unsigned int getArea() const;

  virtual void setX(int);
  virtual void setY(int);
  virtual void setRadius(unsigned int);
};                              // !NE PAS OUBLIER LE ;

Cette déclaration de classe C++ ressemble beaucoup à son équivalent Java à quelques différences syntaxiques près et en particulier le point virgule en fin de bloc qui est indispensable !

Commentaires sur le .hh

  1. static signifie que PI est une variable de classe (qui est de plus, constante, comme expliqué ultérieurement). On rappelle qu'une variable de classe a une représentation unique en mémoire et qu'elle existe indépendemment du fait que cette classe ait ou non des instances. (note: la gestion mémoire d'une variable de classe est en fait similaire à celle d'une variable "static" en C, d'où le nom qui est resté)

  2. les variables x, y et radius sont des variables d'instance. Chaque objet ("objet" signifiant "instance de classe") possède sa propre copie personnelle des variables d'instance (d'où leur nom).

  3. le mot clé public indique (comme en Java) un accès sans restriction aux variables et méthodes. A la différence de Java, cette clause porte sur tout ce qui suit (attention: il y a un symbole : après le mot-clé)

  4. On trouve ensuite les déclarations des constructeurs. De même que n'importe quelle méthode (ou fonction "classique" déclarée en dehors des classes), ceux-ci peuvent être surchargés ["overloading" en anglais]. Ceci signifie que le même nom peut être utilisé pour déclarer et définir plusieurs méthodes dans une même classe à condition que leurs signatures soient différentes.

  5. En plus de la surcharge, C++ permet également de spécifier des paramètres par défaut. Ainsi, la 2eme version du constructeur peut indifféremment être appelée avec 2 ou 3 arguments (le dernier valant 10 par défaut). Noter que les paramètres par défaut doivent toujours être en fin de liste.

  6. le mot clé static indique une méthode de classe. Rappelons qu'une méthode de classe est, fonctionnellemnt, grosso modo la même chose qu'une fonction "classique" du langage C. Elle n'a pas accès aux variables d'instance (car elle n'est liée à aucune instance), mais a par contre accès aux variables de classe (de sa classe). Elle doit être préfixée par le nom de sa classe suivi de :: lors de l'invocation (sauf cas particulier; voir plus loin)

  7. Cette ligne et les suivantes déclarent des méthodes d'instance. Les méthodes d'instance sont typiques de l'orienté objet :

    Noter la présence du mot-clé virtual. Ce mot-clé n'est pas obligatoire pour déclarer une méthode d'instance (il suffit qu'il n'y ait pas static). Il est cependant souhaitable ou indispensable de le mettre dans la plupart des cas (nous reviendrons plus tard sur ce point important). Enfin, dans ce contexte, const indique les méthodes qui ne modifient pas les variables d'instance (voir le chapitre "constance").

Fichier circle.cc

#include <iostream>                    // #1
#include "circle.hh"

const float Circle::PI = 3.14;         // #2


int main(int argc, char** argv) {      // #3

  float pi = Circle::getPI();          // #4

  Circle* sh = new Circle(50, 200);    // #5 !NE PAS OUBLIER LE SIGNE *
  sh->setX(15);                        // #6
  int y = sh->getY();
  sh->setRadius(sh->getRadius() + 35);
  cout << "position: x=" << sh->getX() << " y=" << sh->getY() << "\n";  // #7

  exit(0);                             // #8
}

Circle::Circle(int _x, int _y, unsigned int _r) {  // #9
  x = _x;
  y = _y;
  radius = _r;
}

Circle::Circle() {
  x = y = 0;
  radius = 0;
}
 
float Circle::getPI() {return PI;}

int Circle::getX() const {return x;}
int Circle::getY() const {return y;}
unsigned int Circle::getRadius() const {return radius;}
unsigned int Circle::getArea() const {return int(PI * radius * radius);}

void Circle::setX(int _x) {x = _x;}
void Circle::setY(int _y) {y = _y;}
void Circle::setRadius(unsigned int _r)  {radius = _r;}

Commentaires sur le .cc

  1. on commence, comme en langage C par inclure textuellement les headers appropriés. iostream est le header standard des entrées-sorties en C++ (noter que certaines versions obsolètes nécessitent d'inclure iostream.h)

  2. C++ nécessite de définir les variables de classe (une et une seule fois) dans le code source (sauf cas particulier des constantes que l'on verra ensuite). On en profite par initialiser ces variables (elles sont initialisées à 0 par défaut). Noter la notation :: typique du C++ qui permet d'accéder aux variables et méthodes de classe.

  3. la fonction main est le point d'entrée du programme, comme en C. Les paramètres argc et argv permettent de récupérer les arguments de la ligne de commande qui a lancé ce programme (argc est le nombre d'arguments, nom du programme compris, argv est un tableau de chaînes de caractères)

  4. pour se convaincre qu'une méthode de classe est effectivement indépendante de l'existence d'instances, on appelle immédiatement la méthode getPI(). Noter de nouveau l'utilisation de la notation ::. Celle-ci est nécessaire pour indiquer qu'il s'agit bien d'une méthode de la classe Circle. Elle peut être omise lorsqu'il n'y a pas d'ambiguité (typiquement à l'intérieur d'une autre méthode de la même classe).

    Cette notation permet de résoudre les problèmes de collision de noms : plusieurs classes pourraient avoir une méthode getPI() sans que cela pose problème. C'est la principale différence entre les méthodes de classes et les fonctions "classiques" du langage C (dont l'unicite doit être établie, ce qui peut être source de problèmes insolubles lorsque l'on utilise des bibliothèques).

  5. cette ligne définit une variable locale sh qui va pointer vers une nouvelle instance (ie. un "objet") de la classe Circle. L'utilisation de la primitive new est identique à Java : new alloue la mémoire puis appelle les constructeurs dans l'ordre descendant (ceux des super-classes d'abord) : c'est ce qu'on appelle le chaînage des constructeurs

    Seule différence par rapport a Java : la variable sh est un pointeur d'où la présence du signe * (en Java, il n'y aurait pas de signe * et sh serait une référence). Noter par ailleurs que le 3eme argument du constructeur est omis (car sa valeur par défaut est spécifiée dans le .hh)

  6. les lignes suivantes montrent comment invoquer des méthodes d'instance. Comme rappelé précédemment, celles-ci s'appliquent toujours à un objet donné (pointé par la variable sh dans le cas présent). Comme sh est un pointeur il faut utiliser la notation -> (et non un . comme pour les références en Java).

    Remarques:

  7. l'instruction cout << ... permet d'afficher sur la sortie standard (out) une concatenation de chaînes de caractères et de valeurs numériques (ne pas oublier de mettre des << entre chaque argument). On pourrait également afficher sur la sortie des erreurs (cerr) ou lire depuis l'entrée standard (cin) de la même manière (mais avec >> au lieu de << dans le dernier cas). Il est également possible (mais pas recommandé) d'utiliser les classiques fonctions printf() et scanf() du C.

  8. la fonction exit sert à sortir du programme en renvoyant un code d'erreur (0 signifiant "pas d'erreur")

  9. les lignes suivantes définissent l'implémentation des constructeurs et des différentes méthodes de la classe Circle. Noter une fois encore la notation :: et le fait que les mot-clé static et virtual ne sont pas répétés (ils doivent figurer uniquement dans le header).

    On rappelle enfin (une dernière fois !) que les méthodes de classe ont seulement accès aux variables de classe (puisque liées à aucune instance !) tandis que les méthodes d'instance ont également accès aux variables d'instance de l'objet auquel elle s'appliquent (la méthode setX( ) a par exemple accès aux variables de l'objet pointé par la variable sh à la ligne #6).

Où est la magie ?

Comment une méthode de classe peut-elle "retrouver" les variables de l'objet concerné ? Très simplement : l'adresse de cet objet est recopiée dans this un paramètre caché qui est rajouté par le compilateur à chaque méthode d'instance. Ainsi, le code:

    int Circle::getX() {return x;}
    sh->getX();

est implicitement transformé en (l'équivalent de):

    int Circle::getX(Circle *this) {return this->x;}
    Circle::getX(sh);

par le compilateur. Et le tour est joué !

Règles de protection d'accès

Ce sont à peu près les mêmes qu'en Java :

A la différence de Java, il n'y a pas de protection "package" (le défaut de Java) mais les classes peuvent avoir des "amis" (friend que l'on verra plus tard).

Une bonne règle de programmation (observée dans cet exemple !) est de toujours déclarer les variables private ou (éventuellement) protected, sauf cas particuliers. Les constructeurs doivent presque toujours être public (sinon on ne pourra pas instancier la classe !). Les méthodes sont généralement public ou protected selon leur usage.

Et les structures ?

Vous vous rappelez sans doute de l'existence d'un mot-clé struct en langage C. Cette notion est totalement équivalente à celle de classe en C++ à une différence près : tous les membres d'une struct sont publics par défaut.

Terminologie: méthodes et fonctions

Nous avons jusqu'à présent parlé de méthodes de classe, méthodes d'instance et fonctions "classiques" du C. Ce vocabulaire peut varier selon les auteurs. Noter en particulier que les méthodes propres à une classe sont souvent appelées fonctions membres (member functions) tandis que les fonctions "classiques" sont appelées fonctions non-membres (non-member functions) dans le jargon C++. Dans la suite du texte, on utilisera indifféremment le terme "fonction" ou "méthode" lorsqu'il n'y a pas d'ambiguité.


2. Des parents et des enfants

Une des particularités typiques de l'orienté objet est la possibilité pour une classe d'hériter d'une (héritage simple) ou de plusieurs (héritage multiple) super-classes. En voici un exemple :

Fichier rect.hh

class Rect {
  int x, y;
  unsigned int width, height;

public:
  Rect();
  Rect(int x, int y, unsigned int width, unsigned int height);

  virtual int getX() const {return x;}   // #2
  virtual int getY() const {return y;}
  virtual unsigned int getWidth()  const {return width;}
  virtual unsigned int getHeight() const {return height;}

  virtual unsigned int getArea() const;

  virtual void setX(int);
  virtual void setY(int);
  virtual void setWidth(unsigned int);
  virtual void setHeight(unsigned int);
};                              // !NE PAS OUBLIER LE ;


class Square : public Rect {     // #1
public:
  Square();
  Square(int x, int y, unsigned int width);

  virtual void setWidth(unsigned int);
  virtual void setHeight(unsigned int);
};                              // !NE PAS OUBLIER LE ;

Commentaires sur le .hh

  1. la notation : public indique que la classe Square hérite publiquement des membres de la classe Rect. C'est l'équivalent du extends de Java, les règles d'héritage étant similaires :

    Remarques :

     
  2. les méthodes peuvent être implémentées directement dans les headers (au lieu d'être définies dans les fichiers sources .cc comme c'est normalement le cas). Ceci a pour effet de rendre ces méthodes inline (mot-clé qui peut être rajouté explicitement) : le code de ces méthodes est (théoriquement) recopié dans les .cc où elles sont appelées.

    avantages:

    inconvénients:

    Il ne faut donc utiliser cette possibilité qu'avec parcimonie ... (par exemple, dans notre programme, les accesseurs getX( ), getY( ), ne font en fait strictement rien et ne sont pas susceptibles de changer)

  3. on aurait également pu adopter le principe inverse et faire dériver Rect de Square mais cela aurait nécessité de redéfinir davantage de méthodes ... (et de toute façon, il n'est pas très clair que cette classe Square soit vraiment très utile ... sauf pour les besoins de l'exemple !)

Fichier rect.cc

#include <iostream>
#include "rect.hh"


Rect::Rect() {
  x = y = 0;
  width = height = 0;
}

Rect::Rect(int _x, int _y, unsigned int _w, unsigned int _h) {
  x = _x;
  y = _y;
  width  = _w;
  height = _h;
}

unsigned int Rect::getArea() const {return width * height;}

void Rect::setX(int _x) {x = _x;}
void Rect::setY(int _y) {y = _y;}
void Rect::setWidth(unsigned int _w)   {width  = _w;}
void Rect::setHeight(unsigned int _h)  {height = _h;}

// ================================================================

Square::Square() : Rect() {}                     // #1

Square::Square(int _x, int _y, unsigned int _w)  // #1
 : Rect(_x, _y, _w, _w) {}

void Square::setWidth(unsigned int w)   {width = height = w;}
void Square::setHeight(unsigned int h)  {width = height = h;}

Commentaires sur le .cc

  1. les lignes #1 montrent comment définir le constructeur d'une sous-classe à partir de celui d'une super-classe. Le code situé entre le : et la { spécifie la manière d'appeler le constructeur de la super-classe. C'est l'équivalent de l'appel de super( ) en Java

  2. on peut se demander à quoi est censé servir un constructeur sans argument. La réponse est double :

Héritage multiple et classes imbriquées

Contrairement à Java, C++ permet à une classe d'hériter d'autant de super-classes qu'on le souhaite. La syntaxe est comme suit (imaginons le cas - assez stupide - d'une classe qui serait à la fois un carré et un cercle) :

    class Zorglub : public Circle, public Square { ... }

    Zorglub::Zorgub(int x, int y, unsigned int r)
    : Circle(x, y, r), Square(x, y, r, r) { ...}   // appel des constructeurs

On voit tout de suite les problèmes qui vont se poser : Circle et Square possédant des variables et des méthodes qui ont le même nom. Un autre cas de figure gênant est celui où les super-classes ont un ancêtre commun : va t'il être hérité une seule ou plusieurs fois (ie. une fois par super-classe qui en hérite ?)

Ces questions sortent du cadre de ce tutoriel d'initiation au C++. On notera pour résumer :

Enfin, l'héritage multiple tend à instaurer un ensemble de relations complexes entre les classes qui peuvent bien finir à ressembler ... à un plat de spaghettis. Comme toute bonne chose, l'héritage multiple est à utiliser avec modération et uniquement lorsque c'est réellement utile. Pour répondre à cette question il faut d'abord déterminer si une simple imbrication de classes ["inner classes"] ne serait pas suffisante :

    class Zorglub {
       class Circle circle;
       class Square square;
       ....
    }

    Zorglub::Zorgub(int x, int y, unsigned int r)
    : circle(x, y, r), square(x, y, r, r) { ...}   // initialisation des membres

L'imbrication est parfois une alternative préférable car elle introduit moins de contraintes dans le graphe d'héritage des classes. Elle permet en outre de dupliquer les classes imbriquées à volonté (on pourrait avoir un champ square1 et un champ square2, etc.).


3. Interfaces, classes abstraites, polymorphisme

Classes abstraites

La spécification d'interfaces et autres classes abstraites constitue un autre point important de la programmation orientée objet. Le but d'une interface est de spécifier un ensemble cohérent de déclarations de fonctions qui vont pouvoir s'appliquer à une famille de classes d'objets. Cette spécification doit être indépendante de l'implémentation, chaque classe pouvant implémenter la fonction correspondante à sa guise.

Par exemple, les trois classes vues précédemment implémentent toutes une méthode getArea( ) mais il va de soi que ce calcul n'est pas le même pour un cercle et un rectangle. Il serait par contre fort commode de pouvoir manipuler ces objets de manière générique, par exemple en les mélangeant dans une liste ou un vecteur, sans avoir à considérer leur type précis.

La spécification d'interface est aussi une aide précieuse au développement de projets un peu importants. Une fois que les fonctionnalités des classes ont été ainsi spécifiées, plusieurs équipes peuvent alors travailler en parallèle pour réaliser l'implémentation de ces différentes classes, les interfaces constituant leur "langage commun".

La notion d'interface est modélisée par les classes abstraites en C++. Une classe abstraite est une classe dont au moins une méthode est abstraite, c'est-à-dire non implémentée et non implémentable. exemple :

    class Shape {
       virtual unsigned int getArea() = 0;  // pure virtual function
       ....
    }

La notation virtual type function() = 0 signifie que la méthode est abstraite (pure virtual function en jargon C++).

Une classe abstraite ne peut être instanciée (puisqu'elle est incomplète). Elle ne peut que servir de classe de base à une hiérarchie de classes qui devront obligatoirement implémenter ses méthodes abstraites (une interface garantit donc que les méthodes spécifiées seront effectivement toutes implémentées dans les classes dérivées).

Remarques :

Fichier shape1.hh

class Shape {   // version 1 : abstract class
  int x, y;

public:
  Shape();
  Shape(int x, int y);

  virtual int getX() const;
  virtual int getY() const; 
  virtual unsigned int getWidth() const = 0;
  virtual unsigned int getHeight() const = 0;

  virtual unsigned int getArea() const = 0;

  virtual void setX(int);
  virtual void setY(int);
  virtual void setWidth(unsigned int)  = 0;
  virtual void setHeight(unsigned int) = 0;

  virtual void draw() const = 0;
  virtual void print() const = 0;
  virtual void read() = 0;
};

Fichier shape2.hh

class Shape {   // version 2 : interface
public:
  virtual int getX() const = 0;
  virtual int getY() const = 0;
  virtual unsigned int getWidth() const = 0;
  virtual unsigned int getHeight() const = 0;

  virtual unsigned int getArea() const = 0;

  virtual void setX(int) = 0;
  virtual void setY(int) = 0;
  virtual void setWidth(unsigned int)  = 0;
  virtual void setHeight(unsigned int) = 0;

  virtual void draw() const = 0;
  virtual void print() const = 0;
  virtual void read() = 0;
};

Remarques :

polymorphisme: liaison dynamique

Les classes abstraites n'ont de sens que si le langage est pourvu d'un mécanisme adéquat de liaison des méthodes aux classes. Considérons l'exemple suivant:

Fichier main2.cc

#include <iostream>
#include <shape.hh>
#include <circle.hh>
#include <rect.hh>

int main(int argc, char** argv) {

  Shape* tab[] = new Shape* [10];           // #1
  unsigned int count = 0;

  tab[count++] = new Circle(0, 0, 100);     // #2
  tab[count++] = new Rect(10, 10, 35, 40);
  tab[count++] = new Square(0, 0, 60);

  for (int k = 0; k < count; k++) {        // #3
    cout << "Area = " << tab[k]->getArea() << " \n";
    tab[k]->draw();
  }

  exit(0);
}

1. Au début de la fonction main() on commence par créer un "tableau générique de Shapes". Remarquer que ce tableau est un tableau de pointeurs car :

Noter également la syntaxe: tableau = new type [nb_elems] qui permet de créer un tableau en mémoire dynamique (on utiliserait la fonction malloc() en langage C). Inversement, pour détruire ce tableau, il faudrait écrire: delete [ ] tableau;  (attention: ne pas oublier les crochets et ne pas appeler free() au lieu de delete !).

2. On fait ensuite pointer les 3 premiers éléments du tableau vers des objets de classes distinctes (mais qui sont obligatoirement des sous-classes de Shape).

3. On exécute une boucle qui affiche la surface de chaque élément puis le dessine à l'écran. Dans la mesure où chaque élément tab[k] est de type Shape*, la question est de savoir quelles fonctions getArea( ) et draw( ) sont appelées et pourquoi.

méthodes virtuelles et non-virtuelles

Deux cas sont possibles en C++:

Le premier cas correspond à ce que fait Java. Ce mécanisme est appelé liaison dynamique (liaison dynamique avec l'objet) ou liaison tardive (liaison faite à l'éxecution : on ne peut pas savoir ce vers quoi pointera tab[k] à la compilation dans le cas général).

Par opposition, le deuxième cas s'appelle liaison statique : tout est décidé à la compilation sans tenir compte du type des objets pointés.

La liaison dynamique offre une forme de polymorphisme qui constitue l'un des principaux intérêts des langages objets type Java ou C++. Ce mécanisme est essentiel lorsque l'on crée des bibliothèques ou des des programmes un peu importants pour concevoir des interfaces et pour traiter des objets de (sous-)classes différentes de manière sure et générique, comme dans l'exemple précédent.

Pour éviter les erreurs

La règle en C++ est de mettre systématiquement le mot-clé virtual lorsque l'on déclare une méthode d'instance puis de réfléchir ensuite ... En effet, le comportement logique souhaité est généralement celui de la liaison dynamique (ce devrait être le défaut comme en Java si C++ était un langage "idéal"). L'oubli du mot-clé virtual peut conduire à des absurdités particulièrement difficiles à détecter a posteriori (par exemple l'appel de la "mauvaise" méthode getArea() ou draw() dans notre exemple). A l'inverse, l'emploi des méthodes non virtuelles n'est généralement (réellement) nécessaire que :

Appel des méthodes des super-classes

Il est parfois nécessaire qu'une méthode redéfinie dans une sous-classe commence par appeler la méthode de même nom de sa super-classe (comme c'est d'ailleurs implicitement le cas pour les constructeurs). Par exemple, on pourrait définir une classe Disk qui hériterait de Circle qui aurait pour effet de tracer un cercle (comme Circle) et de remplir la surface du disque d'une autre couleur. Sa méthode draw() pourrait être définie comme suit:

  void Disk::draw() {
     .....             // code pour remplir la surface du disque
     Circle::draw();   // trace le cercle
}

L'expression: Circle::draw(); appelle la méthode draw() de la classe Circle sur cet objet (qui est de type Disk). Cette façon de faire permet d'éviter de réécrire inutilement du code. Elle est également plus puissante et plus sure qu'une simple duplication de code: si le développeur de la classe Circle améliore cette classe (par exemple pour afficher un contour en pointillé°), la classe Disk profitera immédiatement de cette amélioration.

D'une manière générale la duplication de code doit être systématiquement évitée : elle tend à rendre les programmes incompréhensibles par autrui et difficiles à maintenir: il suffit d'oublier de mettre à jour une des parties dupliquées pour que le code ne se comporte plus de manière homogène (certaines fonctionnalités vont être tantôt disponibles, tantôt indisponibles voire carrément incompatibles et conduire à des incohérences ou des plantages à l'exécution).

4. Constance

Le mot-clé const permet d'interdire que la valeur d'une variable soit changée (cette valeur ne peut être initialisée qu'au moment où elle la variable est créée, comme avec les variables final de Java). En C++, ce mot-clé sert également à indiquer qu'une méthode d'instance ne peut pas modifier les variables de l'objet auquel elle s'applique.

Il est essentiel de spécifier systématiquement ce qui est constant et ce qui ne l'est pas lorsqu'on écrit un programme (ceci permettra d'éviter de nombreuses incohérences et erreurs à l'exécution). Attention: il faut songer à cet aspect dès le début de l'écriture du programme pour éviter des effets "boule de neige" (changer la constance a posteriori entraîne généralement des modifications en chaîne fort pénibles ...)

Exemples

// fichier truc.hh

class Truc {
public:
  static const string INVITE;               // #1hh
  static const float PI = 3.14;             // #2hh

private:
  const int code1, code2;                   // #3hh
  string str;

public:
  Truc(int code1, int code2);

  void copy(const char* ps);                // #4hh
  void copy(const string s);                // #5hh  (pas satisfaisant)

  int getCode1() const {return code1;}      // #6hh
  int getCode2() const {return code2;}

  const char* bonjour() {return "bonjour";} // #7hh
};


// fichier truc.cc

#include <string>
#include "truc.hh"

const string Truc::INVITE = "how do you do Dear C++ user?";   // #1cc

Truc::Truc(int c1, int c2) : code1(c1), code2(c2) {}          // #3cc

Truc::copy(const char* ps) {str = ps;}  
Truc::copy(const string s) {str = s;}                         // #5cc  (pas satisfaisant)


  1. on déclare d'abord une variable de classe constante (la classe string sert à manipuler des chaînes de caractères en C++). Cette variable doit être définie et initialisée en même temps dans le .cc (voir #1cc)

  2. on fait de même pour la variable PI. Dans le cas particulier des types de base (comme int) les variables de classe constantes peuvent être initialisées directement dans le header (pas de définition de PI dans le .cc)

    Remarques :

  3. la ligne #3hh déclare des variables d'instance constantes. Ces variables doivent être initialisées d'une manière particulière par le constructeur, comme le montre la ligne #3cc (l'expression: : code1(c1) signifie: code1 = c1). Leur valeur ne peut être initialisée ni modifié dans le corps du constructeur ni d'aucune autre méthode.

    Note: il est d'ailleurs possible d'initialiser toutes les variables d'instance de cette manière, ce qui peut être une bonne règle pour clarifier le code.

  4. la ligne #4hh montre une déclaration de méthode dont les paramètres sont constants. Ceci signifie que ces paramètres sont "read only" et ne pourront donc être modifiés par cette méthode (en fait c'est la chaîne de caractères, c'est-à-dire la valeur pointée par le paramètre qui ne peut être modifiée comme nous le verrons dans la section suivante)

  5. bien qu'elle ressemble à la précédente, la déclaration de constance ligne #5hh est en fait inutile. Par défaut le passage des arguments se fait par valeur en C++ (de même qu'en langage C) : l'argument qui est donné à cette fonction est recopié dans le paramètre 's'. Il est donc inutile de spécifier 's' constant.

    Remarques :

  6. la ligne #6 montre comment déclarer une méthode d'instance "constante" (noter le mot-clé const à la fin de la signature de la fonction). Ce type de méthode ne peut pas modifier les variables d'instance de l'objet.

    Les méthodes const ont la particularité (importante!) de pouvoir être appelées sur des objets constants. Par exemple une méthode const pourrait être appelée sur la "variable constante" INVITE (typiquement pour afficher sa valeur, etc.) alors que cela ne serait pas possible avec une méthode non-const (typiquement pour modifier sa valeur, etc.).

    En d'autres termes, il est indispensable de déclarer les méthodes const lorsque c'est nécessaire, sinon on ne pourra strictement rien faire sur les objets constants !

  7. attention, la fonction bonjour() n'est pas une méthode const (mais elle devrait l'être !). Ici le mot-clé const signifie juste que la valeur retournée est constante !

Cas des pointeurs

Dans le cas des variables qui sont des pointeurs, il est possible de spécifier si c'est le pointeur ou la valeur pointée qui est constant (ou les deux). Exemples:

  const char* s1 = "abcd";        // valeur pointee constante
  char* const s2 = "abcd";        // pointeur constant (NB: INCORRECT dans ce cas!)
  const char* const s3 = "abcd";  // les deux

Cette syntaxe peut paraître un peu cryptique a priori mais elle est en fait assez simple: le const porte en fait sur tout ce qui suit:

  const char *s1;      // c'est *s1 qui est const !
  char* const s2;      // c'est s2 qui est const !

Remarque: la deuxième ligne (qui définit s2) est incorrecte en C++ ANSI. Les "string litterals" comme "abcd" sont de type const char*, lequel est incompatible avec le type de "s2" comme expliqué dans la section suivante (note: certains compilateurs obsolètes ne vérifient pas cette règle).

Conversions const / non-const

Il est possible d'initialiser une constante avec une variable ou une constante (mais uniquement à sa création, sa valeur ne pouvant changer ensuite). Mais attention, l'inverse n'est pas vrai dans le cas des pointeurs:

  const char* s1 = "abcd";
  char* s4 = s1;           // ILLEGAL!
  char* s5 = "abcd";       // ILLEGAL!
  char* const s2 = "abcd"; // ILLEGAL!

Un pointeur ne peut pas pointer sur un objet constant (par exemple la chaîne littérale "abcd") sauf s'il est spécifié que sa valeur pointée est constante (ie. "const" est avant le *) :

  const type* a;
  type* b = a;    // ILLEGAL!

Il s'agit juste d'une règle de bon sens: si un objet est constant (par exemple "abcd") il serait absurde (et dangereux!) de le redéclarer variable un peu plus loin !

Notons à ce propos que toute modification (détournée, puisque théoriquement interdite par le compilateur) du contenu d'un objet constant risque d'entraîner un plantage (car le compilateur met parfois les objets constants dans des zones mémoire "read only"). C'est en particulier souvent le cas pour les chaînes littérales comme "abcd" !

Il est cependant parfois nécessaire (à ses risques et périls !) de rendre des objets constants non constants. On doit alors faire comme suit:

  const type* a;
  type* b = const_cast<type*>(a);    // SYNTAXIQUEMENT CORRECT (mais à éviter)

Constance logique

Les variables mutables servent à résoudre le problème de la constance logique. Dans les exemples précédents, le mot-clé const indique une constance physique : l'objet est censé être réellement constant en mémoire (et toute utilisation de const_cast est donc potentiellement dangereuse).

Il existe des cas où l'on veut "donner l'impression" que l'objet est constant à l'utilisateur d'une librairie sans qu'il le soit vraiment completement : la plupart des membres de cet objet seront effectivement constants, sauf certaines variables internes servant typiquement à allouer des ressources système.

Considérons par exemple la méthode draw( ) de nos Shapes. Cette méthode est logiquement constante car elle ne modifie par l'objet auquel elle s'applique du point de vue de l'utilisateur. Mais il est possible qu'elle ne soit pas physiquement constante car devant allouer certaines ressources graphiques au premier appel (et donc modifier certains membres internes de l'objet).

Ces variables membres sont déclarées mutable. Elles peuvent être modifiées par une méthode const dans un objet déclaré constant. Cette technique est parfaitement correcte car le compilateur "sait de quoi il retourne" (contrairement au cas des changements de types arbitraires avec const_cast<>).

5. Références

Les références sont des sortes de "pointeurs propres" (on peut du moins les voir comme tels même si ce terme est en fait abusif). Comme les pointeurs, elles permettent de référencer les adresses des objets. Mais contrairement aux pointeurs, ce sont des constantes (une référence "pointe" toujours sur la même adresse). Les références ont deux fonctions principales:

Leur usage en C++ ressemble à Java sur le plan syntaxique mais leur sémantique est différente (en particulier en ce qui concerne l'affectation : voir plus loin). De fait les "références" de Java ressemblent plus à des pointeurs (ce sont des variables) qu'à de véritables références. Et, comme vous le verrez ensuite, l'usage des références C++ est beaucoup plus strict que celui des références Java (en particulier, une référence C++ ne peut pas pointer sur NULL, ni, comme dit précédemment, changer de valeur).

N'oubliez pas d'autre part qu'il n'y a pas de garbage collecting ("ramasse miette") en C++ standard et que c'est au programmeur de gérer correctement la mémoire (c'est-à-dire de la désallouer explicitement et de faire en sorte qu'il n'y ait pas de références "pendantes", pointant sur des zones qui n'existent plus).

Remplacer les pointeurs

Nous allons maintenant réécrire le premier exemple circle.cc en utilisant des références aux lieu des pointeurs.

#include <iostream>
#include "circle.hh"

int main(int argc, char** argv) {

  Circle& c = *new Circle(50, 200);   // #1
  c.setX(15);                         // #2
  int y = c.getY();
  c.setRadius(c.getRadius() + 35);
  cout << "position: x=" << c.getX() << " y=" << c.getY() << "\n"; 

  exit(0);
}


class Olympic {                       // #3
  class Circle &c1, &c2, &c3, &c4, &c5;
public:
  Olympic(Circle* pc1, Circle* pc2, Circle* pc3, Circle* pc4, Circle* pc5)
  : c1(*pc1), c2(*pc2), c3(*pc3), c4(*pc4), c5(*pc5) {}  // #4
};

void foo() {
  Olympic& oly = *new Olympic(new Circle(0,0,10),
                              new Circle(0,8,10),
                              new Circle(0,16,10),
                              new Circle(5,5,10),
                              new Circle(5,10,10)
                             );
}

  1. la notation Circle& c indique que c est une référence. La partie droite de l'affectation doit être déréférencée (signe * avant le new). Une référence doit (et ne peut) être initialisée qu'au moment de sa définition (car c'est une constante).

  2. l'appel des méthodes d'un objet se fait comme en Java : en utilisant un . au lieu d'une ->. Il en va de même pour accéder aux variables d'instance (lorsqu'elles sont accessibles, ce qui n'est pas le cas ici puisque les variables sont déclarées private dans le header).

  3. la classe Olympic montre un exemple où les variables d'instances sont des références. Noter la manière dont ces références sont initialisées en ligne #4 (comme les constantes). Le signe * est nécessaire pour déréferencer les pointeurs pc1, pc2, etc. comme à la ligne #1 devant le new.

Il n'y a pas d'"arithmétique des références" comme pour les pointeurs (ceci permettant d'éviter pas mal d'erreurs). Par ailleurs, comme vu précédemment, les références sont forcement initialisées (ce qui évite les références nulles ou indéfinies). Il est par contre aisé de convertir les références en pointeurs et vice-versa:

  int i1 = 1;
  int i2 = 2;

  int* p = &i1;                 // p pointe sur i1
  cout << "p=" << *p << "\n";   // il faut une *

  int& r2 = i2;                 // r2 référence i2
  cout << "r2=" << r2 << "\n";  // il ne faut pas d' *

  int& r1 = *p;                 // r1 référence *p c'est-a-dire i1
  cout << "r1=" << r1 << "\n";

  p = &r2;                      // p référence l'objet pointé par r2 cad. i2
  cout << "p=" << *p << "\n";

  r1 = r2;                      // !!!!!
  cout << "r1=" << r1 << "\n";
  cout << "i1=" << i1 << "\n";  // que constate-t'on ?

Comme en langage C, l'opérateur & (eg. p = &i2 ) permet d'obtenir l'adresse d'un objet et l'opérateur * (eg. r1 == *p ) permet de déréférencer un pointeur (ie. d'obtenir la valeur pointée). De ce point de vue, on voit que les références se comportent comme des variables "normales" (non pointeurs) : on doit utiliser ces deux opérateurs de la même manière avec r1 et r2 qu'avec i1 et i2.

Attention: la ligne marquée d'un !!!! peut induire en erreur: r1 = r2 signifie que la valeur référencée par r2 est recopiée dans la valeur référencée par r1. En d'autres termes, c'est strictement équivalent à : i1 = i2. Ainsi, r1 continue à référencer i1 et r2 à référencer i2 (comme dit précédemment, les références ne peuvent être initialisées qu'au moment de leur création et continuent de référencer le même objet par la suite).
Ce comportement peut désorienter le programmeur Java (r1 = r2 aurait alors pour effet de faire pointer r1 sur l'objet pointé par r2)

Passage par référence

Le passage par référence des arguments des fonctions remplit le même rôle que le passage par pointeurs en langage C (mais plus simplement !) :

  1. pouvoir récupérer la valeur d'arguments modifiés à l'intérieur d'une fonction

  2. éviter d'avoir à recopier un objet de taille importante en ne passant que son adresse à la fonction (en d'autres termes: éviter le passage par valeur). Dans ce cas, le paramètre sera généralement constant.

L'exemple suivant montre comment écrire une fonction swap qui échange la valeur de deux entiers:

void swap(int& i1, int& i2) {
  int tmp = i1;
  i1 = i2;
  i2 = tmp;
}

int main() {
  int a = 1, b = 2;
  swap(a, b);
  cout << "a=" << a << " b=" << b << "\n";
}

Les paramètres i1 et i2 vont (respectivement) pointer sur a et b. L'inversion de i1 et i2 dans swap aura donc également pour effet d'inverser a et b. La encore, mis à part la présence du & lors de la déclaration des paramètres, on voit que les références s'utilisent comme des variables "normales".

Le second exemple montre comment écrire correctement la deuxième méthode copy( ) de la classe Truc vue précédemment.

class Truc {
 ....
  const int code1, code2;
  string str;

public:
  Truc(int code1, int code2);
  
  void copy(const char* ps) {str = ps;}
  void copy(const string& s) {str = s;}  // !référence
  ....
};

int main() {
  Truc& t = *new Truc(1, 2);
  string bla = "bla bla bla";

  t.copy(bla);
}

Le paramètre s de copy( ) est maintenant une référence. L'argument bla est passé par référence ce qui évite la recopie (inutile) de son contenu. Le mot-clé const est maintenant utile et nécessaire : il indique que la fonction ne peut pas modifier ce que référence s, et donc que la valeur bla restera inchangée.

6. Mémoire

La gestion de la mémoire est plus complexe en C++ qu'en Java car les objets peuvent être alloués dans les 3 zones mémoires habituelles:

Cette possibilité, qui n'existe pas en Java (les objets sont toujours alloués en mémoire dynamique) offre des avantages importants (en particulier en ce qui concerne le temps d'exécution et la généralité du langage) mais nécessite une bonne compréhension des mécanismes mis en jeu.

D'autre part, comme dit précédemment, Java offre des mécanismes de garbage collecting en standard pour récupérer automatiquement la mémoire qui n'est plus utilisée, ce qui n'est pas le cas de C++. Toutefois, contrairement à une idée assez répandue, C++ offre également des mécanismes de libération mémoire automatique dans certains cas (ceci dépendant de la manière dont la mémoire est allouée).

De plus, certaines techniques (comme les "handle classes", les "smart pointers", le comptage de références) permettent de gérer automatiquement la mémoire dans tous les cas de figure pour les classes qui les implémentent (certaines librairies offrent d'ailleurs ce type de service en standard). Pour finir, il existe des implémentations de garbages collectors pour C++ (qui sont plus ou moins efficaces mais peuvent convenir à nombre d'applications usuelles).

Objets en mémoire dynamique

C'est le cas que l'on a vu de manière explicite jusqu'à présent: les objets sont créés dans le "tas" ("heap" en anglais) à l'aide de la primitive new. C'est similaire à l'utilisation de new en Java ou même de l'appel de la fonction malloc() en langage C (sauf qu'il n'y a pas d'appel de constructeurs avec malloc()).

En C++, les objets alloués de cette manière doivent être explicitement détruits à l'aide de la primitive delete (saufs cas particuliers - qui dépassent le cadre de ce tutoriel - où des mécanismes spécifiques de libération mémoire automatique sont utilisés).

La primitive delete travaille de manière inverse de new: elle commence par appeler les destructeurs de l'objet puis elle libère la mémoire. Comme pour les constructeurs, les destructeurs sont appelés pour toutes les super-classes de l'objet (on parle de chaînage des destructeurs) mais dans l'ordre inverse (on appelle d'abord le destructeur de la classe de l'objet, puis de sa super-classe, etc.).

Chaque classe possède donc un (et un seul) destructeur en C++. Si on ne le précise pas explicitement, un destructeur vide (qui ne fait rien à part désallouer la mémoire) est ajouté par le compilateur. Une déclaration de destructeur se fait comme suit:

  class Truc {
    public:
      Truc(int i, float x);     // constructeur
      virtual ~Truc();          // destructeur
  };

  ....

  void foo() {
    Truc* p = new Truc(5, "3.14");
    ....
    delete p;
    p = 0;                      // !!!NE PAS OUBLIER
  }

Le destructeur a le même nom que la classe précédé du signe ~ Il n'a pas d'argument (on ne peut donc définir plusieurs destructeurs pour une même classe).

De même que les méthodes d'instance, les destructeurs peuvent être (optionnellement) virtuels. Pour la même raison que pour les méthodes il est préférable qu'ils les soient (sinon on risque de ne pas appeler le(s) bon(s) destructeur(s) dans les cas ou le polymorphisme est utilisé).

!Attention :

Objets en mémoire statique

La mémoire statique contient les variables globales (et les variables static) du C ainsi que les variables de classe du C++ (également appelées static pour la même raison). La taille des objets statiques est connue à la compilation et ces objets sont pré-initialisés à 0.

Les objets statiques sont automatiquement créés au lancement du programme (ie. avant l'appel de la fonction main( )) et détruits lorsqu'il se termine. Dans le cas d'objets C++, l'appel des constructeurs et destructeurs se fait de la même façon que précédemment (ie. à la création et à la destruction de l'objet bien que l'on n'appelle jamais new ni delete).

Exemple : supposons que l'on veuille créer une classe Color qui modélise une couleur à l'écran :

// fichier color.hh

class Color {
  int r, g, b;

public:
  static const Color red, green, blue;
  
  Color();
  Color(const Color&);
  Color(int r, int g, int b);

  void set(const Color&);
  void set(int r, int g, int b);
};


// fichier color.cc

const Color Color::red(255, 0, 0);
const Color Color::green(0, 255, 0);
const Color Color::blue(0, 0, 255);

Color::Color() {r = g = b = 0;}
Color::Color(const Color& c) {r = c.r; g = c.g; b = c.b;}
Color::Color(int _r, int _g, int _b) {r = _r; g = _g; b = _b;}
..etc..

Les variables de classe Color::red, Color::green, Color::blue sont des objets "à part entière" contrairement aux cas précédents ou les variables étaient des pointeurs ou des références qui pointaient sur des objets. Dans ce cas la variable "contient l'objet" au lieu de pointer dessus.

Ces variables et leur contenu sont donc stockées dans la mémoire statique. Dans le cas présent elles sont constantes, mais ce n'est nullement obligatoire. Il est par ailleurs possible de créer d'autres variables (pointeurs ou références) qui pointent dessus en suivant les règles habituelles :


void foo() {
  const Color& r_red =  Color::red;  // reference sur Color::red
  const Color* p_red = &Color::red;  // pointeur sur Color::red

  Color::red.set(Color::blue);       // INTERDIT car "red" est const !
  r_red.set(Color::blue);            // IDEM !
  p_red->set(Color::blue);           // IDEM !
}

Objets dans la pile

La pile ("stack" en anglais) sert à stocker les variables locales (dites automatiques) et les paramètres des fonctions. Cette zone est automatiquement allouée (et agrandie) chaque fois que l'on appelle une fonction (ou une fonction dans une fonction et ainsi de suite). La métaphore classique pour expliquer ce mécanisme est celui de la pile d'assiettes (chaque assiette correspondant à la zone mémoire nécessaire pour contenir les variables et paramètres nécessaires à l'exécution de la fonction).

C++ permet de créer des objets dans la pile, selon le même principe que précédemment: au lieu d'être des pointeurs ou des références, les variables sont des objets à part entière.


void foo() {
  Color grey(128, 128, 128);
  Color& r_grey =  grey;             // reference sur grey
  Color* p_grey = &grey;             // pointeur sur grey

  grey.set(Color::blue);             // OK, grey n'est pas const
  r_grey.set(Color::blue);           // fait la meme chose
  p_grey->set(Color::blue);          // fait la meme chose
}

Un point important est que les objets crées dans la pile sont automatiquement détruits quand on sort de la fonction. Ceci est parfois très avantageux, sauf bien sur si l'on veut que les objets continuent d'exister après la terminaison de la fonction (auquel ca il faut utiliser new pour allouer les objets dans la mémoire dynamique)

La encore l'appel des constructeurs et destructeurs se fait comme prévu à la création et à la destruction des objets.

Variables d'instance

Les variables d'instance peuvent également être des objets à part entière.

  class Disk {
    Color foreground, background;
    //...etc...

    Disk();
    Disk(const Color& fg, const Color& bg);
    //...etc...
  };

  Disk::Disk() {}
  Disk::Disk(const Color& fg, const Color& bg) : foreground(fg), background(bg) {}

Remarquer :

Initialisation et affectation

Le fait de pouvoir manipuler des variables qui sont "objets à part entière" et non simplement des pointeurs ou des références complique quelque peu les choses dans le cas de l'affectation et de l'initialisation. Considérons l'exemple suivant qui vise à écrire une classe String "évoluée" qui évite d'utiliser directement les chaînes de caractères du C (rappelons à ce propos que la classe string de la librairie standard du C++ fait justement cela).

  class String {
    char* c_s;

    static char* c_sdup(const char* s);

  public:
    String(const char*);
    String(const String&);
    String();  
    ~String();    
                            // A COMPLETER (voir section suivante) !
    void set(const char*);
    void set(const String&);
  };

  
  char* String::c_sdup(const char* s) {return s ? strdup(s) : 0;}

  String::String()                  {c_s = 0;}
  String::~String()                 {if (c_s) free(c_s); c_s = 0;}

  String::String(const char* s)     {c_s = c_sdup(s);}
  String::String(const String& s)   {c_s = c_sdup(s.c_s);}

  void String::set(const char* s)   {c_s = c_sdup(s);}
  void String::set(const String& s) {c_s = c_sdup(s.c_s);}


  void foo() {
    String s0;              // 1e constructeur

    String s1("abcd");      // 2e constructeur
    String s2 = "xyz";      // 2e constructeur

    String s3(s1);          // 3e constructeur
    String s4 = s2;         // 3e constructeur

    s0.set("iiii");
    s0.set(s2);
    s0 = s2;                // ??? que se passe-t'il ???
  }

Les constructeurs, ainsi que les méthodes set() dupliquent la chaîne de caractères C (fonction strdup()). Si ce n'était pas le cas, les variables d'instances c_s pointeraient sur des chaînes qui pourraient être modifiées ou détruites arbitrairement ailleurs dans le programme : les résultats seraient vite catastrophiques, nos instances de String contenant rapidement à peu près n'importe quoi !

Inversement, le destructeur prend soin de libérer la mémoire en détruisant la chaîne de caractères C (fonction free() propre au C). Noter que l'on a pris soin que le constructeur sans argument initialise c_s à 0 de telle sorte ce pointeur soit toujours correctement initialisé quelque soit la manière de créer l'objet.

Les premières lignes de la fonction foo( ) montrent différents cas d'appel des constructeurs :

Ce qu'il faut faire impérativement

Le problème précédent est typique et très fréquent : dès qu'un objet contient des pointeurs ou des références (ou qu'il inclut des membres qui en contiennent), sa copie "brute" peut conduire à des catastrophes car les objets pointés ne sont pas dupliqués. Il y a deux solutions radicales à ce problème :

Ceci est possible car il est possible de surcharger les opérateurs en C++ (voir section suivante). Pour régler le problème précédent, il suffit donc de faire comme suit:

  class String {
    char *c_s;
  public:
    String(const String&);
    String& operator=(const String&);
    //etc...
  };

  String::String(const String& s)             {c_s = c_sdup(s.c_s);}
  String& String::operator=(const String& s)  {c_s = c_sdup(s.c_s); return *this;}


7. En bref

Dans cette introduction à C++, nous avons pu voir que c'est un langage riche, puissant, efficace mais un peu complexe (ceci étant la contrepartie de cela). La syntaxe parfois un peu rébarbative ne doit pas vous effrayer : on s'y fait en réalité assez vite avec un peu de pratique. Les principales chausses trappes ont été expliquées dans cette introduction : on peut donc assez facilement les éviter en suivant les conseils indiqués.

La gestion mémoire est clairement plus complexe qu'en Java, mais c'est aussi la raison pour laquelle C++ est nettement plus efficace en terme de temps de calcul. Et cela permet aussi au langage d'être plus général.

Pour donner un exemple, il est parfaitement possible de définir des bibliothèques mathématiques orientées objet en C++ avec d'excellentes performances (théoriquement les mêmes qu'en C ou peu s'en faut). Ceci est impossible en Java, ne serait-ce que parce que l'on ne peut pas créer d'objets dans la pile (or la gestion de la mémoire dynamique et le garbage collecting sont des procédés coûteux lorsque l'on doit gérer beaucoup d'objets de manière intensive)

Le reste de cette section donne quelques indications sur d'autres aspects du langage.

Surcharge des opérateurs

Il est possible de surcharger presque tous les opérateurs standard et donc de changer le comportement associées aux signes = == < > + - ++ -- += -= * / -> () [] etc.

le principe est toujours à peu près le même que dans le cas de l'opérateur = vu précédemment à quelques subtilités près.

Noter que la surcharge de [ ] permet de créer des pseudo-tableaux dont l'argument peut être de type quelconque (par exemple une chaîne de caractères). Ceci sert typiquement à implémenter des systèmes de mémoire associative.

La surcharge de ( ) permet de créer des "objets fonctionnels", c'est-à-dire des objets que l'on peut entre-autres utiliser comme des fonctions. Cette fonctionnalité est fort utile pour réaliser des algorithmes génériques (en particulier ceux de la STL)

Enfin la surcharge de de -> permet de créer des "smart pointers" capables de réaliser des actions complexes (par exemple initialiser l'objet à droite du -> ou aller le chercher dans une base de donnée). Ils peuvent aussi permettre de gérer la libération de la mémoire dynamique de manière automatique (en association avec d'autres mécanismes).

Exceptions

Leur fonctionnement est similaire en Java et en C++ à quelques détails près. En particulier :

Pointeurs de fonctions et de méthodes

On rappelle que le langage C permet de définir des pointeurs de fonctions. C++ permet de plus de définir des pointeurs de fonctions membres (c'est-à-dire de méthodes d'instances) ce qui est parfois fort utile.

Transtypage implicite

C++ permet, comme C, un certain nombre de changement de types implicites (par exemple:    int i = c;    c étant un char) mais est plus restrictif que C sur ce chapitre.

C++ permet également de spécifier ou d'interdire des changements de types implicites en redéfinissant les constructeurs et l'opérateur = C'est par exemple ce que nous avons fait précédemment dans le cas de la classe String lorsque nous avons écrit:

  class String {
    ....
  public:
    String(const char*);
    String& operator=(const String&);
    ...
  };
ceci permet ensuite d'écrire :
    String s2 = "xyz";
    s2 = "abcd";
et donc de faire un changement de type implicite.

Enfin, l'orienté objet garantit un transtypage automatique vers les super-classes (un Square "est aussi" un Rect qui "est aussi" une Shape).

Transtypage explicite, static_cast, dynamic_cast

Il est également possible d'effectuer des changements de types explicites ("cast" en anglais) mais ceux-ci sont potentiellement dangereux. Ils devraient théoriquement être rarissimes en orienté objet du fait du transtypage implicite vers les super-classes et de la possibilité d'utiliser des méthodes virtuelles.

Trois opérateurs existent toutefois pour imposer explicitement un changement de type :

Enfin, le C++ permet aussi de déterminer dynamiquement le type des objets via une interface appelée RTTI (mais ceci est généralement inutile, sauf pour certains cas particuliers ou si le style de programmation ne respecte pas les principes de l'orienté objet).

Types partiellement définis

Les types partiellement définis sont indispensables en C++ (et en langage C) :

Supposons par exemple que l'on veuille déclarer la classe Truc qui contienne des pointeurs ou des références vers des objets des classes Muche et Bidule:

  class Truc {
    Muche* m;
    Bidule& b;
  };

Cette déclaration impose que les classes Truc et Muche aient été préalablement déclarées, et donc d'inclure leurs headers respectifs. Mais:

  1. les déclarations de Truc et Muche et nécessitent peut-être elles aussi de connaître celle de Truc, d'où une dépendance croisée (ceci peut conduire à un véritable "sac de noeuds" si l'on a beaucoup de classes)

  2. on veut peut-être maintenir la déclaration de Muche cachée car: susceptible de changer, liée à l'implémentation interne de la bibliothèque (donc indésirable dans l'API client), ou tout simplement protégée par des droits de propriété.

La solution est simplissime :

  class Muche;

  class Truc {
    Muche* m;
    class Bidule& b;
  };

Il suffit de dire quelque part au compilateur que Muche et Bidule sont des classes, même si ces classes sont (pour l'instant) indéfinies. Muche* et Bidule& deviennent alors des types partiellement définis ce qui suffit pour que leur déclaration soit correcte dans le header. (mais il sera par contre nécessaire d'inclure leur déclaration complète dans le fichier code source pour pouvoir les créer, les initialiser, accéder à leurs membres, etc.

Attention, cette technique ne peut marcher que pour des pointeurs ou des références (pas pour les membres qui sont des objets à part entière car le compilateur a besoin de connaître leur taille et leur constructeur).

Empêcher les redéfinitions de Headers

Ce point est lié au précédent lorsque l'on a des dépendances croisées entre headers, ou, plus simplement, lorsque le hasard fait que le même header est inclus plusieurs fois dans le méme fichier code source (ceci arrive fréquemment car les headers peuvent eux-même inclure des headers et ainsi de suite).

La solution standard et systématique consiste à utiliser les directives #ifndef et #define du précompilateur C et C++:

// header truc.h

#ifndef _truc_h_
#define	_truc_h_

  class Truc {
    //etc...
  };

#endif

On est ainsi assuré que le header truc.h ne sera effectivement inclus qu'une seule fois dans les fichiers code source. (et ce quelque soit le nombre de fois ou apparait #include "truc.h")

Linkage

Lorsque l'on dispose de code écrit en C et en C++ (certaines méthodes du code C++ appelant des fonctions du code C) on peut procéder comme suit:

Paradoxalement, il est plus difficile de lier entre-eux plusieurs fichiers objets résultant de la compilation de fichiers sources C++ avec des compilateurs différents. Ceux-ci sont généralement tout simplement incompatibles car le format de sortie des compilateurs C++ n'est pas normalisé (même pour une même plateforme : ainsi le code généré par CC, le compilateur de Sun est incompatible avec celui généré par gcc, le compilateur de GNU).

Friends

Une classe CC peut déclarer une autre classe ou une fonction déclarées friend. Ceci permet à cette dernière d'accéder librement à tous les membres de CC. Ceci ressemble un peu à la notion de protection "package" en java, mais avec un niveau de granularité beaucoup plus fin.

  class Truc {
    friend class Muche;  // Muche aura acces aux membre de Truc
    ...
  };  

Namespaces

Les namespace servent à définir des espaces de noms séparés. Cette notion ressemble à celle de "package" en Java, sauf qu'elle n'a aucune incidence sur les droits d'accès aux membres des classes. Le but premier des namespaces est d'éviter les problème de collisions de noms (c'est-à-dire lorsque plusieurs classes ou plusieurs fonctions ont le même nom dans des bibliothèques différentes que l'on doit utiliser simultanément).

Templates et STL

Les templates sont à la base de la STL (Standard Template Library) une bibliothèque standard qui implémente de nombreux algorithmes et structures de données génériques (par exemple pour gérer des vecteurs, les listes, des piles, des files d'attente, des cartes associatives, des tables de hash code, etc)

Pour plus d'informations sur la STL voir:

Pour plus d'informations sur les templates, la STL et d'autres aspects de C++ voir:


home page page cours    Eric Lecolinet - ENST / INFRES - http://www.enst.fr/~elc