[T.P] Un gestionnaire de discographie

Depuis le début de ce cours, nous avons eu l’occasion de découvrir de nombreux concepts, que l’on a pratiqués lors des exercices et des mini-T.P.

Dans ce chapitre, nous allons pour la première fois attaquer un gros T.P, dont le but va être d’utiliser toutes les notions acquises jusqu’à présent pour produire un programme complet et fonctionnel. C’est votre baptême du feu, en quelque sorte. Mais ne vous inquiétez pas, on va y aller pas à pas. ;)

L'énoncé

Lors de ce T.P, vous allez créer un gestionnaire de discographie en ligne de commande. C’est un logiciel qui va permettre d’ajouter des morceaux avec album et artiste à votre discographie. Il donnera alors la possibilité d’afficher votre discographie de plusieurs manières différentes, ainsi que de l’enregistrer et de la charger à partir de fichiers.

Toutes les actions devront être faisables par l’utilisateur en ligne de commande. Il aura une petite invite de commande attendant qu’il rentre ses ordres.

> 

Ajout de morceaux

Pour ajouter un morceau à la discographie, l’utilisateur le fera simplement en tapant une commande de cette forme :

> ajouter morceau | album | artiste

Les informations sont optionnelles. Par exemple, l’utilisateur doit pouvoir taper ça :

> ajouter morceau | album

Ou ça :

> ajouter morceau | | artiste

Ou encore ceci :

> ajouter morceau

Et même ceci :

> ajouter

Lorsque des informations sont omises, la discographie enregistrera le morceau avec les mentions « Morceau inconnu », « Album inconnu » ou « Artiste inconnu ».

Affichage de la discographie

L’utilisateur doit pouvoir afficher sa discographie de trois manières différentes, que l’on va illustrer par un exemple. Les commandes d’affichage commenceront toutes par le même mot.

> afficher

Prenons pour exemple la discographie suivante.

Morceau Album Artiste
Bad Bad Michael Jackson
Bloody Well Right Crime of the Century Supertramp
It’s Raining Again …Famous Last Words… Supertramp
Buffalo Soldier Confrontation Bob Marley and the Wailers
The Way You Make Me Feel Bad Michael Jackson

Affichage par ordre alphabétique des morceaux

L’utilisateur pourra taper > afficher morceaux, auquel cas le programme devra lui afficher la liste des morceaux par ordre alphabétique.

> afficher morceaux
--> Bad | Bad | Michael Jackson
--> Bloody Well Right | Crime of the Century | Supertramp
--> Buffalo Soldier | Confrontation | Bob Marley and the Wailers
--> It's Raining Again | ...Famous Last Words... | Supertramp
--> The Way You Make Me Feel | Bad | Michael Jackson

Affichage par album

Si l’utilisateur tape > afficher albums, le programme devra lui répondre avec la liste des albums, avec une sous-liste des morceaux de ces albums, le tout étant trié par ordre alphabétique.

> afficher albums
--> Bad | Michael Jackson
   /--> Bad
   /--> The Way You Make Me Feel
--> Confrontation | Bob Marley and the Wailers
   /--> Buffalo Soldier
--> Crime of the Century | Supertramp
   /--> Bloody Well Right
--> ...Famous Last Words... | Supertramp
   /--> It's Raining Again

Affichage par artiste

C’est le même principe : si l’utilisateur tape > afficher artistes, le programme répondra une liste des auteurs, avec une sous-liste d’albums contenant elle-même une sous-liste de morceaux.

> afficher artistes
--> Michael Jackson
   /--> Bad
       /--> Bad
       /--> The Way You Make Me Feel
--> Bob Marley and the Wailers
   /--> Confrontation
       /--> Buffalo Soldier
--> Supertramp
   /--> Crime of the Century
       /--> Bloody Well Right
--> Supertramp
   /--> ...Famous Last Words...
       /--> It's Raining Again

Bien sûr, si vous voulez adapter un peu l’affichage pour qu’il vous plaise plus, vous pouvez. Après tout, l’un des intérêts de savoir programmer, c’est de pouvoir faire des programmes qui nous plaisent !

Enregistrement et chargement d’une discographie

L’utilisateur doit pouvoir enregistrer sa discographie sur le disque, pour pouvoir la sauvegarder lorsqu’il arrête le programme. Il doit aussi pouvoir la charger depuis un fichier. Ces deux fonctionnalités s’utiliseront à travers deux commandes.

> enregistrer nom_fichier
> charger nom_fichier

Quitter le programme

Lorsqu’il souhaite quitter le programme, l’utilisateur aura simplement à taper la commande qui suit.

> quitter

Gestion des erreurs

Le programme devra être robuste aux erreurs de saisie : si l’utilisateur tape une commande invalide, le programme ne devra pas crasher ou avoir un comportement bizarre. Il doit ignorer la commande et signaler à l’utilisateur que la commande est invalide.

> afficher qssqdqs
Erreur : Commande invalide.

Le programme devra aussi être capable de gérer les erreurs dues à l’impossibilité d’ouvrir un fichier, que ce soit en lecture ou en écriture.

Dernières remarques

Ce T.P est votre première confrontation à la conception logicielle. La difficulté ne réside plus seulement dans la compréhension et l’utilisation du langage, mais aussi dans la conception : il va vous falloir réfléchir à comment organiser votre code.

L’objectif est que vous réfléchissiez à une manière d’avoir un code propre, robuste, agréable à lire, ouvert aux évolutions, et bien sûr fonctionnel.

C’est une tâche difficile, mais rassurez-vous : comme c’est votre première fois, nous vous proposons un guide pour pouvoir avancer dans le T.P. Prenez cependant le temps de réfléchir et cassez-vous la tête sur le T.P sans utiliser le guide. Cela vous confrontera à la difficulté et vous fera progresser. Ensuite, vous pourrez lire le guide pour vous aiguiller vers une solution. Et comme d’habitude, on vous proposera un corrigé complet à la fin.

Guide de résolution

Analyse des besoins

