Je n'avais pas réalisé pour le "nothing". C'est le seul élément qui est résolu au runtime et pas a la compilation. Du coup, le type retourné par divM ne peut pas être fixe a la compilation (j'avais pensé en premier a un Mayb<T, tag>, avec tag = lvalue, rvalue ou nothing). Et cet élément au runtime fait que l'on a besoin d'un paramètre interne (au lieu d'utiliser un simple tag dispatching).
C'est assez problématique en fait… et probablement contraire a la philosophie du C++. Nothing correspond en fait a une erreur (division par 0). En C++, un tel problème devrait être géré directement lors de la saisie des données, pas lors du traitement (le nothing correspond finalement a une gestion des erreurs par type de retour). Du coup, cette erreur devrait être gérée par une exception, voire un assert.
Voyez la très légère différence dans la déclaration de type de chaque fonction. À quoi cela a-t-il servi ? La fonction error abandonne l’exécution du programme et affiche une erreur. Si votre programme a pour seule utilité de faire une division, ce n’est pas très grave, mais si votre calculette plantait définitivement chaque fois que vous cherchez à faire une opération n’ayant pas de sens, cela deviendrait vite pénible.
Parce que, justement, en C++, on VEUT que cela plante. Pour pouvoir corriger le code (ajouts de tests sur les IO par exemple).
Et sans ce nothing, la résolution du type Maybe est entièrement faite a la compilation, basée uniquement sur le type de valeurs (rvalue ou lvalue). Une version simple ressemblerait a cela (sans la gestion des rvalue/lvalue) :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 | #include <functional>
#include <iostream>
#include <utility>
template<typename T>
class Monad {
public:
template<typename ...Args>
constexpr Monad(Args&&... args) noexcept : _value{std::forward<Args>(args)...} {}
template<typename ...Args>
inline constexpr static Monad just(Args&&... args) noexcept {
return Monad(std::forward<Args>(args)...);
}
template<typename F, typename ...Args>
inline constexpr Monad bind(F&& f, Args&&... args) const {
return Monad(f(_value, std::forward<Args>(args)...));
}
private:
T _value;
};
int main() {
auto divM = [](auto x, auto y) { return x / y; };
auto div0M = [divM](auto x){ return divM(x, 0.0); };
auto div5M = [divM](auto x){ return divM(x, 5.0); };
auto div7M = [divM](auto x){ return divM(x, 7.0); };
auto print = [](auto x){ std::cout << x << std::endl; return x; };
Monad<double>::just(35.0).bind(div5M).bind(print).bind(div0M).bind(div7M);
double xx { 35.0 };
Monad<double>::just(xx).bind(div5M).bind(print).bind(div0M).bind(div7M);
}
|
La version complète pour gérer les rvalue sans copie est un peu plus lourde, il faut évaluer le type des arguments (avec is_rvalue_reference/is_lvalue_reference) et choisir le bon tag (avec conditional). Et bien sur adapter le type de _value en fonction du tag. (Finalement, on a quelque chose de similaire a des expressions template).
(Mais d'un autre cote, si j'ai bien compris la transparence du référentiel, HJaskell passe tout par copie. Du coup, la gestion rvalue/lvalue est un optimisation, un petit plus que l'on a avec le C++).
Je me pose quand même la question de l’intérêt de la chose (le tuto n'est qu'un intro aux monades, du coup j'imagine qu'il a des choses plus puissantes). Au final, le but est simplement d'avoir les fonctions just/bind en membre plutôt qu'en fonction libre. On peut sans problème utiliser std::bind ou créer des lambdas, seul l'ordre d'appel des fonctions changent :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | int main() {
auto divM = [](auto x, auto y) { return x / y; };
auto div0M = [divM](auto x){ return divM(x, 0.0); };
auto div5M = [divM](auto x){ return divM(x, 5.0); };
auto div7M = [divM](auto x){ return divM(x, 7.0); };
auto print = [](auto x){ std::cout << x << std::endl; return x; };
// en appelant directement les lambdas
auto x = div7M(div0M(print(div5M(35.0))));
// en declarant des lambdas just et bind
auto just = [](auto x){ return x; };
auto bind = [](auto f, auto x){ return f(x); };
auto y = bind(div7M, bind(div0M, bind(print, bind(div5M, just(35.0)))));
}
|
On peut même s'amuser a surcharger * pour faire de la composition de fonctions (évalué de gauche a droite)
| template<typename T, typename F>
T operator*(T&& x, F&& f) {
return f(std::forward<T>(x));
}
just(35.0) * div5M * print * div0M * div7M;
|
Je suppose que tout cela est faisable en Haskell. Du coup, pourquoi faire des monades ?
Un point sur les lambdas : il n'est pas possible de faire du perfect forwarding (puisque l'on ne peut pas passer le type template dans std::forward). Du coup, cela passe obligatoirement par lvalue. Du coup, si on veut un support correcte les lvalue/rvalue, il faut fournir les 2 versions de lambdas, ou alors utiliser des fonctions template classiques.
Ce qui me gêne avec cette transposition des monades en C++, c'est que l'on essaie de copier une syntaxe, pas de trouver une syntaxe qui apporte les mêmes avantages que les monades pour Haskell. Je suis convaincu de l’intérêt du fonctionnel en C++ (voir les DSEL, boost.proto, les ranges, etc), mais l’intérêt du code de cet exemple me convaincs pas trop. Surtout que l'on "oublie" de prendre en comptes ce qui fait la force du C++ (compile-time/runtime, rvalue/lvalue, etc)
(Il faudrait demander l'avis a Flob90, qui maîtrise mieux que moi le fonctionnel)
(Cf le blog de Bartosz Milewski pour la prog fonctionnelle en C++ : http://bartoszmilewski.com/category/monads/)