Quelques raccourcis :

Université de Sherbrooke, développement du jeu vidéo, COA

Vous trouverez ici quelques documents et quelques liens pouvant, je l'espère, vous être utiles.

Les documents qui vous sont fournis ici le sont pour vous rendre service.

Je travaille très fort sur chacun de mes cours. Veuillez ne pas vendre (ou donner) les documents que je vous offre ici à qui que ce soit sans mon consentement. Si des abus surviennent, je vais cesser de rendre ce matériel disponible à toutes et à tous.

Si ces documents vous rendent service, faites-le moi savoir. Mon adresse de courriel est disponible via la page où on trouve mon horaire.

Si vous êtes curieuses ou curieux de voir un vieil examen (celui de la cohorte 02!), pour voir le style de question que vous pouvez rencontrer, voir UdeS-COA--Controle-Final--Cohorte-02.pdf (mais ne vous basez pas sur les questions pour le contenu, qui évolue à chaque année)

Plan de cours

À propos des séances en classe

Les quelques sections qui suivent réfèrent à ce que nous avons fait (ou allons faire) en classe cette session.

Séance

Date

Détail

S00

Vendredi 4 sept. 7h-10h (13h-16h en France)

Au menu :

  • Brève présentation du plan de cours
  • Présentation sommaire des travaux de la session (TP00 et TP01)
  • Discussion de ce que signifie être OO, selon vos divers points de vue et vos expériences préalables. C'est un sujet complexe en soi et simplement en discuter est un terreau fertile pour faire réagir les neurones
  • Jongler avec un petit programme de devinette, pour dérouiller nos articulations et notre cerveau....

En vue de notre prochaine rencontre, je vous ai proposé de vous amuser avec le problème de la rédaction d'un jeu de Mastermind.

S01

Vendredi 11 sept. 7h-10h (13h-16h en France)

Au menu :

Si vous souhaitez faire des exercices, dans les notes de cours :

  • Les templates sont discutés une première fois dans POO – Volume 02, pp. 11-31
  • Une brève introduction à la bibliothèque standard est proposée dans POO – Volume 02, pp. 50-95
  • Une introduction aux foncteurs est proposée dans POO – Volume 02, pp. 152-173
  • Une introduction aux λ est proposée dans POO – Volume 02, pp. 180-193
  • Une introduction aux singletons est proposée dans POO – Volume 02, pp. 222-241
  • La génération de nombres pseudoaélatoires est proposée dans POO – Volume 02, pp. 116-119

Pour vous donner une idée de ce à quoi la matière d'aujourd'hui peut servir, imaginez ceci :

// ...
class Objet3D {
   // ...
public:
   virtual void Draw(IDirect3DDevice9 *device) = 0;
   virtual ~Objet3D() = default;
   // ...
};
class Dessiner {
   IDirect3DDevice9 *dev;
public:
   Dessiner(IDirect3DDevice9 *dev) : dev{ dev } {
   }
   void operator()(const Object3D *p) const {
      p->Draw(dev);
   }
};
#include <vector>
#include <algorithm>
int main() {
   using namespace std;
   vector<Object3D*> v;
   IDirect3DDevice9 *device = ...
   //
   // remplir v...
   //
   // Tout afficher avec un foncteur (exemple)
   //
   for_each(begin(v), end(v), Dessiner{ device });
   // Tout libérer avec une λ (exemple)
   for_each(begin(v), end(v), [](Objet3D* p) {
      delete p;
   });
   // ... dans un cas comme celui-ci, on peut faire plus simple encore ;)
   for(auto p : v)
      delete p;
}

Comme vous pouvez le voir, afficher un monde (ici : les objets 3D vers lesquels pointent les éléments du vecteur v) et le nettoyer (ici, on suppose que les pointés peuvent être supprimés par delete, mais il y a des alternatives) devient tout simple quand on sait s'exprimer à l'intérieur des idiomes de notre langage.

Autre exemple simple mais sympathique : supposons un jeu où il y a des monstres et où il faut périodiquement filtrer ceux qui sont morts. Supposons que Monstre soit à peu près comme suit :

class Monstre {
   // ...
public:
   bool est_mort() const;
   // ...
   virtual ~Monstre();
};

... donc que Monstre soit une classe polymorphique telle que ce sont principalement ses dérivés que nous utilisons en pratique. Ainsi, une fonction qui filtre les monstres morts d'un vector<Monstre*> pourrait être :

vector<Monstre*> filtrer_morts(vector<Monstre*> v) {
   // [begin(v),p) est une séquence de non-morts, [p,end(v)) est une séquence de morts
   auto p = partition(begin(v), end(v), [](const Monstre *p) { return !p->est_mort(); });
   for_each(p, end(v), [](const Monstre *p) { delete p; }); // destruction des morts
   v.erase(p, end(v)); // élimination des éléments détruits
   return v;
}

... ou plus simplement encore :

vector<Monstre*> filtrer_morts(vector<Monstre*> v) {
   auto p = partition(begin(v), end(v), [](const Monstre *p) { return !p->est_mort(); });
   for_each(p, end(v), [](const Monstre *p) { delete p; }); // destruction des morts
   return { begin(v), p }; // on retourne ceux qui restent
}

Essayez de programmer cette fonction sans recours aux algorithmes standards et aux λ. Vous constaterez sans doute que le problème n'est pas banal...

s/o

Du 13 au 18 septembre

Cette semaine, je serai (virtuellement) à CppCon 2020. Vous pourrez me suivre (à travers ../../Sujets/Orthogonal/cppcon2020.html) si vous le souhaitez.

S02

Vendredi 25 sept. 7h-10h (13h-16h en France)

Au menu :