Pour concevoir un programme efficacement, il faut analyser les besoins auxquels il doit répondre — autrement dit les tâches qu’il doit être capable de réaliser — pour arriver à des problèmes plus atomiques, auxquels on peut répondre directement par un morceau de code, une fonction, une structure de données…

Ici, les besoins sont définis par l’énoncé ; on va donc partir de là et les découper en petits problèmes.

Un interpréteur de commande

Déjà, l’utilisateur donne ses instructions au moyen d’une ligne de commande. Notre logiciel doit donc être capable de lire et comprendre les commandes de l’utilisateur, ainsi que de détecter les commandes invalides.

Il y a plusieurs types de commande : les commandes d’ajout de morceau, les commandes de sauvegarde/chargement de la bibliographie, et bien sûr les commandes d’affichage. Celles-ci se décomposent ensuite en plusieurs modes d’affichage. On a donc deux tâches distinctes.

  • Lire et interpréter les commandes.
  • Exécuter les commandes.

On va avoir une fonction d’exécution pour chaque type de commande, et des fonctions qui se chargeront de lire les commandes et d’appeler la fonction d’exécution adéquate ; ce seront nos fonctions d’interprétation. Nous reviendrons un peu plus tard sur l’implémentation, mais on a déjà une idée du schéma de fonctionnement global.

Le besoin de stocker la discographie

Évidemment, il nous faut un moyen de mémoriser la discographie, que ce soit pendant l’exécution du programme (stockage en mémoire de travail dans une variable) ou sur le long terme (stockage sur disque dans un fichier). On dégage donc ici deux besoins techniques :

  • Le premier est de pouvoir représenter la bibliographie dans notre programme ; en C++, cela revient à peu de choses près à définir un type adapté. C’est par cet aspect que nous rentrerons en premier dans les détails techniques ; le choix de commencer par là est pertinent car cette représentation est centrale et sera omniprésente dans le reste du code.
  • Le second est de réussir à sauvegarder et charger cette représentation dans un fichier ; on doit entre autres choisir une représentation de la bibliothèque sous un format texte.

Le besoin de gérer les erreurs d’entrée

L’énoncé explique qu’il faut gérer les erreurs d’entrée - et ce de manière user friendly. Plus précisément, il faut que nos fonctions d’interprétation détectent les erreurs. Elles doivent aussi pouvoir les remonter. Pour cela, on peut tout simplement utiliser le système d’exceptions. On y reviendra plus en détails un peu plus loin.

Attaquons le code !

On peut maintenant aborder la conception plus précise des différents points abordés précédemment, ainsi que l’implémentation. Comme on l’a dit, on va commencer par un point central : la représentation de la discographie.

Représentation de la discographie

Une discographie peut simplement être vue comme une collection de morceaux. D’après l’énoncé, les informations dont on a besoin pour un morceau sont son nom, son album et son artiste. Ainsi :

  • Un artiste a comme unique donnée son nom.
  • Un album a lui aussi un nom.
  • Un morceau a pour données importantes son nom, ainsi que l’album et l’artiste correspondants.
  • Une discographie est une collection de morceaux, de taille variable évidemment.

Ces constats étant fait, cela devrait vous aider à trouver des structures de données adaptées.

Structures de données

Un choix possible est le suivant : représenter les artistes, albums et morceaux par des structures simples, et la discographie par un alias, à l’aide de using.

struct Artiste
{
    std::string nom;
};

struct Album
{
    std::string nom;
};

struct Morceau
{
    std::string nom;
    Album album;
    Artiste compositeur;
};

// Une discographie n'est rien d'autre qu'un ensemble de morceaux.
using Discographie = std::vector<Morceau>;

Fonctions de base

Une fois nos structures de données définies, il est commun de créer des fonctions de base qui vont nous permettre de les manipuler de manière agréable. C’est typiquement le moment de créer des surcharges pour certains opérateurs pour nos types nouvellement créés. Notamment, on aimerait pouvoir afficher facilement nos différentes structures, ainsi que les lire et les écrire dans un fichier. On va donc avoir besoin de surcharger les opérateurs de flux.

Je vous suggère dans un premier temps d’essayer de trouver les prototypes de telles fonctions, puis d’essayer de les implémenter.

Prototypes
std::ostream & operator<<(std::ostream & sortie, Artiste const & artiste);
std::ostream & operator<<(std::ostream & sortie, Album const & album);
std::ostream & operator<<(std::ostream & sortie, Morceau const & morceau);
std::istream & operator>>(std::istream & entree, Morceau & morceau);
Implémentations
// Fonction utilisée pour nettoyer les entrées.
// Elle sera réutilisée à plusieurs endroits.
std::string traitement_chaine(std::string const & chaine)
{
    std::string copie { chaine };

    // D'abord on enlève les espaces au début...
    auto premier_non_espace { std::find_if_not(std::begin(copie), std::end(copie), isspace) };
    copie.erase(std::begin(copie), premier_non_espace);

    // ...puis à la fin.
    std::reverse(std::begin(copie), std::end(copie));
    premier_non_espace = std::find_if_not(std::begin(copie), std::end(copie), isspace);
    copie.erase(std::begin(copie), premier_non_espace);
    std::reverse(std::begin(copie), std::end(copie));

    return copie;
}

// On surcharge l'opérateur << pour être capable d'afficher le type Artiste.
std::ostream & operator<<(std::ostream & sortie, Artiste const & artiste)
{
    sortie << artiste.nom;
    return sortie;
}

// On surcharge l'opérateur << pour être capable d'afficher le type Album.
std::ostream & operator<<(std::ostream & sortie, Album const & album)
{
    sortie << album.nom;
    return sortie;
}

// On surcharge l'opérateur << pour être capable d'afficher le type Morceau.
std::ostream & operator<<(std::ostream & sortie, Morceau const & morceau)
{
    sortie << morceau.nom << " | " << morceau.album << " | " << morceau.compositeur;
    return sortie;
}

