TD2 - Object-oriented design **************************** .. include:: ../BoutonGoogleTrad.rst Ce TD a pour objectif de vous faire concevoir le modèle objet le plus adapté pour répondre à une problématique technique. La quantité de code à produire n'est pas forcément importante, cependant il va falloir étudier en profondeur les différentes choix de modélisation pour trouver le plus adéquat. Il est tout à fait normal de tâtonner et d'essayer plusieurs options. Il est encore tout à fait normal que ces options s'avèrent infructueuses pour diverses raisons. La conception d'un modèle objet adapté à un besoin spécifique nécessite plusieurs essais. Nous vous proposons dans le cadre de ce TD d'effectuer cette exploration de manière guidée. Par soucis de simplification, nous travaillons avec une classe matrice minimale : * Pas d'utilisation de template * Taille fixe (3x3 ou 2x2) * Utilisant des valeurs entières * Avec 1 constructeur sans arguments * Avec 1 constructeur avec une liste de valeurs * Un couple de Getter / Setter * Sans opérateurs arithmétiques Les structures de données sous-jacentes sont laissées à votre libre choix. Le contexte =========== Dans le domaine des réseaux de neurones profonds (deep-learning) ou des calculs appliquées (simulation physique, rendu 3D..), on utilise des matrices de très grande taille. La quantité de données est si importante (plusieurs Gigas) qu'il faut faire très attention pour arriver à stocker l'intégralité des données en mémoire vive. Dans ce contexte, on cherche à limiter la consommation de la mémoire pour ces deux opérations : * Copie d'une matrice * Construction de la transposée d'une matrice Voici un code exemple : .. code-block:: matrice M({ ...}); auto T = M.transpose(); auto E = T.copy(); Ces deux opérations, sans effort particulier, entraînent la duplication des données de la matrice *M*, triplant l'espace mémoire utilisé ! Nous devons donc mettre en place un système garantissant que ces deux opérations n'allouent pas de mémoire supplémentaire. Un de vos camarades a utilisé la librairie *Numpy* de calcul matriciel. Il explique que cette librairie a mis en place une solution basée sur les **vues** : une *vue* correspond à une matrice ne contenant pas de données propres. Toutefois, elle présente la même interface, avec les mêmes accesseurs (getter/setter), de sorte que l'utilisateur ne perçoit aucune différence entre la vue et la matrice originale. Comme elle ne possède pas de données propres, la *vue* permet d'éviter la surconsommation de mémoire. Cahier des charges ================== Voici la liste des demandes : * Chaque objet doit fournir des getter/setter * Depuis un objet *M*, on doit fournir deux méthodes permettant de retourner : * La transposée de *M* * Une copie de *M* Les objets retournés par ces deux fonctions ne doivent pas dupliquer de données. L'optimisation de la mémoire reste la priorité absolue. .. warning:: Nous vous présentons ci-dessous deux choix d’implémentation, probablement ceux auxquels vous penserez en premier. Nous tenons à les introduire, car ils conduisent à des écueils ; cela vous permettra de comprendre plus rapidement pourquoi. Trouver la bonne modélisation est très difficile et demande une véritable remise en question de l’énoncé de départ. Proposition 1 ============= Un de vos camarades fait une première proposition : la classe *matrice* étant déjà programmée, il suffit de créer la classe *vue* par dérivation de cette dernière et de lui ajouter un lien pointant vers les données de la matrice originale. Cette approche soulève un premier problème : la classe *vue* héritant de *matrice*, elle contient aussi un champ de données et même s'il n'est pas utilisé, il prend de la place en mémoire. Pour résoudre ce problème, votre camarade propose alors d'utiliser des *vector* plutôt que des *array*, l'avantage étant qu'en l'absence de données, l'objet *vector* vide prendra peu de place en mémoire. Un autre camarade fait remarquer que l'on pourrait inverser le sens de l'héritage. Tout d'abord, on met en place une classe *Vue* sans champ de données et ensuite une classe *Matrice* héritant de *Vue* et augmentée d'un container de données quelconque. Un dernier camarade conclut : si l'héritage n'a rien d'évident, alors faisons une unique classe *VueMatrice* qui peut prendre le rôle de l'une ou de l'autre classe en fonction d'un attribut interne. Voici ci-dessous un résumé de ces trois propositions : .. image:: vuematrice.png :align: center :scale: 60% Voici un grand moment d'hésitation : qui doit hériter de qui et doit-on se passer de l'héritage ? Il est difficile de savoir et aucun argument ne semble plus convaincant qu'un autre : * Dans le premier scénario, la classe *Vue* contient un champ de données vide qu'on ne doit pas utiliser, c'est un peu bancal. * Dans le deuxième, la classe matrice fille contient un lien vers une autre matrice, lien qui ne doit pas être utilisé. * Le troisième scénario s'avère en fait similaires aux précédents, l'héritage a juste été masqué par l’utilisation d'un test: *If (ViewClass) {...} else {...}* Cas pratique ============ Nous considérons le code suivant : .. code-block:: vue test() { Matrice M({ ...}); Vue V = M.copy(); return V; } Quelle que soit l'option choisie pour modéliser la classe *Matrice* et la classe *Vue*, nous avons un problème majeur : * Lorsque l'on atteint la fin de la fonction *test()*, la matrice *M* est détruite car c'est un objet local. Le champ de données contenu dans *M* est donc aussi détruit dans la foulée. La vue *V* retournée par cette fonction est donc associée à un objet détruit. Le fait que *V* dispose d'un smart pointeur vers *M*, ne change rien au problème. Nous nous permettons de vous donner un dernier indice : dans les modélisations proposées, un de nos problèmes est qu'il y a redondance : .. image:: resume.png :align: center :scale: 60% En effet, la classe *Matrice* : * Gère les données * Propose une interface de getter/setter et la classe *Vue* : * Propose une interface de getter/setter S'il est tout à faice concevable que la classe *Matrice* dispose des données et pas la classe *Vue*, il est plus étrange que ces deux classes proposent la même interface. En effet, cette redondance crée de la confusion alors qu'en POO, on cherche plutôt à ce que chaque classe tienne un rôle bien identifié. Grâce à cette dernière remarque, essayez de trouver une modélisation répondant facilement à tous nos besoins. .. dropdown:: Encore un dernier indice Dès le début, la description de la classe *Vue* et de la classe *Matrice* nous a induit en erreur en nous poussant à nous conformer à l'énoncé et à mettre en place ces deux entités. Hors, ça fonctionne mal. L'indice ci-dessus suggère que chaque classe devrait gérer son job et uniquement son job. Cela sous-entend qu'il nous faudrait une classe *Data* pour stocker les données et une classe *Interface* fournissant les getter/setter et autres fonctions. Cela est très perturbant, car dans la bataille, la classe *Matrice* a disparu ! Et que va t'il se passer lorsque l'on crée un objet matrice maintenant ? Scénarios de tests ================== Vous êtes maintenant en chemin pour proposer un modèle objet fonctionnel. Pour être sur que vous avez fait le bon choix, voici plusieurs situations complexes que votre modèle doit pouvoir gérer. Dissociation ------------ On va s’intéresser ici au cas suivant : * Création d'une matrice *M1* * Création d'une copie *M2* de *M1* Si une valeur de *M1* ou de *M2* est modifiée, alors chaque objet devient indépendant. La modification ne porte que sur l'objet modifié, pas sur l'autre. .. code-block:: Matrix M1({1,2,3,4}); auto M2 = M1.copy(); M2(0,0) = 8; cout << M1; cout << M2; >> 1,2 >> 3,4 >> 8,2 >> 3,4 On peut complexifier ce cas en considérant un objet *M1* et deux de ses copies *M2* et *M3*. La modification d'un de ces objets déclenche sa dissociation du groupe. .. code-block:: Matrix M1({1,2,3,4}); auto M2 = M1.copy(); auto M3 = M1.copy(); M2(0,0) = 8; cout << M1; cout << M2; cout << M3; >> 1,2 >> 3,4 >> 8,2 >> 3,4 >> 1,2 >> 3,4 En mémoire, il y a seulement 2 containers de données, un pour le groupe *M1/M3* et l'autre pour *M2*. Survie ------ Supposons que *M2* soit une copie de *M1* et que *M1* soit détruite, *M2* doit lui survivre et l'accès aux valeurs de *M2* ne doit pas provoquer d'erreur. .. code-block:: Matrix test() { Matrix M1({1,2,3,4}); auto M2 = M1.copy(); return M2; } int main() { auto M3 = test(); M3.print(); } >> 1,2 >> 3,4 Cascadage --------- On pense ici au cas où l'on crée une copie *M2* d'un objet *M1* et une copie *M3* depuis *M2* et ainsi de suite... A la fin des ces opérations, les données doivent être représentées une seule fois en mémoire. .. code-block:: Matrix M1({1,2,3,4}); auto M2 = M1.copy(); auto M3 = M2.copy(); auto M4 = M3.copy(); Nous soulevons une question : comment organiser ces dépendances ? Lorsque nous cascadons plusieurs copies, au moment où l'on appelle la fonction *get()* de *M10*, va-t-elle appeler la fonction *get* de *M9* qui à son tour va propager sa demande vers *M8* et ainsi de suite ? Cet empilement d'appels engendrerait une perte de performance notable : 10 copies imbriquées impliqueraient 10 appels à la fonction *get* pour résoudre le *get* initial. Il faut éviter cette situation. Etape 1 : mise en place ======================= * Codez votre modélisation avec une classe offrant : * Des accesseurs * La fonction *copy()* * Validez votre implémentation en testant les scénarios : dissociation, survie et cascadage Etape 2 : transposée ==================== Mise en place de la transposée ------------------------------ On peut remarquer que la transposée d'une transposée amène à la situation initiale. Ainsi, on peut gérer simplement ce cas en indiquant si la vue courante correspond à une transposition. Les accesseurs peuvent tenir compte de cette information pour accéder à la bonne cellule de la matrice. * Ajoutez la gestion de la transposée * Les cas connexes, comme la dissociation d'une transposée, doivent générer une matrice valide * Validez en ajoutant des scénarios de test Etape 3 : en mode pro ===================== Nous avons rempli les objectifs demandés, mais nous devons penser aux utilisateurs. En effet, même si notre modélisation fonctionne correctement, nous n'avons pas traité toutes les syntaxe possibles et si l'on tient à faire un travail professionnel, il faut que la classe *Matrice* propose suffisamment d'options pour couvrir toutes les syntaxes habituelles. Constructeur de recopie ----------------------- Voici sa syntaxe : .. panels:: :column: col-lg-5 p-2 | **Syntaxe du constructeur de recopie** : | | *Matrice(const Matrice& other)* Ce constructeur est appelé lorsque l'utilisateur écrit le code suivant : .. code-block:: Matrice S(M); Matrice T = M; Commentaire : * Ligne 1 : La première ligne est évidente puisqu'elle correspond à la syntaxe associée au constructeur de recopie : un objet de même type est transmis au constructeur pour créer un nouvel objet qui correspondra à une copie. * Ligne 2 : La deuxième ligne est moins évidente. En effet, en voyant un signe =, on aurait tendance à penser que c'est un opérateur d'affectation qui sera appelé. Cependant, il n'en est rien. Comme l'objet *T* n'existe pas encore et qu'il est créé à cette ligne, c'est bien une construction depuis un objet existant *M* qui s'opère, d'où l'appel au constructeur de recopie. **Travail à effectuer** : Ajoutez un constructeur de recopie à votre classe *Matrice* et testez le. Opérateur d'affectation ----------------------- Prenons un exemple : .. code-block:: A = B; Lorsque la ligne *A=B* est exécutée, il ne s'agit pas d'une construction puisque l'objet *A* existe déjà. Les ressources de *A* sont ainsi réutilisées pour recopier les données de *B*. Il y a donc appel de l'opérateur d'affectation pour gérer ce cas : .. panels:: :column: col-lg-5 p-2 | **Syntaxe de l'opérateur d'affectation** : | | *Matrice& operator=(const Matrice& other)* **Travail à effectuer** : Ajoutez un opérateur d'affectation à votre classe matrice et testez le. Travail à rendre sur Github Classroom ===================================== * Tout le code doit contenir dans un seul fichier * Tous les tests demandés doivent être présents et actifs depuis le *main()* * Renommez votre fichier *FINAL_View.cpp* * Uploadez le fichier sur votre espace github **Ce projet est évalué et compte dans votre note finale**. Tout code généré automatiquement ou ne respectant pas les consignes rapporte 0 point.