Formation C++

Avant propos

Cette formation fut présentée par Arnaud de Bossoreille de Ribou le Jeudi 22 février 2001 à l'Ecole Centrale de Paris dans le cadre de l'association VIA - Centrale Réseaux et du projet VideoLAN. Elle fût suivie par une formation spécifique à l'environnement MS Windows présentée par Boris Dorès.

Introduction

Le C++ est un langage de programmation objet dérivé du langage C. La présente formation est destinée aux personnes ayant déjà une connaissance du langage C. Elle a pour but d'exposer les concepts avancés apportés par le C++ au langage C. C'est pourquoi il ne sera fait référence au langage C qu'au seul titre de comparaison didactique.

La programmation orientée objet

La programmation orientée objet consiste à regrouper un ensemble de données et de comportements en une entité appelée objet. Les données sont en général appelées membres et les comportements méthodes. L'intérêt est de pouvoir associer un comportement commun à plusieurs objets dont les membres sont bien évidemment distincts. A partir de là il est possible d'ajouter une quantité illimitée de fonctionnalités à ces objets. Dans la suite il ne sera question que de celles mises à disposition par le C++.

Les concepts non-objet introduits par le C++

Cette première partie va permettre d'exposer un certain nombre de concepts indispensables pour débuter la programmation en C++.

Commentaires

Le C++ accepte deux types de commentaires :

Exemple :

{
  /* Un commentaire
     multiligne     */

  // commentaire spécifique C++

  int i = 12; // je mets i à 12
}

Déclaration des variables

Une variable peut être déclarée à n'importe quel endroit d'un programme écrit en C++. Elle est détruite et n'est plus valide dès que le programme sort du domaine de sa déclaration. Exemple :

void Function(void)
{
  // Déclaration
  int i = 12;
  char *p = NULL;

  // Code C++
  p = (char*)malloc(42 * sizeof(char));

  // Autre déclaration
  unsigned short j;

  // Suivant les compilateurs le domaine de validité de k est soit le corps de
  // la fonction (ex: VC++) soit uniquement le corps de la boucle 'for'
  // (ex: gcc).
  for(int k = 12; k < 42; k++)
  {
    // Le domaine de validité de z est restreint au corps de la boucle 'for'.
    int z;
  }
}

A noter que le domaine de déclaration de la variable k dépend du compilateur.

Allocation dynamique (opérateurs new et delete)

En C l'allocation dynamique se fait à l'aide des fonctions malloc() et free(). La fonction malloc() nécéssite l'utilisation de l'opérateur sizeof().

En C++ l'utilisation des malloc() et free() est déconseillée au profit des opérateurs new et delete. Ils s'utilisent de la façon suivante :

{
  int *pInt = new int;

  delete pInt;

  char *pStr = new char[42];

  delete[] pStr;
}

l'opérateur new alloue les données et l'opérateur delete les libère. Ces opérateurs effectuent des opérations supplémentaires, qui seront décrites plus loin, lorsque l'allocation porte sur des objets.

A noter qu'un appel à malloc() en C++ doit impérativement être "casté", en effet il est interdit d'assigner un void* à un int* sans utiliser le cast (int*) devant l'appel à malloc(). L'utilisation de l'opérateur new ne nécéssite pas de cast puisque le type de pointeur est explicitement donné.

Références

Les références peuvent être considérées comme des pointeurs. Leur utilisation permet d'alléger la syntaxe du code en particulier quand elles sont utilisées dans les prototypes de fonction. Exemple :

{
  int a = 12;
  // a vaut 12

  int &b = a;
  // b vaut 12

  a = 42;
  // a et b valent 42

  b = 2;
  // a et b valent 2
}

void funcref(int& iArg)
{
  iArg = - iArg;
}

void func(int iArg)
{
  iArg = - iArg;
}

{
  int i = 12;
  funcref(i);
  // i vaut maintenant -12

  func(i);
  // i vaut toujours -12
}

