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).
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.
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++).
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 !
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: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.
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 completPour 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:
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 ?
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.
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 :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 :
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.
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.
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.
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".
Remarquesusing TrucPtr = std::shared_ptr<Truc>; typedef std::shared_ptr<Truc> TrucPtr;
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)
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:
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) :
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 crée une Socket qu'il connecte au serveur via la méthode connect(). Celle-ci précise à quelle machine et à quel port il faut se connecter. Par defaut le port est 3331 (le même que pour le serveur) et la machine est 127.0.0.1, ce qui signifie que le client doit tourner sur la même machine que le serveur. La méthode connect() renvoie 0 si la connexion réussit et une valeur négative en cas d'erreur.
S'il n'y a pas de firewall bloquant les connexions le client peut tourner sur une autre machine à condition de mettre l'adresse de la machine du serveur à la place de 127.0.0.1.
Si la connexion est réalisée, le client lance une boucle infinie (pour quitter, taper ^C ou ^D) qui demande une chaîne de cacatères à l'utilisateur puis l'envoie au serveur via la méthode writeLine(). Le client bloque jusqu'à la réception de la réponse retournée par le serveur qui est lue par la méthode readLine().
Côté serveur, une lambda est donnée en argument à TCPServer(), le constructeur du serveur. Cette lambda sera appelée chaque fois que le serveur recevra une requête du client. La lambda recupère la requête via son argument request, effectue un traitement, puis retourne une réponse vers le client via son argument response.
Pour l'instant cette fonction se contente de copier dans response "OK:" suivi de la valeur de request. C'est cette fonction qu'il vous faudra adapter pour qu'elle fasse le traitement voulu.
Note : on rappelle que les lambdas peuvent capturer les variables de la fonction où elles sont appelées (et le pointeur caché this des objets).
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) :
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,
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:
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;
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: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).
La suite de ce TP (télécommande en Java Swing) est disponible ici.