Pipeline pattern, une solution ?

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

Bonjour,

je n'arrive pas à trouver le bon pattern pour régler un problème de pipeline qu'on pourrait résumer par un petit schéma :

1
input ---[stage 1]---> output1 = input2 ---[stage 2]---> output2 = input3 ---[stage 3]---> output3 ---> ...

Donc à partir d'un input, réussir à trouver l'output qui est le résultat du processing de $N$ stages tel que:

$output = stage_{N}(stage_{N-1}(...stage_{2}(stage_{1}(input))...))$

La difficulté étant que chaque input ou output peut être d'un type différent (un entier, un nombre décimal, une matrice, un vecteur, …). Bien sûr, pour que la chaîne fonctionne l'input du stage $M$ est du même type que l'output du stage $M-1$.

Voilà une solution (à base de class templates) qui ne marche pas. J'aimerais avoir un comportement final qui imite ce qui est contenu dans le main():

 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
#include <iostream>

template <typename T, typename P>
class Stage
{
    protected:
        Stage* next;    
        T* input;
        P* output;

    public:
        Stage(): next(nullptr), input(nullptr), output(nullptr) {}

        void add(Stage* n) {
            if (next) {
              next->add(n);
            }
            else {
              next = n;
              next->set_output();
              next->set_input(output);
            }
        }

        void set_input(T* in) { input = in; }
        void set_output() { output = new P; }

        void compute() {
            process();
            if (next)
                return next->compute();
            else
                std::cout << output << endl;;
        }

        virtual void process() = 0;
        virtual ~Stage() {};
};

class Add: public Stage<int,int> {
    private:
        int c;
    public:
        Add(int m = 1): c{m} {}
        void process() { *output = *input + c; }
};

class Threshold: public Stage<int,bool> {
    private:
        int t;
    public:
        Threshold(int m = 1): t{m} {}
        void process() { *output = (bool) *input < t; }
};


int main(int argc, char* argv[]) {
    Stage* stage1 = new Add(5);
    Stage* stage2 = new Add(2);
    Stage* stage3 = new Threshold(100);
    stage1->add(stage2);
    stage1->add(stage3);

    int* in = new int(42);
    stage1->set_input(in); 
    stage1->compute(); // should cout 1 (because 42+5+2=49 < 100)
}

Quel serait une bonne solution pour arriver au comportement voulu ? En prenant en compte qu'il est important de garder l'héritage de classes (une classe abstraite Stage dont hérite toutes les implémentations) et que les variables input et output doivent rester des pointeurs vers des variables externes.

Merci d'avance. :)

+0 -0

Je n'ai jamais rencontré une situation de ce genre, mais le minimum que je puisse faire est d'utiliser des pointeurs intelligents. Ou, au moins, si tes pointeurs ne sont pas responsables de ce qu'il pointe, spécifie le avec un type aliasing qui l'indique du genre :

1
2
template<typename T>
using notOwnerPtr = T*;

Pour ton cas précis, même si tu utilises des pointeurs qui ne sont pas responsables de ce qu'ils pointent à l'intérieur de ta classe, crée des std::unique_ptr dans ton main pour gérer la mémoire.

(ou encore mieux, utilise des références si tu n'as pas besoin de pointeurs)

Sinon, j'utiliserais volontiers un pattern où les fonctions virtuelles sont privées et où tu as une fonction publique non virtuelle qui les appelle (je ne me souviens pas du nom du pattern, je crois que c'est pImpl, pour private Implementation)

+0 -0

A partir du moment où tu as quelque chose de dynamique pour construire ton pipeline, il est complexe de chainer des bouts dont le type exact ne peut pas être connu à la compilation.

Il va falloir faire des choses complexes comme du type-erasure avec du boost::any ou assimilé ou alors peut-être avec des visiteurs pour joindre des bouts de natures potentiellement incompatibles.

Tu peux éventuellement regarder ce qui se fait du côté des DSEL pour le calcul distribué en C++. Ils ont souvent des assemblages de fonctions qui font différentes tâches à grands renforts de templates.

Par contre, si tu veux du dynamique, tu seras très vite limité comme noté par @lmghs.

@mehdidou99 , le pattern pour les fonctions virtuelles privées c'est le NVI pour non virtual interface.