On dit que l'argument iArg de la fonction funcref est passé par référence. iArg est ainsi accessible autant en lecture qu'en écriture. Lorsque l'argument n'est pas passé par référence sa valeur n'est pas modifiée puisque la modification agit dans ce cas sur une copie de l'argument.

Surcharge de fonction

Le C++ introduit une certaine souplesse dans le nommage des fonctions. Il est en effet possible d'avoir 2 fonctions portant le même nom mais ne prenant pas des arguments de même type. L'édition des liens se base uniquement sur le type des arguments et leur nombre pour déterminer la fonction à utiliser. Le type de retour n'est pas significatif dans cette détermination. Ainsi déclarer 2 fonctions qui prennent le même nombre d'arguments de même type et ayant un type de retour différent est interdit. Exemple :

int function(int i);

int function(double dVal);

void function(int i, char *str);

// Interdit (ne compile pas) car le type de retour n'est pas significatif.
void function(int i);

Valeur par défaut des arguments

Il est possible en C++ de spécifier des valeurs par défaut pour les arguments d'une fonction lors de sa déclaration. La seule contrainte est que les arguments qui suivent un argument ayant une valeur par défaut doivent eux aussi avoir une valeur par défaut. Exemple :

void fct1(char *str, int i = 0, void *p = NULL);

// Interdit, p doit avoir une valeur par défaut.
void fct2(char *str, int i = 0, void *p);

L'entité de base en C++ : la classe

Tout d'abord il est important de distinguer deux notions :

Il est possible d'avoir plusieures instances d'une même classe. Une méthodes agit (sauf cas particulier) sur une et une seule instance de la classe à laquelle elle appartient.

Déclaration

Une classe se déclare de la façon suivante :

class C_Class
{
public:
  // un membre de type entier
  int m_iMember;
  // un membre de type C_OtherClass
  C_OtherClass m_cOtherMember;

  // une méthode qui ne prend pas d'arguments et qui renvoie un entier
  int Method();
  // une méthode qui prend un entier en argument et qui ne renvoie rien
  void OtherMethod(int iArg);
};

Pour définir une classe on utilise le mot clé class (voir exemple). Les membres étant de simples variables, ils sont déclarés de la même façon qu'en C mais à l'intérieur d'une classe. Il en va de même pour les méthodes qui ne sont rien d'autre que des fonctions avec argument(s) et valeur de retour.

Instanciation

Les instances de classe sont des variables au même titre que les entiers. Elles se déclarent donc de la même façon :

{

  //...

  // Instanciation locale
  C_Class cInstance;

  // Instanciation dynamique (pointeur)
  C_Class *pInstance = NULL;
  pInstance = new C_Class;

  //...

  delete pInstance;

  //...

}

Il est important de noter que l'instanciation dynamique d'une classe ne fait pas appel à la fonction malloc mais au mot clé new qui fait appel en interne à malloc. De plus la destruction d'une instance de classe créée avec new se fait à l'aide du mot clé delete.

Implémentation des méthodes

Chaque méthode est identifiée à l'aide du nom de la classe à laquelle elle appartient et de son propre nom, ces 2 éléments étant séparés par l'opérateur "::". L'implémentation d'une méthode se fait ainsi :

class C_Int
{
private:
  int m_iValue;

public:
  int GetValue();
};


int C_Int::GetValue()
{
  return m_iValue;
}

Accès aux membres et aux méthodes

Les méthodes d'une classe sont en général invoquée à partir d'une instance de la classe à l'aide de l'opérateur "." pour les instances et "->" pour les pointeurs d'instance. Il en va de même pour les membres. Exemple :

{
  C_Int cInt;

  int i = cInt.GetValue();
  int j = cInt.m_iValue;

  C_Int *pInt = &cInt;

  i = pInt->GetValue();
  i = (*pInt).m_iValue;
}