// Conversion d'un flux d'entrée (std::cin, chaîne, fichier) en type Morceau.
std::istream & operator>>(std::istream & entree, Morceau & morceau)
{
    std::string mot {};
    std::ostringstream flux {};

    // Récupération du nom du morceau.
    while (entree >> mot && mot != "|")
    {
        flux << mot << " ";
    }

    std::string nom_morceau { flux.str() };
    if (std::empty(nom_morceau))
    {
        nom_morceau = "Morceau inconnu";
    }
    morceau.nom = traitement_chaine(nom_morceau);
    flux.str(std::string {});

    // Récupération du nom de l'album.
    while (entree >> mot && mot != "|")
    {
        flux << mot << " ";
    }

    std::string nom_album { flux.str() };
    if (std::empty(nom_album))
    {
        nom_album = "Album inconnu";
    }
    morceau.album.nom = traitement_chaine(nom_album);
    flux.str(std::string {});

    // Récupération du nom de l'artiste.
    while (entree >> mot)
    {
        flux << mot << " ";
    }

    std::string nom_artiste { flux.str() };
    if (std::empty(nom_artiste))
    {
        nom_artiste = "Artiste inconnu";
    }
    morceau.compositeur.nom = traitement_chaine(nom_artiste);
    flux.str(std::string {});

    return entree;
}

Remarquez l’utilisation des flux de chaînes de caractères pour gagner en clarté et en élégance. Ils seront beaucoup utilisés dans ce T.P.

Vous pouvez aussi voir que l’on a défini une fonction en plus, la fonction traitement_chaine : c’est une fonction utilitaire créée pour éviter la répétition de code.

Comme nous sommes des programmeurs consciencieux, nous voulons écrire des tests pour nous assurer que ce que nous avons déjà écrit fonctionne bien. En plus de vérifier qu’une entrée valide est acceptée, nous devons aussi penser aux cas limites ainsi qu’aux erreurs, pour mesurer la résistance et la bonne conception de notre programme.

Par exemple, "Frica | Frica | Carla's Dreams" et "Levels | Levels | Avicii" sont des entrées valides et complètes, même si dans la seconde il y a beaucoup d’espaces. De même, "Subeme la radio | | Enrique Iglesias" et "Duel of the fates | |" aussi, quand bien même certaines informations sont manquantes.

Tests unitaires
// Test qu'une entrée complète est valide.
void test_creation_morceau_entree_complete()
{
    std::istringstream entree { "Frica | Frica | Carla's Dreams" };
    Morceau morceau {};

    entree >> morceau;

    assert(morceau.nom == "Frica" && u8"Le nom du morceau doit être Frica.");
    assert(morceau.album.nom == "Frica" && u8"Le nom de l'album doit être Frica.");
    assert(morceau.compositeur.nom == "Carla's Dreams" && u8"Le nom du compositeur doit être Carla's Dreams.");
}

// Test d'une entrée complète avec beaucoup d'espaces.
void test_creation_morceau_entree_espaces_partout()
{
    std::istringstream entree { "Levels       |  Levels   |     Avicii" };
    Morceau morceau {};

    entree >> morceau;

    assert(morceau.nom == "Levels" && u8"Le nom du morceau doit être Levels.");
    assert(morceau.album.nom == "Levels" && u8"Le nom de l'album doit être Levels.");
    assert(morceau.compositeur.nom == "Avicii" && u8"Le nom du compositeur doit être Avicii.");
}

// Test d'une entrée avec seulement le nom de la chanson est valide.
void test_creation_morceau_entree_chanson_artiste()
{
    std::istringstream entree { "Subeme la radio | | Enrique Iglesias" };
    Morceau morceau {};

    entree >> morceau;

    assert(morceau.nom == "Subeme la radio" && u8"Le nom du morceau doit être Subeme la radio.");
    assert(morceau.album.nom == "Album inconnu" && u8"Le nom de l'album doit être Album inconnu.");
    assert(morceau.compositeur.nom == "Enrique Iglesias" && u8"Le nom du compositeur doit être Enrique Iglesias.");
}

// Test d'une entrée avec seulement le nom de la chanson.
void test_creation_morceau_entree_chanson_uniquement()
{
    std::istringstream entree { "Duel of the fates | |" };
    Morceau morceau {};

    entree >> morceau;

    assert(morceau.nom == "Duel of the fates" && u8"Le nom du morceau doit être Duel of the Fates.");
    assert(morceau.album.nom == "Album inconnu" && u8"Le nom de l'album doit être Album inconnu.");
    assert(morceau.compositeur.nom == "Artiste inconnu" && u8"Le nom du compositeur doit être Artiste inconnu.");
}

// Test d'une entrée vide.
void test_creation_morceau_entree_vide()
{
    std::istringstream entree { "| |" };
    Morceau morceau {};

    entree >> morceau;

    assert(morceau.nom == "Morceau inconnu" && u8"Le nom du morceau doit être Morceau inconnu.");
    assert(morceau.album.nom == "Album inconnu" && u8"Le nom de l'album doit être Album inconnu.");
    assert(morceau.compositeur.nom == "Artiste inconnu" && u8"Le nom du compositeur doit être Artiste inconnu.");
}

Le stockage dans un fichier

Maintenant que l’on a construit nos structures de base pour pouvoir représenter une bibliographie en mémoire, on peut créer des fonctions pour sauvegarder et charger une discographie dans un fichier.

Implémentation
void enregistrement(Discographie const & discographie, std::string const & nom_fichier)
{
    std::ofstream fichier { nom_fichier };
    if (!fichier)
    {
        throw std::runtime_error("Impossible d'ouvrir le fichier en écriture.");
    }

    for (Morceau const & morceau : discographie)
    {
        fichier << morceau << std::endl;
    }
}

void chargement(Discographie & discographie, std::string const & nom_fichier)
{
    std::ifstream fichier { nom_fichier };
    if (!fichier)
    {
        throw std::runtime_error("Impossible d'ouvrir le fichier en lecture.");
    }

    std::string ligne {};
    while (std::getline(fichier, ligne))
    {
        Morceau morceau {};

        std::istringstream flux { ligne };
        flux >> morceau;

        discographie.push_back(morceau);
    }
}

L’affichage de la discographie

Autre point qu’on a soulevé, l’affichage. Nous voudrions pouvoir afficher la discographie par albums, par artistes ou par titres. Plus précisément, on remarque que pour chaque mode d’affichage, il y a en réalité deux composantes distinctes : la manière d’afficher à proprement parler, mais aussi l’ordre dans lequel sont affichés les morceaux, c’est-à-dire le tri de la discographie. Nous dégageons ainsi six fonctions à implémenter : une de tri et une d’affichage pour chaque mode d’affichage.

Prototypes
void tri_morceau(Discographie& discographie);
void tri_album(Discographie& discographie);
void tri_artiste(Discographie& discographie);

void affichage_morceau(Discographie const & discographie)
void affichage_album(Discographie const & discographie);
void affichage_artiste(Discographie const & discographie);
Implémentation
void tri_morceau(Discographie& discographie)
{
    std::sort(std::begin(discographie), std::end(discographie), [](Morceau const & lhs, Morceau const & rhs)
    {
        return lhs.nom < rhs.nom;
    });
}

void tri_album(Discographie& discographie)
{
    std::sort(std::begin(discographie), std::end(discographie), [](Morceau const & lhs, Morceau const & rhs)
    {
        if (lhs.album.nom < rhs.album.nom)
            return true;

        return lhs.album.nom == rhs.album.nom && lhs.nom < rhs.nom;
    });
}

void tri_artiste(Discographie& discographie)
{
    std::sort(std::begin(discographie), std::end(discographie), [](Morceau const & lhs, Morceau const & rhs)
    {
        if (lhs.compositeur.nom < rhs.compositeur.nom)
            return true;

        if (lhs.compositeur.nom == rhs.compositeur.nom)
        {
            if (lhs.album.nom < rhs.album.nom)
                return true;

            return lhs.album.nom == rhs.album.nom && lhs.nom < rhs.nom;
        }

        return false;
    });
}

void affichage_morceau(Discographie const & discographie)
{
    for (Morceau const & morceau : discographie)
    {
        std::cout << "--> " << morceau << std::endl;
    }
}

void affichage_album(Discographie const & discographie)
{
    Album album_precedent{};
    for (Morceau const & morceau : discographie)
    {
        if (morceau.album.nom != album_precedent.nom)
        {
            std::cout << "--> " << morceau.album << " | " << morceau.compositeur << std::endl;
        }
        std::cout << "\t/--> " << morceau.nom << std::endl;

        album_precedent = morceau.album;
    }
}

void affichage_artiste(Discographie const & discographie)
{
    Artiste artiste_precedent{};
    Album album_precedent{};

    for (Morceau const & morceau : discographie)
    {
        if (morceau.compositeur.nom != artiste_precedent.nom)
        {
            std::cout << "--> " << morceau.compositeur << std::endl;
        }

        if (morceau.album.nom != album_precedent.nom)
        {
            std::cout << "\t/--> " << morceau.album << std::endl;
        }

        std::cout << "\t\t/--> " << morceau.nom << std::endl;

        artiste_precedent = morceau.compositeur;
        album_precedent = morceau.album;
    }
}

La manière dont j’ai remarqué le fait qu’il y avait deux parties bien distinctes à envisager - et donc à traiter dans des fonctions séparées - est très intéressante, donc j’aimerais vous l’expliquer.

Au départ, le tri se faisait directement dans les fonctions d’affichage. Or des fonctions d’affichage, vu leurs rôles, ne devraient pas avoir à modifier la discographie ; en bon programmeur qui respecte la const-correctness, je souhaitais donc que ces trois fonctions prennent en paramètres une Discographie const& et non pas une Discographie&. Mais le tri modifie la discographie, donc je ne pouvais pas. C’est comme ça que j’ai remarqué que la logique d’affichage comprenait en fait deux sous-tâches indépendantes, à traiter séparément.

Ainsi, on est dans un cas où faire attention à la const-correctness permet d’améliorer la conception du code.

Pour lier tout ça, on peut créer une quatrième fonction qui lancera les fonctions du mode d’affichage demandé. Comme nos possibilités d’affichage sont limitées, on peut les regrouper dans une énumération.

Prototypes
enum class Affichage { Artiste, Album, Morceau };

void affichage(Discographie& discographie, Affichage type_affichage);
Implémentation
// L'ensemble des possibilités d'affichage de la discographie.
enum class Affichage { Artiste, Album, Morceau };

// Fonction liant ce qui précède pour gérer toute la logique d'affichage.
void affichage(Discographie& discographie, Affichage type_affichage)
{
    if (type_affichage == Affichage::Album)
    {
        tri_album(discographie);
        affichage_album(discographie);
    }
    else if (type_affichage == Affichage::Artiste)
    {
        tri_artiste(discographie);
        affichage_artiste(discographie);
    }
    else if (type_affichage == Affichage::Morceau)
    {
        tri_morceau(discographie);
        affichage_morceau(discographie);
    }
    else
    {
        // Par exemple si on met à jour l'énumération mais qu'on oublie d'ajouter la fonction correspondante.
        throw std::runtime_error("Commande d'affichage inconnue.");
    }
}

On utilise ici une exception qui ne devrait en principe jamais être lancée, car nous ne tomberons jamais dans le else. Mais si, plus tard, on modifiait le programme pour ajouter une nouvelle forme d’affichage, sans mettre à jour cette partie du code, l’exception nous le rappellera.

Le système de commandes

Il nous reste encore à interagir avec l’utilisateur, à l’aide du système de commandes. On a fait ressortir deux problèmes principaux, l’interprétation et l’exécution des commandes. On gardera en tête le fait qu’il faut gérer les erreurs.

Prototypes
// L'ensemble des actions qu'il est possible de faire.
enum class Commande { Afficher, Ajouter, Enregistrer, Charger, Quitter };

// Affichage et récupération de ce l'utilisateur écrit.
std::string recuperer_commande();
// Analyse du texte reçu en entrée.
std::tuple<Commande, std::string> analyser_commande(std::string const & commande_texte);
// La commande à exécuter ainsi que la suite des instructions.
bool executer_commande(Discographie & discographie, Commande commande, std::string const & instructions);