Si vous souhaitez faire des exercices, dans les notes de cours :

  • L'héritage privé est expliqué dans POO – Volume 01, pp. 55-66
  • Une introduction aux objets opaques, aux interfaces et aux fabriques (avec un survol de l'idiome pImpl) est proposée dans POO – Volume 02, pp. 136-151
  • Les λ-expressions sont décrites dans POO – Volume 02, pp. 180-193

S03

Vendredi 2 oct. 7h-10h (13h-16h en France)

Au menu :

  • Q01
  • Discussion des accès aux flux en C++
  • Retour sur certains aspects de la séance S02 :
    • retour sur std::unique_ptr
    • présentation de std::make_unique()
    • premier contact avec le mot clé friend et son rôle, à la fois dans l'encapsulation et la définition de meilleures interfaces
    • premier contact (sommaire) avec la sémantique de mouvement
  • Truc pour comprendre la sémantique de mouvement : une classe Noisy
  • Début de la conception d'un tableau dynamique de int

Si vous souhaitez faire des exercices, dans les notes de cours :

  • L'amitié (friend) est décrite dans POO – Volume 02, pp. 251-257
  • Les pointeurs intelligents sont discutés dans POO – Volume 02, pp. 120-135. Notez que c'est un sujet vaste sur lequel nous reviendrons à plusieurs reprises
  • L'essentiel de ce qui a été présenté aujourd'hui se trouve sur les liens susmentionnés. Toutefois, la semaine prochaine, le coeur de notre réflexion se trouvera dans le document POO – Volume 02, pp. 96-115 (voir en particulier la page 111 pour la sémantique de mouvement, qui risque de surprendre certains d'entre vous)

Pour un exemple d'utilisation de std::unique_ptr avec une fonction de nettoyage du pointé qui soit différente de l'opérateur delete (qui est le comportement par défaut), voici un petit exemple opérationnel. J'ai utilisé un truc qui expose un Release() bidon pour me coller un peu à ce que vous utilisez avec DirectX, mais sachez que ce qui se fait avec DirectX (et COM en général) est plus subtil que ce que laisse entendre cet exemple :

#include <iostream>
#include <memory>
using namespace std;
struct ID3DMachinChouette {
   virtual int fonction_tres_importante() = 0;
   virtual void Release() = 0;
   friend void relacher(ID3DMachinChouette *);
protected:
   virtual ~ID3DMachinChouette() = default;
};
class MachinChouette : public ID3DMachinChouette {
   //
   // Remarquez: toutes les méthodes sont privées. Pourtant,
   // ça fonctionne. Pourquoi?
   //
   int fonction_tres_importante() override {
      return 3; // calcul très savant, évidemment
   }
   void Release() override {
      delete this; // urg! Mais propre et légal
   }
};
void relacher(ID3DMachinChouette *p) {
   if (p) p->Release();
}
auto creer_machin_chouette() {
   return unique_ptr<ID3DMachinChouette, void(*)(ID3DMachinChouette*)>{
      new MachinChouette, relacher
   };
}
int main() {
   auto p = creer_machin_chouette();
   cout << p->fonction_tres_importante() << endl;
}

Le destructeur de p, qui est une instance du type unique_ptr<ID3DMachinChouette> et mène en fait vers un MachinChouette, est responsable de détruire le pointé, et le fait à l'aide d'un appel à relacher() tel que nous le lui avons demandé.

Notez que j'ai utilisé auto pour le type de p. Ceci signifie « p est du type de l'expression utilisée pour l'initialiser ». Dans ce cas, puisque p est initialisé par ce que retourne creer_machin_chouette(), alors son type est :

unique_ptr<ID3DMachinChouette, void(*)(ID3DMachinChouette*)>

Ce qui rend cette écriture lourde est le fait que nous utilisons une fonction comme outil de nettoyage, et que le type des pointeurs de fonctions n'est pas le plus élégant que nous ait légué le langage C. Une alternative utilisant un foncteur dont l'opérateur () serait générique nous donnerait l'écriture suivante (plus élégante, vous en conviendrez). Les modifications sont indiquées à même les commentaires :

#include <iostream>
#include <memory>
using namespace std;
struct ID3DMachinChouette {
   virtual int fonction_tres_importante() = 0;
   virtual void Release() = 0;
   friend struct Relacher; // <-- ICI
protected:
   virtual ~ID3DMachinChouette() = default;
};
class MachinChouette : public ID3DMachinChouette {
   //
   // Remarquez: toutes les méthodes sont privées. Pourtant,
   // ça fonctionne. Pourquoi?
   //
   int fonction_tres_importante() override {
      return 3; // calcul très savant, évidemment
   }
   void Release() override {
      delete this; // urg! Mais propre et légal
   }
};
struct Relacher { // <-- ICI (toute la classe)
   template <class T>
      void operator()(T p) const {
         p->Release();
      }
};
auto creer_machin_chouette() { // <-- ICI
   return unique_ptr<ID3DMachinChouette, Relacher>{ new MachinChouette }; // <-- ICI
}
int main() {
   auto p = creer_machin_chouette();
   cout << p->fonction_tres_importante() << endl;
}

Dans ce cas, même la construction du unique_ptr dans creer_machin_chouette() est plus légère, du fait que seule une instance de Relacher peut servir à titre de nettoyeur, et du fait que Relacher a un constructeur par défaut (tout à fait suffisant). En plus, on vient d'épargner un peu d'espace en mémoire en enchâssant Relacheur dans le type de l'objet plutôt que dans ses états.

S04

Vendredi 2 oct. 12h-15h (18h-21h en France)

Au menu :

  • Q02
  • Conception d'un tableau dynamique de int, pour explorer diverses ramifications (pas toujours évidentes) de la conception d'un tel conteneur
  • Présentation de l'idiome d'affectation sécuritaire

Pour celles et ceux qui le souhaitent, le Tableau modélisant un (simpliste et encore incomplet) tableau dynamique d'entiers que nous avons écrit ressemblait à ceci :

#include <cstddef> // std::size_type
#include <algorithm>
#include <ostream>
class Tableau {
public:
   using value_type = int;
   using size_type = std::size_t;
   using pointer = value_type*;
   using const_pointer = const value_type*;
   using reference = value_type&;
   using const_reference = const value_type&;
private:
   pointer elems{};
   size_type nelems{},
             cap{};
public:
   [[nodiscard]] size_type size() const noexcept {
      return nelems;
   }
   [[nodiscard]] bool empty() const noexcept {
      return !size();
   }
   [[nodiscard]] size_type capacity() const noexcept {
      return cap;
   }
private:
   [[nodiscard]] bool full() const noexcept {
      return size() == capacity();
   }
public:
   using iterator = pointer;
   using const_iterator = const_pointer;
   [[nodiscard]] iterator begin() noexcept {
      return elems;
   }
   [[nodiscard]] const_iterator begin() const noexcept {
      return elems;
   }
   [[nodiscard]] const_iterator cbegin() const noexcept {
      return begin();
   }
   [[nodiscard]] iterator end() noexcept {
      return begin() + size();
   }
   [[nodiscard]] const_iterator end() const noexcept {
      return begin() + size();
   }
   [[nodiscard]] const_iterator cend() const noexcept {
      return end();
   }
   Tableau() = default;
   Tableau(size_type n, const_reference val)
      : elems{ new value_type[n] }, nelems{ n }, cap{ n } {
      std::fill(begin(), end(), val);
   }
   Tableau(const Tableau &autre)
      : elems{ new value_type[autre.size()] }, nelems{ autre.size() }, cap{ autre.size() } {
      std::copy(autre.begin(), autre.end(), begin());
   }
   Tableau(Tableau && autre) noexcept
      : elems{ std::exchange(autre.elems, nullptr) },
        nelems{ std::exchange(autre.nelems, 0) },
        cap{ std::exchange(autre.cap, 0) } {
   }
   ~Tableau() {
      delete [] elems;
   }
   void swap(Tableau &autre) noexcept {
      using std::swap;
      swap(elems, autre.elems);
      swap(nelems, autre.nelems);
      swap(cap, autre.cap);
   }
   Tableau& operator=(const Tableau &autre) {
      Tableau{ autre }.swap(*this);
      return *this;
   }
   Tableau &operator=(Tableau &&autre) noexcept {
      Tableau{ std::move(autre) }.swap(*this);
      return *this;
   }
   [[nodiscard]] reference operator[](size_type n) noexcept {
      return elems[n];
   }
   [[nodiscard]] const_reference operator[](size_type n) const noexcept {
      return elems[n];
   }
   [[nodiscard]] bool operator==(const Tableau &autre) const noexcept {
      return size() == autre.size() && std::equal(begin(), end(), autre.begin());
   }
   [[nodiscard]] bool operator!=(const Tableau &autre) const noexcept {
      return !(*this == autre);
   }
   void push_back(const_reference val) {
      if(full()) grow();
      elems[size()] = val;
      ++nelems;
   }
   void pop_back() noexcept {
      --nelems;
   }
   void clear() noexcept {
      nelems = {};
   }
   [[nodiscard]] reference front() noexcept {
      return (*this)[0];
   }
   [[nodiscard]] const_reference front() const noexcept {
      return (*this)[0];
   }
   [[nodiscard]] reference back() noexcept {
      return (*this)[size() - 1];
   }
   [[nodiscard]] const_reference back() const noexcept {
      return (*this)[size() - 1];
   }
private:
   void grow() {
      const auto nouv_cap = capacity()? static_cast<size_type>(capacity() * 1.5) : 42;
      auto p = new value_type[nouv_cap];
      std::copy(begin(), end(), p);
      delete [] elems;
      elems = p;
      cap = nouv_cap;
   }
};

std::ostream &operator<<(std::ostream &os, const Tableau &tab) {
   for (auto n : tab)
      os << n << ' ';
   return os;
}

//
// code client naïf
//
#include <iostream>
#include <iterator>
using namespace std;
int main() {
   Tableau t;
   for(int i = 0; i != 100; ++i)
      t.push_back(i + 1);
   cout << "Apres 100 insertions, " << t.size() << " elems (capacite de " << t.capacity()
        << ")\nValeurs : " << t << endl;
   if (auto p = find_if(begin(t), end(t), [](int n) { return n > 10 && n % 2 == 0; });
      p != end(t))
      cout << "\n\n" << *p << endl;
}

Si vous souhaitez faire des exercices, dans les notes de cours :

  • Le coeur de notre réflexion se trouvera dans le document POO – Volume 02, pp. 96-115

S05

Vendredi 9 oct. 7h-10h (13h-16h en France)

Au menu :

Si vous souhaitez faire des exercices, dans les notes de cours :

  • Le coeur de notre réflexion se trouvera dans le document POO – Volume 02, pp. 96-115

S06

Vendredi 16 oct. 7h-10h (13h-16h en France)

Au menu :

Si vous souhaitez faire des exercices, dans les notes de cours :

S07

Vendredi 23 oct. 7h-10h (13h-16h en France)

Au menu :

Remise du TP00

S08

Vendredi 30 oct. 7h-10h (13h-16h en France)

Au menu :

Pour vous divertir, un petit exercice :

  • Écrivez le trait nbits_traits<T> tel que nbits_traits<T>::value sera le nombre de bits utilisés pour représenter un T
  • Pour implémenter ce trait, considérez que :
    • sizeof(T) est le nombre de bytes qu'occupe un T en mémoire
    • le nombre de bits dans un byte n'est pas nécessairement huit (même si ce l'est dans l'immense majorité des cas). Techniquement, le nombre de bits dans un byte est décrit par par la constante std::numeric_limits<unsigned char>::digits
    • puisqu'on ne peut instancier un void, faites en sorte que nbits_traits<void>::value soit zéro
  • Écrivez une constante générique nbits_traits_v<T> équivalente à nbits_traits<T>::value
  • Écrivez le trait same_size<T,U> tel que same_size<T,T>::value soit vrai et que same_size<T,U>::value soit vrai seulement si nbits_traits<T>::value==nbits_traits<U>::value
  • Écrivez une constante générique same_size_v<T,U> équivalente à same_size<T,U>::value
  • Écrivez le trait suffix_trait<T> tel que :
    • en général, ce trait ne soit pas défini et corresponde à un type incomplet (donc que le nom soit suivi d'un simple « ; », même pas d'accolades)
    • la méthode de classe suffix_trait<T>::value() retourne une std::string ayant pour valeur un suffixe valide pour T lorsque les littéraux de ce type portent effectivement un suffixe ("l" pour long, "ll" pour long long, "u" pour unsigned int, "ul" pour unsigned long, "ull" pour unsigned long long, "f" pour float et "l" pour long double)
// ...
#include <iostream>
#include <string>
using namespace std;
template <class T>
   void test(ostream &os, const string &nom) {
      os << "Une instance du type " << nom << " occupe " << nbits_traits_v<T> << " bits en memoire\n";
      if constexpr (same_size_v<T,int>) {
         os << "\t... c'est autant qu'un int!" << endl;
      } else {
         os << "\t... ce n'est pas autant qu'un int" << endl;
      }
   }
int main() {
   test<short>(cout, "short");
   test<float>(cout, "float");
   test<double>(cout, "double");
   test<string>(cout, "string");
   // ceci ne compilerait pas
   // cout << "Le suffixe d'un littéral int est : " << suffix_trait<int>::value() << endl;
   cout << "Le suffixe d'un littéral float est : " << suffix_trait<float>::value() << endl;
   cout << "Le suffixe d'un littéral long double est : " << suffix_trait<long double>::value() << endl;
   cout << "Le suffixe d'un littéral unsigned int est : " << suffix_trait<unsigned int>::value() << endl;
}
  • Introduction au clonage :
  • Activité pratique :
    • prenez comme base de travail la classe Tableau<T> (la version avec pointeur brut, pas la version avec un unique_ptr)
    • notez que nous avions mis en place une stratégie fixe de croissance de la capacité du conteneur – la fonction grow() doublait la capacité du Tableau<T>)
    • cependant, doubler n'est pas toujours la meilleure option (c'est efficace en termes de vitesse, mais beaucoup moins efficace en termes de consommation de mémoire – c'est une stratégie gourmande!)
    • vote tâche est de proposer une stratégie pour que le code client puisse choisir la stratégie de croissance d'un Tableau<T>, et de montrer le code client de votre classe une fois votre stratégie appliquée
    • nous discuterons de différentes options en fin de séance
  • Étude de diverses stratégies envisageables pour prendre en charge la stratégie de croissance au besoin d'un tableau dynamique
    • l'ordre de présentation des approches n'est pas tant de la pire à la meilleure mais bien de la plus susceptible d'être familière à la plus susceptible d'être surprenante (mon évaluation; vous me direz si je me suis trompé)
  • La liste d'approches pour implémenter une stratégie de croissance couvertes en classe (à partir de la version dite « vanille ») inclut :
  • Cette liste n'est pas exhaustive, mais permet de comparer diverses approches (c'est un cours de conception OO, après tout!). Et il existe des solutions plus simples