Salut.

Si tes types sont fixés à la compilation, tu peux faire un truc à base de variadic templates et de std::function. L'exemple qui suit ne rentre pas dans la contrainte de hiérarchie de classe que tu te pose, mais ça peut te donner des pistes. Ce code requiert le support de C++14.

  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
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
#include <functional>
#include <iostream>

template <typename F, typename... T>
struct FirstOf
{
    using Type = F;
};

template <typename T, typename... L>
struct LastOf : LastOf<L...>
{};

template <typename L>
struct LastOf<L>
{
    using Type = L;
};

template <std::size_t I, std::size_t N, typename... T>
struct FunctionUnroll;

template <std::size_t I, std::size_t N, typename T, typename... Others>
struct FunctionUnroll<I, N, T, Others...> : FunctionUnroll<I+1, N, Others...>
{};

template <std::size_t I, typename T, typename U, typename... Others>
struct FunctionUnroll<I, I, T, U, Others...>
{
    using Type = U(T);
};

template <typename... T>
class Process
{
    static_assert(sizeof...(T) > 1, "Process must have types for input and output");

    template <std::size_t... I>
    struct Helper
    {
        Helper(){}
        
        using Functors = std::tuple<std::function<typename FunctionUnroll<0, I, T...>::Type>...>;
    };
    
    template <std::size_t... I>
    static auto make_helper(std::index_sequence<I...>)
    {
        return Helper<I...>{};
    }

    using Functors = typename decltype(make_helper(std::make_index_sequence<sizeof...(T)-1>{}))::Functors;

    public:
    Process(Functors f) : functors{std::move(f)}
    {}

    typename LastOf<T...>::Type call(typename FirstOf<T...>::Type&& first)
    {
        typename LastOf<T...>::Type last{};
        call_rec_<0, typename LastOf<T...>::Type, T...>(std::forward<typename FirstOf<T...>::Type>(first), last);
        return last;
    }

    private:
    template <std::size_t I, typename Last, typename U, typename... Others>
    typename std::enable_if<sizeof...(Others) != 1>::type call_rec_(U&& u, Last& last)
    {
        auto&& res = std::get<I>(functors)(std::forward<U>(u));
        call_rec_<I+1, Last, Others...>(std::move(res), last);
    }

    template <std::size_t I, typename Last, typename U, typename... Others>
    typename std::enable_if<sizeof...(Others) == 1>::type call_rec_(U&& u, Last& last)
    {
        last = std::get<I>(functors)(std::forward<U>(u));
    }

    Functors functors;
};

double multwo(int i)
{
    return i * 2;
}

std::string tostring(double d)
{
    return std::to_string(d);
}

std::string word(std::string const& n)
{
    if (n.size() == 0)
        return "";
    if (n[0] == '4')
        return "four";
    return "number";
}

int main()
{
    Process<int, double, std::string, std::string> p{std::make_tuple(multwo, tostring, word)};
    std::cout << p.call(2) << '\n';
}

+0 -0

Merci pour toutes ces réponses. J'ai bien fait de poser la question. La solution est plus ardue que prévue si et va me demander un peu plus de travail que je l'imaginais !

La construction dynamique du pipeline est indispensable et non questionnable. Je peux/vais essayer de lever quelques contraintes sur les types de données des I/O (dans l'idéal, je veux pouvoir gérer des vecteurs et matrices de rang 2 et 3 (on connait leur type à la compilation, leur dimensionnalité que pendant l'exécution); Ça me fait penser que je pourrais peut-être regarder comme ils font ça dans les librairies de deep learning, à creuser).

Je vais faire mes recherches, et si je trouve quelque chose de concluant, je viendrais vous dire ça.

+0 -0

En limitant mon nombre de types possibles pour les I/O, j'ai trouvé une solution relativement simple sur le bon conseil de Davidbrcz. Les I/O sont encapsulés dans une structure qui contient les données et un getter/setter avec un template pour choisir le bon type. La solution ci-dessous marche (avec des pointeurs intelligents!).

Cela implique que:

  • les types possibles doivent être défini dans le typedef.
  • accéder au getter et au setter se fait en spécialisant la fonction. (+: génére une erreur en cas de stage incompatibles / -: un peu plus verbeux)
  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
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
#include "boost/variant.hpp"
#include <iostream>
#include <memory>

