Journal Sortie de GHC 8.0.2 et une petite histoire de typage statique

32
19
jan.
2017

Sommaire

GHC, le compilateur Haskell le plus utilisé, est sorti en version 8.0.2, vous pouvez consulter l'annonce de la sortie datée du 11 janvier 2017 ainsi que les notes de version

Il s'agit principalement d'une version de suivi qui corrige plus de deux cent bugs depuis la version 8.0 dont l'annonce de la sortie
avait été faite sur http://linuxfr.org. Donc à ce titre, il n'y a pas grand chose à raconter. Le futur, c'est GHC 8.2 qui devrait arriver aux environs d'avril si on en croit le planning. Celle-ci devrait apporter principalement des améliorations de performance de l'environnement d'exécution parallèle et du ramasse-miettes.

Comme ce journal frôle le journal bookmark et que je tient à mon karma, je vais vous présenter une fonctionnalité d'Haskell, que l'on retrouve dans de nombreux langages, j'ai nommé les ADT, où types de donnée algébriques

Il s'agit d'un croisement un peu douteux entre les struct, bien connus de nombreux langages de programmation, et les union, utilisés en C/C++ et qui sont une sorte d'enum. Le tout gonflé aux stéroïdes de la généricité, de la sécurité et du sucre syntaxique.

Remarque : ce journal a été rédigé dans l'espace de rédaction de dépêche, pour profiter de la collaboration pour corriger notamment de nombreuses coquilles. Merci aux participants, palm123, Anthony Jaguenaud et Lucas pour leur remarques bénéfiques.

ADT : un struct

Un ADT c'est un type. Commençons d'abord par la partie qui ressemble à des struct. Si je veux faire un point à 3 dimensions en Haskell, je ferai :

data Point = PointC Float Float Float deriving (Show)

Il s'agit de la déclaration du type Point. Ce type peut être construit grâce à un constructeur PointC qui prend 3 Float et renvoi un Point. Le type de ce constructeur est PointC :: Float -> Float -> Float -> Point, une fonction qui prend un Float, puis un autre Float, et encore un Float et renvoie un Point. Le nom du constructeur est libre, il aurait très bien pu être le même que le nom du type.

La clause deriving (Show) sert à générer automatiquement des fonctions pour l'affichage.

Un petit exemple de cas d'utilisation dans le shell interactif GHCI :

Prelude> PointC 1 2 3
PointC 1.0 2.0 3.0
Prelude> PointC 4 5 6
PointC 4.0 5.0 6.0

Le constructeur PointC peut aussi servir à déconstruire" les valeurs si il apparaît du coté gauche du signe = :

Prelude> PointC a b c = PointC 1 2 3
Prelude> a
1.0
Prelude> b
2.0
Prelude> c
3.0

Ceci est très pratique lors de la création de fonctions :

Prelude> getX (PointC x _ _) = x
Prelude> getY (PointC _ y _) = y
Prelude> getZ (PointC _ _ z) = z
Prelude> norm (PointC x y z) = sqrt (x * x + y * y + z * z)
Prelude> p = PointC 1 2 3
Prelude> getX p
1.0
Prelude> getY p
2.0
Prelude> getZ p
3.0
Prelude> norm p
3.7416575

Nous avons donc vu qu'un type en Haskell peut être vu comme un struct / objet dans d'autres langages, c'est à dire un agrégat de champs de type hétérogène. Si cela vous inquiète, on peut aussi donner des noms aux champs :

data Point = Point {x :: Float, y :: Float, z :: Float}

Mais ceci est une autre histoire.

ADT : un enum

Les "enum" dans de nombreux langages permettent de crée un type pouvant être représenté par plusieurs valeurs. L'exemple d'école :

data Couleur = Rouge | Vert | Bleu | Marron | Noir | Blanc deriving (Show)

Ici nous avons crée le type Couleur et nous lui avons associé 6 constructeurs différents. Observez bien le | entre les constructeurs, il représente l'alternative.

L'usage du nom "constructeur" ici est souvent troublante pour qui n'est pas habitué, dites vous simplement que Rouge est une fonction qui ne prend pas d'argument et renvoie une valeur de type Couleur, en ce sens, c'est un constructeur de Couleur.

On peut utiliser ces constructeurs pour créer des objets de type Couleur:

Prelude> Rouge
Rouge
Prelude> Bleu
Bleu

On peut aussi réaliser différentes opérations dessus grâce à de la déconstruction comme vu précédemment. Dans la fonction réaction qui suit, je liste les différents cas de couleur, le _ servant de joker. En Haskell, on peut définir des fonctions en listant les cas :

réaction Rouge = "Cool"
réaction Bleu = "Cool aussi, mais je préfère le rouge"
réaction Vert = "Bof Bof"
réaction Noir = "Moche"
-- cas générique
réaction _ = "Je n'aime pas les autres couleurs"

Et l'usage dans l'interpréteur nous donne :

Prelude> réaction Rouge
"Cool"
Prelude> réaction Blanc
"Je n'aime pas les autres couleurs"

Nous avons vu comment réaliser en Haskell l'équivalent des "enum" que l'on peut trouver dans d'autres langages.

ADT : enum avancés, ou union

Le C et le C++ proposent un mécanisme d'union, où un type peut contenir au choix plusieurs sous-types. Par exemple :

union Forme
{
     struct {
          float cote;
     } carre;

     struct {
          float coteA;
          float coteB;
     } rectangle;
};

Je ne reviendrai pas sur son usage en C, sachez seulement que le type Forme peut contenir soit un float cote, soit deux float coteA et coteB. L'usage des deux simultanément est indéfini et on ajoute souvent à la structure de donnée un marqueur pour informer l'utilisateur du type de la donnée réellement stockée.

En haskell, ce type peut être facilement représenté par un ADT combinant struct (ou type "produit") et enum (ou type "somme") :

data Forme = Carré Float | Rectangle Float Float deriving (Show)

