Journal Les types fantômes

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
19
25
août
2012

Dans une application de gestion (au sens large) qui traite nombre informations, on peut facilement se retrouver avec moult variables textuelles voyageant dans le code au gré des traitements.

Le risque arrive vite d'avoir pléthores de fonctions prenant des chaînes en argument. Évidemment une chaîne étant équivalente à une autre, les fautes d'étourderies et autres valeurs mal traitées traitées (inversions, oublis), impliquent assez vite des erreurs survenant à l'exécution.

En paradigme objet, on peut s'amuser à créer un objet par champ, ce qui peut être lourd et non adapté car les ORMs sont tentent d'insérer une logique objet non adaptée à la logique relationnelle. Les ORMs courrament utilisés implique qu'une ligne de la base soit équivalent à une instance, mais que se passe t-il lorsque qu'une fonction traite divers champs de divers table, ou encore qu'on ait besoin d'une requête un peu complexe pour récupérer certaines valeurs non associées à des objets ?

En paradigme fonctionnel, on a la possibilité d'utiliser les types fantômes, correspondant à un champ de la base de donnée.
Ces types permettent de faire croire que deux éléments, tous deux d'un type de base ( genre string ou int) sont de types différents.

L'exemple explicatif sera en ocaml (un haskelien aura la bonté de nous le traduire, je n'en doutes pas).

Les types fantômes en OCaml

(*signature du module, ou encore définition des prototypes comme une interface en java*)
module T : sig
(*on défini nos types spécialisés*)
    type prenom
    type nom
    (*on devra passer par ces fonctions pour créer les chaines typées. *)
    val makeNom    : string -> nom 
    val makePrenom : string -> prenom 
        val fromNom    : nom -> string
    val fromPrenom : prenom -> string