using namespace std;

typedef boost::variant<int, float, bool, string> variant_type;
struct IOhandler {
    variant_type data;
    template <typename T>
    T& get_data() { 
        return boost::get<T>(data); 
    }
    template <typename T>
    void set_data(const T& in) { 
        data = in;
    }   
};

class Stage
{
    protected:
        unique_ptr<Stage> next;    
        shared_ptr<IOhandler> input;
        shared_ptr<IOhandler> output;

    public:
        Stage(): next(nullptr), input(nullptr), output(shared_ptr<IOhandler>(new IOhandler)) {}

        void add_stage(unique_ptr<Stage>& n) {
            if (next) {
              next->add_stage(n);
            }
            else {
              next = std::move(n);
              next->link_IO(output);
            }
        }

        void link_IO(shared_ptr<IOhandler> in) { input = in; }

        void init_input() { 
            input = shared_ptr<IOhandler>(new IOhandler);
        }

        template <typename T, typename P>
        T& launch(const P& data) {
            input->data = data;
            compute();
            return get_last_output<T>();
        }

        template <typename T> 
        T& get_last_output() {
            if (next)
                return next->get_last_output<T>();
            else
                return output->get_data<T>();
        }

        void compute() {
            process();
            if (next)
                next->compute();
        }

        virtual void process() = 0;
        ~Stage() {
            next.reset(nullptr);
            input.reset();
            output.reset();
        }
};

class Add: public Stage {
    private:
        int c;
    public:
        Add(int m = 1): c{m} {}
        void process() {  
            output->data = input->get_data<float>() + c; 
        }
};

class Threshold: public Stage {
    private:
        int t;
    public:
        Threshold(int m = 1): t{m} {}
        void process() { 
            if (input->get_data<float>() < t)
                output->set_data<string>("Bailabamba !");
            else
                output->set_data<string>("Hey Macarena !");
        }
};

int main(int argc, char* argv[]) {
    unique_ptr<Stage> stage1 = unique_ptr<Stage>(new Add(1));
    unique_ptr<Stage> stage2 = unique_ptr<Stage>(new Add(10));
    unique_ptr<Stage> stage3 = unique_ptr<Stage>(new Threshold(100));
    stage1->add_stage(stage2);
    stage1->add_stage(stage3);
    stage1->init_input();

    float kk = 42.7;
    cout << "result: " << stage1->launch<string>(kk) << endl;
    kk =  145.10;
    cout << "result: " << stage1->launch<string>(kk) << endl;
    cout << "end()" << endl;  
}

Merci !

@Richou D. Degenne : Comment ça pourrait régler le problème ? Je ne vois pas.

@lanfeust313 : Quelques remarques sur ton code :

  • Quand est-ce que le code client pourrait utiliser process de lui-même ? Je pense qu'elle devrait être privée (sauf si je me trompe sur l'utilisation de ta classe)
  • Si tu as accès au C++14, utilise std::make_unique. Même si dans ce cas précis ce n'est pas obligatoire, c'est joli syntaxiquement parlant.
  • Même si ça ne règle pas le problème, tu pourrais utiliser l'inférence de type, ça ne mange pas de pain.
  • Le using namespace std; me gène. Il n'est pas fait pour être utilisé dans du code moderne, il sert juste à faciliter l'adaptation des codes datant d'avant la normalisation.
  • std::shared_ptr avec parcimonie. Quand tu peux, essaie de garder un seul responsable de la mémoire. Dans ton cas, je pense que tu pourrais n'utiliser que des std::unique_ptr.
  • Il me semble que la convention de nommage que tu utilises est réservée par la norme pour la SL.
  • Tu n'es pas obligé de créer 40 000 variables intermédiaires dans ton main, tu peux créer les stages directement dans l'appel de fonction :
1
2
3
std::unique_ptr<Stage> stage1{std::make_unique<Add>(1)};
stage1->add_stage(std::make_unique<Add>(10));
stage1->add_stage(std::make_unique<Treshold(100));

J'en ajouterai peut-être d'autres, mais là je n'ai pas le temps ;)

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte