INF224
Travaux Pratiques C++/Objet

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

Liens utiles

Préambule

Objectif

Le but de ce TP est de créer l'ébauche du logiciel d'une set-top box multimédia permettant de jouer 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. 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).

Rendu

Le rendu aura lieu en fin d'UE. Les deux parties (ce TP) et la partie Java/Swing seront rendues en même temps (car elles sont liées). Les instructions pour rendre le TP se trouvent à la fin du TP Java/Swing.

Un README décrivant brièvement votre travail vous sera demandé aisni qu'un Makefile (cf. plus bas). 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 commentaire ou, mieux, entre #ifdef VERSION_TRUC et #endif, comme expliqué dans le TP.

Environnement de développement (IDE)

Il est indispensable d'utiliser un outil approprié pour développer du code C++. Il doit permettre : 1) d'afficher le code correctement (avec coloriage syntaxique), 2) de compiler dans l'IDE, 3) (si possible) de déboguer dans l'IDE.

Certains environnements n'offrent pas les fonctionnalités 2) et 3), souvent parce qu'ils sont mal configurés. Si c'est un choix personnel, faites comme bon vous semble. Sinon, je vous encourage vivement à changer d'IDE. Les messages des compilateurs C++ sont souvent longs, nombreux, et pas toujours limpides, ce qui nécessite de pouvoir lire le code en même temps que les erreurs. Un IDE adapté vous fera gagner un temps précieux !

Sur les machines de l'école (ou la votre si vous l'installez) vous pouvez par exemple utiliser QtCreator, qui a l'avantage d'être compatible avec Makefile. Il est accessible à l'école depuis le menu Développement ou depuis le Terminal en tapant la commande qtcreator &. Les autres outils ne sont pas nécessairement configurés pour du C++.

Si vous voulez travailler sur votre machine, pensez à installer l'IDE à l'avance car cette opération peut prendre pas mal de temps. Pensez aussi à installer le package C++ (e.g. avec Visual Studio) et attention aux versions (e.g. Eclipse pour Java vs. Eclipse pour C++).

Makefile

Le problème avec les IDEs, c'est qu'ils ne sont pas portables (il faut avoir l'IDE sur sa machine pour recompiler). Nous vous demandons donc de créer également un Makefile. Ce fichier permettra de tout compiler et même de lancer le programme en tapant make run dans le Terminal. Makefile est standard sous Unix et Mac et disponible sous Windows.

Attention un programme sans Makefile sera considéré non rendu car nous ne pourrons pas recompiler votre programme !

TP

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. Ne pas faire de coupé collé, mais utiliser la commande adéquate du navigateur (généralement dans le menu contextuel).
  5. C'est prêt, vous pouvez lancer l'IDE et importer main.cpp.
Pour créer un projet Makefile compatible 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 plus tard en cliquant sur "Ouvrir un fichier ou projet..." dans le menu "Fichier" (ou, avec certains environnements, juste en double-cliquant sur le fichier .creator).

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 std::ostream, c'est-à-dire un flux de sortie. Ceci vous permettra d'utiliser la même fonction pour afficher sur le Terminal, dans un fichier ou dans 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 une fonction main(). Cette fonction ne doit pas se trouver dans l'implémentation d'une classe car ceci interdirait sa réutilisation. 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 par la suite) respecter le principe d'encapsulation: on ne doit jamais accéder aux variables 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 comprendre de nombreux attributs mais on va faire simple pour ne pas perdre de temps :

Ces deux classes devront être déclaré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 (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). 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 autre programme (par exemple, sous Linux, "mpv" pour une vidéo ou "imagej" pour une photo) via la fonction standard system(), exemple:

    system("mpv path &");    // path 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 data() de la string résultante pour la convertir en char * (car system() prend une char * en argument). N'oubliez pas le & afin de lancer l'autre programme en tâche de fond.

Comme pour la fonction d'affichage:

Contrairement à la fonction d'affichage:

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 ce qui précède comme demandé, il ne sera plus possible d'instancer des objets de la classe de base. Pourquoi ?

5e Etape: Traitement uniforme (en utilisant le polymorphisme)

On veut maintenant pouvoir traiter de manière uniforme une liste comprenant à la fois des photos et des vidéos sans avoir à se préoccuper de leur type.

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 doit traiter tous les objets dérivant de la classe de base de la même manière.

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. La principale différence est que les Films comporteront des chapitres permettant 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.

Ecrire la classe Film, qui doit avoir : Notes :

Connaissant ce qui précède, la question est de savoir comment le modifieur doit stocker le tableau dans l'objet Film. Considérez les points suivants :

De même, réflechissez à ce que l'accesseur doit retourner. il ne doit en effet pas permettre à l'appelant de modifier le contenu du tableau (ce qui romprait également le principe d'encapsulation).

Implementez votre classe et vérifiez que le resultat est correct en modifiant et/ou détruisant le tableau qui est passé en argument puis en appelant la fonction d'affichage de l'objet (NB: il faut répéter ces opérations de sorte que la mémoire soit réutilisée).

Remarque : ces questions ne sont pas propres aux tableaux, elles se posent chaque fois qu'un modifieur a en argument un pointeur ou une référence. La question est en fait de savoir qui possède et contrôle les données.

7e étape. Destruction et copie des objets

Contrairement à Java, C/C++ ne gère pas la mémoire dynamique automatiquement : comme il n'y a pas de ramasse miettes, ce qui est créé avec new occupe de la mémoire jusqu'à la terminaison du programme, sauf si on le detruit avec delete. (Remarque : ce probleme ne se pose qu'avec ce qui est créé avec new, delete ne doit jamais être utilisé dans un autre cas).

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 les objets ? Modifiez le code de manière à l'éviter.

La copie d'objet peut également poser problème quand ils ont des variables d'instance qui sont des pointeurs. Quel est le problème et quelles sont les solutions ? Implementez-en une.

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 std::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 std::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 std::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, qui comprennent un client et un serveur ainsi que des utilitaires qui facilitent la gestion des sockets (le client est constitué des fichiers client.cpp, ccsocket.cpp ; le serveur des fichiers server.cpp, tcpserver.cpp, ccsocket.cpp). Pour les compiler vous pouvez taper : make -f Makefile-cliserv dans le Terminal.

Le serveur doit être lancé en premier et terminé en dernier.


Le client et le serveur utilisent les classes Socket, ServerSocket et SocketBuffer qui permettent de faciliter l'utilisation des sockets : Noter 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

Remarque: 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 et n'est donc pas portable (par exemple N7Contact8Address2E pour Contact::Address avec g++) La solution la plus simple est donc de faire comme conseillé plus haut. Il existe aussi des extensions pour décoder les noms, par exemple avec g++ :

  #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