Si vous souhaitez faire des exercices, dans les notes de cours :

  • Le clonage est discuté dans le document POO – Volume 01, pp. 204-214

S09

Vendredi 6 nov. 7h-10h (13h-16h en France)

Au menu :

Si vous souhaitez faire des exercices, dans les notes de cours :

  • La gestion avancée de la mémoire est décrite dans le document POO – Volume 03, pp. 148-195

Une infrastructure d'expérimentation est disponible ici si vous souhaitez jouer avec diverses techniques de gestion de la mémoire allouée dynamiquement. Amusez-vous bien!

Je donne parfois à titre de minitest pratique ce que vous trouverez sur Exercice-Orque-Memoire.html mais je ne le ferai pas cette session faute de temps. Vous pouvez quand même vous amuser à faire l'exercice pour voir ce à quoi vous arriverez, et pour en discuter entre vous / avec moi si vous en avez envie!

S10

Vendredi 13 nov. 7h-10h (13h-16h en France)

Au menu :

  • Poursuite de notre étude des mécanismes de gestion avancée de la mémoire sous divers angles
    • retour sur un tableau dynamique générique, en tenant compte des mécanismes que nous avons couverts à la séance S09
      • quelques fonctionnalités de gestion de mémoire brute
    • examen de ce que font les conteneurs standards, par exemple std::vector et std::list, à l'aide d'allocateurs standards
    • difficultés intrinsèques à la saine gestion de la mémoire dans un tel conteneur
      • cas types des méthodes insert() et erase()
    • recours au mouvement ou à la copie pour les éléments
    • direction des copies et des mouvements
  • Les adaptateurs non-propriétaires de leurs données, comme string_view de C++ 17 et span<T> de C++ 20

