Comprendre les monades

C’est quoi ? À quoi ça sert ?

Le problème exposé dans ce sujet a été résolu.

Il y a un léger souci avec le code C++ du Maybe. Les objets temporaires en C++ ont une durée de vie limitée. Typiquement, une fois sorti de la fonction à laquelle tu passes fait ton objet temporaire, l'objet cesse d'exister et tout pointeur dessus devient invalide.

On peut voir ceci à l'aide du code suivant:

Sur Coliru, en utilisant clang ça donne '2.07442e-317' au lieu de donner '7'.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int main()  {
    std::function<const Maybe<double> (const double&)> div0M
        = [](const double x){ return divM(x, 0.0); };
    std::function<const Maybe<double> (const double&)> div5M
        = [](const double x){ return divM(x, 5.0); };
    std::function<const Maybe<double> (const double&)> div7M
        = [](const double x){ return divM(x, 7.0); };
    std::function<const Maybe<double> (const double&)> print
        = [](const double x){ std::cout<<x<<std::endl; return Maybe<double>::Just(x); };

    Maybe<double>::Just(35.0).bind(div5M).bind(print).bind(div0M).bind(div7M);
    return 0;
}

Pour implémenter un Maybe en C++, on est obligé de copier l'objet. T'as donc deux possibilités: (1) utiliser des unions (ou une union sur des tableaux de char comme ce que fait boost::optional) en utilisant des placement new et des appels explicites aux destructeurs (option clean mais beaucoup plus complexe pour un exemple aussi simple) ou (2) utiliser des shared_ptr (ou boost::optional) comme le code suivant (lignes 2, 13 et 16).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <functional>
#include <memory> // ou #include <boost/optional.hpp>

template <typename A>
class Maybe {
    private:
    enum class Constr {
        Nothing,
        Just
    };

    const Constr    _c;
    const std::shared_ptr<const A>  _a; // ou const boost::optional<const A> _a;

    Maybe()           noexcept : _c(Constr::Nothing), _a(nullptr) { } // ou _a(boost::none)
    Maybe(const A& a) noexcept : _c(Constr::Just), _a(std::make_shared<const A>(a)) { } // ou _a(a)

// ..le reste est inchangé
};
+2 -0

Merci d@rk-marouane pour ta correction, je l’ai intégrée.

Sinon, j’ai eu un certain nombre d’échanges avec Vayel, qui m’ont amené à faire des modifications supplémentaires, essentiellement dans la première section, dont les explications sont plus détaillées, pour être plus accessibles à quelqu’un qui ne serait pas familier de la programmation fonctionnelle.

Du coup, mon compte-rendu sur la méthode de correction tentée par Vayel : j’aime pas. En version longue, voici tout ce que je reproche à cette solution.

  • Elle crée une copie du tutoriel, qui s’ajoute à la liste de mes tutoriels. Si quatre personnes décident de m’aider de cette manière-là, j’aurai cinq exemplaires de mon tuto dans la liste « Mes tutoriels ». Imaginez l’horreur pour quelqu’un comme Mewtow, qui peut avoir six ou sept tutos en bêta en même temps. ^^
  • Quand on me fait une liste de remarques sur le forum, je peux répondre à tout d’un coup. Avec ce système, je dois éditer chaque extrait séparément pour répondre aux remarques concernant chacun.
  • Il n’y a pas de notification lorsqu’un coauteur modifie un tuto commun. Du coup, on est obligés de s’envoyer un MP à chaque fois pour se dire « Je t’ai répondu ! ».
  • Les autres relecteurs ne voient pas les remarques qui sont faites, ni ce que j’y réponds, ce qui génère plusieurs problèmes :
    • plusieurs auteurs sont susceptibles de faire les mêmes remarques en parallèle, démultipliant le nombre de réponses que l’auteur doit apporter ;
    • les explications que l’auteur donne sur un choix pédagogique ou autre ne sont pas publiques, donc inaccessibles à ceux qui ne passent pas par ce système ;
    • il n’existe aucun moyen simple de signaler aux autres relecteurs les changements apportés entre deux versions de la bêta ;
    • l’auteur est obligé de copier-coller les changements appliqués dans chaque version-relecteur du tuto vers sa version-maître, car ces versions-relecteur ne sont pas cohérentes entre elles, et un simple import écraserait les modifications apportées par un autre relecteur.

Ce qui fait donc beaucoup d’inconvénients pour un avantage somme toute assez minime. Au final, l’expérience d’aujourd’hui m’a plus fait penser au « coautorat » déjà testé avec Vayel sur le tuto RSA, où j’écrivais et il commentait, et ainsi de suite.