Chaque méthode a un paramètre caché "this" qui est un pointeur sur l'instance à partir de laquelle elle est invoquée. Ce paramètre caché sert le plus souvent à faire la distinction entre un membre d'une classe et une variable déclarée localement dans une de ses méthodes. Le plus souvent la partie "this->" est omise dans l'implémentation des méthodes pour accéder aux membres et aux méthodes de la classe.

Membres et méthodes "static"

Les membres précédés du mot clé "static" sont communs à toutes les instances de la classe à laquelle ils appartiennent, à un instant donné ils ont la même valeur pour toutes les instances de cette classe. En ce qui concerne les méthodes statiques, elle n'agissent sur aucune instance particulière (sauf si l'implémentation modifie explicitement une instance). Il est possible d'accéder à de tels membres et méthodes de la façon habituelle si l'on possède une instance. Si ce n'est pas le cas on utilise l'opérateur "::" précédé du nom de la classe. Exemple :

class C_Class
{
public:
  static int m_iStaticMember;
  static int StaticMethod();
};


{
  int i = C_Class::m_iStaticMember;
  i = C_Class::StaticMethod();

  C_Class cInst1;
  C_Class cInst2;

  cInst1.m_iStaticMember = 12;
  cInst2.m_iStaticMember = 42;

  i = cInst1.m_iStaticMember;
  // i vaut 12
}

A noter que dans une méthode statique, le paramètre "this" n'existe pas puisqu'elle n'agit pas sur une instance particulière de la classe.

Mot clé "const"

Une méthode suivie du mot clé "const" est une méthode qui ne modifie pas les membres de la classe. Si tel était le cas, une erreur de compilation se produirait.

Le mot clé "const" est également utilisé devant un argument d'une fonction (ou d'une méthode) pour dire que cet argument ne sera pas modifié par l'invocation de la fonction (ou de la méthode).

La présence ou non de ce mot clé a une influence sur le code généré par le compilateur mais le fait de l'omettre n'a pas d'influence sur le fonctionnement pur de l'application.

Analogie avec le langage C

En C on utilise des structures et des fonctions qui prennent un pointeur sur ces structures pour effectuer des traitements. En C++ on utilise les instances de classe et leurs méthodes.

Constructeur et destructeur

Il existe deux méthodes particulières appelées constructeur et destructeur, leur nom se déduit de celui de la classe.

Constructeur

Une méthode qui porte le nom de la classe est un constructeur. Un constructeur peut prendre des arguments (il peut donc y avoir plusieurs constructeurs pour une même classe) et n'a pas de type de retour, surtout pas void. Le type de retour est en réalité caché et n'est autre que la classe elle même. Un constructeur est invoqué à chaque fois qu'une nouvelle instance est créée et sert à initialiser les membres.

On distingue parmis les constructeurs d'une part le constructeur par défaut qui ne prend pas d'arguments et d'autre part le constructeur de copie qui prend comme unique argument une référence sur une instance de la classe. Le constructeur de copie est invoqué à chaque fois qu'une instance est assignée à une autre par l'opérateur '='. S'il n'existe pas, le contenu de la première instance est copié bit à bit vers la deuxième instance. Le constructeur de copie est nécéssaire pour cloner une instance dont les données sont allouées dynamiquement.

Destructeur

Une méthode dont le nom est composé du caractère '~' et du nom de la classe est appelée destructeur. Un destructeur ne prend pas d'arguments et n'a pas de type de retour. Il ne peut y avoir qu'un seul destructeur par classe. Le destructeur est invoqué à chaque fois qu'une instance doit être détruite et sert à nettoyer les membres (désallocation de la mémoire dynamique...).

Cas pratique d'invocation

Voici un exemple de code dont le fonctionnement va être détaillé :

class C_Class
{
public:
  C_Class();                    // Constructeur par défaut
  C_Class(C_Class &cInstance);  // Constructeur de copie
  C_Class(int iArg);

  ~C_Class();                   // Destructeur
};


{
  C_Class cInstance1;
  C_Class cInstance2(42);

  cInstance1 = cInstance2;

  C_Class *pInstance3 = new C_Class(cInstance1);

  delete pInstance3;
}