Remarquez la création d’une énumération pour représenter les différents types de commande ; ce n’est pas nécessaire, c’est juste un type de base de plus pour pouvoir lire élégamment le type de commande dans la console.

Essayez maintenant d’implémenter de telles fonctions.

Implémentations
// Récupération de ce que l'utilisateur écrit.
std::string recuperer_commande()
{
    std::cout << "> ";

    std::string commande {};
    std::getline(std::cin, commande);
    return commande;
}

// L'ensemble des actions qu'il est possible de faire.
enum class Commande { Afficher, Ajouter, Enregistrer, Charger, Quitter };

// Analyse du texte reçu en entrée.
std::tuple<Commande, std::string> analyser_commande(std::string const & commande_texte)
{
    // Pour traiter la chaîne comme étant un flux, afin d'en extraire les données.
    std::istringstream flux { commande_texte };

    std::string premier_mot {};
    flux >> premier_mot;
    premier_mot = traitement_chaine(premier_mot);

    std::string instructions {};
    std::getline(flux, instructions);
    instructions = traitement_chaine(instructions);

    if (premier_mot == "afficher")
    {
        // Le mode d'affichage.
        return { Commande::Afficher, instructions };
    }
    else if (premier_mot == "ajouter")
    {
        // Les informations à ajouter.
        return { Commande::Ajouter, instructions };
    }
    else if (premier_mot == "enregistrer")
    {
        // Le fichier à écrire.
        return{ Commande::Enregistrer, instructions };
    }
    else if (premier_mot == "charger")
    {
        // Le fichier à lire.
        return { Commande::Charger, instructions };
    }
    else if (premier_mot == "quitter")
    {
        // Chaîne vide, car on quitte le programme sans autre instruction.
        return { Commande::Quitter, std::string {} };
    }
    else
    {
        // On a reçu du texte qui est incorrect.
        throw std::runtime_error("Commande invalide.");
    }
}

// La commande à exécuter ainsi que la suite des instructions.
bool executer_commande(Discographie & discographie, Commande commande, std::string const & instructions)
{
    if (commande == Commande::Afficher)
    {
        if (instructions == "artistes")
        {
            affichage(discographie, Affichage::Artiste);
        }
        else if (instructions == "albums")
        {
            affichage(discographie, Affichage::Album);
        }
        else if (instructions == "morceaux")
        {
            affichage(discographie, Affichage::Morceau);
        }
        else
        {
            // Si on se trouve ici, alors c'est qu'il y a une erreur dans les instructions d'affichage.
            throw std::runtime_error("Commande d'affichage inconnue.");
        }
    }
    else if (commande == Commande::Ajouter)
    {
        std::istringstream flux { instructions };
        Morceau morceau {};
        flux >> morceau;
        discographie.push_back(morceau);
    }
    else if (commande == Commande::Charger)
    {
        chargement(discographie, instructions);
    }
    else if (commande == Commande::Enregistrer)
    {
        enregistrement(discographie, instructions);
    }
    else if (commande == Commande::Quitter)
    {
        // Plus rien à faire, on quitte.
        return false;
    }

    return true;
}

Comme on l’a dit, on utilise les exceptions pour faire remonter les erreurs en cas de commande invalide. C’est un usage logique des exceptions, puisque les erreurs de commande de l’utilisateur sont indépendantes de la volonté du programmeur. Les exceptions sont donc toutes indiquées pour gérer ce genre de cas.

Finalisation

Maintenant que toutes nos fonctionnalités sont programmées, il suffit de tout intégrer en écrivant une fonction main, qui aura pour principal mission d’initialiser le programme (en lançant d’abord les tests) et de faire tourner une boucle d’invite de commandes jusqu’à ce que l’utilisateur décide de quitter.

Fonction principale
int main()
{
    // Lancement préalable des tests unitaires.
    test_creation_morceau_entree_complete();
    test_creation_morceau_entree_espaces_partout();
    test_creation_morceau_entree_chanson_artiste();
    test_creation_morceau_entree_chanson_uniquement();
    test_creation_morceau_entree_vide();

    // On préremplit la discographie.
    Artiste const dave_brubeck { "Dave Brubeck" };
    Artiste const secret_garden { "Secret Garden" };
    Artiste const indochine { "Indochine" };

    Album const time_out { "Time Out" };
    Album const songs_from_a_secret_garden { "Songs from a Secret Garden" };
    Album const l_aventurier { "L'aventurier" };
    Album const paradize { "Paradize" };

    Morceau const take_five { "Take Five", dave_brubeck, time_out };
    Morceau const blue_rondo_a_la_turk { "Blue Rondo a la Turk", dave_brubeck, time_out };
    Morceau const nocturne { "Nocturne", secret_garden, songs_from_a_secret_garden };
    Morceau const aventurier { "L'aventurier", indochine, l_aventurier };
    Morceau const j_ai_demande_a_la_lune { "J'ai demandé à la lune", indochine, paradize };

    Discographie discographie { take_five, blue_rondo_a_la_turk, nocturne, aventurier, j_ai_demande_a_la_lune };

    bool continuer { true };
    do
    {
        try
        {
            std::string entree { recuperer_commande() };
            auto[commande, instructions] = analyser_commande(entree);
            instructions = traitement_chaine(instructions);
            continuer = executer_commande(discographie, commande, instructions);
        }
        catch (std::runtime_error const & exception)
        {
            std::cout << "Erreur : " << exception.what() << std::endl;
        }

    } while (continuer);

    return 0;
}

Il ne faut pas oublier de gérer les erreurs remontées par notre programme. Ici ce n’est pas compliqué, il suffit d’expliquer l’erreur à l’utilisateur et de lui redemander une entrée.