end = struct
    (*Nous sommes dans l'implémentation du module, on défini que nos types particuliers sont des chaînes*)
    type nom = string
    type prenom = string
    (*la définition de type dans la signature nous garantie que l'on renvoi bien un type T, alors que l'implémentation est une bête fonction identité*)
    let makeNom    s = s
    let makePrenom s = s
    let fromPrenom s = s
    let fromNom    s = s
end;;

On va maintenant constater que bien que l'on construit deux chaines, les fonctions makeNom et makePrenom étant codées comme des fonctions identités, on obtient deux types différents

let a = T.makeNom ("1 nom");;
let b = T.makePrenom ("1 prenom");;
b = a;;

Le compilateur nous indique qu'il y a erreur de type, en effet on compare deux types différends :

line 1, characters 4-5:
Error: This expression has type T.nom but an expression was expected of type T.prenom

L'intérêt de cet outil de typage est d'avoir la garantie que l'on ne va pas se tromper de champ, ainsi, dans un découpage Modèle/Vue/Controlleur, toutes les fonctions du modèles vont traiter un type correspondant à chaque champ de la base de donnée :

(* Soit le type :*)
type client = { prenom : T.prenom ; nom : T.nom ; id : int};;

let chercheClientByPrenom prenom nom clients =
    List.find (fun cli ->  cli.prenom = prenom || cli.nom = nom) clients

que Ocaml détecte comme une fonction de type :

val chercheClientByPrenom : T.prenom -> T.nom -> client list -> client = <fun>

Conclusion

En obligeant le développeur à n'utiliser, par construction, à la sortie d'un espèce d'ORM ou de simples requêtes SQL, ces types fantômes, on garantie que ce sont bien les bonnes donnés qui seront traités à la bonne place, car le compilateur refusera de compiler s'il y a une erreur.

Il existe d'autre manière de profiter des possibilités de typage de cette race de langage fonctionnel, en particulier en utilisant un type somme, mais je trouve cette manière plus propre, car on regroupe toutes les définitions de "choses" (un nom, un prénom, un numéro de facture) dans un seul module, dédié à cela.

J'avoue ne pas avoir d'idée de la manière avec laquelle on pourrait implémenter ce genre de chose dans des langages objets plus classique. Mon expérience m'a appris que l'utilisation d'ORM automatique classique ne suffit pas à garantir l'absence de problèmes.

  • # Comme Hibernate ne savait pas que c'était impossible, ils l'ont fait.

    Posté par  (site web personnel) . Évalué à 8.

    Les ORMs courrament utilisés implique qu'une ligne de la base soit équivalent à une instance, mais que se passe t-il lorsque qu'une fonction traite divers champs de divers table, ou encore qu'on ait besoin d'une requête un peu complexe pour récupérer certaines valeurs non associées à des objets ?

    public static class DTO {
        public Long foo;
        public String bar;
    }
    
    public List<DTO> list() {
        return HibernateUtil.getSession()
            .createSQLQuery("select a.foo as foo, b.bar as bar from <select achement compliqué>")
            .addScalar("foo")
            .addScalar("bar")
            .setResultTransformer(Transformers.aliasToBean(DTO.class))
            .list();
    }
    
    

    (oui ça map sur un objet m'enfin c'est de l'OO hein… je suppose que tu voulais dire qu'Hibernate ne map que sur des entités)

  • # Haskell newbie

    Posté par  . Évalué à 1. Dernière modification le 25 août 2012 à 18:12.

    C'est comme ça ?

    data Nom
    data Prénom
    
    data T f = T String deriving (Eq)
    
    a = T "un prénom" :: T Prénom
    b = T "un nom"    :: T Nom
    
    test = a == b
    
    
        Couldn't match expected type `Prénom' with actual type `Nom'
        Expected type: T Prénom
          Actual type: T Nom
        In the second argument of `(==)', namely `b'
        In the expression: a == b
    
    

    Les vrais haskeliens n'hésitez pas à corriger.

    (ya un petit bug dans la coloration syntaxique, haskell supporte très bien le code en utf-8)

    Please do not feed the trolls

    • [^] # Re: Haskell newbie

      Posté par  . Évalué à 2.

      Des accents dans le nom de variables ?

      • [^] # Re: Haskell newbie

        Posté par  . Évalué à 1.

        Oui, c'est c'est pas du vrai code. Cela dit, pour les lettres grecs c'est dommage de jamais en mettre. Combien de fois dans du code j'ai vu des Lambda écrit en toutes lettres alors que Λ serait bien plus facile à lire. Enfin bref.

        Sinon, pour une jolie interface, on la ferait comme ça ?

        makeNom s = T s :: T Nom
        makePrénom s = T s :: T Prénom
        
        fromNom :: (T Nom) -> String
        fromNom (T s) = s
        
        fromPrénom :: (T Prénom) -> String
        fromPrénom (T s) = s
        
        

        Je dois réécrire 15 fois chaque lignes pour que ça marche, je crois que c'est le langage le plus capricieux que j'ai croisé (et les messages d'erreurs de ghc n'aident pas beaucoup).

        Please do not feed the trolls

      • [^] # Re: Haskell newbie

        Posté par  (site web personnel) . Évalué à 4.

        Bah en Haskell j'en sais rien mais en Java par exemple, les identifiants sont en Unicode, donc à part quelques règles (du style ne pas commencer un nom de variable par un chiffre) tu peux utiliser à peu près tout ce que tu veux… c'est un peu chercher la merde mais tu peux.

        • [^] # Re: Haskell newbie

          Posté par  . Évalué à 3.

          tu peux utiliser à peu près tout ce que tu veux… c'est un peu chercher la merde mais tu peux.

          C'était le sens de ma remarque plus haut.

    • [^] # Re: Haskell newbie

      Posté par  . Évalué à 4.

      Le type fantôme peut être avantageusement déclaré via newtype:

      the type is checked at compile time, at run time the two types can be treated essentially the same, without the overhead or indirection normally associated with a data constructor.

      On peut ensuite aussi jouer avec l'extension OverloadedStrings comme suit:

      {-# Language OverloadedStrings #-}
      module PhantomType where
      
      import GHC.Exts(IsString(..))
      
      data Firstname
      data Lastname
      
      newtype P a = P { fromPhantom :: String }
      
      instance IsString (P a) where
          fromString = P
      
      prénom = "George"   :: P Firstname
      nom    = "Abitbolt" :: P Lastname
      
      data Client = Client (P Firstname) (P Lastname)
      
      client1 = Client prénom nom
      client2 = Client "Charles" "Bronson"
      client3 = Client ("PasBill" :: P Firstname) ("PasGates" :: P Lastname)
      
      

      Du coup on a plus du tout besoin d'utiliser de constructeur.
      Mais on peut toujours mettre de façon explicite le type de la chaîne.

      client4 = Client nom prénom
      
      
      test = nom == prénom
      
      

      ne compileront pas.

  • # En C++...

    Posté par  (site web personnel) . Évalué à 9.

    Je suis fan de types fantômes, et j'en colle partout (trop?). Mais quand je fais du C++ en lieu et place d'OCaml, je m'assure de toujours avoir dans un coin ma template StrongId:

    template<typename DISCRIMINANT, typename TYPE>
    class StrongId
    {
    public:
      StrongId(const TYPE & type):
        _type(type)
      {
      }
    
      bool operator==(const StrongId<DISCRIMINANT, TYPE> & other)
      {
        return _type == other._type;
      }
    
      TYPE _type;
    };
    
    class DistanceDiscriminant;
    class TempsDiscriminant;
    
    typedef StrongId<DistanceDiscriminant, int> DistanceT;
    typedef StrongId<TempsDiscriminant, int> TempsT;
    
    int main()
    {
      DistanceT d1(1);
      DistanceT d2(2);
      d1 == d2;
      TempsT t1(1);
      d1 == t1; // Erreur de compile
    }
    
    

    Il est ensuite possible d'ajouter des méthodes supplémentaires pour comparer, sérialiser, afficher… Voire de rajouter un peu de template magic pour permettre de diviser une distance par un temps et d'obtenir une vitesse.

    • [^] # Re: En C++...

      Posté par  (site web personnel) . Évalué à 2.

      Ce qui est marrant dans cet exemple, c'est que ce n'est pas le compilateur qui détecte l'erreur de type, mais c'est le fait que le compilateur exécute le code liés aux templates qui va le faire déboucher sur une erreur d'exécution lors de la phase de compilation.

      « Il n’y a pas de choix démocratiques contre les Traités européens » - Jean-Claude Junker

      • [^] # Re: En C++...

        Posté par  . Évalué à 5.

        Sauf que dans ce cas là, c'est une vraie erreur de type.

        Tu à un objet de type StrongId<DistanceDiscriminant, int> à comparer avec un StrongId<TempsDiscriminant, int>, or la substitution donne
        bool StrongId<DistanceDiscriminant, int>::operator==(const StrongId<DistanceDiscriminant, int> & other) (ça manquerai d'un const d'ailleurs)

        Tu à donc une méthode qui prend un const StrongId<DistanceDiscriminant, int> & auquel tu essaye de faire passer un StrongId<TempsDiscriminant, int>& et le compilateur ne trouve pas de conversion implicite possible. Ce n'est pas différent de faire passer un int pour un std::string.

        Ça aurai pu être une erreur d'instanciation si c'était défini comme étant

        template <typename TYPE2>
        bool operator==(const StrongId<DISCRIMINANT, TYPE2> & other)
        {
          return _type == other._type;
        }
        
        

        et que tu voulais comparer un StrongId<DistanceDiscriminant, int> et un StrongId<DistanceDiscriminant, std::string> (même si ça n'a aucun sens).

  • # Mapping Object-Relationnel

    Posté par  (site web personnel) . Évalué à 4.

    Pour les heureux qui ont la chance de ne pas s'être encore cassé les dents sur la problématique dite du Défaut d'impédance il existe un très bon article sur le Wikipédia anglophone:
    https://en.wikipedia.org/wiki/Object-relational_impedance_mismatch

    Merci pour ton exemple même s'il ne résoud qu'une partie des problèmes.

  • # Un peu faible

    Posté par  (site web personnel) . Évalué à 6.

    Ça me paraît être une utilisation très simple des types fantômes, et ça ressemble plus à l'insertion d'une dose de typage nominal en utilisant des signatures de modules que des types fantômes full-fledged©.

    La manière de faire usuelle est plutôt du genre :

    module T :
    sig
      type +'a t = private string
      type nom = [`NOM] t
      type prenom = [`PRENOM] t
      val cast : string -> 'a t
    end =
    struct
      type 'a t = string
      type nom = [`NOM] t
      type prenom = [`PRENOM] t
      let cast s = s
    end
    
    

    (et éventuellement d'autres fonctions de création.)

    Le code est plus générique, et permet en sus des trucs bien sioux. Notez bien par exemple la covariance du type +'a t dans la signature. En la mettant contravariante et à l'aide des variants polymorphes, on peut mimer le sous-typage des records ou des objets. J'ai pas d'exemples simples sous la main, mais la technique est assez employée.

    • [^] # Re: Un peu faible

      Posté par  (site web personnel) . Évalué à 6.

      Je dirais même plus: pas besoin de langage fonctionnel pour faire ça, on peut y arriver avec n'importe quel langage fortement typé… Et même en C, en bidouillant un peu pour profiter du fait que les structures en C sont fortement typées:

      #include <stdio.h> 
      
      typedef char* string_t ;
      typedef struct prenom { string_t string; } prenom_t;
      typedef struct nom { string_t string; } nom_t;
      
      nom_t make_nom(string_t string) { nom_t nom = {string}; return nom; };
      prenom_t make_prenom(string_t string) { prenom_t prenom = {string}; return prenom; };
      
      string_t from_nom(nom_t nom) { return nom.string; };
      string_t from_prenom(prenom_t prenom) { return prenom.string; };
      
      void main(void)
      {
        /* prenom_t prenom = make_nom("Matthieu"); */ /* Erreur: initialisation invalide. */
        prenom_t prenom = make_prenom("Matthieu"); /* OK */
        /* printf("%s\n", from_nom(prenom));  */         /* Erreur: erreur: incompatible type for argument 1 of ‘from_nom’. */
        printf("%s\n", from_prenom(prenom));          /* OK. */
      return 0;
      }
      
      

      Les vrais types fantômes sont definis par le fait que ce sont des types paramètres, mais dont le paramètre n'apparait pas dans la définition: http://www.haskell.org/haskellwiki/Phantom_type

      http://l-lang.org/ - Conception du langage L

      • [^] # Re: Un peu faible

        Posté par  (site web personnel) . Évalué à 3.

        Ta remarque sur le fait qu'on paramétrise le type mais pas l'expression, est celle qui donne tout son sens.
        C'est d'autant plus intéressants qu'on s'en sort avec des types inhabités.
        Par exemple :

        data Serviettes
        data Torchons
        
        data Phantom a = P String
        
        a :: Phantom Serviettes
        a = P "Jean"
        
        b :: Phantom Torchons
        b = P "Paul"
        
        test = a == b
        
        

        nous donne l'erreur

        Couldn't match expected type `Serviettes'
        with actual type `Torchons'

        alors qu'il n'existe aucune expression de type Serviette ou Torchon.

        On a donc statiquement la garantie qu'on ne mélangera pas des serviettes avec des torchons.

      • [^] # Re: Un peu faible

        Posté par  (site web personnel) . Évalué à 2.

        l'avantage de ocaml est d'avoir à écrire qu'une seul fois les fonction qui manipule les 2 types de la même façon. Je ne suis pas sûr que cela soit possible en C.

        Par exemple pour redéfinir l'opérateur concatenation ().

        "La première sécurité est la liberté"

        • [^] # Re: Un peu faible

          Posté par  . Évalué à 1.

          On peut n'écrire qu'une seule fois les fonctions,

          typedef struct prenom { char * s; } Prenom;
          typedef struct nom { char * s; } Nom;
          
          #define makemaker(type) type make##type(char * s){ \
                                   type tmp = {s};           \
                                   return tmp;               \
                                }
          #define makeconcat(type) void concat##type(type a, type b, type res){ \
                                       strcpy(res.s, a.s);                      \
                                       strcat(res.s, b.s);                      \
                                   }
          
          // il faut quand même faire les déclarations
          makemaker(Nom)
          makemaker(Prenom)
          makeconcat(Nom)
          makeconcat(Prenom)
          
          

          Please do not feed the trolls

    • [^] # Re: Un peu faible

      Posté par  (site web personnel) . Évalué à 2.

      Très intéressant !

      Questions :

      • Quel est l'intérêt pratique de la covariance ? J'ai du mal à imaginer
      • Comment ta fonction de cast "sait" si elle va renvoyer un type nom ou prénom ?

      « Il n’y a pas de choix démocratiques contre les Traités européens » - Jean-Claude Junker

      • [^] # Re: Un peu faible

        Posté par  . Évalué à 3.

        La covariance est là pour permettre la généralisation polymorphe des expressions de la forme T.cast "foo" (essaie sans et avec le +). J'en parle ici sur reddit. La fonction de cast va renvoyer un terme polymorphe de type 'a T.t, à toi de l'utiliser de la façon qui va bien (je suis d'accord sur le fait que, du coup, l'exemple est un peu bizarre).

        • [^] # Re: Un peu faible

          Posté par  (site web personnel) . Évalué à 2. Dernière modification le 03 septembre 2012 à 12:07.

          J'ai essayé

          module Foo : sig
            type 'a foo
            val inject : 'a -> 'a foo
          end = struct
           type 'a foo = 'a
           let inject x = x
          end
          
          
          module Foo : sig
            type +'a foo
            val inject : 'a -> 'a foo
          end = struct
           type 'a foo = 'a
           let inject x = x
          end
          
          

          Et les deux versions se comportent exactement de la même façon, ne restreignent rien

           Foo.inject [];;
          - : 'a list Foo.foo = <abstr> 
          
          
           Foo.inject 6;;
          - : int Foo.foo = <abstr> 
          
          

          Je suppose que c'est normal ?

          « Il n’y a pas de choix démocratiques contre les Traités européens » - Jean-Claude Junker

          • [^] # Re: Un peu faible

            Posté par  . Évalué à 2.

            Tu as dû te tromper, chez moi Foo.inject [] ne donne pas le même résultat dans le premier cas.

            • [^] # Re: Un peu faible

              Posté par  (site web personnel) . Évalué à 2.

              C'est très bizarre ! Voici mon transcript :

              $ ocaml
                      OCaml version 4.00.0
              
              # module Foo : sig
                type 'a foo
                val inject : 'a -> 'a foo
              end = struct
               type 'a foo = 'a
               let inject x = x
              end            ;;            
              module Foo : sig type 'a foo val inject : 'a -> 'a foo end
              # Foo.inject [];;
              - : '_a list Foo.foo = <abstr>
              # Foo.inject 6;; 
              - : int Foo.foo = <abstr>
              # module Foo : sig
                type +'a foo
                val inject : 'a -> 'a foo
              end = struct
               type 'a foo = 'a
               let inject x = x
              end            ;;            
              module Foo : sig type +'a foo val inject : 'a -> 'a foo end
              # Foo.inject [];;
              - : 'a list Foo.foo = <abstr>
              # Foo.inject 6;; 
              - : int Foo.foo = <abstr>
              # 
              
              

              « Il n’y a pas de choix démocratiques contre les Traités européens » - Jean-Claude Junker

              • [^] # Re: Un peu faible

                Posté par  . Évalué à 3.

                # Foo.inject [];;
                - : '_a list Foo.foo = <abstr>
                
                
                # Foo.inject [];;
                - : 'a list Foo.foo = <abstr>
                
                

                Tu ne jouais pas au jeu des sept différences quand tu étais petit ?

                let li = Foo.inject [];;
                List.iter print_int li;;
                List.iter print_string li;;
                
                
                • [^] # Re: Un peu faible

                  Posté par  (site web personnel) . Évalué à 2.

                  Tu ne jouais pas au jeu des sept différences quand tu étais petit ?

                  Non ça me gonflais, je démontais mes jouets ;-)

                  Au temps pour moi :-)

                  N'étant pas dans les petits papiers de Leroy et Garrigues, ça signifie quoi '_a  ? Qu c'est "plus" polymorphique ?

                  « Il n’y a pas de choix démocratiques contre les Traités européens » - Jean-Claude Junker

                  • [^] # Re: Un peu faible

                    Posté par  . Évalué à 3.

                    '_a n'est pas une variable polymorphe, c'est une variable d'inférence encore inconnue, mais son premier usage fixera sa valeur (donc le bout de code que j'ai mis, qui l'utilise à deux types différents, va échouer avec une erreur de typage). Pour plus de détails sur pourquoi le + règle le problème, il faut lire (le début de) l'article sur la "relaxed value restriction".

Suivre le flux des commentaires

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