Mais voici donc la bêta corrigée des dernières remarques en date !

+0 -0

Je n'ai pas lu l'article donc je ne réagit qu'au code C++.

  • un shared_ptr va faire une allocation dynamique + copie. Cela ne sert à rien, autant faire directement une copie (on a toujours la possibilité d'utiliser A = shared_ptr<B> si on veut sur le Tas).
  • les raw pointers, c'est mal il parait…
  • pourquoi avoir une variable membre inutilisée dans Nothing ? Pourquoi avoir un pointeur au lieu d'une référence ?
  • il existe une autre approche, c'est de surcharger les fonctions pour accepter les rvalue et les lvalue, ce n'est pas nécessaire de jouer avec les pointeurs (avec des const& et &&, voire avec du perfect forwarding et/ou des variadic template).
  • pourquoi un membre pour l'énumération, qui prend de la place en mémoire, alors que le type est fixé et déductible à la compilation ?
  • attention avec std::function + lambda. Cela fait un cast et empêche l'optimisation du compilateur. Préférer auto (Effective Moderne C++, Item 5). Et plus généralement, cela manque de auto, pour simplifier le code.

Sans être entré plus que cela dans le code, je crois qu'il est possible d'éviter les overhead.

EDIT : en fait, ta fonction bind semble être l'équivalent en fonction membre de std::bind. A priori, on pourrait écrire :

1
bind(div, bind(div, bind(div, 35, 5), 0), 7);
+0 -0

Je vais voir pour écrire un code rapide.

@d@rk-marouane

Attention sur les références universelles. Je parle de faire cela :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
template <typename A>
class Maybe {
    Maybe(const A& a) noexcept;
    Maybe(A && a) noexcept;

    static const Maybe Just(const A& a) noexcept;
    static const Maybe Just(A&& a) noexcept;

    template <typename B>
    const Maybe<B> bind(std::function<const Maybe<B> (const A&)> f) const;
    template <typename B>
    const Maybe<B> bind(std::function<const Maybe<B> (A&&)> f) const;
};

Ce ne sont pas des références universelles dans ce cas, mais de simples rvalue reference.

+0 -0

Justement, ce sont des references universelles, pas des rvalue :/ EDIT Je dois peut être replongé dans une doc C++

Pour la correction de code, que penses tu de ça? Coliru

  • boost::optional pour la copie
  • plus de std::function

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <functional>
#include <iostream>
#include <type_traits>
#include <boost/optional.hpp>

template <typename A>
class Maybe {
    private:
    const boost::optional<const A>  _a;

    Maybe()           noexcept : _a(boost::none) { }
    Maybe(const A& a) noexcept
        : _a(a)
        { }

    public:
    static const Maybe Nothing()        noexcept;
    static const Maybe Just(const A& a) noexcept;

    template <typename F>
    auto bind(F f) const;

    template <typename B>
    friend std::ostream& operator<< (std::ostream& out, const Maybe<B>& m);
};

template <typename A>
const Maybe<A> Maybe<A>::Nothing() noexcept {
    return Maybe();
}

template <typename A>
const Maybe<A> Maybe<A>::Just(const A& a) noexcept  {
    return Maybe<A>(a);
}

template <typename A> template <typename F>
auto Maybe<A>::bind(F f) const {
    using MaybeB = typename std::result_of<F(A)>::type;
    if (this->_a == boost::none) return MaybeB::Nothing();
    return f(*_a);
}

const Maybe<double> divM(const double x, const double y)    {
    if (y == 0) return Maybe<double>::Nothing();
    return Maybe<double>::Just(x / y);
}

int main()  {
    auto div0M
        = [](const double x){ return divM(x, 0.0); };
    auto div5M
        = [](const double x){ return divM(x, 5.0); };
    auto div7M
        = [](const double x){ return divM(x, 7.0); };
    auto print
        = [](const double x){ std::cout<< x <<std::endl; return Maybe<double>::Just(x); };
    

    Maybe<double>::Just(35.0).bind(div5M).bind(print).bind(div0M).bind(div7M);
}

+0 -0

Je recopie mon edit :

En ajoutant "bêtement" les fonctions avec && (la version avec le pointeur nu… et hop, une fuite mémoire), le code retourne bien "7".

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Maybe(A&& a) noexcept : _c(Constr::Just), _a(new A{a}) { }

template <typename A>
const Maybe<A> Maybe<A>::Just(A&& a) noexcept  {
    return Maybe<A>(std::move(a));
}

template <typename A> template <typename B>
const Maybe<B> Maybe<A>::bind(
        std::function<const Maybe<B> (A&&)> f
        ) const {
    if (this->_c == Maybe<A>::Constr::Nothing) return Maybe<B>::Nothing();
    return f(*_a);
}

HS : après réflexion, bind et just ne sont que des fonctions adaptatrices, qui appellent d'autres fonctions (f dans le premier cas, le constructeur dans le second). Donc en fait, ce sont des bons candidats pour des références universelles. Et variadic template pour bind.

@d@rk-marouane

Non, ce ne sont pas des références universelles.

1
2
3
4
5
6
7
template<class T>
class A {
    void f(T&&); // pas une référence universelle

    template<class U>
    void g(U&&); // référence universelle
};

Cf Effective Moderne C++, Item 24.

+0 -0

Je recopie mon edit :

@d@rk-marouane

Non, ce ne sont pas des références universelles.

1
2
3
4
5
6
7
template<class T>
class A {
    void f(T&&); // pas une référence universelle

    template<class U>
    void g(U&&); // référence universelle
};

Cf Effective Moderne C++, Item 24.

gbdivers

Ah merci c'est plus clair!

Pour ce qui est du code sans lib externe, il y a ça Coliru (unions obligatoires).

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <functional>
#include <iostream>
#include <type_traits>

template <typename A>
class Maybe {
    private:
    enum class Constr {
        Nothing,
        Just
    };
    
    union Internal
    {
        Internal(){}
        Internal(A const& a)
        { new(&value)A(a); }
        A value;
    };

    const Constr     _c;
    Internal  _a;

    Maybe()           noexcept :_c(Constr::Nothing) { }
    Maybe(const A& a) noexcept
        :_c(Constr::Just), _a(a)
        { }
    

    public:
    
    Maybe(const Maybe& m): _c(m._c)
    {
        if(_c == Constr::Just)
            new(&_a.value)A(m._a.value);
    }
    ~Maybe()
    {
        if (this->_c == Maybe<A>::Constr::Just)
            _a.value.~A();
    }
    static const Maybe Nothing()        noexcept;
    static const Maybe Just(const A& a) noexcept;

    template <typename F>
    auto bind(F f) const;

    template <typename B>
    friend std::ostream& operator<< (std::ostream& out, const Maybe<B>& m);
};

template <typename A>
const Maybe<A> Maybe<A>::Nothing() noexcept {
    return Maybe();
}

template <typename A>
const Maybe<A> Maybe<A>::Just(const A& a) noexcept  {
    return Maybe<A>(a);
}

template <typename A> template <typename F>
auto Maybe<A>::bind(F f) const {
    using MaybeB = typename std::result_of<F(A)>::type;
    if (this->_c == Maybe<A>::Constr::Nothing) return MaybeB::Nothing();
    return f(_a.value);
}

const Maybe<double> divM(const double x, const double y)    {
    if (y == 0) return Maybe<double>::Nothing();
    return Maybe<double>::Just(x / y);
}

int main()  {
    auto div0M
        = [](const double x){ return divM(x, 0.0); };
    auto div5M
        = [](const double x){ return divM(x, 5.0); };
    auto div7M
        = [](const double x){ return divM(x, 7.0); };
    auto print
        = [](const double x){ std::cout<< x <<std::endl; return Maybe<double>::Just(x); };
    

    Maybe<double>::Just(35.0).bind(div5M).bind(print).bind(div0M).bind(div7M);
}

Bien évidemment, toute correction est la bienvenue.

+0 -0

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)