Et voilà ! On a construit pas à pas toutes les briques de notre programme, jusqu’à avoir tout le code pour le faire fonctionner. La démarche à retenir est la suivante : il faut réfléchir à une manière de découper notre problème initial — ici « faire une discographie » — en sous-problèmes, par exemple « lire et comprendre une commande », qui eux-mêmes seront découpés en sous-problèmes, jusqu’à arriver à des briques de base simples à réaliser — par exemple « créer un type représentant les morceaux ».

Corrigé complet

Bon, j’espère que vos phases de recherches et d’avancées à l’aide du guide ont été fructueuses !

Que vous ayez réussi ou pas à avoir un résultat fonctionnel, l’important est que vous ayez bien réfléchi au T.P. C’est en vous confrontant à vos difficultés que vous progresserez.

Bref, trêve de bavardages, voici sans plus attendre une solution possible au T.P, issue du guide ci-dessus.

Corrigé complet
#include <algorithm>
#include <cassert>
#include <cctype>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <tuple>
#include <vector>

std::string traitement_chaine(std::string const & chaine)
{
    std::string copie{ chaine };

    // D'abord on enlève les espaces au début...
    auto premier_non_espace{ std::find_if_not(std::begin(copie), std::end(copie), isspace) };
    copie.erase(std::begin(copie), premier_non_espace);

    // ...puis à la fin.
    std::reverse(std::begin(copie), std::end(copie));
    premier_non_espace = std::find_if_not(std::begin(copie), std::end(copie), isspace);
    copie.erase(std::begin(copie), premier_non_espace);
    std::reverse(std::begin(copie), std::end(copie));

    return copie;
}

struct Artiste
{
    std::string nom;
};

// On surcharge l'opérateur << pour être capable d'afficher le type Artiste.
std::ostream & operator<<(std::ostream & sortie, Artiste const & artiste)
{
    sortie << artiste.nom;
    return sortie;
}

struct Album
{
    std::string nom;
};

// On surcharge l'opérateur << pour être capable d'afficher le type Album.
std::ostream & operator<<(std::ostream & sortie, Album const & album)
{
    sortie << album.nom;
    return sortie;
}

struct Morceau
{
    std::string nom;
    Artiste compositeur;
    Album album;
};

// On surcharge l'opérateur << pour être capable d'afficher le type Morceau.
std::ostream & operator<<(std::ostream & sortie, Morceau const & morceau)
{
    sortie << morceau.nom << " | " << morceau.album << " | " << morceau.compositeur;
    return sortie;
}

// Conversion d'un flux d'entrée (std::cin, chaîne, fichier) en type Morceau.
std::istream & operator>>(std::istream & entree, Morceau & morceau)
{
    std::string mot{};
    std::ostringstream flux{};

    // Récupération du nom du morceau.
    while (entree >> mot && mot != "|")
    {
        flux << mot << " ";
    }

    std::string nom_morceau{ flux.str() };
    if (std::empty(nom_morceau))
    {
        nom_morceau = "Morceau inconnu";
    }
    morceau.nom = traitement_chaine(nom_morceau);
    flux.str(std::string{});

    // Récupération du nom de l'album.
    while (entree >> mot && mot != "|")
    {
        flux << mot << " ";
    }

    std::string nom_album{ flux.str() };
    if (std::empty(nom_album))
    {
        nom_album = "Album inconnu";
    }
    morceau.album.nom = traitement_chaine(nom_album);
    flux.str(std::string{});

    // Récupération du nom de l'artiste.
    while (entree >> mot)
    {
        flux << mot << " ";
    }

    std::string nom_artiste{ flux.str() };
    if (std::empty(nom_artiste))
    {
        nom_artiste = "Artiste inconnu";
    }
    morceau.compositeur.nom = traitement_chaine(nom_artiste);
    flux.str(std::string{});

    return entree;
}

// Test qu'une entrée complète est valide.
void test_creation_morceau_entree_complete()
{
    std::istringstream entree{ "Frica | Frica | Carla's Dreams" };
    Morceau morceau{};

    entree >> morceau;

    assert(morceau.nom == "Frica" && u8"Le nom du morceau doit être Frica.");
    assert(morceau.album.nom == "Frica" && u8"Le nom de l'album doit être Frica.");
    assert(morceau.compositeur.nom == "Carla's Dreams" && u8"Le nom du compositeur doit être Carla's Dreams.");
}

// Test d'une entrée complète avec beaucoup d'espaces.
void test_creation_morceau_entree_espaces_partout()
{
    std::istringstream entree{ "Levels       |  Levels   |     Avicii" };
    Morceau morceau{};

    entree >> morceau;

    assert(morceau.nom == "Levels" && u8"Le nom du morceau doit être Levels.");
    assert(morceau.album.nom == "Levels" && u8"Le nom de l'album doit être Levels.");
    assert(morceau.compositeur.nom == "Avicii" && u8"Le nom du compositeur doit être Avicii.");
}

// Test d'une entrée avec seulement le nom de la chanson est valide.
void test_creation_morceau_entree_chanson_artiste()
{
    std::istringstream entree{ "Subeme la radio | | Enrique Iglesias" };
    Morceau morceau{};

    entree >> morceau;

    assert(morceau.nom == "Subeme la radio" && u8"Le nom du morceau doit être Subeme la radio.");
    assert(morceau.album.nom == "Album inconnu" && u8"Le nom de l'album doit être Album inconnu.");
    assert(morceau.compositeur.nom == "Enrique Iglesias" && u8"Le nom du compositeur doit être Enrique Iglesias.");
}

// Test d'une entrée avec seulement le nom de la chanson.
void test_creation_morceau_entree_chanson_uniquement()
{
    std::istringstream entree{ "Duel of the fates | |" };
    Morceau morceau{};

    entree >> morceau;

    assert(morceau.nom == "Duel of the fates" && u8"Le nom du morceau doit être Duel of the Fates.");
    assert(morceau.album.nom == "Album inconnu" && u8"Le nom de l'album doit être Album inconnu.");
    assert(morceau.compositeur.nom == "Artiste inconnu" && u8"Le nom du compositeur doit être Artiste inconnu.");
}