Lors de l'invocation d'un constructeur tous les membres de la classe sont instanciés à l'aide du constructeur par défaut (qui ne prend pas de paramètre) sauf si l'on spécifie explicitement la façon de construire un membre. S'il n'y a pas de constructeur par défaut pour un membre cette spécification est obligatoire. Exemple :

class C_Class1
{
  C_Class1(int iArg1);
};

class C_Class2
{
public:
  C_Class2(int iArg2);

  C_Class1 m_cClass1;
};


// Ici C_Class1 n'a pas de constructeur par défaut, il est donc obligatoire
// de spécifier le constructeur à utiliser avec la valeur de ses arguments
// qui peuvent faire référence aux arguments du constructeur de C_Class2.
C_Class2::C_Class2(int iArg2) : m_cClass1(iArg2);
{
}

Les droits d'accès aux membres et aux méthodes

Dans les exemples précédents le mot clé "public" a été utilisé sans explication. Il s'agit d'un mot clé pour définir qui à le droit d'accéder aux membres et aux méthodes et il est toujours suivi de ":".

Mot clé "public"

Tout ce qui se trouve dans la partie publique d'une classe est accessible à tout endroit du code sauf dans le cas de l'héritage privé qui sera décrit plus loin.

Mot clé "protected"

Tout ce qui se trouve dans la partie protegée d'une classe est accessible uniquement aux méthodes de cette classe et des classes héritères sauf en cas d'héritage privé.

Mot clé "private"

Tout ce qui se trouve dans la partie privée d'une classe n'est accessible qu'aux méthodes de cette même classe.

Exception: classes et méthodes amies

Il est possible de déclarer une classe ou une méthode d'une classe (ou même une fonction) comme étant amie d'une classe grace au mot clé "friend". Il est ainsi possible à la classe/méthode/fonction amie d'accéder à tous les membres et toutes les méthodes de la classe, même s'ils sont privés. Exemple :

class C_Class1
{
public:
  void PublicMethod();

protected:
  void ProtectedMethod();

private:
  int m_iPrivateMember;
};

class C_Class2
{
  // C_Class1 a acces à tous les membres et toutes les méthodes de C_Class2
  friend C_Class1;

private:
  void PrivateMethod();
};

A noter que si aucun droit n'est spécifié les membres et les méthodes sont implicitement déclarés privés.

La surcharge d'opérateur

De même que l'on utilise les opérateurs '=', "+"... sur des entiers, il est possible de les utiliser sur des classes à condition de les définir: c'est la surcharge d'opérateur. On peut ainsi "additionner" des instances de classe, les "multiplier" etc. On peut ainsi surcharger les opérateurs + - * / % ^ & | ~ ! = < > += -= *= /= %= ^= &= |= << >> >>= <<= == != <= >= && || ++ -- ->* , -> [] () new delete. Exemple :

/*************************************/
/* Extract from the VideoLAN project */
/*************************************/

class C_String
{
public:
  C_String();
  C_String(const C_String& strSrc);
  C_String(const char* pszSrc);

  // Copy operators
  C_String& operator = (const C_String& strSrc);
  C_String& operator = (const char *pszSrc);

  // String concatenations
  C_String operator + (const C_String& strToken) const;
  C_String operator + (const char *pszToken) const;

private:
  // String length
  unsigned int m_iLength;
  // Null terminated traditional string
  char* m_pszBuff;
};


C_String::C_String()
{
  m_iLength = 0;
  m_pszBuff = new char[1];
  m_pszBuff[0] = '\0';
}

C_String::C_String(const C_String& strSrc)
{
  m_iLength = strSrc.m_iLength;
  m_pszBuff = new char[m_iLength+1];
  memcpy(m_pszBuff, strSrc.m_pszBuff, m_iLength+1);
}