Ici nous avons un type Forme qui contient deux constructeurs :
- Carré, qui associe un Float à une Forme
- Rectangle, qui associe deux Float à une Forme.

Contrairement aux langages qui supportent les enum où les unions, on remarque notamment que n'importe quel type complexe peut apparaître dans les différents cas de l'énumération.

Les outils décrit précédemment, de construction et de déconstruction par analyse de cas fonctionnent également. Ainsi on peut crée des Forme :

Prelude> Carré 10
Carré 10.0
Prelude> Rectangle 20 30
Rectangle 20.0 30.0

Et on peut faire des fonctions qui vont traiter notre type par analyse de cas :

surface (Carré c) = c * c
surface (Rectangle a b) = a * b

Ici la fonction surface déconstruit un Carré et renvoie sa surface. Si la déconstruction n'est pas possible (car c'est un Rectangle), alors la fonction passe au cas suivant). À l'usage :

Prelude> surface (Carré 10)
100.0
Prelude> surface (Rectangle 5 3)
15.0

Plusieurs remarques :

  • Le compilateur nous protège et ce de plusieurs manières :

    • Si j'avais oublié de gérer les cas Rectangle, le compilateur m'aurait prévenu.
    • Contrairement aux unions en C/C++, on ne peut pas confondre un Rectangle et un Carre, c'est de nombreuses heures de recherche d'erreurs qui disparaissent soudainement.
  • La syntaxe et l'usage sont succincts, c'est agréable à mon goût. La même chose est possible dans de nombreux langages, par exemple en C++, grâce à l'utilisation de "variant" mais l'usage est lourd. Comparez le programme entier en Haskell à la version C++ :

data Forme = Carré Float | Rectangle Float Float

surface (Carré c) = c * c
surface (Rectangle a b) = a * b

main = do
   let carré = Carré 10
       rectangle = Rectangle 5 3

   print (surface carré)
   print (surface rectangle)

La version C++ équivalente suivante utilise boost::variant, en c++17 nous utiliserons std::variant :

#include <iostream>
#include <boost/variant.hpp>

struct Carre
{
    float c;
};

struct Rectangle
{
    float a;
    float b;
};

using Forme = boost::variant<Carre, Rectangle>;

class surface
{
public:
    float operator()(const Carre &carre) const
    {
        return carre.c * carre.c;
    }

    float operator()(const Rectangle &rectangle) const
    {
        return rectangle.a * rectangle.b;
    }
};

int main()
{
    Forme carre = Carre{10};

    Forme rectangle = Rectangle{5, 3};

    // affiche 100
    std::cout << boost::apply_visitor(surface(), carre) << std::endl;
    // affiche 15
    std::cout << boost::apply_visitor(surface(), rectangle) << std::endl;
}

Ce code passe par la définition du type en trois étapes : définition des sous types Carre et Rectangle et définition du type Forme comme un variant, un choix, entre les deux précédents types.

La classe surface est ici un visiteur qui propose une surcharge de l'opérateur float operator(const T &t) pour chaque sous type T que peut contenir notre variant.

La fonction boost::apply_visitor est chargée d'appeler la bonne surcharge de l’opérateur operator() de surface en fonction du contenu du variant passé en second paramètre.

ADT : exemple plus poussé

Alors pourquoi je vous raconte tout cela. En premier lieu, j'aime bien. En second lieu, je me dis que cela peut vous intéresser à Haskell ou au moins vous sensibiliser à l'existence de ce type d'outil et peut-être que vous les utiliserez dans vos projets futurs. Par exemple, dans mon travail quotidien, je fais du C++, mais Haskell m'a beaucoup influencé et j'utilise tous les jours des boost::variant. Mon opinion là-dessus c'est que même si la syntaxe en C++ est verbeuse à souhait, cela sauve de certaines situations. Au final je pense que le code est plus robuste.

Pour finir, je vais vous donner un exemple d'un problème que je rencontre souvent dans des API et qui serait, à mon sens, mieux traité avec des ADT. C'est le cas traditionnel des valeurs sentinelles. Je fais un peu concurrence au journal de cette semaine sur la prévention de bug en Ocaml grâce à un typage plus strict. Là où ce journal s'intéressait à la définition d'un type synonyme mais incompatible, je m'intéresse à la définition d'un type totalement diffèrent permettant de représenter un contexte différent et ainsi de supprimer les cas impossibles et de rendre plus robustes les cas possibles.

Introduction

Pour appuyer mon propos, intéressons-nous à une libraire C++ que j'utilise tous les jours, OpenEXR, qui permet, entre autre, de lire et d'écrire des images au format EXR très utilisé dans l'industrie de l'image. La page github d'OpenEXR.

Cette librairie propose entre autre la lecture et l'écriture de fichiers via plusieurs threads, ce qui est une fonctionnalité très pratique quand l'écriture de plusieurs Go d'images en séquentiel est le facteur limitant sur des machines à 24 coeurs.

Le point de l'API qui nous intéresse est le suivant, les fonctions setGlobalThreadCount et globalThreadCount :

void    setGlobalThreadCount (int count);
int globalThreadCount();

Alors, si on lit la documentation, on peut voir que count dans setGlobalThreadCount sert à définir le nombre de thread utilisés globalement pour réaliser les écritures.

En cherchant un peu, on tombe sur ce commentaire :

  • The functions in this file query and control the total number of worker threads, which will be created globally for the whole library. Regardless of how many input or output files are opened simultaneously, the library will use at most this number of worker threads to perform all work. The default number of global worker threads is zero (i.e. single-threaded operation; everything happens in the thread that calls the library).

Traduction à l'arrache :

La fonction setGlobalThreadCount controle le nombre total de threads […] la bibliothèque utilisera au maximum ce nombre de threads. Le nombre par défaut est zéro, ce qui signifie que les opérations ne seront pas parallélisées.