// Test d'une entrée vide.
void test_creation_morceau_entree_vide()
{
    std::istringstream entree{ "| |" };
    Morceau morceau{};

    entree >> morceau;

    assert(morceau.nom == "Morceau inconnu" && u8"Le nom du morceau doit être Morceau inconnu.");
    assert(morceau.album.nom == "Album inconnu" && u8"Le nom de l'album doit être Album inconnu.");
    assert(morceau.compositeur.nom == "Artiste inconnu" && u8"Le nom du compositeur doit être Artiste inconnu.");
}

using Discographie = std::vector<Morceau>;

void tri_morceau(Discographie& discographie)
{
    std::sort(std::begin(discographie), std::end(discographie), [](Morceau const & lhs, Morceau const & rhs)
    {
        return lhs.nom < rhs.nom;
    });
}

void tri_album(Discographie& discographie)
{
    std::sort(std::begin(discographie), std::end(discographie), [](Morceau const & lhs, Morceau const & rhs)
    {
        if (lhs.album.nom < rhs.album.nom)
            return true;

        return lhs.album.nom == rhs.album.nom && lhs.nom < rhs.nom;
    });
}

void tri_artiste(Discographie& discographie)
{
    std::sort(std::begin(discographie), std::end(discographie), [](Morceau const & lhs, Morceau const & rhs)
    {
        if (lhs.compositeur.nom < rhs.compositeur.nom)
            return true;

        if (lhs.compositeur.nom == rhs.compositeur.nom)
        {
            if (lhs.album.nom < rhs.album.nom)
                return true;

            return lhs.album.nom == rhs.album.nom && lhs.nom < rhs.nom;
        }

        return false;
    });
}

// Affichage de la discographie par morceau, dans l'ordre alphabétique.
void affichage_morceau(Discographie const & discographie)
{
    for (Morceau const & morceau : discographie)
    {
        std::cout << "--> " << morceau << std::endl;
    }
}

// Affichage de la discographie par album, dans l'ordre alphabétique.
void affichage_album(Discographie const & discographie)
{
    Album album_precedent{};
    for (Morceau const & morceau : discographie)
    {
        if (morceau.album.nom != album_precedent.nom)
        {
            std::cout << "--> " << morceau.album << " | " << morceau.compositeur << std::endl;
        }
        std::cout << "\t/--> " << morceau.nom << std::endl;

        album_precedent = morceau.album;
    }
}

// Affichage de la discographie par artiste, dans l'ordre alphabétique.
void affichage_artiste(Discographie const & discographie)
{
    Artiste artiste_precedent{};
    Album album_precedent{};

    for (Morceau const & morceau : discographie)
    {
        if (morceau.compositeur.nom != artiste_precedent.nom)
        {
            std::cout << "--> " << morceau.compositeur << std::endl;
        }

        if (morceau.album.nom != album_precedent.nom)
        {
            std::cout << "\t/--> " << morceau.album << std::endl;
        }

        std::cout << "\t\t/--> " << morceau.nom << std::endl;

        artiste_precedent = morceau.compositeur;
        album_precedent = morceau.album;
    }
}

// L'ensemble des possibilités d'affichage de la discographie.
enum class Affichage { Artiste, Album, Morceau };

void affichage(Discographie& discographie, Affichage type_affichage)
{
    if (type_affichage == Affichage::Album)
    {
        tri_album(discographie);
        affichage_album(discographie);
    }
    else if (type_affichage == Affichage::Artiste)
    {
        tri_artiste(discographie);
        affichage_artiste(discographie);
    }
    else if (type_affichage == Affichage::Morceau)
    {
        tri_morceau(discographie);
        affichage_morceau(discographie);
    }
    else
    {
        // Par exemple si on met à jour l'énumération mais qu'on oublie d'ajouter la fonction correspondante.
        throw std::runtime_error("Commande d'affichage inconnue.");
    }
}

void enregistrement(Discographie const & discographie, std::string const & nom_fichier)
{
    std::ofstream fichier{ nom_fichier };
    if (!fichier)
    {
        throw std::runtime_error("Impossible d'ouvrir le fichier en écriture.");
    }

    for (Morceau const & morceau : discographie)
    {
        fichier << morceau << std::endl;
    }
}

void chargement(Discographie & discographie, std::string const & nom_fichier)
{
    std::ifstream fichier{ nom_fichier };
    if (!fichier)
    {
        throw std::runtime_error("Impossible d'ouvrir le fichier en lecture.");
    }

    std::string ligne{};
    while (std::getline(fichier, ligne))
    {
        Morceau morceau{};

        std::istringstream flux{ ligne };
        flux >> morceau;

        discographie.push_back(morceau);
    }
}

// Récupération de ce l'utilisateur écrit.
std::string recuperer_commande()
{
    std::cout << "> ";

    std::string commande{};
    std::getline(std::cin, commande);
    return commande;
}

// L'ensemble des actions qu'il est possible de faire.
enum class Commande { Afficher, Ajouter, Enregistrer, Charger, Quitter };

// Analyse du texte reçu en entrée.
std::tuple<Commande, std::string> analyser_commande(std::string const & commande_texte)
{
    // Pour traiter la chaîne comme étant un flux, afin d'en extraire les données.
    std::istringstream flux{ commande_texte };

    std::string premier_mot{};
    flux >> premier_mot;
    premier_mot = traitement_chaine(premier_mot);

    std::string instructions{};
    std::getline(flux, instructions);
    instructions = traitement_chaine(instructions);

    if (premier_mot == "afficher")
    {
        // Le mode d'affichage.
        return { Commande::Afficher, instructions };
    }
    else if (premier_mot == "ajouter")
    {
        // Les informations à ajouter.
        return { Commande::Ajouter, instructions };
    }
    else if (premier_mot == "enregistrer")
    {
        // Le fichier à écrire.
        return{ Commande::Enregistrer, instructions };
    }
    else if (premier_mot == "charger")
    {
        // Le fichier à lire.
        return { Commande::Charger, instructions };
    }
    else if (premier_mot == "quitter")
    {
        // Chaîne vide, car on quitte le programme sans autre instruction.
        return { Commande::Quitter, std::string {} };
    }
    else
    {
        // On a reçu du texte qui est incorrect.
        throw std::runtime_error("Commande invalide.");
    }
}