S11

Vendredi 20 nov. 7h-10h (13h-16h en France)

Au menu : à venir

Pour le code exploré avec std::variant, qui a semblé vous plaire, nous sommes passés de cette version (polymorphique classique) :

#include <iostream>
using namespace std;

struct Sphere;
struct Box;
struct AABB;

struct Volume {
   virtual bool collides(const Volume&) const = 0;
   virtual bool collides(const Sphere&) const = 0;
   virtual bool collides(const Box&) const = 0;
   virtual bool collides(const AABB&) const = 0;
   virtual ~Volume() = default;
};

struct Sphere : Volume {
   bool collides(const Volume &autre) const override {
      return autre.collides(*this);
   }
   bool collides(const Sphere &autre) const override {
      cout << "Sphere x Sphere" << endl;
      return true;
   }
   bool collides(const Box &autre) const override {
      cout << "Sphere x Box" << endl;
      return true;
   }
   bool collides(const AABB &autre) const override {
      cout << "Sphere x Box" << endl;
      return true;
   }
};
struct Box : Volume {
   bool collides(const Volume &autre) const override {
      return autre.collides(*this);
   }
   bool collides(const Sphere &autre) const override {
      cout << "Box x Sphere" << endl;
      return true;
   }
   bool collides(const Box &autre) const override {
      cout << "Box x Box" << endl;
      return true;
   }
   bool collides(const AABB &autre) const override {
      cout << "Box x AABB" << endl;
      return true;
   }
};
struct AABB : Volume {
   bool collides(const Volume &autre) const override {
      return autre.collides(*this);
   }
   bool collides(const Sphere &autre) const override {
      cout << "AABB x Sphere" << endl;
      return true;
   }
   bool collides(const Box &autre) const override {
      cout << "AABB x Box" << endl;
      return true;
   }
   bool collides(const AABB &autre) const override {
      cout << "AABB x AABB" << endl;
      return true;
   }
};

class Obj3D {
   // ...
public:
   virtual const Volume &volume() const = 0;
   virtual ~Obj3D() = default;
};
class Mario : public Obj3D {
   Box box;
   const Volume &volume() const override { return box; }
};
class Pacman : public Obj3D {
   Sphere sphere;
   const Volume &volume() const override { return sphere; }
};

void test(const Obj3D &a, const Obj3D &b) {
   if (a.volume().collides(b.volume()))
      ;
}
int main() {
   test(Mario{}, Pacman{});
}

... à cette version avec std::variant :

#include <iostream>
#include <variant>
using namespace std;

struct Sphere {};
struct Box {};
struct AABB {};

using Volume = variant<Sphere, Box, AABB>;

class Obj3D {
   // ...
public:
   virtual Volume volume() const = 0;
   virtual ~Obj3D() = default;
};
class Mario : public Obj3D {
   Box box;
   Volume volume() const override { return box; }
};
class Pacman : public Obj3D {
   Sphere sphere;
   Volume volume() const override { return sphere; }
};

