INF224
Travaux Pratiques C++/Objet

Eric Lecolinet - Télécom ParisTech - Dept. INFRES

Liens utiles

Et aussi :

Exercices

Le but de ces travaux pratiques est de créer l'ébauche du logiciel d'une set-top box multimédia permettant de jouer de la musique, des vidéos, des films, d'afficher des photos, etc. Ce logiciel sera realisé par étapes, en se limitant à la déclaration et l'implémentation de quelques classes et fonctionnalités typiques que l'on complétera progressivement. Noter qu'il est utile de lire le texte de chaque étape en entier avant de la traiter (en particulier les notes ou remarques qui donnent des indications).

Comme il faudra rendre le TP et un README décrivant brièvement votre travail pensez à noter au fur et à mesure ce que vous avez fait et les réponses aux questions. Lorsqu'il y a des des modifications importantes du code source (en particulier dans la fonction main()) il est utile de conserver la version antérieure en commentaires ou, mieux, entre #ifdef VERSION_TRUC et #endif, comme expliqué plus bas.

1e Etape: Démarrage

  1. Ouvrir une fenêtre Terminal
  2. Créer un répertoire pour les fichiers de ce TP, par exemple : mkdir inf224
  3. Aller dans ce répertoire : cd inf224
  4. Copier les fichiers Makefile et main.cpp dans ce répertoire. Du fait de la présence de tabulations dans le Makefile, ne pas faire de coupé collé, utiliser la commande du navigateur Web pour enregister le fichier. Si le navigateur rajoute l'extension .txt au Makefile il faut l'enlever.
  5. Lancer un IDE approprié pour le développement C/C++ (pas un simple éditeur de texte comme gedit, etc.). Vous pouvez par exemple utiliser QtCreator qui est accessible depuis le menu Programmation ou depuis le Terminal en tapant la commande qtcreator &.
  6. ATTENTION : votre programme doit pouvoir être compilé et exécuté juste en tapant la commande make run dans le Terminal depuis une machine Unix de l'Ecole. Si vous utilisez un autre IDE que QtCreator (Emacs, Eclipse, etc.) il doit utiliser ce Makefile pour compiler le projet. Un programme qui ne compile pas en tapant make sera considéré non rendu !
Pour créer un projet compatible avec un Makefile avec QtCreator : Ceci va créer un fichier qui a le nom du projet avec l'extension .creator. Ce fichier permettra de réouvrir votre projet lors d'une session ultérieure, en sélectionnant Fichier puis Ouvrir un fichier ou projet... dans QtCreator (ou en double cliquant sur ce fichier sur votre machine perso si elle est bien installée).

2e Etape: Classe de base

Ecrire la déclaration (fichier header .h) et l'implémentation (fichier source .cpp) de la classe de base de l'arbre d'héritage des classes d'objets multimédia. Cette classe de base contiendra ce qui est commun à tous les objets multimédia. On s'en servira ensuite pour définir des sous-classes spécifiques à chaque type de donnée (par exemple une classe photo, vidéo, film, morceau de musique, etc.)

Pour créer ces deux fichiers, dans QtCreator cliquer : Fichier puis Nouveau fichier ou projet... puis Classe C++ (le nom du .h et du .cpp sera forgé à partir de celui de la classe). Noter aussi que, par convention, les noms de vos classes devront commencer par une majuscule et ceux des variables et des fonctions par une minuscule.

Pour simplifier, cette classe de base n'aura que deux variables d'instance:

Déclarer et implémenter deux constructeurs (un sans argument, un avec arguments), le destructeur, ainsi que les accesseurs ("getters") et modifieurs ("setters") pour pouvoir lire ou modifier les variables d'instance.

Déclarer et implémenter une méthode d'affichage permettant d'afficher la valeur des variables de l'objet. Cette méthode, qui s'inspirera de l'exemple vu en cours, ne modifiera pas l'objet et affichera les données sur un ostream, c'est-à-dire un flux générique de sortie. Ceci vous permettra d'utiliser la même fonction pour afficher sur le Terminal, un fichier ou un buffer de texte, ce qui sera utile plus tard. Concrètement:
Par ailleurs pensez à :
Pour compiler le fichier et corriger les erreurs

3e Etape: Programme de test