// La commande à exécuter ainsi que la suite des instructions.
bool executer_commande(Discographie & discographie, Commande commande, std::string const & instructions)
{
    if (commande == Commande::Afficher)
    {
        if (instructions == "artistes")
        {
            affichage(discographie, Affichage::Artiste);
        }
        else if (instructions == "albums")
        {
            affichage(discographie, Affichage::Album);
        }
        else if (instructions == "morceaux")
        {
            affichage(discographie, Affichage::Morceau);
        }
        else
        {
            // Si on se trouve ici, alors c'est qu'il y a une erreur dans les instructions d'affichage.
            throw std::runtime_error("Commande d'affichage inconnue.");
        }
    }
    else if (commande == Commande::Ajouter)
    {
        std::istringstream flux{ instructions };
        Morceau morceau{};
        flux >> morceau;
        discographie.push_back(morceau);
    }
    else if (commande == Commande::Charger)
    {
        chargement(discographie, instructions);
    }
    else if (commande == Commande::Enregistrer)
    {
        enregistrement(discographie, instructions);
    }
    else if (commande == Commande::Quitter)
    {
        // Plus rien à faire, on quitte.
        return false;
    }

    return true;
}

int main()
{
    // Lancement préalable des tests unitaires.
    test_creation_morceau_entree_complete();
    test_creation_morceau_entree_espaces_partout();
    test_creation_morceau_entree_chanson_artiste();
    test_creation_morceau_entree_chanson_uniquement();
    test_creation_morceau_entree_vide();

    // On préremplit la discographie.
    Artiste const dave_brubeck{ "Dave Brubeck " };
    Artiste const secret_garden{ "Secret Garden" };
    Artiste const indochine{ "Indochine" };

    Album const time_out{ "Time Out" };
    Album const songs_from_a_secret_garden{ "Songs from a Secret Garden" };
    Album const l_aventurier{ "L'aventurier" };
    Album const paradize{ "Paradize" };

    Morceau const take_five{ "Take Five", dave_brubeck, time_out };
    Morceau const blue_rondo_a_la_turk{ "Blue Rondo a la Turk", dave_brubeck, time_out };
    Morceau const nocturne{ "Nocturne", secret_garden, songs_from_a_secret_garden };
    Morceau const aventurier{ "L'aventurier", indochine, l_aventurier };
    Morceau const j_ai_demande_a_la_lune{ "J'ai demandé à la lune", indochine, paradize };

    Discographie discographie{ take_five, blue_rondo_a_la_turk, nocturne, aventurier, j_ai_demande_a_la_lune };

    bool continuer{ true };
    do
    {
        try
        {
            std::string entree{ recuperer_commande() };
            auto[commande, instructions] = analyser_commande(entree);
            instructions = traitement_chaine(instructions);
            continuer = executer_commande(discographie, commande, instructions);
        }
        catch (std::runtime_error const & exception)
        {
            std::cout << "Erreur : " << exception.what() << std::endl;
        }

    } while (continuer);

    return 0;
}

Une façon parmi d’autres

Ce code n’est pas LA solution, mais une parmi tant d’autres. Le vôtre est différent, mais vous êtes malgré tout arrivés à la solution ? Bravo, vous pouvez être fiers. ;)

Conclusion et pistes d'améliorations

Voilà qui conclut ce TP, dans lequel vous avez pu vous confronter pour la première fois à la conception d’un programme complet, plus ambitieux que les petits exercices. Cela nous a permis de voir la démarche qu’il faut avoir en tête pour pouvoir créer du code de qualité, c’est-à-dire expressif et surtout évolutif.

Justement, cette évolutivité vous permet d’ajouter des fonctionnalités à votre programme à votre guise, et ce de manière efficace. Vous pouvez ajouter ce que vous voulez. Ci-dessous, voici quelques idées.

  • Ajouter des options d’affichage comme l’affichage des morceaux d’un seul album ou artiste, grâce à des commandes du type > afficher artiste Dave Brubeck par exemple.
  • Ajouter des options de tri des morceaux, par BPM par exemple.
  • Avoir un fichier de configuration du logiciel permettant à l’utilisateur de choisir son invite de commandes, son séparateur dans les affichages et dans les commandes d’ajout, les puces à utiliser dans les listes affichées, etc. En bref, rendre le logiciel configurable. Un tel fichier pourrait ressembler à ça, pour les réglages donnant les mêmes affichages que ceux qu’on a codés en dur.
invite=">"
separateur_affichage="|"
separateur_ajout="|"
puce_niveau1="-->"
puce_niveau2="/-->"
puce_niveau3="/-->"
  • Notre code n’est pas entièrement testé. Il ne tient qu’à vous d’écrire les tests manquants.

Les possibilités d’ajout sont infinies, vous pouvez faire ce que vous souhaitez. :)

N’hésitez pas, lorsque vous voulez ajouter des fonctionnalités, à continuer avec la même démarche : commencez par réfléchir, décomposez ce que vous voulez faire en petits problèmes, créez des types pratiques s’il le faut, etc.

Conseil d’ami

Je vous conseille de lire le chapitre suivant avant de faire des ajouts au TP. En effet, vous serez alors capables de mieux organiser votre code, ce qui vous aidera à le faire évoluer pour ajouter des nouvelles fonctionnalités.


En résumé

  • Vous vous êtes confrontés pour la première fois à la réalité de la conception d’un programme informatique.
  • On a pu en tirer une démarche pour avoir un code lisible et évolutif.
  • On a également pu voir l’importance de la réflexion si on veut obtenir un code de qualité.
  • On a aussi vu comment les notions vues jusqu’à présent peuvent se combiner en pratique, pour en tirer le meilleur et ainsi obtenir cette qualité.
  • Nous avons vu quelques pistes possibles d’amélioration, mais il ne tient qu’à vous d’ajouter les fonctionnalités que vous souhaitez, comme vous le souhaitez.