struct Visiteur {
   bool operator()(Sphere, Sphere) const { cout << "Sphere x Sphere" << endl; return true; }
   bool operator()(Sphere, Box) const { cout << "Sphere x Box" << endl; return true; }
   bool operator()(Sphere, AABB) const { cout << "Sphere x AABB" << endl; return true; }
   bool operator()(Box, Sphere) const { cout << "Box x Sphere" << endl; return true; }
   bool operator()(Box, Box) const { cout << "Box x Box" << endl; return true; }
   bool operator()(Box, AABB) const { cout << "Box x AABB" << endl; return true; }
   bool operator()(AABB, Sphere) const { cout << "AABB x Sphere" << endl; return true; }
   bool operator()(AABB, Box) const { cout << "AABB x Box" << endl; return true; }
   bool operator()(AABB, AABB) const { cout << "AABB x AABB" << endl; return true; }
};

bool collides(Volume v0, Volume v1) {
   return visit(Visiteur{}, v0, v1);
}

void test(const Obj3D &a, const Obj3D &b) {
   if (collides(a.volume(), b.volume()))
      ;
}

int main() {
   test(Mario{}, Pacman{});
}

... à cette version encore plus simple (dans cet exemple hyper simpliste) aussi avec std::variant :

#include <iostream>
#include <variant>
using namespace std;

struct Sphere {};
struct Box {};
struct AABB {};

ostream &operator<<(ostream &os, Sphere) { return os << "Sphere"; }
ostream &operator<<(ostream &os, Box) { return os << "Box"; }
ostream &operator<<(ostream &os, AABB) { return os << "AABB"; }

using Volume = variant<Sphere, Box, AABB>;

class Obj3D {
   // ...
public:
   virtual Volume volume() const = 0;
   virtual ~Obj3D() = default;
};
class Mario : public Obj3D {
   Box box;
   Volume volume() const override { return box; }
};
class Pacman : public Obj3D {
   Sphere sphere;
   Volume volume() const override { return sphere; }
};

struct Visiteur {
   template <class T, class U>
      bool operator()(T a, U b) const { cout << a << " x " << b << endl; return true; }
};

bool collides(Volume v0, Volume v1) {
   return visit(Visiteur{}, v0, v1);
}

void test(const Obj3D &a, const Obj3D &b) {
   if (collides(a.volume(), b.volume()))
      ;
}

int main() {
   test(Mario{}, Pacman{});
}

... ou encore :

#include <iostream>
#include <variant>
using namespace std;

struct Sphere {};
struct Box {};
struct AABB {};

ostream &operator<<(ostream &os, Sphere) { return os << "Sphere"; }
ostream &operator<<(ostream &os, Box) { return os << "Box"; }
ostream &operator<<(ostream &os, AABB) { return os << "AABB"; }

using Volume = variant<Sphere, Box, AABB>;

class Obj3D {
   // ...
public:
   virtual Volume volume() const = 0;
   virtual ~Obj3D() = default;
};
class Mario : public Obj3D {
   Box box;
   Volume volume() const override { return box; }
};
class Pacman : public Obj3D {
   Sphere sphere;
   Volume volume() const override { return sphere; }
};

bool collides(Volume v0, Volume v1) {
   return visit([](auto a, auto b) { cout << a << " x " << b << endl; }, v0, v1);
}

void test(const Obj3D &a, const Obj3D &b) {
   if (collides(a.volume(), b.volume()))
      ;
}

int main() {
   test(Mario{}, Pacman{});
}

Pour le côté plus divertissant, nous avons aussi fait :

#include <iostream>
#include <variant>
using namespace std;

struct Viz {
   template <class T>
   void operator()(T val) const { cout << val << endl; }
   void operator()(const string &s) { cout << '\"' << s << '\"' << endl; }
};

int main() {
   variant<int, float, string> v = 3;
   visit(Viz{}, v);
   v = "Coucou";
   visit(Viz{}, v);
   visit([](auto &&val) { cout << val << endl; }, v);
}

... de même que :

#include <iostream>
#include <variant>
using namespace std;

template <class ... Ts>
struct Combine : Ts... {
   using Ts::operator()...;
};
template <class ... Ts> Combine<Ts...> combine(Ts ...) { return {}; }

int main() {
   variant<int, float, string> v = 3;
   visit(combine(
      [](auto val) { cout << val << endl; },
      [](const string &s) { cout << '\"' << s << '\"' < endl; }
   ), v);
}

S12

Vendredi 27 nov. 7h-10h (13h-16h en France)

Au menu :

  • Q08
  • Suites sur std::variant
  • Survol du Class Type Argument Deduction (CTAD)
  • Emprunt au cours de l'hiver, à la demande de la classe :
  • Revisiter le problème de la fonction assez_proches() à l'aide de concepts de C++ 20
  • Quelques bribes de métaprogrammation pour nous permettre de faire des choix éclairés...
    • par exemple (et ce n'est qu'un exemple), comment une implémentation de std::copy() peut-elle choisir entre utiliser std::memcpy() et faire une boucle réalisant des affectations élément par élément?
    • conséquemment, pourquoi devrions-nous (presque) systématiquement utiliser std::copy() (et les fonctions du même acabit) plutôt que de chercher à l'optimiser?
    • et de manière corollaire : comment permettre à std::copy(), à ses cousines et à nos conteneurs d'être aussi efficaces que possible avec nos objets?

S13

Vendredi 4 déc. 7h-10h (13h-16h en France)

Au menu : cours dont vous êtes, par vos questions, les artisans. Si vous n'avez pas de questions particulières, je ferai :

  • Q09
  • Comment créer une DLL et en exposer des services (laboratoire dirigé)
  • Que signifie extern "C"?

Pour s'amuser...

  • Quelques énigmes à résoudre. Vous y réfléchissez, vous proposez votre approche, je vous propose un truc, on discute...

Énigme: un(e) collègue vous propose le code suivant :

#ifndef FORME_H
#define FORME_H
//
// Forme.h
//
#include <iosfwd>
class Forme {
public:
   using value_type = char;
private:
   value_type symbole_{ '*' };
public:
   Forme() = default;
   Forme(value_type symbole) : symbole_{symbole} {
   }
   value_type symbole() const {
      return symbole_;
   }
   virtual void dessiner(std::ostream &os) const = 0;
   virtual ~Forme() = default;
};
void afficher_formes(Forme *tab, std::size_t n);
#endif
//
// Forme.cpp
//
#include "Forme.h"
#include <iostream>
using namespace std;
ostream& operator<<(ostream &os, const Forme &f) {
   return f.dessiner(os), os;
}
void afficher_formes(Forme *tab, size_t n) {
   for (auto p = tab; p != tab + n; ++p)
      cout << *p << endl;
}
//
// Rectangle.h
//
#ifndef RECTANGLE_H
#define RECTANGLE_H
#include "Forme.h"
template <class T>
   constexpr bool est_entre_inclusif(T candidat, T minVal, T maxVal) {
      return minVal <= candidat && candidat <= maxVal;
   }