Un programme exécutable nécessite la présence d'une fonction main(). Cette fonction ne doit pas se trouver dans l'implémentation d'une classe car ceci interdirait sa réutilisation ultérieure. On va donc l'implementer dans un autre fichier, ici main.cpp.

Pour tester, créer quelques instances de la classe de base (en utilisant new) dans main() et vérifier que la fonction d'affichage affiche correctement la valeur des attributs dans le Terminal. Noter que votre code doit (et devra toujours par la suite) respecter le principe d'encapsulation : on ne doit donc jamais accéder aux attributs des objets autrement que par des méthodes.

Pensez :

4e Etape: Photos et videos

On va maintenant créer deux sous-classes de la classe de base, l'une correspondant à une photo, l'autre à une vidéo. Ces classes pourraient bien sûr comprendre de nombreux attributs mais on va faire simple pour ne pas perdre de temps :

Ces deux classes devront être déclarées (et implémentées) dans des fichiers qui leurs sont propres pour obtenir une plus grande modularité et faciliter la réutilisation. Ces classes étant simples, on pourra les implémenter dans les headers si on le souhaite (auquel cas il ne doit pas y avoir de fichier .cpp).

Déclarer et implémenter les constructeurs, les accesseurs, les modifieurs et la méthode d'affichage (qui doit avoir la même signature que dans la classe parente, const compris s'il y en a un). Pour simplifier le code, on pourra éventuellement ne définir qu'un seul constructeur par classe grâce aux paramètres par défaut. N'oubliez pas d'initialiser les variables qui ont des types de base dans les constructeurs sinon leur valeur sera aléatoire (contrairement aux objets, qui sont initialisés automatiquement grâce à leurs constructeurs).

Enfin, déclarer et implémenter une méthode qui permette de jouer l'objet multimédia, c'est-à-dire, suivant le cas, d'afficher la photo ou de jouer la vidéo. Concrètement, cette fonction appellera un programme Unix (par exemple "mpv" pour une vidéo ou "imagej" pour une photo) via la fonction standard system(), exemple:

    system("mpv nom_du_fichier &");    // nom_du_fichier est le chemin complet
    
Pour créer l'argument de system() il suffit de concaténer les strings avec l'opérateur +, puis d'appeler la méthode c_str() de la string résultante pour la convertir en char * (car system() prend une char * en argument). N'oubliez pas le & afin de lancer le programme Unix en tâche de fond.

Comme pour la fonction d'affichage, la fonction pour jouer l'objet ne modifie pas l'objet et elle doit être déclarée dans les classes Photo et Video et dans la classe de base afin de permettre un appel polymorphique sur la hiérarchie de classes. Cependant, contrairement à la fonction d'affichage, elle ne peut pas avoir d'implementation au niveau de la classe de base (car a priori chaque type d'objet nécessite un utilitaire différent pour être joué). Comment appelle-t'on ce type de méthode et comment faut-il les déclarer ?