C_String::C_String(const char* pszSrc)
{
  ASSERT(pszSrc);

  m_iLength = strlen(pszSrc);
  m_pszBuff = new char[m_iLength + 1];
  memcpy(m_pszBuff, pszSrc, m_iLength + 1);
}


C_String& C_String::operator = (const C_String& strSrc)
{
  // To avoid pbs if the str = str operation is tried
  if(*this != strSrc)
  {
    // Delete old buffer
    ASSERT(m_pszBuff);
    delete[] m_pszBuff;

    // Store the new string
    m_iLength = strSrc.m_iLength;
    m_pszBuff = new char[m_iLength + 1];
    memcpy(m_pszBuff, strSrc.m_pszBuff, m_iLength+1);
  }

  return *this;
}

C_String& C_String::operator = (const char *pszSrc)
{
  ASSERT(pszSrc);

  // Delete current instance
  ASSERT(m_pszBuff);
  delete[] m_pszBuff;

  // Store the new string
  m_iLength = strlen(pszSrc);
  m_pszBuff = new char[m_iLength + 1];
  memcpy(m_pszBuff, pszSrc, m_iLength+1);

  return *this;
}

C_String C_String::operator + (const C_String& strToken) const
{
  C_String strResult(m_pszBuff);
  strResult += strToken;

  return strResult;
}

C_String C_String::operator + (const char* pszToken) const
{
  ASSERT(pszToken);

  C_String strResult(m_pszBuff);
  strResult += pszToken;

  return strResult;
}


{
  C_String str1("avant");
  C_String str2("après");

  str1 = str2; // "après"

  str1 = "avant";

  C_String str3(str1 + str2); // "avantaprès"

  str3 = str1 + str3 + str2; // "avantavantaprèsaprès"
}

L'héritage

L'héritage est une notion importante des langages orientés objets. Le principe est de se baser sur une (héritage simple) ou plusieurs (héritage multiple) classes mères pour en créer une nouvelle en héritant des membres et des méthodes des classes mères et en en modifiant certains comportements (méthodes virtuelles. L'héritage n'a pas de limite en ce qui concerne les niveaux. Chaque instance d'une classe héritant d'une ou plusieurs autres classes peut être considérée comme une instance d'une de ces ancêtres.

Héritage simple

Il consiste à créer une classe fille à partir d'une unique classe mère et se fait de la façon suivante :

class C_Class1
{
public:
  int Method();
};

class C_Class2 : public C_Class1
{
public:
  int Method();
};

C_Class1::Method()
{
  return 1;
}

C_Class2::Method()
{
  return 2;
}


{
  C_Class1 *pClass = new C_Class2;
  int i = pClass->Method();
  // i vaut 1 car la méthode invoquée est C_Class1::Method()

  i = ((C_Class2*)pClass)->Method();
  // i vaut 2 car la méthode invoquée est C_Class2::Method()
}

Dans l'exemple précédent, l'héritage est dit public, tous les membres privés de C_Class1 sont invisibles par C_Class2, tous les membres protégés et publics de C_Class1 sont visibles par C_Class2. Il est va de même pour les méthodes. En cas d'héritage protégé, les membres protégés de C_Class1 deviennent privés dans C_Class2 tout en restant visibles par C_Class2 et les membres publics deviennent protégés (de même pour les méthodes). En cas d'hétitage privé, tous les membres deviennent privés et visibles par C_Class2 (de même pour les méthodes). Les règles concernant les droits d'accès sont ensuite déduites du nouveau status des membres et des méthodes.

De même que l'on peut spécifier un constructeur à utiliser pour chaque membre, on peut spécifier le constructeur à utiliser pour construire l'instance de la classe mère, spécification rendue obligatoire par l'absence de constructeur par défaut :

class C_Class1
{
public:
  C_Class1(int iArg);
};

class C_Class2 : public C_Class1
{
public:
  C_Class2(int iArg, const char* str);
};


C_Class2::C_Class2(int iArg, const char* str) : C_Class1(iArg)
{
}

Méthodes virtuelles