class DimensionIncorrecte {};
#include <ostream>
class Rectangle : public Forme {
public:
   using size_type = int;
private:
   static constexpr const size_type MIN_LARGEUR = 1, MAX_LARGEUR = 80;
   static constexpr const size_type MIN_HAUTEUR = 1, MAX_HAUTEUR = 25;
   size_type largeur_,
             hauteur_;
   static constexpr size_type valider_largeur(size_type largeur) {
      return est_entre_inclusif(largeur, MIN_LARGEUR, MAX_LARGEUR)? largeur : throw DimensionIncorrecte{};
   }
   static constexpr size_type valider_hauteur(size_type hauteur) {
      return est_entre_inclusif(hauteur, MIN_HAUTEUR, MAX_HAUTEUR)? hauteur : throw DimensionIncorrecte{};
   }
public:
   Rectangle(size_type largeur, size_type hauteur)
      : largeur_{ valider_largeur(largeur) },
        hauteur_{ valider_hauteur(hauteur) }
   {
   }
   Rectangle(size_type largeur, size_type hauteur, value_type symbole)
      : Forme{ symbole },
        largeur_{ valider_largeur(largeur) },
        hauteur_{ valider_hauteur(hauteur) }
   {
   }
   size_type largeur() const {
      return largeur_;
   }
   size_type hauteur() const {
      return hauteur_;
   }
   void dessiner(std::ostream &os) const {
      using std::endl;
      for (size_type hau = 0; hau < hauteur(); ++hau) {
         for (size_type lar = 0; lar < largeur(); ++lar)
            os << symbole();
         os << endl;
      }
   }
};
#endif
//
// Principal.cpp
//
#include "Rectangle.h"
#include "Forme.h"
int main() {
   Rectangle tab[] {
      Rectangle{ 1, 3 }, Rectangle{ 4, 2 }, Rectangle{ 3, 3 }
   };
   enum { N = sizeof(tab) / sizeof(tab[0]) };
   afficher_formes(tab, N); // BOUM!
}

Ce collègue vous informe que ce code compile sans avertissement mais, à sa grande surprise, plante sauvagement à l'exécution, apparemment une fois le 1er Rectangle affiché à la console. Quelques questions se posent :

  • Pourquoi ce code est-il syntaxiquement correct mais sémantiquement incorrect?
  • Comment pourrait-on le corriger?
  • À quoi devrait-on faire attention dans le futur pour ne pas revivre de tels ennuis?

Proposition de solution à l'énigme A. Proposition de solution à l'énigme A. Notez que std::span<T> est la solution standard et contemporaine à ce genre d'incident

Énigme B : il arrive souvent qu'on ait besoin d'une zone tampon temporaire (un Buffer temporaire, en jargon) pour entreposer des données. Supposons que nous ayons une politique à l'effet que, si nous avons besoin de 4 Ko (4096 bytes) ou moins, nous souhaitions allouer ce tampon sur la pile (classe std::array ou tableau brut, à votre convenance) alors que si nous avons besoin de plus d'espace, nous souhaitions privilégier un vecteur. Nous voulons donc que, dans le code ci-dessous, présumant sizeof(int)==4 et sizeof(double)==8, lbi soit un tableau de 1000 int et que lbd soit un vecteur de 1000 double. Comment y arriver?

#include "local_buffer.h" // <-- votre contribution
#include <algorithm>
int main() {
   using namespace std;
   auto lbi = create_local_buffer<int,1000>(); // lbi --> array<int,1000>
   auto lbd = create_local_buffer<double, 1000>(); // lbd --> vector<double>
   fill(begin(lbi), end(lbi), -1);
   fill(begin(lbd), end(lbd), 3.14159);
}

Proposition de solution à l'énigme B. Proposition de solution à l'énigme B.

Énigme C : vous souhaitez détecter dynamiquement les erreurs de conversion menant à des pertes d'information, comme par exemple dans le programme suivant :

#include <iostream>
#include <limits>
int main() {
   using std::numeric_limits;
   short s = numeric_limits<short>::max();
   int i = s;
   ++i;
   s = i; // <-- ICI
   // ...
}

Proposez une approche permettant, à l'aide d'une syntaxe semblable à celle d'un opérateur de transtypage ISO, de faire en sorte qu'on puisse ne permettre une telle conversion que si elle ne mène pas à une perte d'information ou à une erreur de calcul. Par exemple, en supposant que cette opération se nomme checked_cast, on aurait :

#include <iostream>
#include <limits>
int main() {
   using std::numeric_limits;
   short s = numeric_limits<short>::max();
   int i = s;
   ++i;
   s = checked_cast<short>(i); // <-- ICI (lèverait une exception)
   // ...
}

Petit complément : prenez soin de réfléchir aux cas pour lesquels votre approche serait applicable et aux cas pour lesquels elle ne le serait pas, du moins pas raisonnablement. Êtes-vous en mesure de valider certains de ces a priori dès la compilation du code client?

Proposition de solution à l'énigme C. Proposition de solution à l'énigme C.

Énigme D : soit la classe Tableau<T,N> suivante :

#include <algorithm>
template <class T, std::size_t N>
   class Tableau {
   public:
      using value_type = T;
      using size_type = std::size_t;
      using iterator = value_type*;
      using const_iterator = const value_type*;
   private:
      value_type elems[N]{};
   public:
      constexpr size_type size() const {
         return N;
      }
      iterator begin() noexcept {
         return std::begin(elems);
      }
      const_iterator begin() const noexcept {
         return std::begin(elems);
      }
      iterator end() noexcept {
         return std::end(elems);
      }
      const_iterator end() const noexcept {
         return std::end(elems);
      }
      Tableau() = default;
      template <class U>
         Tableau(const Tableau<U,N> &autre) {
            using std::copy;
            copy(autre.begin(), autre.end(), begin());
         }
      //
      // Sainte-Trinité implicitement Ok
      //
      value_type& operator[](size_type n) {
         return elems[n];
      }
      value_type operator[](size_type n) const {
         return elems[n];
      }
      bool operator==(const Tableau &autre) const {
         using std::equals;
         return equals(begin(), end(), autre.begin());
      }
      bool operator!=(const Tableau &autre) const {
         return !(*this == autre);
      }
   };

