Typeclasses Haskell en C++
2015-04-26Les typeclasses de Haskell ont des similarités avec ce que les langages orientés objet appellent des interfaces ou des classes abstraites : elles définissent un contrat, fournissent parfois une implémentation par défaut… Elles utilisent même un vocabulaire assez proche : on dit d’un type qui est une instance d’une typeclass qu’il en “dérive”.
Parmi les nombreuses différences, une est particulièrement intéressante : implémenter une typeclass pour un type donné se fait sans modifier la déclaration du type en question, alors qu’une classe dans la majorité des langages orientés objets doit déclarer explicitement la liste des interfaces qu’elle implémente. Une exception notable : Go. Mais en C++, qui d’ailleurs n’a pas de réelle notion d’interface (simplement de classe abstraite), c’est impossible.
Pendant le développement de ma petite
bibliothèque MCL, j’ai choisi de représenter
les transformation de couleurs comme des endomoprhismes ; ça m’a conduit
à implémenter un équivalent de la fonction mconcat
de Haskell, pour
réduire une liste de transformations à une seule. Souhaitant que le code
reste générique, il m’a fallu trouver un moyen d’exprimer la notion de
monoïde, et donc de typeclass, ce qui m’a poussé à commencer, comme
d’habitude, un petit sous-projet.
Le code qui a résulté de cette petite expérience est sur Github, ce post en détaille la progression. Si vous aviez le moindre doute : ce n’est pas quelque chose que je vous recommande “en vrai”. N’essayez pas de faire ça chez vous. Sauf pour vous amuser. S’amuser, c’est bien.
Spécialisation partielle
Commençons là où tout a commencé : parlons de Monoid
. C’est quoi, un
monoïde ? En gros, c’est un ensemble (au sens mathématique du
terme), muni d’une fonction binaire associative et de son élément
neutre. En Haskell, un monoïde est exprimé de cette manière :
Comment traduire ça en C++? L’approche naïve est d’utiliser des fonctions (template), qu’il suffit de définir pour chaque type pour lequel nous voulons implémenter notre typeclass. Il y a deux manières de redéfinir des fonctions : surcharge et spécialisation. Spécialiser une fonction template, c’est en créer une définition différente pour un type explicitement donné ; le compilateur sait qu’il s’agit bien d’une seule et même fonction, avec plusieurs spécialisations. Surcharger se fait au contraire en déclarant une nouvelle fonction, qui porte le même nom, mais avec des paramètres différents.
Mais hélas, aucune des deux solutions ne peut marcher… D’une part parce que la spécialisation partielle de fonctions n’est pas autorisée, d’autre part parce que la surcharge produira forcément des fonctions ambigües que le compilateur ne sera pas capable de différencier…
Ce qui signifie que nous devons passer à une autre solution : les traits !
Traits
Les traits sont un outil extrêmement utile lorsqu’on manipule du code
C++ fortement template. En pratique, un trait, c’est une méta-fonction
sur des types: une structure template qui associe des informations
(types, constantes, fonctions) au type passé en paramètre. Et comme ils
sont implémentés avec des struct, les traits supportent la
spécialisation partielle, ce qui est précisément ce dont nous avons
besoin. Donc, si nous déplaçons tout le code dans une classe Monoid
et
que nous utilisons la spécialisation partielle…
…ça marche à la perfection ! Par soucis de simplicité, nous pouvons
définir deux fonctions de wrapping, en dehors de la classe
Monoid
. Elles peuvent facilement être inlinées par le compilateur, et
sont surtout du sucre syntaxique.
Mais cette solution qui marche si bien pour Monoid
n’est pas
parfaite. L’appliquer telle quelle pour Functor
a mis en évidence
quelques limitations.
Méta-problèmes
En Haskell, Functor
n’est pas défini pour ce qu’on appelle un type
concret (de kind *
), mais pour un
type paramétré (de kind * -> *
). Essayer de faire la même chose en
C++ est tout sauf évident…
Le premier problème est que les conteneurs de la STL n’ont rarement
qu’un seul paramètre ; vector
en a par exemple deux, ce qui signfie
qu’il n’est pas possible de lui créer une implémentation de Functor
,
qui n’attend que des types avec un seul paramètre. Ce problème semble de
prime abord évitable grâce aux template type synonyms de C++11, tel
Vec
introduit dans l’exemple ci-dessus. Mais le compilateur a parfois
un peu de mal à s’y retrouver, entre autre parce que selon le standard,
ces synonymes ne sont jamais utilisés pour la
détection des arguments template template,
ce qui nous oblige à expliciter le type F dans l’appel à fmap :
fmap<Vec>(f, v)
.
Ce compromis pourrait sembler acceptable à défaut d’être élégant, mais
cesse d’etre convaincant lorsqu’on réfléchit à notre prochaine
typeclass : Monad
. Car pour celle-ci, nous allons vouloir surcharger
les opérateurs >>
et >>=
. Et expliciter les paramètres templates
d’opérateurs est très laid… Ce qui veut dire que nous avons le choix
entre implémenter des wrappers pour chaque classe que nous voulons
utiliser qui n’a pas le bon nombre de paramètres (les conteneurs
standard, Either
…), et repartir de zéro pour trouver une nouvelle
façon de représenter Functor
et Monad
…
Encore des templates, toujours des templates
Comment, du coup, faire en sorte que le compilateur identifie
correctement nos types tout en gardant une certaine simplicité
d’utilisation ? Hé bien, tout simplement : utiliser des types concrets
dans la spécialisation des templates. Monad
devrait être implémentée
pour Vec<A>
plutôt que pour Vec
. En contrepartie, il faudra fournir
plus d’informations lors de l’implémentation.
La seule limitation, pour l’opérateur >>=
(“bind”), est due au fait
que le compilateur déduit les types MB
et A
du paramètre f
, en
conséquence de quoi il n’est pas possible de passer à bind
une
fonction qui ne soit pas exactement de type std::function
, telle une
lambda ou un pointeur sur fonction. Mais à ce détail près, nous avons
des monades ! Le code ci-dessous affiche correctement
[0,1,2,3,4,5,6,7,8]
(à condition d’implémenter show
).
Pour conclure
Le lecteur attentif aura probablement remarqué qu’il n’y avait pas de
définition par défaut de la classe Monad
. La raison en est simple : ce
n’est pas possible. Il n’y a aucun moyen, à partir du paramètre MA
, de
déduire les types M
et A
. Mais ce n’est pas nécessairement une
mauvaise chose : ce design est celui qui a en général les messages
d’erreur les plus lisibles. Typiquement, essayer d’appeler fmap
sur un
type A
quelconque donne le message d’erreur suivant avec clang :
implicit instantiation of undefined template 'Functor<A<int> >'
.
Et… c’est tout. Une méthode pour exprimer des typeclasses à la Haskell
en C++. Tout le code présenté ici (ainsi que quelques autres
typeclasses) est sur
Github. Bien que certaines
ont potentiellement une vraie utilité (tel Monoid
), l’impact
catastrophique sur les performances de Monad
et de Functor
sur les
listes ou les vecteurs fait de ces typcelasses une alternative peu
intéressante à du code écrit à la main utilisant les
algorithmes standard de
C++. Elles restent néanmoins une manière amusante d’explorer et de
tester les limites du type system de C++.
La suite, comme implémenter la monad Cont par exemple, est laissé en exercice aux lecteurs/trices motivé·e·s. :)
Aller plus loin
En faisant la relecture de cet article, j’ai découvert le blog
Functional C++ ; leur méthode
pour implémenter la
typeclass Monoid
est assez proche de la mienne. Mais ils font aussi des choses amusantes
avec les conteneurs de la STL. Une lecture vivement recommandée !