1
2
3
4
5
6
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/)

+0 -0

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.

Parce que ce n’est pas le but de la présentation. Le but, c’est de montrer qu’il est possible de faire des monades (et pas « un outil qui apporte les mêmes avantages qu’une monade mais n’est pas une monade1 ») dans un langage où la programmation fonctionnelle est mal intégrée2. La conclusion assez logique étant que c’est possible, mais clairement pas optimal.

Du coup, désolé, mais tes solutions qui optimisent l’outil en s’éloignant du fonctionnement d’une monade ne m’intéressent pas. :-) Merci de ta contribution, mais je vais en rester au code existant, qui fonctionne, et c’est tout ce que je lui demande.


  1. Dans la même optique, dire que Nothing devrait être géré par une exception est hors-sujet, puisqu’on cherche justement à ce qu’il ne soit pas géré par une exception. ;-) 

  2. De même que le dernier exemple en Erlang sert à montrer que c’est possible, mais clairement pas très utile, dans un langage fonctionnel faiblement typé. 

+0 -0

Le runtime vs compile-time est un détail (en termes d’implémentation). Le code qui fait la même chose :

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <functional>
#include <iostream>
#include <utility>

template<typename T>
class Monad {
public:
    constexpr Monad() = default;
    constexpr Monad(T const& value) noexcept : _value{&value} {}
    constexpr Monad(T&& value) noexcept : _owner{true}, _value{new T{value}} {}
    constexpr Monad(Monad const& m) noexcept : _value{m._value} {}
    constexpr Monad& operator=(Monad const& m) noexcept { 
        _owner = false; _value = m._value; return *this; }
    ~Monad() { if(_owner) { delete _value; } }