On souhaite savoir quelle est la meilleure manière d'implémenter les opérateurs == et != dans les cas suivants :

  • Si on compare un Tableau<T,N> avec un Tableau<T,M> pour M != N
  • Si on compare un Tableau<T,N> avec un Tableau<U,N> pour deux types T et U distincts, et
  • Si on compare un Tableau<T,N> avec un Tableau<U,M> pour M != N et deux types T et U distincts

Proposition de solution à l'énigme D. Proposition de solution à l'énigme D.

Énigme: vous avez conçu une classe Tableau<T> dans laquelle vous avez implémenté l'opérateur <. En version abrégée, cela vous donne :

#include <cstddef>
template <class T>
   class Tableau {
   public:
      using value_type = T;
      using size_type = std::size_t;
      using iterator = value_type*;
      using const_iterator = const value_type*;
   private:
      value_type *elems{};
      size_type nelems{}, cap{};
   public:
      // ...
      bool operator<(const Tableau &autre) const {
         for(auto p = begin(), q = autre.begin(); p != end() && q != autre.end(); ++p, ++q)
            if (*p != *q)
               return *p < *q;
         return p == end() && q != autre.end();
      }
   };

Cela dit, vous constatez rapidement qu'implémenter les opérateurs <=, > et >= en copiant / collant le code de l'opérateur < puis en ajustant ce code est... périlleux. Comment pourriez-vous simplifier votre existence?

Proposition de solution à l'énigme E. Proposition de solution à l'énigme E.

Pour le temps qu'il nous restera, nous ferons un peu de métaprogrammation...

Le petit exemple de métaprogrammation que j'ai fait à la fin de la séance est ci-dessous. En espérant que ça vous ait diverti!

#include <iostream>
using namespace std;

template <class T, T val>
   struct integral_constant_ {
      using type = T;
      static constexpr auto value = val;
      constexpr auto operator()() const { return value; }
   };

//
// Avec C++17, on aurait pu écrire ceci:
//
//template <auto val>
//   struct integral_constant__ {
//      using type = decltype(val);
//      static constexpr auto value = val;
//      constexpr auto operator()() const { return value; }
//   };

template <bool val>
   using bool_constant_ = integral_constant_<bool, val>;

using true_type_ = bool_constant_<true>;
using false_type_ = bool_constant_<false>;

template <int N>
   struct int_ : integral_constant_<int, N> { };

//
// Andrei Alexandrescu (adaptation contemporaine de ses idées)
//
template <class ...>
struct type_list;

/*
//
// Dans le code original d'Alexandrescu, pré-C++11, ça donnait quelque chose comme :
//
template <class,class> class type_list;
struct fin_{};

typedef type_list<
   int_<2>, type_list<
      int_<3>, type_list<
         int_<5>, type_list<
           int_<7>, type_list<
              int_<11>, fin_
           >
         >
      >
   >
> ze_prems;
*/

//
// Version « à la pièce »
//

//template <class TL> struct static_somme;
//template <class T, class ... Q>
//struct static_somme<type_list<T, Q...>>
//   : int_<T::value + static_somme<type_list<Q...>>::value> {
//};
//template <>
//struct static_somme<type_list<>> : int_<0> {
//};
//
//template <class TL> struct static_produit;
//template <class T, class ... Q>
//struct static_produit<type_list<T, Q...>>
//   : int_<T::value * static_produit<type_list<Q...>>::value> {
//};
//template <>
//struct static_produit<type_list<>> : int_<1> {
//};

//
// Version plus générale
//
template <class T, class U>
   struct sum_ : int_<T::value + U::value> {
};
template <class T, class U>
   struct prod_ : int_<T::value * U::value> {
};

template <class TL, template <class, class> class F, class Init>
   struct static_accumulate_;
template <class T, class... Q, template <class, class> class F, class Init>
   struct static_accumulate_<type_list<T, Q...>, F, Init> 
      : F<T, static_accumulate_<type_list<Q...>, F, Init>> {
   };
template <template <class, class> class F, class Init>
   struct static_accumulate_<type_list<>, F, Init> : Init {
   };

template <class ... Ts>
   using static_somme = static_accumulate_<type_list<Ts...>, sum_, int_<0>>;
template <class ... Ts>
   using static_produit = static_accumulate_<type_list<Ts...>, prod_, int_<1>>;

int main() {
   cout << static_somme<int_<2>, int_<3>, int_<5>, int_<7>, int_<11>>::value << endl;
   cout << static_produit<int_<2>, int_<3>, int_<5>, int_<7>, int_<11>>::value << endl;
}

Notez qu'avec template<auto>, si on se limite aux entiers, on peut écrire tout simplement :

template <auto ... Vs> constexpr auto somme() {
   return (Vs + ...);
}
int main() {
   cout << somme<2, 3, 5, 7, 11>() << endl; // on peut mêler int, short, unsigned long, etc.
}

... ou utiliser des techniques plus conventionnelles. Il y a de la place pour tout ça.

S14

Mardi 8 déc. 12h-15h ou 13h-16h (18h-21h ou 19h-22h en France)

Chic examen plein d'amour

« S15 »

 

Semaine de finalisation et de présentation du projet, avec présentation (date et heure à venir)

Code des cas sous étude dans les notes de cours

Ce qui suit vous est gracieusement offert dans le but de vous épargner une recopie pénible d'exemples et de tests proposés dans les notes de cours.

Exercice à faire suite à la séance S00

L'exercice proposé est celui de la construction d'un petit jeu de devinette

Exercice à faire suite à la séance S01

L'exercice proposé est celui de la construction d'un petit jeu de Mastermind

Consignes des travaux pratiques

Les consignes des travaux pratiques en lien avec le cours sont ci-dessous.

Consignes propres aux deux travaux pratiques

De manière générale :

Consignes du TP00

Date de remise : début de la séance S06.

Ce travail s'intègre aux travaux pratiques de vos autres cours de la session, et est à faire sur une base individuelle. Réfléchissez à l'application pertinente et justifiée par vous-même dans l'un ou l'autre de ces travaux pratiques :

Si vous éprouvez des difficultés à intégrer un élément technique précis, je suis ouvert à ce que vous proposiez une thématique technique équivalente. Dans le doute sur le caractère équivalent ou non de votre proposition, contactez-moi!

