Pour les détails quant à cette démarche et pour des liens vers les autres questions : index.html#profquiz.
Pour un rappel des principes : index.html#profquiz_principes
Pour cette question, les solutionnaires suivants sont disponibles :
Cette question fut proposée le 20 mai 2013.
Rédigez un programme qui :
Il est essentiel que le programme ne laisse fuir aucune ressource. Vous comprendrez que ce problème pourrait être proposé en deuxième session du DEC en informatique (technique ou SIM), alors portez attention aux connaissances requises de la part des étudiantes et des étudiants, de même qu'aux concepts mis de l'avant dans votre solution et de ce vers quoi ils peuvent mener votre enseignement par la suite.
Source : ceci est un exemple classique (et mal enseigné) dans la littérature... Mais vous pouvez faire mieux, n'est-ce pas?
Ce problème se prête à une pédagogie pour des étudiantes et des étudiants ayant eu au moins un premier contact avec la programmation orientée objet, du fait qu'il met en scène de l'héritage et du polymorphisme. Le recours à un nombre a priori inconnu d'objets de divers types implique aussi un recours à une forme ou l'autre d'allocation dynamique de mémoire. Pour cette raison, je suppose ici que la gestion des erreurs d'entrée/ sortie de part et d'autre n'est pas un obstacle.
La solution proposée dans chaque cas ci-dessous suit la même forme, indépendamment du langage utilisé. Cette forme est expliquée ici pour éviter la redondance :
Nom en C# | Nom en C++ | Explication |
---|---|---|
AfficherTitreEtape() |
afficher_titre_etape() |
Formate une chaîne de caractères dans un cadre. Cette fonction est perfectible, mais j'ai laissé la même logique décorative dans chaque exemple |
LireEntierPositif() |
lire_entier_positif() |
Lit une entrée au clavier et la valide. La condition de validité est que l'entrée représente un entier strictement positif. Nuance entre les implémentations : celle en C# lit une ligne à la fois alors que celle en C++ lit un mot à la fois |
LireEntierBorne() |
lire_entier_borne() |
Lit une entrée au clavier et la valide. La condition de validité est que l'entrée représente un entier situé inclusivement entre deux bornes. La cohérence des bornes est validée. Nuance entre les implémentations : celle en C# lit une ligne à la fois alors que celle en C++ lit un mot à la fois |
AfficherMenu() |
afficher_menu() |
Affiche des options de menu tirées d'une séquence de chaînes de caractères, et leur associe un numéro. Les numéros affichés sont dans pour une séquence de éléments |
LireChoix() |
lire_choix() |
Lit et valide un choix parmi des options de menu tirées d'une séquence de chaînes de caractères. Le choix doit se trouver dans pour une séquence de éléments |
Forme |
Forme |
Classe abstraite à partir de laquelle s'articule la logique du programme. Toute forme sait se dessiner. |
Carré |
Carre |
Dérivé de Forme. Se dessine en affichant son nom |
Rectangle |
Rectangle |
Dérivé de Forme. Se dessine en affichant son nom |
Triangle |
Triangle |
Dérivé de Forme. Se dessine en affichant son nom |
LireForme() |
lire_forme() |
Offre à l'usager un choix parmi les formes disponibles, lit et valide son choix, puis construit et retourne la forme choisie |
LireNbFormes() |
lire_nb_formes() |
Lit et valide le nombre de formes à créer |
LireFormes() |
lire_formes() |
À partir du nombre de formes désiré, crée un conteneur de formes, y dépose chacune des formes choisies par l'usager, et retourne le conteneur une fois celui-ci rempli |
Main() |
main() |
Réalise le travail demandé pour cette question |
Les affichages dans chaque cas sont identiques (aux accents près; lorsque l'on travaille en mode console avec C#, la mise en place de la gestion des accents est automatisée alors que ce n'est pas le cas avec C++, mais j'ai estimé que c'était secondaire ici – si vous le jugez important, il existe des solutions toutes faites).
Une solution possible en C# serait la suivante :
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace z10
{
class Program
{
static void AfficherTitreÉtape(string titre)
{
const int LARGEUR_LIGNE = 70;
int largeurCadreCentral = (LARGEUR_LIGNE - 2 - titre.Length) / 2;
Console.WriteLine(new string('-', LARGEUR_LIGNE));
Console.WriteLine("{0} {1} {0}", new string('-', largeurCadreCentral), titre); // perfectible
Console.WriteLine(new string('-', LARGEUR_LIGNE));
}
static int LireEntierPositif()
{
int nbLu = -1;
do
{
try
{
Console.Write("Entrez un entier strictement positif : ");
nbLu = int.Parse(Console.ReadLine());
}
catch (FormatException)
{
}
}
while (nbLu <= 0);
return nbLu;
}
//
// Note: les bornes sont inclusives
//
static int LireEntierBorné(int minVal, int maxVal)
{
if (maxVal < minVal)
throw new ArgumentException(string.Format("Méthode LireEntierBorné(), maxVal = {0} et minVal = {1}", maxVal, minVal));
bool ok = false;
int nbLu = 0;
do
{
try
{
Console.Write("Entrez une valeur entière entre {0} et {1} inclusivement : ", minVal, maxVal);
nbLu = int.Parse(Console.ReadLine());
if (minVal <= nbLu && nbLu <= maxVal)
ok = true;
}
catch (FormatException)
{
}
}
while (!ok);
return nbLu;
}
static void AfficherMenu(string[] options)
{
for (int i = 0; i < options.Length; ++i)
{
Console.WriteLine("{0} : {1}", i + 1, options[i]);
}
}
static int LireChoix(string[] options)
{
return LireEntierBorné(1, options.Length);
}
abstract class Forme
{
public abstract void Dessiner();
}
class Carré : Forme
{
public override void Dessiner()
{
Console.WriteLine("Je suis un Carré");
}
}
class Rectangle : Forme
{
public override void Dessiner()
{
Console.WriteLine("Je suis un Rectangle");
}
}
class Triangle : Forme
{
public override void Dessiner()
{
Console.WriteLine("Je suis un Triangle");
}
}
static Forme LireForme()
{
string[] options = { "Carré", "Rectangle", "Triangle" };
AfficherMenu(options);
int choix = LireChoix(options);
Forme forme = null;
switch (options[choix-1])
{
case "Carré":
forme = new Carré();
break;
case "Rectangle":
forme = new Rectangle();
break;
case "Triangle":
forme = new Triangle();
break;
}
return forme;
}
static int LireNbFormes()
{
AfficherTitreÉtape("Entrée du nombre de formes souhaité");
return LireEntierPositif();
}
static Forme[] LireFormes(int nbFormes)
{
AfficherTitreÉtape("Entrée des formes souhaitées");
Forme[] formes = new Forme[nbFormes];
for (int i = 0; i < formes.Length; ++i)
{
Console.WriteLine("Forme #{0}", i + 1);
formes[i] = LireForme();
}
return formes;
}
static void Main(string[] args)
{
int nbFormes = LireNbFormes();
Forme[] formes = LireFormes(nbFormes);
foreach (Forme f in formes)
f.Dessiner();
}
}
}
J'ai utilisé un foreach pour les répétitives où l'indice n'était pas important. Le code serait plus élégant si nous éliminions la partie validation des entrées, mais bon, c'est jamais très joli ce genre de truc.
Notez que C# permet les sélectives sur les chaînes de caractères, comme le fait Java d'ailleurs, un détail dont je me suis servi ici. Notez aussi que l'initialisation à null de la variable forme dans la méthode LireForme() est obligatoire pour éviter que le compilateur ne se plaigne d'un risque à l'exécution, bien que ce risque soit évité en pratique par la validation faite dans la méthode LireEntierBorne().
Une variante éliminant le recours à un tableau, encore en C#, serait :
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace z10
{
class Program
{
static void AfficherTitreÉtape(string titre)
{
const int LARGEUR_LIGNE = 70;
int largeurCadreCentral = (LARGEUR_LIGNE - 2 - titre.Length) / 2;
Console.WriteLine(new string('-', LARGEUR_LIGNE));
Console.WriteLine("{0} {1} {0}", new string('-', largeurCadreCentral), titre); // perfectible
Console.WriteLine(new string('-', LARGEUR_LIGNE));
}
static int LireEntierPositif()
{
int nbLu = -1;
do
{
try
{
Console.Write("Entrez un entier strictement positif : ");
nbLu = int.Parse(Console.ReadLine());
}
catch (FormatException)
{
}
}
while (nbLu <= 0);
return nbLu;
}
//
// Note: les bornes sont inclusives
//
static int LireEntierBorné(int minVal, int maxVal)
{
if (maxVal < minVal)
throw new ArgumentException(string.Format("Méthode LireEntierBorné(), maxVal = {0} et minVal = {1}", maxVal, minVal));
bool ok = false;
int nbLu = 0;
do
{
try
{
Console.Write("Entrez une valeur entière entre {0} et {1} inclusivement : ", minVal, maxVal);
nbLu = int.Parse(Console.ReadLine());
if (minVal <= nbLu && nbLu <= maxVal)
ok = true;
}
catch (FormatException)
{
}
}
while (!ok);
return nbLu;
}
static void AfficherMenu(string[] options)
{
for (int i = 0; i < options.Length; ++i)
{
Console.WriteLine("{0} : {1}", i + 1, options[i]);
}
}
static int LireChoix(string[] options)
{
return LireEntierBorné(1, options.Length);
}
abstract class Forme
{
public abstract void Dessiner();
}
class Carré : Forme
{
public override void Dessiner()
{
Console.WriteLine("Je suis un Carré");
}
}
class Rectangle : Forme
{
public override void Dessiner()
{
Console.WriteLine("Je suis un Rectangle");
}
}
class Triangle : Forme
{
public override void Dessiner()
{
Console.WriteLine("Je suis un Triangle");
}
}
static Forme LireForme()
{
string[] options = { "Carré", "Rectangle", "Triangle" };
AfficherMenu(options);
int choix = LireChoix(options);
Forme forme = null;
switch (options[choix-1])
{
case "Carré":
forme = new Carré();
break;
case "Rectangle":
forme = new Rectangle();
break;
case "Triangle":
forme = new Triangle();
break;
}
return forme;
}
static int LireNbFormes()
{
AfficherTitreÉtape("Entrée du nombre de formes souhaité");
return LireEntierPositif();
}
static List<Forme> LireFormes(int nbFormes)
{
AfficherTitreÉtape("Entrée des formes souhaitées");
List<Forme> formes = new List<Forme>();
for (int i = 0; i < nbFormes; ++i)
{
Console.WriteLine("Forme #{0}", i + 1);
formes.Add(LireForme());
}
return formes;
}
static void Main(string[] args)
{
int nbFormes = LireNbFormes();
List<Forme> formes = LireFormes(nbFormes);
foreach (Forme f in formes)
f.Dessiner();
}
}
}
Le type générique List<T> implémente un tableau dynamique de T, même si ça peut ne pas être évident en se basant sur son nom. Ce n'est vraiment pas une liste chaînée! Si vous n'êtes pas convaincus, écrivez un programme déclarant une vulgaire List<int> et ajoutez une vaste quantité d'éléments à la fin, puis faites de même en ajoutant au début. Ça devrait rendre évident à vos yeux les choix d'implémentation qui se cachent derrière ce type.
Une solution possible en C++ serait :
#include <vector>
#include <string>
#include <iostream>
#include <memory>
#include <algorithm>
using namespace std;
void afficher_titre_etape(string titre)
{
const int LARGEUR_LIGNE = 70;
const int largeurCadreCentral = (LARGEUR_LIGNE - 2 - titre.size()) / 2;
const string DEMI_CADRE = string(largeurCadreCentral, '-');
cout << string(LARGEUR_LIGNE, '-') << endl;
cout << DEMI_CADRE << ' ' << titre << ' ' << DEMI_CADRE << endl; // perfectible
cout << string(LARGEUR_LIGNE, '-') << endl;
}
int lire_entier_positif()
{
int nbLu = -1;
do
{
cout << "Entrez un entier strictement positif : ";
if (!(cin >> nbLu))
{
cin.clear();
cin.ignore();
}
}
while (nbLu <= 0);
return nbLu;
}
//
// Note: les bornes sont inclusives
//
class BornesIncorrectes {};
int lire_entier_borne(int minVal, int maxVal)
{
if (maxVal < minVal)
throw BornesIncorrectes();
int nbLu = 0;
bool ok = false;
do
{
cout << "Entrez une valeur entière entre " << minVal << " et " << maxVal << " inclusivement : ";
if (!(cin >> nbLu))
{
cin.clear();
cin.ignore();
}
else if (minVal <= nbLu && nbLu <= maxVal)
ok = true;
}
while (!ok);
return nbLu;
}
void afficher_menu(vector<string> options)
{
for (int i = 0; i < options.size(); ++i)
{
cout << i + 1 << " : " << options[i] << endl;
}
}
int lire_choix(vector<string> options)
{
return lire_entier_borne(1, options.size());
}
class Forme
{
public:
virtual void dessiner() const = 0;
};
class Carre : public Forme
{
public:
void dessiner() const override
{
cout << "Je suis un Carre" << endl;
}
};
class Rectangle : public Forme
{
public:
void dessiner() const override
{
cout << "Je suis un Rectangle" << endl;
}
};
class Triangle : public Forme
{
public:
void dessiner() const override
{
cout << "Je suis un Triangle" << endl;
}
};
unique_ptr<Forme> lire_forme()
{
const int CHOIX_CARRE = 0,
CHOIX_RECTANGLE = 1,
CHOIX_TRIANGLE = 2;
string opts[] = { "Carre", "Rectangle", "Triangle" };
vector<string> options (begin(opts), end(opts));
// vector<string> options { "Carre", "Rectangle", "Triangle" };
afficher_menu(options);
int choix = lire_choix(options);
unique_ptr<Forme> forme;
switch(choix - 1)
{
case CHOIX_CARRE:
forme = unique_ptr<Forme>(new Carre);
break;
case CHOIX_RECTANGLE:
forme = unique_ptr<Forme>(new Rectangle);
break;
case CHOIX_TRIANGLE:
forme = unique_ptr<Forme>(new Triangle);
break;
}
return forme;
}
int lire_nb_formes()
{
afficher_titre_etape("Entrée du nombre de formes souhaite");
return lire_entier_positif();
}
vector<unique_ptr<Forme>> lire_formes(int nb_formes)
{
afficher_titre_etape("Entree des formes souhaitees");
vector<unique_ptr<Forme>> formes;
for (int i = 0; i < nb_formes; ++i)
{
cout << "Forme #" << i + 1 << endl;
formes.push_back(lire_forme());
}
return formes;
}
int main()
{
int nb_formes = lire_nb_formes();
auto formes = lire_formes(nb_formes);
for(auto &f : formes)
f->dessiner();
}
Il y a quelques irritants dans cette implémentation :
void afficher_menu(vector<string> options)
{
for (int i = 0; i < options.size(); ++i)
{
cout << i + 1 << " : " << options[i] << endl;
}
}
void afficher_menu(const vector<string> &options)
{
for (vector<string>::size_type i = 0; i < options.size(); ++i)
{
cout << i + 1 << " : " << options[i] << endl;
}
}
J'ai documenté le tout pour expliquer aux gens qui seraient moins familiers avec les idiomes contemporains de C++ certaines pratiques, mais cette documentation peut laisser l'impression d'une grande différence de complexité entre C++ et C#. Regardons de plus près le tout à partir d'un tableau comparatif :
Version C# | Version C++ | Remarques |
---|---|---|
|
| Pour la version C#, j'ai laissé sur place les using que Visual Studio insère par défaut, même si la plupart sont superflus dans ce cas-ci. Pour la version C++, j'ai inséré les directives #include requises, mais je n'ai pas pris soin de faire des using individuels comme je le ferais dans mon propre code, privilégiant un using global sur std. Je pense que cela reflète ce qu'on demande à nos étudiant(e)s de S2. |
|
| Je pense que les différences principales entre ces deux implémentations est que C# force à utiliser new pour les string construites avec deux paramètres, et que C++ permet d'avoir des constantes dynamiques en plus des constantes statiques. J'ai utilisé des minuscules pour la constante dynamique; ce choix esthétique peut être débattu. Le reste me semble cosmétique, sans plus. |
|
| Tel que noté dans des questions antérieures, les entrées/ sorties de C++ ne lèvent pas d'exceptions; toutefois, un flux en erreur doit être nettoyé. Les exceptions constituent un thème essentiel, bien sûr, et il faut en venir à les enseigner éventuellement. J'en utilise d'ailleurs une dans le code C++ plus bas. |
|
| Voir les remarques sur les entrées/ sorties ci-dessus. En C#, les exceptions s'inscrivent dans une arborescence imposée (on tend à dériver nos exceptions de la classe ApplicationException), ce qui impose de survoler le concept d'héritage un peu rapidement, typiquement avant de pouvoir l'aborder avec sérieux. J'ai vécu cette situation au SIM; ça se gère mais ça entraîne un peu de confusion (mineure) pendant quelques semaines, le temps qu'on puisse approfondir le tout. Ici, je contourne le problème avec un ArgumentException général, mais c'est une mauvaise pratique. Petite remarque : le code que j'ai écrit en C# est peu recommandable. En effet, il formate une chaîne créée dynamiquement dans une levée d'exception, ce qui pourrait mener à une exception Out of Memory. Une exception pendant un traitement d'exception. Très vilain. En C++, on tend à utiliser des classes dont le type représente le problème souligné. Dans mon code, ces classes sont souvent vides, le type suffisant à détailler le problème, mais il est bien sûr possible de leur intégrer attributs, constructeurs, méthodes et ainsi de suite. Pas besoin d'utiliser new (en fait, en C++, on utilise très peu souvent new de manière directe; en pratique, c'est presque disparu du code client, mais j'y reviendrai). |
|
| Ici, tel qu'indiqué plus haut, j'ai accepté un avertissement en C++ pour afficher l'indice de chaque option en plus de sa valeur, mais c'est pas gentil. Du code correct utiliserait le bon type pour l'indice, mais on pourrait débattre qu'il s'agirait d'une écriture compliquée en S2. Personnellement, je la montrerais, puisque c'est un meilleur design que de supposer int partout et que c'est une pratique très homogène, mais je comprendrais que les opinions varient sur le sujet. Quand on n'a pas choisi d'afficher les indices, le code devient beaucoup plus simple, évidemment :
Si on souhaite la simplicité avant la vitesse, on peut même enlever le & avant la variable s et tout fonctionne sans problème. |
|
| Peu de choses à dire ici, du point de vue d'un cours de S2; personnellement, j'enseignerais le passage des objets const & si je donnais le cours avec C++, quitte à en faire une recette, mais c'est une question d'hygiène plus qu'une obligation conceptuelle. Si nous écrivions du code multiprogrammé, toutefois, le code C# serait dangereux et il faudrait discuter des aléas du partage (implicite en C#) du tableau. |
|
| À mon avis, les distinctions entre les deux versions sont cosmétiques. Je suis de ceux qui pensent que abstract est un choix plus élégant que virtual ... =0 pour une méthode abstraite, soit dit en passant, même si je comprends les choix de part et d'autre. Notez aussi que C++ permet d'indiquer au compilateur qu'une méthode d'instance est const, donc qu'elle ne modifie pas l'objet qui en est propriétaire; cette considération d'hygiène est absente de C# (snif!). La possibilité de qualifier une méthode const est l'un des trucs les plus douloureux pour moi quand j'utilise C#, mais il est clair que l'utiliser en C++ demande aussi de l'expliquer, donc ceci demande un effort d'enseignement et d'apprentissage supplémentaire. |
|
| Évidemment, const fait partie de la signature de la méthode dessiner(), ce qui se répercute sur les enfants. Outre cela, ici encore, les implémentations sont semblables, sauf peut-être pour le fait que C++ supporte d'autres formes d'héritage que l'héritage public, et que l'héritage privé y est le mode par défaut (ce qui explique le : public Forme). Sais pas pour vous, mais dans mon expérience, en S2, on voit plus ça comme une recette (en S4, on peut aller plus en profondeur sur des questions de design comme celle-là, du moins si le langage le permet). J'ai mis le mot-clé contextuel override en C++ aussi, puisqu'il est supporté depuis C++ 11 (final aussi, dans le même sens qu'en Java et au sens de sealed en C#). Plusieurs sont contents; moi, override me laisse un peu froid (sealed et final, par contre, je suis plutôt content), mais je m'attends à ce que ce soit utilisé alors c'est sage de le montrer peu importe le langage. |
|
| Ici, on a un cas intéressant à mon avis :
Notez que le code C++ proposé ici est correct en C++ 11 mais en C++ 14, on remplacera ceci :
... par cela :
Ça devrait déjà être ainsi, d'ailleurs, mais ça a échappé au comité de standardisation. La plupart des gens (dont moi) ont déjà codé un make_unique() en attendant, et std::make_unique() est déjà accepté pour C++ 14. |
|
| Les différences entre les implémentations ici me semblent cosmétiques. |
|
| Les différences entre les implémentations me semblent cosmétiques ici aussi. C# alloue les objets dynamiquement, C++ le fait automatiquement, et C++ demande une réflexion à savoir si un objet alloué dynamiquement sera partagé ou non (par défaut, ne pas partager est plus sécuritaire, évidemment) alors on y privilégie unique_ptr (il existe aussi shared_ptr lorsque ça semble plus à propos). En C#, partager est la seule avenue; c'est plus simple mais plus dangereux dès qu'un programme devient multiprogrammé. |
|
| Enfin, les différences ici sont encore une fois de l'ordre du cosmétique. Notez que j'ai utilisé auto en C++ comme type de formes pour éviter d'écrire vector<unique_ptr<Forme>> alors que j'ai écrit List<Forme> en C#, mais en C# on aurait aussi pu écrire var pour obtenir le même effet. Je n'étais pas certain si c'était autant dans les moeurs, mais c'est probablement la chose à faire. Détail syntaxique : en C++, les membres des objets accédés directement le sont par l'opérateur . alors que ceux accédés indirectement le sont par l'opérateur ->, alors qu'en C# ou en Java il n'y a pas d'accès direct aux objets, ce qui impose le recours à new un peu partout mais permet de se limiter à l'opérateur . pour accéder aux membres d'un référé. |
Si nous souhaitons discuter avec des étudiantes et des étudiants un peu plus avancés, alors il devient possible d'aborder des solutions un peu plus élégantes. En particulier, nous pourrions combiner des tableaux associatifs et des fabriques pour éliminer les sélectives.
Ainsi, en C#, nous pourrions écrire :
// ...
static Forme LireForme()
{
Dictionary<string, Func<Forme>> fabriques = new Dictionary<string,Func<Forme>>();
fabriques.Add("Carré", ()=> new Carré());
fabriques.Add("Rectangle", ()=> new Rectangle());
fabriques.Add("Triangle", ()=> new Triangle());
string[] options = { "Carré", "Rectangle", "Triangle" };
AfficherMenu(options);
int choix = LireChoix(options);
return fabriques[options[choix - 1]]();
}
// ...
Dans LireForme(), nous associons un nom de Forme (ceux dans le tableau de choix de menu) avec une fonction de fabrication (une Func<Forme>), et nous créons ces fonctions de fabrication à l'aide de λ-expressions, comme il se doit. Pour un aperçu de la syntaxe des λ en C#, voir ceci. Tristement, la correspondance entre le type retourné par la λ et le type du dictionnaire doit être directe en C#, ce qui force un transtypage (toujours une idée discutable). Il n'y a pas de raison pour ne pas accepter de λ-expressions qui retournent des enfants de Forme dans ce cas-ci; ça ressemble nettement à un bogue de spécification langagière.
De même, en C++, nous pourrions écrire :
// ...
#include <functional>
#include <map>
// ...
unique_ptr<Forme> lire_forme()
{
map<string, function<unique_ptr<Forme>()>> fabriques;
fabriques["Carre"] = []() { return unique_ptr<Forme>(new Carre); };
fabriques["Triangle"] = []() { return unique_ptr<Forme>(new Triangle); };
fabriques["Rectangle"] = []() { return unique_ptr<Forme>(new Rectangle); };
string opts[] = { "Carre", "Rectangle", "Triangle" };
vector<string> options (begin(opts), end(opts));
// vector<string> options { "Carre", "Rectangle", "Triangle" };
afficher_menu(options);
int choix = lire_choix(options);
return fabriques[options[choix-1]]();
}
// ...
La std::map joue en C++ le même rôle que le Dictionary de .NET. Les différences sont cosmétiques. Pour comprendre la syntaxe des expressions λ en C++, voir cet article. On n'a pas besoin de transtyper le type de retour de nos fonctions de fabrication dans ce cas-ci puisqu'un enfant (p. ex. : Carre) est un cas particulier de son parent (Forme).