Modifier le Makefile si nécessaire (on rappelle qu'il ne faut pas mettre les .h dans SOURCES). Compiler, corriger les éventuelles erreurs et tester le programme. Si vous avez fait correctement ce qui précède, il ne sera plus possible d'instancer des objets de la classe de base. Pourquoi ?

5e Etape: Traitement générique (en utilisant le polymorphisme)

On veut maintenant pouvoir traiter génériquement une liste comprenant à la fois des photos et des vidéos. Pour ce faire créer dans main.cpp un tableau dont les éléments sont tantôt une photo tantôt une vidéo. Ecrire ensuite une boucle permettant d'afficher les attributs de tous les élements du tableau (ou de les "jouer"). Cette boucle n'a pas besoin de connaître le type des élements : elle doit pouvoir traiter de la même manière tous les objets dérivant de la classe de base.

Quelle est la propriété caractéristique de l'orienté objet qui permet de faire cela ? Qu'est-il spécifiquement nécessaire de faire dans le cas du C++ ? Quel est le type des éléments du tableau : est-ce que ce tableau contient les objets ou des pointeurs vers ces objets ? Pourquoi ? Comparer à Java.

Compiler, exécuter, et vérifier que le résultat est correct.

6e étape. Films et tableaux

On veut maintenant définir une sous-classe Film dérivant de la classe Video. Une particularité des films est qu'ils sont composés de plusieurs chapitres ce qui permet d'accéder rapidement à une partie du film. Pour ce faire on va utiliser un tableau d'entiers contenant la durée de chaque chapitre. Il serait en fait préférable d'utiliser un vecteur de la librairie C++ mais on va ne pas le faire dans cette question pour illustrer certaines difficultés que peuvent poser les pointeurs et tableaux en C et C++.

Ecrire la classe Film, qui doit avoir : Attention :

Que faut-il faire pour que l'objet Film ait plein contrôle sur ses données et que son tableau de durées des chapitres ne puisse pas être modifié (ou pire, détruit) à son insu ? (c'est l'objet qui doit pouvoir modifier ce qui lui appartient, pas les autres !)

Attention, le même problème se pose si un accesseur retourne directement ce tableau sans prendre les précautions nécessaires : la encore le contenu du tableau n'est pas récopié et l'appelant peut le modifier à sa guise. Quelle est la solution très simple que propose C/C++ pour éviter ce problème ?

Plus généralement, ces problèmes se posent chaque fois qu'une des variable d'instance d'un objet pointe sur un objet ou un tableau. La question est alors de savoir qui a la responsabilité du pointé.

Implementez votre classe et vérifiez que le resultat est correct en modifiant et/ou détruisant le tableau qui lui est passé en argument puis en appelant la fonction d'affichage de l'objet (NB: il faut répéter l'opération pour vérifier que c'est correct car les erreurs de mémoire sont en partie aléatoires).

7e étape. Destruction et copie des objets

Contrairement à Java ou C#, C/C++ ne gère pas la mémoire dynamique automatiquement (*) : comme il n'y a pas de ramasse miettes, tout ce qui a été créé avec new doit être détruit avec delete sinon on aura des fuites mémoires. Parmi les classes précédemment écrites quelles sont celles qu'il faut modifier afin qu'il n'y ait pas de fuite mémoire quand on détruit leurs instances ?

De même, la copie d'objets peut poser problème dans certains cas. Pourquoi et que faudrait-il faire ?

Modifiez le code de manière à éviter les fuites mémoire. Si vous n'avez pas pris de retard, modifiez également le code pour gérer la copie correctement (sinon dites juste ce qu'il faudrait faire). Faites quelques tests dans main() pour vérifier que tout se passe comme souhaité (créer, copier et détruire plusieurs objets).

(*) Note : on rappelle que contrairement à la mémoire dynamique (celle gérée par new et delete), la mémoire globale/static et la pile sont gérées automatiquement : les variables globales ou static sont détruites quand on sort du programme, les paramètres et variables locales des fonctions (pile) quand on sort de la fonction.

8e étape. Créer des groupes

On va maintenant créer une nouvelle classe servant à contenir un groupe d'objets dérivant de la classe de base. Un groupe peut contenir un ensemble d'objets similaires (e.g. un groupe pour toutes les photos et un autre pour toutes les vidéos) ou pas (e.g. un groupe pour les photos et vidéos de vacances).

Pour ce faire on va utiliser la classe template list< > de la librairie standard qui permet de créer une liste d'objets (dont il faut préciser la classe entre les < >). Notez qu'il s'agit d'utiliser une classe template existante, pas d'en créer une nouvelle.

Deux stratégies sont possibles :

La première stratégie nécessite de définir des méthodes dans la classe groupe pour gérer la liste. La seconde stratégie évite ce travail car elle permet d'hériter des méthodes de list. Elle est donc plus rapide à implémenter mais offre moins de contrôle (on ne choisit pas les noms des méthodes comme on veut et on hérite de toutes les méthodes de list y compris certaines qui sont peut-être inutiles ou pas souhaitables).

Pour aller plus vite, écrivez cette classe en utilisant la seconde stratégie. Définir les méthodes suivantes:

Le groupe ne doit pas détruire les objets quand il est détruit car un objet peut appartenir à plusieurs groupes (on verra ce point à la question suivante). On rappelle aussi que la liste d'objets doit en fait être une liste de pointeurs d'objets. Pourquoi ? Comparer à Java.

Pour tester, créez quelques groupes dans main() en les peuplant de photos, videos ou films et en faisant en sorte que des objets appartiennent à plusieurs groupes. Appelez la fonction d'affichage des groupes pour vérifier que tout est OK.

9e étape. Gestion automatique de la mémoire