Notez que ce que vous proposerez doit se distinguer des exemples proposés par votre chic prof. Présentez chaque application en la mettant en contexte et en décrivant son fonctionnement, code à l'appui. Montrez clairement les éléments que vous intégrez à vos projets, et mettez en valeur la pertinence de vos choix. Le but de ce travail est de vous aider à progresser dans votre développement, pas de vous nuire.

Exemple de format possible

Pour qu'on se comprenne bien, voici le genre de truc auquel je m'attends (adaptez à votre style personnel, évidemment), mais avec un exemple simpliste. Supposons que vous avez refactorisé une fonction écrite à l'ancienne pour profiter des algorithmes standards (notez que dans cet exemple, on a aussi une λ alors c'est un « deux pour un »!) :

Cher chic prof, dans notre projet, nous avons rapidement produit beaucoup de code pour s'apercevoir, en cours de route, qu'il était devenu difficile à entretenir. Je me suis penché sur quelques fonctions particulièrement « douloureuses » à mes yeux, et j'ai essayé de les retravailler pour que nous puissions mieux les comprendre et les faire évoluer.

En particulier, j'ai rencontré cette chose :

int trouver_plus_pres_de(Point pt, vector<Point> candidats) {
   if (candidats.size() == 0) return -1; // personne
   int reponse = 0; // optimiste
   double plus_proche_dist = numeric_limits<double>::max(); // loin!
   for(int i = 0; i < candidats.size(); ++i) {
      if (pt != candidats[i] && pt - candidats[i] < plus_proche_dist) {
         reponse = i;
         plus_proche_dist = pt - candidats[i];
      }
   }
   return reponse;
}

C'est pas très joli, j'en conviens. En y regardant de plus près, on y voit :

Je me suis demandé si je serais capable de mettre en mots ce que la fonction fait, comme tu l'as suggéré en classe, et je me suis dit que le nom de la fonction faisait bien ce travail : cette fonction sert à trouver le Point dans candidats qui est le plus proche de pt (sans y être superposé). Je me suis dit alors que je pourrais y aller comme ceci :

Si candidats est vide
    Retourner -1 // cas dénégéré
plus_proche ← trouver premier autre que pt dans candidats
Si plus_proche = pt
   Retourner 0 // seul au monde
distance_plus_proche ← *plus_proche - pt
Tant que je n'ai pas épuisé les Points candidats
   plus_proche ← trouver plus proche que plus_proche
   distance_plus_proche ← *plus_proche - pt
Retourner distance(debut(candidats), plus_proche)

Ce qui se traduit directement en code :

int trouver_plus_pres_de(const Point &pt, const vector<Point> &candidats) {
   if (candidats.empty()) return -1; // cas dégénéré
   auto plus_proche = find_if_not(next(begin(candidats)), end(candidats), pt);
   if (plus_proche == end(candidats)) return 0; // seul au monde
   auto distance_plus_proche = *plus_proche - pt;
   for(plus_proche = find_if(plus_proche, end(candidats), [&](const Point &p) { return p != pt && pt - p < distance_plus_proche; });
       plus_proche != end(candidats);
       plus_proche = find_if(plus_proche, end(candidats), [&](const Point &p) { return p != pt && pt - p < distance_plus_proche; }))
      distance_plus_proche = *plus_proche - pt
   return distance(begin(candidats), plus_proche);
}

C'était pas si mal, mais ce n'était pas encore le bonheur. Entre autres, il y avait un peu plus de code qu'avant, ce qui me rendait triste.

J'ai pris sur moi de retravailler ma solution. En nommant la λ, c'était déjà un peu mieux (mais fallait vraiment capturer par référence, sinon ça ne fonctionnait pas) :

int trouver_plus_pres_de(const Point &pt, const vector<Point> &candidats) {
   if (candidats.empty()) return -1; // cas dégénéré
   auto plus_proche = find_if_not(next(begin(candidats)), end(candidats), pt);
   if (plus_proche == end(candidats)) return 0; // seul au monde
   auto distance_plus_proche = *plus_proche - pt;
   auto pred = [&](const Point &p) { return p != pt && pt - p < distance_plus_proche; };
   for(plus_proche = find_if(plus_proche, end(candidats), pred);
       plus_proche != end(candidats);
       plus_proche = find_if(plus_proche, end(candidats), pred))
      distance_plus_proche = *plus_proche - pt
   return distance(begin(candidats), plus_proche);
}

Cependant, j'en ai discuté avec mes collègues et nous nous sommes aperçus que ce dont nous avions vraiment besoin, c'était la distance la plus courte entre un Point candidat et pt. Nous nous donnions la peine de repérer une position alors que nous ne voulions qu'un minimum! J'ai donc réécrit le tout plus simplement :

double plus_courte_distance_de(const Point &pt, const vector<Point> &candidats) {
   if (candidats.empty()) return -1.0; // cas dégénéré
   auto cur = find_if_not(begin(candidats), end(candidats), [&pt](const Point &p) { return p != pt; });
   if (cur == end(candidats)) return 0.0; // seul au monde
   return accumulate(next(cur), end(candidats), *cur - pt, [&pt](double so_far, const Point &p) {
      return pt == p? so_far : std::min(so_far, pt - p);
   });
}

... et c'est plus direct, plus clair et plus rapide (voir résultats des tests en annexe). Voilà!

Notez qu'il arrive régulièrement que des étudiant(e)s de ce cours arrivent au point où il leur faut livrer ce travail pratique et font le constat qu'ils n'ont pas encore commencé à appliquer des techniques et des concepts du cours, ayant (consciemment ou non) fait le « choix » de rester dans le confort de leurs habitudes. Ne vous faites pas prendre; prévoyez le coup en mettant le plus systématiquement possible en pratique ce que vous voyez dans le cours!

Consignes du TP01

Date de remise : début de la séance S12 (en fait, quand vous aurez fini votre projet, car c'est la priorité)

Il se fait tard, vous souhaitez produire et conclure votre session, et je souhaite pouvoir savoir où vous en êtes sans toutefois vous surcharger. Voici ce que je vous propose :

L'idée est de produire une très brève autocritique de contribution individuelle et de design global qui puisse servir de rampe de lancement au projet de cet hiver. Autocritique ne signifie pas autoflagellation : personne n'est parfait et l'école est un lieu d'apprentissage.


Valid XHTML 1.0 Transitional

CSS Valide !