On tombe aussi sur des discussion github intéressantes, dont je ne trouve plus le lien, désolé, traitant du moyen de fournir à setGlobalThreadCount une valeur qui correspond au nombre de thread système optimal (sans avoir à trouver celui-ci, on peut imaginer qu'il puisse changer à la volée en fonction de la charge de la machine, où dans un environnement virtualisé, en fonction des besoins), et les débats tournaient autour du fait de mettre -1 comme nombre de thread pour ce cas de figure. Ce n'est pas implémenté à ma connaissance dans openEXR, mais supposons que cela le soit.

Donc en gros, voici le comportement que nous pouvons imaginer pour setGlobalThreadCount(n) :

  • Si n > 0, alors c'est le nombre de thread utilisé globalement
  • Si n = 0, alors il n'y aura pas de multi threading
  • Si n = -1, alors on utilise le nombre de thread machine
  • Si n = -12, autre cas particulier que nous pourrions imaginer.

Le problème

Premier problème, en tant qu'utilisateur, je n'avais pas conscience de l'existence des cas 0, -1 et -12 sans lire le code source et la documentation d'OpenEXR.

Second problème, on va se planter, LARGEMENT, en beauté. Qui ? Les développeurs d'OpenEXR sans doute, et moi en utilisant leur API. Comment je le sais ? Parce que je me suis planté.

Où pouvons-nous nous planter ? Partout où le nombre global de thread est utilisé. Si le cas particulier 0, -1 et -12 n'est pas géré explicitement, et bien c'est un bug. Cela peut faire des choses marrantes, comme par exemple créer 0 thread de travail, et répartir le travail entre eux, ce qui donne un blocage de l'application.

Troisième problème, le futur. Même si c'est bien géré actuellement, que se passe-t-il demain lorsque quelqu'un va écrire une nouvelle fonction basée sur cette valeur sans connaître l'existence des cas particuliers qui existent ? Et bien cela va marcher, jusqu'à ce que quelqu'un utilise un cas particulier non traité, et là, pan. Ou si quelqu'un ajoute un nouveau cas particulier et ne le gère pas à tous les endroits nécessaires ?

On peut aussi se planter en passant une mauvaise constante par erreur. Imaginons qu'il existe dans le même espace de nom, une constante nommée "NoThreading", mais utilisée par une autre librairie, et ayant pour valeur magique un entier. Si celui-ci est négatif, c'est le drame, le comportement du programme est largement indéfini, au mieux c'est une erreur à l'exécution, au pire ?. Si celui-ci est positif, il faut espérer qu'il ne soit pas trop gros, car je n'aimerais pas créer 100000 threads sur ma machine de production, l'OS ne tiendrait pas.

Ce type de bug potentiel est la raison qui fait que la montée de version sur un gros projet logiciel est difficile du fait de la peur des régressions. Et même le meilleur système de test unitaire ne peut rien garantir à ce sujet.

Je passe aussi sur le problème de documentation et de lecture de code avec l'utilisation de constantes magiques en paramètre de fonction. setGlobalThreadCount(-123) n'est pas très informatif. Alors oui, cela se règle avec des définitions de constante, mais on peut encore se tromper, en définissant la constante à une mauvaise valeur, et rien ne force le développeur à utiliser la constante magique.

Ce problème est présent de partout, dans toutes les bibliothèques que nous utilisons, dans tous les langages que nous utilisons. Ceux-ci proposent des valeurs sentinelles. Python avec la méthode find des chaînes de caractères, qui renvoie -1 si la chaîne n'est pas trouvés (Il y a la version avec exception, c'est moins pire). C++ avec la fonction std::find qui retourne un itérateur vide en cas d'échec et rien qui ne vous force à tester cela.

La solution

La solution passe par la définition d'un type représentant le problème plus finement. Dans le cas d'OpenEXR, et si celui-ci était écrit en Haskell, nous pourrions avoir un type :

-- Word est en entier non signé
data PolitiqueThread = NombreFixé Word | NombreMaximumHardware | PasDeThreading | CasParticulier

Ainsi on pourrait appeller la fonction setGlobalThreadCount de différentes façon :

setGlobalThreadCount (NombreFixé 8)

setGlobalThreadCount NombreMaximumHardware

setGlobalThreadCount PasDeThreading

setGlobalThreadCount CasParticulier

Nous réglons en premier lieu le problème de documentation lors de l'appel. En tant qu'utilisateur, je suis forcé de voir que ce n'est pas juste un entier, et d'au moins voir la documentation avec la liste des cas, qui est automatiquement à jour. Le code est lisible et il est explicite que cette valeur n'est pas anodine.

Nous réglons aussi le problème lors de l'usage. On ne peut plus utiliser les valeurs -1 et -12 et 0 par erreur en considérant qu'il s'agit d'un nombre de thread et non pas d'un cas particulier, car le langage nous force à déconstruire et à gérer les différents cas de déconstruction. Observez comment 0, -1 et -12 n'apparaissent pas :

threadCount <- getGlobalThreadCount
case threadCount of
   NombreFixé n -> "nombre fixé à " ++ show n
   NombreMaximumHardware -> "Fait chauffer la ferme de calcul"
   PasDeThreading -> "Plutôt tranquille"
   CasParticulier -> "Celui-ci je ne l'aime pas"

Nous réglons aussi le problème de l'évolution future et de l'ajout de nouveau cas particulier, puisque le compilateur vas râler aux endroits où tous les cas ne sont pas gérés.

Le problème de passer une valeur qui n'a pas de sens par défaut n'existe plus non plus. Le type PolitiqueThread est incompatible avec un autre type. La seul erreur possible reste de passer un nombre qui n'a pas de sens à NombreFixé. Soit un nombre négatif, soit un nombre trop grand qui ferait exploser le système.

Je n'ai pas de solution parfaite à ce dernier problème. On peut en premier lieu cacher le constructeur NombreFixé et le remplacer par une fonction type :