Comme on l'a vu aux étapes 6 et 7, la gestion de la mémoire dynamique (celle allouée avec new en C++ et malloc() en C) est délicate. On risque en effet, soit de se retrouver avec des pointeurs pendants parce que l'objet qu'ils pointaient à été détruit ailleurs (cf. étape 6), soit avec des fuites mémoires parce l'on n'a pas détruit des objets qui ne sont plus pointés nulle part (cf. étape 7).

Les pointeurs pendants sont une source majeure de plantages ! Les fuites mémoires posent surtout problème si les objets sont gros (e.g. une image 1000x1000) et/ou si le programme s'exécute longtemps (e.g. un serveur Web tournant en permanence). On peut alors rapidement épuiser toute la mémoire disponible (noter cependant que la mémoire allouée de manière standard est toujours récupérée à la terminaison du programme).

Le ramasse miettes de Java et les les smart pointers avec comptage de références de C++ offrent une solution simple à ce problème : les objets sont alors automatiquement détruits quand plus aucun (smart pointer) ne pointe sur eux. Il ne faut donc jamais detruire avec delete un objet pointé par un smart pointer !

Le but de cette question est de remplacer les raw pointers (les pointeurs de base du C/C++) par des smart pointers non intrusifs dans les groupes de la question précédente. Les objets seront alors automatiquement détruits quand ils ne seront plus contenus par aucun groupe. Pour ce faire, utilisez les shared_ptr< > qui sont standard en C++11. Afin de vérifier que les objets sont effectivement détruits, modifiez leurs destructeurs de telle sorte qu'ils affichent un message sur le Terminal avant de "décéder".

Remarques

Enlevez des objets des groupes et vérifiez qu'ils sont effectivement détruits quand ils n'appartiennent plus à aucun groupe (et s'ils ne sont plus pointés par aucun autre smart pointer : noter que si p est un smart pointer p.reset() fait en sorte qu'il ne pointe plus sur rien)

10e étape. Gestion cohérente des données

On va maintenant créer une classe qui servira à fabriquer et manipuler tous les objets de manière cohérente. Elle contiendra deux variables d'instance:

Afin de permettre de retrouver efficacement les éléments à partir de leur nom, ces tables ne seront pas des tableaux ni des listes mais des tables associatives (cf. la classe template map). A chaque élement sera associé une clé de type string qui sera, suivant le cas, le nom de l'objet ou du groupe. Commencez tout d'abord par écrire cette classe, comme d'habitude dans un nouveau fichier. Comme précédemment, on utilisera les smart pointers afin de gérer la mémoire automatiquement.