Dans l'exemple concernant l'héritage simple on souhaiterait en réalité avoir toujours 2 comme résultat de l'invocation de Method(). Il suffit pour cela de rendre cette méthode virtuelle à l'aide du mot clé "virtual" :

class C_Class1
{
public:
  virtual int Method();
};

class C_Class2 : public C_Class1
{
public:
  virtual int Method();
};

C_Class1::Method()
{
  return 1;
}

C_Class2::Method()
{
  return 2;
}


{
  C_Class1 *pClass = new C_Class2;
  int i = pClass->Method();
  // i vaut 2 car la méthode invoquée est C_Class2::Method()

  i = ((C_Class2*)pClass)->Method();
  // i vaut 2 car la méthode invoquée est C_Class2::Method()
}

La méthode invoquée n'est pas déterminée à la compilation, comme dans le premier exemple, mais à l'execution.

En théorie une méthode virtuelle héritée et redéfinie comme non virtuelle redevient "normale". Dans la pratique les compilateurs considère leplus souvent qu'une méthode virtuelle reste virtuelle après héritage.

On appelle méthode virtuelle pure une méthode qui n'a pas d'implémentation. Une classe qui contient de telles méthodes est dite abstraite et ne peut pas être instanciée. Il faut implémenter toutes les méthodes virtuelles pures après un ou plusieurs niveaux d'héritage avant de pouvoir instancier une classe. Une méthode virtuelle pure se déclare de la façon suivante :

class C_Class1
{
public:
  virtual int Method() = 0;
};

class C_Class2 : public C_Class1;
{
public:
  virtual int Method()
  {
    return 12
  };
};

Héritage multiple

Il consiste à vréer une classe fille à partir de plusieurs classes mères. Le type d'héritage (publique, protégé ou privé) est défini pour chaque classe mère. Exemple :

class C_Class10;
class C_Class11;
class C_Class12;

class C_Class20 : public C_Class10, public C_Class11, protected C_Class12
{
  //...
};

Les templates

Le mot clé "template" permet de définir une classe ayant un ou plusieurs type de classe comme parametre. On peut les considérer comme des macros améliorées. Exemple :

template <class T> class C_Class1
{
public
  T* Method1();
  void Method2(const C_Class1<T>& p);

private:
  T m_cMember;
};

class C_Class2


template <class T> T* C_Class1<T>::Method1()
{
  return &m_cMember;
}

template <class T> void C_Class1::Method2(const C_Class1<T>& p)
{
  // ...
}


{
  C_Class1<C_Class2> cInst1;

  C_Class2 *pInst2 = cInst1.Method1();

  cInst1.Method2(cInst1);
}

Les exceptions

Le C++ propose un système d'exception qui permet une gestion des erreurs plus souple qu'en pure code C. On appelle aussi les exception le "bordel organisé" ce qui veut dire ce que ça veut dire.

Une exception est une classe sans aucune particularité ce qui permet de construire une hiérarchie d'exception en fonction de l'erreur détectée. Losrqu'une erreur est détectée le code émet une exception grace au mot clé "throw" qui s'utilise exactement de la même façon que "new". Lorsqu'une exception est émise le programme remonte la pile d'appel des fonctions sans executer aucun code jusqu'à trouver un gestionnaire d'exception adapté à l'exception émise. Un gestionnaire d'exception est caractérisé par un bloc try-catch. Exemple :

class E_Exception1;

class E_Exception2 : public E_Exception1
{
};


// Detection des erreurs
{
  // ...

  // Erreur1 détectée
  throw E_Exception1(...);

  // Erreur2 détectée
  throw E_Exception2(...);
}


Gestionnaire d'exception
{
  try
  {
    // Code pouvant générer une exception
  }
  catch(E_Exception2 e)
  {
    // Traitement en cas d'exception de type E_Exception2 ou dérivé
    // au sens de l'héritage.
    // Ce n'est pas un gestionnaire d'exception adapté à E_Exception1.
  }
}