nombreFixé n
 | n > 0 && n < maximumThread = NombreFixé (fromIntegral n)
 | otherwise = erreurRuntime ("n est trop bizarre: " ++ show n)

-- fromIntegral sert à convertir n qui est un entier signé vers un `Word`.

Cette solution limite la casse. Il y en d'autres. On pourrait par exemple utiliser de l'analyse statique de code en imposant des contraintes sur nombreFixé. Liquid Haskell sait faire cela, mais dans un contexte limité.

Conclusion

En profitant de la sortie de GHC 8.0.2, j'ai essayé de vous sensibiliser un peu plus au problème des valeurs sentinelles. À mon sens, ce problème est grave car il en découle du code peu lisible, peu "naturellement" documenté, peu robuste à l'évolution et peu robuste à l'utilisation normale par un développeur qui ne connaît pas par cœur les détails de l'API qu'il utilise. Une solution est l'emploi d'ADT, ceux-ci sont disponibles dans de nombreux langages, comme Haskell, Caml, Rust, Scala, Swift, … Et peuvent plus ou moins facilement être remplacés par des structures équivalentes à la simplicité près, comme avec les variant en C++.

Ce que je vous ai montré n'est qu'une partie de ce qui est possible avec les ADTs, et un lecteur motivé pourra commencer sa lecture sur la section de wikipedia consacrée aux ADT généralisés

  • # Un vrai manque en Java

    Posté par . Évalué à 7 (+6/-0).

    Ah, les ADT… je crois bien que c'est ce qui me manque le plus en Java depuis que j'ai goûté à Rust. Ça, et le pattern-matching.

    Sinon, merci pour ce journal, qui à mon sens n'aurait pas démérité en tant que dépêche :)

  • # C'est vrai que c'est plus verbeux en c++

    Posté par . Évalué à 3 (+2/-0).

    J'ai essayé d'implémenter le dernier exemple en c++, c'est plus verbeux, notamment à cause du fait qu'il faut commencer par créer les classes qui servent de tags.

    #include <iostream>
    
    using namespace std;
    
    struct Fixed { const int _n; };
    struct Max {};
    struct NoMultithread {};
    
    class Test {
    public:
    
        Test(Fixed f) : _nb_thread{f._n} {}
        Test(Max) : _nb_thread{255} {}
        Test(NoMultithread) : _nb_thread{1} {}
    
        friend ostream& operator<<(ostream& os, const Test& t) {
            return os << t._nb_thread;
        }
    
    private:
        int _nb_thread; // number of thread between 1 to 255
    };
    
    int main()
    {
        Test t1 = Fixed{10};
        Test t2 = Max{};
        Test t3 = NoMultithread{};
        Test t4 = Fixed{-10};
        // les exemples suivant ne compilent pas
        // Test t5 = Test(5);
        // Test t6 = Test{6};
        // Test t7{7};
    
        cout << t1 << endl;
        cout << t2 << endl;
        cout << t3 << endl;
        cout << t4 << endl;
        return 0;
    }

    bépo powered

    • [^] # Re: C'est vrai que c'est plus verbeux en c++

      Posté par (page perso) . Évalué à 3 (+1/-0).

      Ta proposition est intéressante pour ne pas se planter lors de l'appel, mais elle ne protège pas dans l'interne de la classe Test car finalement on y retrouve les valeurs sentinelles (1 et 255). Note que dans l'exemple que je donne, 1 thread n'est pas la même chose que pas de parallélisme et 255 threads (possible bientôt avec l'arrivée des puces intel) n'est pas la même chose que "Maximum système".

      Un développeur pourra d'ailleurs facilement se tromper en créant 255 threads au lieu des 8 qui ma machine peut largement supporter.

      Cette solution ne permet pas non plus de récupérer de façon exploitable facilement le réglage, tu ne peut pas faire de fonction qui renvoie l'un des tags.

      Une solution serait l'utilisation de variant tel que :

      #include <iostream>
      #include <boost/variant.hpp>
      
      struct Fixed { const int _n; };
      struct Max {};
      struct NoMultithread {};
      
      using Setting = boost::variant<Fixed, Max, NoMultithread>;
      
      struct Test {
          explicit Test(Setting s): setting(s) {}
      
          Setting setting;
      };
      
      int main()
      {
          Test t1{Fixed{10}};
          Test t2{Max{}};
          Test t3{NoMultithread{}};
          Test t4{Fixed{-10}};
      
          struct visitor : boost::static_visitor<std::string>
          {
              std::string operator()(const Fixed &) const
              {
                  return "C'est fixé";
              }
      
              std::string operator()(const Max &) const
              {
                  return "Max";
              }
      
              std::string operator()(const NoMultithread &) const
              {
                  return "c'est un NoMultiThread";
              }
          };
      
          std::cout << boost::apply_visitor(visitor(), t1.setting) << std::endl;
          std::cout << boost::apply_visitor(visitor(), t2.setting) << std::endl;
          std::cout << boost::apply_visitor(visitor(), t3.setting) << std::endl;
          std::cout << boost::apply_visitor(visitor(), t4.setting) << std::endl;
      
          return 0;
      }

      Cette solution apporte la même souplesse et la même sécurité que les ADT, mais dieu que c'est verbeux ;)

      • [^] # Re: C'est vrai que c'est plus verbeux en c++

        Posté par (page perso) . Évalué à 2 (+0/-0).

        Voici ma version: moins verbeuse. Pas si différent de l'example.

        #include <iostream>
        #include "boost/variant.hpp"
        #include <boost/hana/functional/overload.hpp>
        using namespace std::string_literals;
        
        
        struct NombreFixé { int nombre; };
        struct NombreMaximumHardware {};
        struct PasDeThreading {};
        struct CasParticulier {};
        
        using PolitiqueThread = boost::variant<NombreFixé, NombreMaximumHardware, PasDeThreading, CasParticulier>;
        
        int main()
        {
            PolitiqueThread threadCount = NombreFixé{10};
        
            std::cout << boost::apply_visitor(boost::hana::overload(
                    [] (NombreFixé f) { return "nombre fixé à "s + std::to_string(f.nombre); },
                    [] (NombreMaximumHardware) { return "Fait chauffer la ferme de calcul"s; },
                    [] (PasDeThreading) { return "Plutôt tranquille"s; },
                    [] (CasParticulier) { return "Celui-ci je ne l'aime pas"s; }
                ), threadCount) << std::endl;
        };

        Résultat: http://melpon.org/wandbox/permlink/LH6QB4tOXjSYpLGz

        (Note: cet exemple ne fonctionne que avec clang car GCC ne supporte pas les accents dans les nom de types)

        On pourrait ajouter un constructeur à NombreFixé avec un assert pour s'assurer que la valeur est correcte.

      • [^] # Re: C'est vrai que c'est plus verbeux en c++

        Posté par . Évalué à 4 (+1/-0).

        Je sais que c'est moins hype que de décrire des typages sophistiqués, mais des factories font ça très bien.

        public final class Test {
            private Test() {}
        
            public static Test fixed(int nbThreads) {}
            public static Test max() {}
            public static Test noMultithread() {}
            public static Test otherCase() {}
        }

        Et oui en principe derrière si tu as besoin de véritablement récupérer le truc, tu fait du polymorphisme à la place du pattern matching si nécessaire.

        Tous les contenus que j'écris ici sont sous licence CC0 (j'abandonne autant que possible mes droits d'auteur sur mes écrits)

        • [^] # Re: C'est vrai que c'est plus verbeux en c++

          Posté par (page perso) . Évalué à 3 (+1/-0).

          Je sais que c'est moins hype que de décrire des typages sophistiqués, mais des factories font ça très bien.

          J'essaye de ne vraiment pas être hype et de régler des vrais problème avec des vrais solutions, les moins complexes possible.

          Le soucis de ton approche, comme je le disais en réponse du même commentaire, c'est comment tu stockes l'information à l’intérieur de Test. Si c'est pour réintroduire des valeurs sentinelles, tu as réglé le problème en terme d'interface avec l'utilisateur, et c'est déjà pas mal, mais tu n'as pas réglé le problème à l'intérieur de la classe, où si la valeur doit ressortir.

          Et oui en principe derrière si tu as besoin de véritablement récupérer le truc, tu fait du polymorphisme à la place du pattern matching si nécessaire.

          Quelle solution à base de polymorphisme proposes-tu ?

          • [^] # Re: C'est vrai que c'est plus verbeux en c++

            Posté par . Évalué à 2 (+0/-1).

            Le soucis de ton approche, comme je le disais en réponse du même commentaire, c'est comment tu stockes l'information à l’intérieur de Test. Si c'est pour réintroduire des valeurs sentinelles, tu as réglé le problème en terme d'interface avec l'utilisateur, et c'est déjà pas mal, mais tu n'as pas réglé le problème à l'intérieur de la classe, où si la valeur doit ressortir.

            Tu as tout loisir de faire ce qui t'arrange. Rien ne te limite et tu prix le faire évoluer comme heu le souhaite savez péter le code utilisateur. Ça dépend vraiment de l'utilisation que tu as. Par défaut j'aurais envi que ce ne soit pas des valeurs, mais un comportement.

            Quelle solution à base de polymorphisme proposes-tu ?

            Je ne suis pas sûr d'avoir saisie l'objectif de l'exemple du dessus. Par contre pour le cas du journal c'est évident: ce sont des stratégies. Les implémenter comme tel ne dois pas être dénué d'intérêt.

            Tous les contenus que j'écris ici sont sous licence CC0 (j'abandonne autant que possible mes droits d'auteur sur mes écrits)

            • [^] # Re: C'est vrai que c'est plus verbeux en c++

              Posté par (page perso) . Évalué à 4 (+2/-0).

              Tu as tout loisir de faire ce qui t'arrange. Rien ne te limite et tu prix le faire évoluer comme heu le souhaite savez péter le code utilisateur. Ça dépend vraiment de l'utilisation que tu as. Par défaut j'aurais envi que ce ne soit pas des valeurs, mais un comportement.

              Dans ce contexte qu'appelles-tu un comportement ? Comment l'implementes-tu ?

              Pour le coté évolution, rien ne t’empêche de d'avoir un type différent en interne que celui que tu présentes à l'utilisateur. Autant cela peut commencer par deux types totalement équivalent, et cela peut évoluer en interne. Force de la solution par ADT c'est que si tu ajoutes un constructeur, ton utilisateur aura des warnings de compilation lui indiquant qu'il n'a pas traité tous les cas.

              Je ne suis pas sûr d'avoir saisie l'objectif de l'exemple du dessus. Par contre pour le cas du journal c'est évident: ce sont des stratégies. Les implémenter comme tel ne dois pas être dénué d'intérêt.

              Tu pourrais donner un exemple de ta solution ? À mon sens, l'implementation que j'ai proposée est clairement une implémentation du pattern strategie, en se servant de boost::variant pour stocker les différentes stratégies. Je trouve que cette approche est plus simple, plus compact et surtout elle apporte un découplage entre la donnée et les opérations que tu peux faire dessus comparé à une implémentation de la même idée utilisant de l'héritage virtuel.

        • [^] # Re: C'est vrai que c'est plus verbeux en c++

          Posté par . Évalué à 3 (+2/-1).

          Je sais que c'est moins hype que de décrire des typages sophistiqués, mais des factories font ça très bien.

          Je ne vois pas en quoi l'approche par ADT est hype ou sophistiquée. Le principe même, à la base des ADT, consiste dans certaines formes logiques des jugements : la disjonction (exclusive) et la conjonction. Un jugement disjonctif, c'est hype et sophistiqué ? Quand un serveur, au restaurant, te demande si dans le menu tu as choisi l'option entrée et plat (conjonction, ou type produit) ou (disjonction ou type somme) plat et dessert, tu trouves cela hype et sophistiqué ?

          Personnellement, ce que je trouve aberrant, c'est de devoir faire des circonvolutions pour exprimer des jugements de typage aussi triviaux et naturels à l'esprit humain.

          Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.

          • [^] # Re: C'est vrai que c'est plus verbeux en c++

            Posté par . Évalué à 3 (+1/-0).

            Pour moi, ce n’est pas forcément naturel… la formation, les habitudes…

            Personnellement, j’aurais utilisé l’héritage .

            class Forme {
                
                virtual int surface() = 0;
            
            };
            
            class Carre:public Forme {
                virtual int surface() { return c*c; };
                
            };
            
            class Rectangle: public Forme {
                virtual int surface() { return l*L; };
                
            };

            On peut utilisé des pointeurs sur Forme et les dynamic cast, ou les appel virtual pour appeler la bonne méthode.

            La question naïve c’est en quoi les ADT sont mieux que l’héritage et les méthodes virtuelles ?

            • [^] # Re: C'est vrai que c'est plus verbeux en c++

              Posté par . Évalué à 7 (+5/-0). Dernière modification le 20/01/17 à 11:06.

              La question naïve c’est en quoi les ADT sont mieux que l’héritage et les méthodes virtuelles ?

              La question pour moi, au départ, est : que cherche-t-on à exprimer ? Quelles sont les relations que l'on pense (avant d'écrire et exprimer sa pensée, il faut d'abord la constituer et la structurer) entre les structures de données ? Ici, il s'agit de rapport disjonctif et conjonctif : union disjointe et produit cartésien d'ensembles (comme dans le cas d'un menu au restaurant : entrée-plat ou plat-dessert).

              Ces précisions faites, à ta question je répondrai : ADT ou héritage et méthodes virtuelles, relativement à la problématique exposée, c'est bonnet blanc et blanc bonnet. Le couple héritage et méthodes virtuelles est le moyen pour exprimer des ADT dans les langages orientés objets. La seule différence avec les langages fonctionnels, c'est que c'est plus verbeux. D'où ma conclusion : c'est quelque peu aberrant de devoir faire des circonvolutions pour exprimer une telle pensée.

              type entree = (* ... *)
              type plat = (* ... *)
              type dessert = (* ... *)
              
              type menu =
               | A : entree * plat -> menu
               | B : plat * dessert -> menu

              Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.

            • [^] # Re: C'est vrai que c'est plus verbeux en c++

              Posté par (page perso) . Évalué à 5 (+3/-0).

              Je ne vais pas te dire que l'héritage c'est débile et que les ADT sont mieux, c'est juste des cas d'usage différents et des débats d'opinion ;) Personnellement j'ai plus souvent besoin d'un ADT que d'un héritage.

              Le débat "héritage" versus "ADT" est souvent proche du problème du couplage et de la notion de comment les choses peuvent évoluer, notamment l'ajout de cas où de comportement.

              L'héritage est une hiérarchie ouverte et à ce titre, n'importe qui peut rajouter un enfant avec son propre comportement. Ce n'est pas forcement souhaitable dans un cas où justement le but de ton type est de restreindre la liste des options. Rajouter un cas dans un ADT n'est pas coûteux, mais on ne peut pas le faire sans que tu sois au courant.

              Entre les deux, le couplage est différent. L'héritage regroupe ensemble des comportements (parfois sans relation). Les ADT regroupent ensemble des cas de figures, et pour chaque comportement, le traitement de chaque cas de figure.

              Dans le cas de l'héritage, si tu veux rajouter un comportement (une méthode), tu dois modifier ta classe de base et chercher toutes les classes enfant pour ajouter ce comportement. Dans le cas d'un ADT, si tu veux rajouter un comportement, tu l'écris où tu veux en listant tous les cas.

              Technique

              Si on veut parler technique, l'ADT, avec sa liste de cas qui est fixée, peut être optimisé par le compilateur. (Je ne dis pas que c'est fait, mais c'est le cas du std::variant). Par exemple, dans le cas du carré et du réctangle, la solution à base d'héritage et de polymorphisme vas te coûter, en ignorant le coût de la vtable, vas te coûter entre 20 et 24 octet par objet:

              • 16 octets alloués sur la pile, le pointeur d’objet et le pointeur de vtable
              • 8 octets pour le rectangle et 4 pour le carré, alloué sur le tas.

              L'appel à la méthode "surface" vas te coûter, en gros, entre 20 et 32 octets à lire (il faudra aller lire la vtable), et cela à trois endroits différents en mémoire, donc tu consommes 3 lignes de cache pour faire ce traitement.

              Maintenant regardons comment peut être layouté un ADT en mémoire, en gros, simplifié, boost::variant ressemblera à :

              struct
              {
                union
                {
                   Carre carre;
                   Rectangle rectangle;
                };
                int which;
              }

              Ce qui donne une taille de 12 bytes, sur la pile, versus entre 20 et 24 bytes, avec une allocation sur le tas. L'appel de la méthode surface te coûtera la lecture de ces 12 bytes (contigus en mémoire) et un branchement sur which.

              Les compilateurs actuels implémentent une méthode de devirtualization pour améliorer cette situation, mais le virtuel garde un coût non négligeable.

              Tout cela n'est pas comparable à Haskell pour lequel chaque objet est alloué sur le tas et avec un énorme overhead ;)

          • [^] # Re: C'est vrai que c'est plus verbeux en c++

            Posté par . Évalué à 4 (+1/-0). Dernière modification le 20/01/17 à 14:18.

            Je ne vois pas en quoi l'approche par ADT est hype ou sophistiquée.

            Pour le hype c'est pour la forme que je l'ai dit aujourd'hui si tu présente une solution orientée fonctionnelle ça plaît (c'est pas lié à linuxfr). Pour sophistiqué, c'est factuel. C'est une expressivité que tu ne trouve que dans les quelques pourcents de langages dont le typage est le plus travaillé. Il n'y a rien de négatif là dedans. Une typage sophistiqué te permet une plus grande expressivité, c'est à dire, qu'il te permet d'avoir le moins d'écart possible entre ta pensé et ce que tu exprime dans ton programme.

            Personnellement, ce que je trouve aberrant,[…]

            Je n'ai pas parlé d'aberration.

            […] c'est de devoir faire des circonvolutions pour exprimer des jugements de typage aussi triviaux et naturels à l'esprit humain.

            Oui alors les types dépendants c'est une logique qu'un gamin de 4 ans comprends tout à fait. Manque de bol le nombre de langages capables de faire ça est très réduit. Chacun s'exprime dans un langage et adapte sa façon de s'exprimer à son langage. Que tu sois un adepte d'haskell ou que tu écrive du cobol, c'est juste une habitude. Je ne dis pas que toutes les approches sont identiques, je dis que chacun trouvera plus naturelle sa façon de s'exprimer.

            Tous les contenus que j'écris ici sont sous licence CC0 (j'abandonne autant que possible mes droits d'auteur sur mes écrits)

            • [^] # Re: C'est vrai que c'est plus verbeux en c++

              Posté par . Évalué à 3 (+1/-0).

              Au temps pour moi, j'avais mal compris ce que tu voulais dire par là. Fréquentant peu le milieu des développeurs (que je lis surtout ici), je ne savais pas que les solutions orientées fonctionnelles étaient à la mode. De toute façon, les modes ça passe et ça repasse. ;-)

              Cela étant, j'ai du mal à considérer les jugements disjonctifs et conjonctifs comme des notions sophistiquées. Ce qu'il y a, c'est qu'en POO la disjonction est abordée par la subdvision des concepts : l'héritage est un processus de spécification d'un genre (comme dans la proposition « un animal est un chien ou un chat ou une girafe… ») qui, comme l'a rappelé Guillaum, est ouvert par essence (nul ne peut dire a priori, c'est-à-dire statiquement, jusqu'où peut s'arrêter la spécification). L'avantage de la subdvision par les ADT est de fournir une partition exhaustive du type à diviser, et donc de pouvoir vérifier statiquement que tous les cas sont traités lors de la manipulation d'une valeur d'un tel type.

              Comme l'a également rappelé Guillaum, les unions du C sont proches des ADT. Ce qu'il y a peut être de nouveau pour certains développeurs, c'est le vocabulaire employé pour qualifier ce genre de types : types de données algébriques. C'est lié aux relations que la disjonction et la conjonction entretiennent entre elles, et la façon dont elles opèrent l'une sur l'autre. Dans un choix comme celui d'un menu : (entrée et plat ) ou (plat et dessert), c'est équivalent au choix : plat et (entrée ou dessert). Ce qui n'est rien d'autre que la règle de distributivité de la multiplication sur l'addition : plat * (entrée + dessert) = (plat * entrée) + (plat * dessert).

              En revanche, sur ce point :

              Oui alors les types dépendants c'est une logique qu'un gamin de 4 ans comprends tout à fait.

              je crois que tu surestimes grandement les capacités des enfants de 4 ans. ;-)

              D'après mon expérience, un enfant de cet âge comprend tout à fait la logique propositionnelle (conjonction, disjonction et implication), c'est à dire celle qui sert de fondement au système de type de Haskell ou OCaml, mais ne sait pas raisonner si on introduit les notions de sujets et prédicats. Usuellement, on a plutôt fixé l'âge de raison à 7 ans; et encore même à 7 ans ce n'en est que des balbutiements.

              Dans l'actualité récente, l'idée d'introduire les notions de sujet et prédicat dans les leçons de grammaire à partir des classes de CM1 a fait couler beaucoup d'encre : L'introduction du prédicat va-t-elle vraiment appauvrir la grammaire française ?. L'article est intéressant à lire, même si la notion n'est pas encore très claire pour son auteur. Comme dans l'exemple qu'il donne au début :

              Par exemple, dans «cette polémique est totalement absurde», «cette polémique» est le sujet ; «est totalement absurde» est le prédicat, c’est-à-dire la partie de la phrase qui dit ce que le sujet fait ou est.

              Ici le sujet est bien « cette polémique », mais le prédicat est « totalement absurde ». L'auxiliaire « être » constitue ce que l'on appelle la copule du jugement, c'est-à-dire la forme, là où le sujet et le prédicat en sont la matière. Comme dans le jugement suivant :

              let l = [1; 2; 3];;
              val l : int list = [1; 2; 3]

              Le sujet est l, le prédicat int list et la copule est signifiée par le : entre eux. Ce que l'on exprimerait en français par la proposition : « la valeur l est une liste d'entiers ».

              En revanche, les types dépendants ou leur forme affaiblie que sont les GADT ressemblent plus à des systèmes de types que je qualifierais de sophistiqués. Comme dans l'exemple suivant avec des GADT pour encoder la taille d'une liste dans son type.

              module Llist = struct
                type z = Z (* zéro *)
                type 'n s = S : 'n -> 'n s (* la fonction successeur *)
              
                type un = z s (* un est le successeur de zéro *)
                type deux = un s (* deux est le successeur de un *)
                type trois = deux s (* trois est le successeur de deux *)
              
                type ('a,'n) t =
                  | [] : ('a, z) t
                  | (::) : 'a * ('a, 'n) t -> ('a, 'n s) t
              
                let rec zipwith :
                  type a b c n. (a -> b -> c) -> (a, n s) t -> (b, n s) t -> (c, n s) t =
                  fun f l l' -> match l, l' with
                    | x :: [], x' :: [] -> (f x x') :: []
                    | x :: y :: l, x' :: y' :: l' ->
                        (f x x') :: zipwith f (y :: l) (y' :: l')
              end;;
              module Llist : sig
                  type z = Z                                                                  
                  type 'n s = S : 'n -> 'n s                                                  
                  type ('a, 'n) t = [] : ('a, z) t | (::) : 'a * ('a, 'n) t -> ('a, 'n s) t   
                  val zipwith :                                                               
                    ('a -> 'b -> 'c) -> ('a, 'n s) t -> ('b, 'n s) t -> ('c, 'n s) t          
              end

              Ici on a des listes chaînées encodées via un GADT avec deux paramètres de types : a qui exprime le type homogène des éléments de la liste et n qui exprime la longueur de la liste (on encode les entiers dans le système de types via une représentation unaire, ce qui convient bien aux listes). Ainsi la fonction zipwith prend une fonction à deux paramètres et deux listes de valeurs, puis renvoie la liste des valeurs prises par la fonction. Mais ici, grâce au GADT, on peut exprimer dans le type de zipwith que les deux listes doivent être non vide et de même longueur (paramètre de type n s), sous peine de déclencher une erreur de typage à la compilation et non une erreur à l'exécution.

              open Llist;;
              
              let l : (int, deux) t = 1 :: 2 :: []
              and l1 : (int, deux) t = 2 :: 3 :: []
              and l2 : (int, trois) t =1 :: 2 :: 3 :: [];;
              
              zipwith (+) l l1;;
              - : (int, un s) t = :: (3, :: (5, []))
              
              (* erreur à la compilation avec l de longueur 2 et l2 de longueur 3 *)
              zipwith (+) l l2;;
              Error: This expression has type (int, trois) t
                     but an expression was expected of type (int, un s) t
                     Type trois = deux s is not compatible with type un s 
                     Type deux = un s is not compatible with type un = z s
                     Type un = z s is not compatible with type z

              Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.

  • # Plus verbeux que le C++

    Posté par (page perso) . Évalué à 9 (+8/-0).

    J'aime bien ces dépêches-là, ça m'oblige à réfléchir comment je ferai ça dans mon langage préféré, qui est… L'Ada :)
    J'ai donc tenté de faire les deux exemples.
    Attention, il y a peut-être mieux :

    with Ada.Text_Io; use Ada.Text_Io;
    
    procedure Variant is
    
       type Formes is (CARRE, RECTANGLE);
    
       type Forme(De_Type : Formes) is 
          record
         case De_Type is
            when CARRE =>
              Cote : Positive;
            when RECTANGLE =>
               Longueur : Positive;
               Largeur : Positive;
         end case;
          end record;
    
       function Surface(La_Forme : Forme) return Positive is
       begin
          case La_Forme.De_Type is
         when CARRE =>
           return La_Forme.Cote ** 2;
         when RECTANGLE =>
            return La_Forme.Longueur * La_Forme.Largeur;
          end case;
       end Surface;
    
       Mon_Carre : Forme := Forme'(De_Type => CARRE, Cote => 2);
       Mon_Rectangle : Forme := Forme'(De_Type => Rectangle, Longueur => 5, Largeur => 2);
    begin
       Put_Line("Surface " & Positive'Image(Surface(Mon_Carre)));
       Put_Line("Surface " & Positive'Image(Surface(Mon_Rectangle)));
    end Variant;

    L'avantage ici est que, comme en Haskell, il est impossible dans un CASE d'oublier une alternative, le compilateur ne voudra jamais.
    De fait, l'ajout d'un élément à l'énumération va forcer le développeur à compléter son record et aussi à compléter la fonction Surface.
    J'ai laissé de côté la notion de mutabilité des variants mais pour ceux que ça intéresse, c'est :)

    Pour le deuxième exemple, c'est plus facile, la programmation multi-processeurs fait partie de la norme et la version 2012 du langage a introduit l'annexe Multiprocessors (ça, c'est )

    with System.Multiprocessors; use System.Multiprocessors;
    with Ada.Text_Io; use Ada.Text_Io;
    
    procedure MT is
       type CPU_CONFS is (TOUS_LES_CPUS, LA_MOITIE_SEULEMENT, AUTRE_CHOSE);
    
       subtype NB_CPU is CPU range 1 .. Number_Of_CPUs;
    
       procedure SetGlobalThreadCount (Config : CPU_CONFS) is -- TOUS_LES_CPUS est comme CPU'Last
       begin
          -- Quelque chose ici avec des Task et autres joyeusetés
          -- en fontion de la conf
    
          case Config is
         when TOUS_LES_CPUS =>
            Put_Line ("Zou, à fond " & CPU'Image(Number_Of_CPUs));
         when LA_MOITIE_SEULEMENT =>
            Put_Line ("Mollo " & CPU'Image(Number_Of_CPUs / 2));
         when AUTRE_CHOSE =>
           Put_Line("Smart");
          end case;
    
       end SetGlobalThreadCount;
    
       procedure SetGlobalThreadCount (combien : NB_CPU) is
       begin
          -- Un autre truc en fonction du nombre de CPU
          Put_Line("nb CPU : " & NB_CPU'Image(combien));
       end SetGlobalThreadCount;
    
    begin
       SetGlobalThreadCount(LA_MOITIE_SEULEMENT);
       SetGlobalThreadCount(12);
    end MT;

    Number_Of_CPUs étant une fonction dont la valeur n'est pas connue à la compilation, le compilateur ne détecte pas que 12 CPUs, c'est trop… Ça donne une jolie exception CONTRAINT_ERROR qu'il reste à gérer.

    Voilà donc ma petite contribution et merci pour le petit exercice et cette petite présentation bien sympa d'exemples de typage statique en Haskell.

  • # Toujours aussi fan d'Haskell

    Posté par . Évalué à 2 (+2/-1).

    J'aime voir à quel point une idée simple peut être exprimée simplement. C'est élégant. Ça me donne envie de m'y remettre pour jouer !

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.