Déclarer et implémenter des méthodes adéquates pour : Pour implémenter ces méthodes vous aurez probablement besoin des méthodes suivantes : map::operator[] (pour l'insertion), map::find() et map::erase(). Faites ensuite quelques essais dans main.cpp pour vérifier que ces méthodes fonctionnent correctement.

Les méthodes précédentes permettent d'assurer la cohérence de la base de données car quand on crée un objet on l'ajoute à la table adéquate. Par contre, ce ne sera pas le cas si on crée un objet directement avec new (il n'appartiendra à aucune table). Comment peut-on l'interdire, afin que seule la classe servant à manipuler les objets puisse en créer de nouveaux ?

Question additionnelle (vous pouvez passer cette question si vous êtes en retard) :

11e étape. Client / serveur

Cette étape vise à transformer votre programme C++ en un serveur qui communiquera avec un client qui fera office de télécommande. Dans cette question le client permettra d'envoyer des commandes textuelles. Plus tard, dans le TP suivant, vous réalisez une interface graphique Java Swing qui interagira avec le serveur de la même manière. Dans la réalité le serveur tournerait sur la set-top box et le client sur un smartphone ou une tablette.

Récuperez ces fichiers. Ils comprennent un client et un serveur ainsi que des fichiers utilitaires qui servent à faciliter la gestion des sockets. Pour les compiler il faut taper make -f Makefile-cliserv dans le Terminal. Lancez d'abord le serveur, puis (depuis un autre Terminal sur la même machine) le client et regardez le code correspondant.


Le client et le serveur utilisent les classes "maison" Socket, ServerSocket et SocketBuffer qui permettent de faciliter l'utilisation des sockets Unix : Noter aussi que :
A vous de jouer :

En vous inspirant de server.cpp, adaptez votre propre programme pour qu'il joue le rôle du serveur de la set-top box et appelle les fonctions adéquates chaque fois qu'il recoit une requête du client. Pour ce faire il faudra définir un protocole de communication simple entre votre serveur et le client (client.cpp que l'on remplacera plus tard par une interface Java Swing). Il faut au moins pouvoir:

Remarques

Questions additionnelles (vous pouvez passer ces questions si vous êtes en retard) :

12e étape. Sérialisation / désérialisation

C++ ne propose pas en standard de moyen de sérialiser les objets de manière portable. On peut utiliser des extensions pour le faire (par exemple Cereal, Boost ou le toolkit Qt) ou juste l'implémenter "à la main" dans les cas simples. C'est ce que l'on va faire maintenant pour les tables d'objets multimédia. Notez qu'il est avantageux d'implémenter en même temps les fonctions d'écriture et de lecture, ces deux fonctionnalités dependant l'une de l'autre.

Ecrivez toutes les méthodes nécessaires en vous inspirant du cours puis testez les dans main() en sauvegardant puis en relisant la table d'objets multimédia (on laisse de côté les groupes). On rappelle:

Questions additionnelles

Remarques

La librairie standard de C++ fournit un moyen de récupérer les noms des classes via la fonction typeid() mais leur format dépend de l'implémentation. Avec g++ ce nom est encodé (par exemple N7Contact8Address2E pour Contact::Address) ce qui n'est pas très "user-friendly", ni très portable. La solution la plus simple est donc de faire comme conseillé plus haut.

Cependant, il existe des extensions dépendantes des implémentations qui permettent d'obtenir les noms décodés. Par exemple avec g++ on peut faire:

  #include <cxxabi.h>

  bool demangle(const char* mangledName, std::string& realName) {
      int status = 0;
      char* s = abi::__cxa_demangle(mangledName, NULL, 0, &status);
      realName = (status == 0) ? s : mangledName;
      free(s);
      return status;
  }

  std::string realname;
  demangle(typeid(Photo).name(), realname);
  cout << realname << endl;
		      
  ptr<Photo> p;
  demangle(typeid().name(), realname);
  cout << realname << endl;
  

13e étape. Traitement des erreurs

La fiabilité des programmes repose sur la qualité du traitement des erreurs en cours d'exécution. Il faut en effet éviter de produire des résultats incohérents ou des plantages résultant de manipulations erronées. Jusqu'à présent nous avons négligé cet aspect dans les questions précédentes.

Exemples: Il existe essentiellement deux stratégies pour traiter les erreurs. La première consiste à retourner des codes d'erreurs (ou un booléen ou un pointeur nul) lorsque l'on appelle une fonction pouvant produire des erreurs. La seconde consiste à générer des exceptions.

Dans le premier cas (codes d'erreurs), il est souhaitable que la fonction réalise une action "raisonnable" en cas d'erreur (par exemple ne rien faire plutôt que planter si on demande de supprimer un objet qui n'existe pas !). Cette solution peut poser deux problèmes:

La seconde solution (exceptions) est plus sûre dans la mesure où les erreurs doivent être obligatoirement traitées via une clause catch sous peine de provoquer la terminaison du programme (ou d'interdire la compilation en Java). Cependant, une utilisation trop intensive des exceptions peut compliquer le code et rendre son déroulement difficile à comprendre.

A vous de jouer ! Gerez les principaux cas d'erreurs comme bon vous semble, en utilisant la première ou la seconde stragégie, ou une combinaison des deux suivant la sévérité des erreurs. Mais faites en sorte que votre code soit cohérent par rapport à vos choix et justifiez les dans le rapport et/ou la documentation générée par Doxygen.

Remarque: pour créer de nouvelles classes d'exceptions en C++ il est préférable (mais pas obligatoire) de sous-classer une classe existante de la bibliothèque standard. L'exception runtime_error (qui dérive de la classe exception) est particulièrement appropriée et son constructeur prend en argument un message d'erreur de type string. Ce message pourra être récupéré au moment de la capture de l'exception grâce à la méthode what() (cf. l'exemple au bas de cette page).

Suite

La suite de ce TP (télécommande en Java Swing) est disponible ici.




Eric Lecolinet - http://www.telecom-paristech.fr/~elc - Telecom ParisTech