    inline constexpr static Monad nothing() noexcept {
        return Monad();
    }

    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 {
        if (!_value) { return nothing(); }
        return Monad(f(*_value, std::forward<Args>(args)...));
    }

    T value() const { if (_value) return *_value; else return 0; }

private:
    bool _owner{};
    T const* const _value{};
};

int main()  {
    auto divM  = [](auto x, auto y) { if (y == 0) return Monad<double>::nothing(); 
        else return Monad<double>::just(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).value();

    double xx { 35.0 };
    Monad<double>::just(xx).bind(div5M).bind(print).bind(div0M).bind(div7M).value();
}

Mais en C++, tu as ce concept runtime vs compile-time, que tu n'as pas en Haskell (je crois ?). Pourquoi penser que la transposition du monade en Haskell correspond a un monade au runtime en C++ et pas uniquement au compile-time ?

Je suis toujours un dubitatif sur ce type de transposition d'une syntaxe d'un langage a un autre (alors que la réutilisation de concept d'un langage dans un autre me pose pas de problème).

+0 -0

OK, merci pour le code.

Je suis toujours un dubitatif sur ce type de transposition d'une syntaxe d'un langage a un autre (alors que la réutilisation de concept d'un langage dans un autre me pose pas de problème).

La syntaxe n’a pas grand chose à voir de l’un à l’autre…

1
Monad<double>::just(35.0).bind(div5M).bind(print).bind(div0M).bind(div7M);
1
2
3
4
5
6
7
divM_3 :: Maybe Double
divM_3 = do
    x1 <- return 35
    x2 <- divM x1 5
    showM x2
    x3 <- divM x2 0
    divM x3 7
+0 -0

Le tutoriel est très intéressant. Je n'ai pas spécialement de remarques concernant le contenu existant, essentiellement parce qu'il touche à des langages que je ne connais pas.

Si jamais tu en éprouves l'envie, je peux citer quelques implémentations de monades utilisées concrètement aujourd'hui dans d'autres environnements, pourtant non fonctionnels à la base (JS, Java, Groovy notamment). Preuve s'il en est que le concept devient presque incontournable.

J'ai en tête, pêle-mêle,

  • les List de Scala,

  • le type Optional de Java 8, (qui ressemble comme deux gouttes d'eau à Maybe)

  • et surtout l'asynchrone et les reactive extensions pour lesquelles, le type Observable se rapproche énormément d'une monade.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Observable<Person> fromName(String name) {
  Observable.lift(new Dog(name: name))
}

Observable<String> toJson(Dog dog) {
  Observable.lift(new JsonBuilder(dog) as String)
}

def observable = Observable.lift('Snoopy') // méthode unit
String s = observable.flatMap(fromName).flatMap(toJson)
assert json == '{"name":"Snoopy"}'

Avec une encore meilleure variante avec la méthode compose (ou concat) qui va composer les différentes fonctions passées en paramètres pour les appliquer à un observable.

C'est très souvent utilisé pour chaîner des opérations asynchrones dans le web (client ET serveur). L'idée étant de définir des pures fonctions, très faciles à tester. Puis à les associer pour composer un traitement sur une entrée donnée.

L'exemple donné dans le tutoriel sur la gestion des erreurs se retrouve dans ce contexte aussi. On décrit une chaîne de traitement, et on lui associe une façon de gérer les erreurs, plutôt que de coller un gigantesque try/catch (de toute façon souvent impossible en mode asynchrone).

Donc pour les intérêts :

  1. Pouvoir se rapprocher du "purement fonctionnel" pas uniquement pour la beauté du geste mais pour l'aspect déterministe et la facilité à tester des primitives "pures"
  2. Lisibilité : 100 fois plus facile de lire toJson then toString then checkResult then … qu'un ensemble de callbacks du style "si ça a merdé avant alors return, sinon toJson, …") évite le fameux "callback hell".

Côté client c'est également utilisé pour chaîner des évènements (requêtes AJAX par exemple), …

Pas sûr que tu aies envie de parler de tout ça, mais si ça te botte, c'est une application "en vogue", et qui fait souffler un vent neuf sur le web ces derniers mois.

+0 -0

Pour l'asynchrone, Lwt et Async en OCaml présentent aussi des interfaces monadiques.

Une autre application intéressante des monades est la monade LogicT, j'en avais entendu parler quand j'implémentais un SAT-solver. Elle permet d'après ce que j'ai compris de gérer le backtracking.

le type Observable se rapproche énormément d'une monade

Javier

À première vue, ça semble en effet coller aux trois critères, mais y’a un truc qui m’étonne un peu.

1
String s = observable.flatMap(fromName).flatMap(toJson)

Comment se fait-il que le résultat de la chaîne soit un String et pas un Observable<String> ?

+0 -0

Hier, je réfléchissais au passage suivant et me demandais quel état le problème avec le fait d'afficher un texte à l'écran.

C’est le moyen qu’a trouvé le Haskell de représenter avec des outils de programmation fonctionnelle pure des opérations qui génèrent des effets de bord, comme afficher un texte à l’écran ou lire le contenu d’un fichier.

En l'occurrence, il me semble que ça se raccroche à ce passage :

Plus fourbe : la transparence du référentiel a aussi pour conséquence qui si deux fonctions ne dépendent pas l’une de l’autre, l’ordre dans lequel elles sont exécutées n’a strictement aucune importance.

En effet, si j'affiche un message à l'écran, l'ordre d'exécution a son importance. Tu pourrais le préciser, parce que tu ne parles ici que des effets de bord dûs aux entrées. :)

+0 -0

J'ajoute mon petit grain de sel à cette discussion.

J'ai entendu parlé des monades pour la première fois il y a environ deux ans. Il se trouve qu'en deux ans, j'ai du voir une petite dizaines de textes introduisant les monades (principalement en Haskell). A chaque fois, l'approche est sensiblement la même, mais les intuitions derrières sont assez différentes.

Mon approche préféré jusqu'alors est celle qui est reprise dans cette video : Don't fear the monad. Cette approche passe par le typage, et explique comment la fonction bind est tout simplement une généralisation de la composition de fonctions.

J'aime beaucoup cette approche car elle me permet de retrouver le type de la fonction bind dès que j'en ai besoin (en sachant que ne programment pas en Haskell, j'en ai besoin que très rarement). C'est d'ailleurs assez fréquent en programmation fonctionnelle d'être guidé par le typage.

L'approche que tu donnes ici est plus pragmatique j'ai l'impression où tu considères la monade comme étant un outil pour fournir une interface pratique. D'ailleurs, tu utilises cette vision pour considérer la monade comme une abstraction multi-paradigme : "On peut faire des monades en C++".

Je voudrais revenir sur ce point quand tu dis que la monade n'est pas limitée au paradigme fonctionnelle. Alors c'est vrai, on peut ré-encoder une monade dans un autre langage supportant un autre paradigme. Ce n'est pas pour autant que cela fournit une bonne abstraction, la preuve vu le code C++ qui est fournit.

En fait, la plupart des abstractions fournies par un langage peuvent se réencoder dans un autre langage (les types algébriques, les pointeurs), seulement cela ne veut pas dire que c'est intéressant de le faire.

Dans un contexte hors programmation fonctionnelle, je n'ai jamais vu quelqu'un utiliser une monade. Si c'est le cas, je serai curieux de savoir pourquoi il le fait et pas autrement. Du coup je me demande quelles sont des motivations pour la partie "Un outils multi-language" ? En quoi cela permet au lecteur de mieux comprendre la monade (la question peut s'adresser aussi aux lecteurs) ?

Enfin j'aurais une dernière critique à faire : les exemples. Je trouve dommage que pour ce tutoriel d'introduction, tu te sois limiter à un exemple aussi simple et presque inutile de la division par 0. Si je n'ai jamais vu les monades avant, et si j'avais pas un minimum d'intuition sur leurs utilités, avec seulement cet exemple j'aurais l'impression que c'est un concept assez peu utile au final.

Un exemple un peu plus concret à mon avis, et qui permet de vraiment comprendre l'intérêt des monades dans des cas d'utilisations conrets c'est de faire un interpréteur pour un langage hyper réduit. Ca reprend exactement ton exemple de la division par 0, mais qu'on généralise un peu plus. On se rend compte alors que la programmation monadique permet alors de modulariser le code.

Les exemples auxquels je pense se trouvent dans l'article qui a introduit les monades dans Haskell : The essence of functionnal programming

Pour tous ceux qui connaissent un minimum la programmation fonctionnelle, les premières pages de l'article se lisent très bien et soulignent l'intérêt des monades en programmation fonctionnelle.

Le dernier point que je souhaite soulevé c'est les monades en théorie des catégories. Pour avoir suivi un cours de catégorie en M2, c'est une notion assez compliquée et dont l'intérêt est loin d'être évident. C'est bien de savoir que la notion originale vient des catégories, mais pour le moment c'est inutile d'aller plus loin. D'autant plus qu'un tutoriel parlant des monades en catégorie aurait besoin de beaucoup trop de prérequis.

Il existe de très nombreuses façons d’aborder les monades, en témoignent les 85 articles sur le sujet que je donne en lien dans la conclusion. Nécessairement, certaines plaisent plus aux uns ou aux autres. Pour ma part, je trouve justement qu’on manque de cours utilisant une approche « pragmatique », c’est-à-dire ne faisant pas appel à des concepts mathématiques, aussi basiques (en apparence, du moins) soient-ils.

Je comprends tout à fait l’approche par la généralisation du concept de composition de fonctions, et d’ailleurs, je l’évoque en passant dans la dernière section. Mais de mon expérience, le fait même de généraliser un concept abstrait est un obstacle à la compréhension de beaucoup de gens : sans cela, on n’aurait aucun mal à faire comprendre les groupes ou les espaces vectoriels…

D’où mon approche.

En fait, la plupart des abstractions fournies par un langage peuvent se réencoder dans un autre langage (les types algébriques, les pointeurs), seulement cela ne veut pas dire que c'est intéressant de le faire.

Je ne suis que moyennement d’accord avec cette affirmation. Faire de la POO en Haskell, ce n’est pas possible, et le C++ se prête très mal à simuler des types algébriques de données (et je parle pas de Python…). Du coup, le fait qu’il soit possible de créer une monade dans un langage impératif est relativement significatif.

Maintenant, je n’ai pas forcément assez insisté sur le fait que c’est effectivement un outil peu adapté au paradigme, je vais corriger ce point-là.

Du coup je me demande quelles sont des motivations pour la partie "Un outils multi-language" ? En quoi cela permet au lecteur de mieux comprendre la monade (la question peut s'adresser aussi aux lecteurs) ?

La quasi-totalité des tutos sur les monades parlent uniquement de Haskell. C’est compréhensible, c’est clairement le langage le plus adapté pour en parler. Le but de cette section, c’est de montrer que Haskell est loin d’être le seul langage où les monades peuvent être un outil adapté. D’où quelques exemples de monades qui marchent bien… et quelques exemples de monades qui ne marchent pas. ^^

Un exemple un peu plus concret à mon avis, et qui permet de vraiment comprendre l'intérêt des monades dans des cas d'utilisations conrets c'est de faire un interpréteur pour un langage hyper réduit.

C’est typiquement le genre de choses que je veux éviter. ;) Faire un interpréteur pour un langage créé pour l’occasion, à mes yeux, ça tient plus de la branlette intellectuelle que d’un bon exemple d’utilisation concrète. Alors qu’un calcul simple dans lequel se glisse un résultat impossible, tout le monde comprend.

Il y a aussi l’exemple de la recherche dans une base de données généalogique, où les fonction père et mère peuvent échouer à chaque génération. C’est sur ça que j’étais parti au départ, mais ça demandait un code beaucoup plus encombré pour que les exemples fonctionnent sans intervention extérieure, donc j’ai laissé tomber.

Le dernier point que je souhaite soulevé c'est les monades en théorie des catégories. Pour avoir suivi un cours de catégorie en M2, c'est une notion assez compliquée et dont l'intérêt est loin d'être évident. C'est bien de savoir que la notion originale vient des catégories, mais pour le moment c'est inutile d'aller plus loin. D'autant plus qu'un tutoriel parlant des monades en catégorie aurait besoin de beaucoup trop de prérequis.

Saroupille

C’est aussi mon point de vue, d’où le fait que je n’en parle pas dans mon cours. Je laisse à quelqu’un d’autre le soin de le faire. :)

+0 -0
Ce sujet est verrouillé.