Comment std :: function est-il implémenté?

98

Selon les sources que j'ai trouvées, une expression lambda est essentiellement implémentée par le compilateur créant une classe avec un opérateur d'appel de fonction surchargé et les variables référencées en tant que membres. Cela suggère que la taille des expressions lambda varie et que, compte tenu de suffisamment de variables de référence, la taille peut être arbitrairement grande .

Un std::functiondevrait avoir une taille fixe , mais il doit être capable d'encapsuler n'importe quel type de callable, y compris n'importe quel lambdas du même type. Comment est-il mis en œuvre? Si std::functionutilise en interne un pointeur vers sa cible, que se passe-t-il lorsque l' std::functioninstance est copiée ou déplacée? Y a-t-il des allocations de tas impliquées?

Miklós Homolya
la source
2
J'ai examiné l'implémentation de gcc / stdlib il y a std::functionquelque temps. Il s'agit essentiellement d'une classe de descripteurs pour un objet polymorphe. Une classe dérivée de la classe de base interne est créée pour contenir les paramètres, alloués sur le tas - puis le pointeur vers celui-ci est conservé comme un sous-objet de std::function. Je crois qu'il utilise le comptage de références comme std::shared_ptrpour gérer la copie et le déplacement.
Andrew Tomazos
4
Notez que les implémentations peuvent utiliser la magie, c'est-à-dire s'appuyer sur des extensions de compilateur qui ne vous sont pas disponibles. Ceci est en fait nécessaire pour certains traits de type. En particulier, les trampolines sont une technique connue non disponible en C ++ standard.
MSalters

Réponses:

78

L'implémentation de std::functionpeut différer d'une implémentation à l'autre, mais l'idée de base est qu'elle utilise l'effacement de type. Bien qu'il existe plusieurs façons de le faire, vous pouvez imaginer qu'une solution triviale (non optimale) pourrait être comme celle-ci (simplifiée pour le cas spécifique de std::function<int (double)>par souci de simplicité):

struct callable_base {
   virtual int operator()(double d) = 0;
   virtual ~callable_base() {}
};
template <typename F>
struct callable : callable_base {
   F functor;
   callable(F functor) : functor(functor) {}
   virtual int operator()(double d) { return functor(d); }
};
class function_int_double {
   std::unique_ptr<callable_base> c;
public:
   template <typename F>
   function(F f) {
      c.reset(new callable<F>(f));
   }
   int operator()(double d) { return c(d); }
// ...
};

Dans cette approche simple, l' functionobjet stockerait uniquement un unique_ptrtype de base. Pour chaque foncteur différent utilisé avec le function, un nouveau type dérivé de la base est créé et un objet de ce type instancié dynamiquement. L' std::functionobjet est toujours de la même taille et allouera de l'espace selon les besoins pour les différents foncteurs du tas.

Dans la vraie vie, il existe différentes optimisations qui offrent des avantages en termes de performances mais compliqueraient la réponse. Le type pourrait utiliser des optimisations de petits objets, la répartition dynamique peut être remplacée par un pointeur de fonction libre qui prend le foncteur comme argument pour éviter un niveau d'indirection ... mais l'idée est fondamentalement la même.


Concernant la question de savoir comment les copies std::function comportement des comportement, un test rapide indique que des copies de l'objet appelable interne sont effectuées, plutôt que de partager l'état.

// g++4.8
int main() {
   int value = 5;
   typedef std::function<void()> fun;
   fun f1 = [=]() mutable { std::cout << value++ << '\n' };
   fun f2 = f1;
   f1();                    // prints 5
   fun f3 = f1;
   f2();                    // prints 5
   f3();                    // prints 6 (copy after first increment)
}

Le test indique qu'il f2obtient une copie de l'entité appelable, plutôt qu'une référence. Si l'entité appelable était partagée par les différents std::function<>objets, la sortie du programme aurait été 5, 6, 7.

David Rodríguez - Dribeas
la source
@Cole "Cole9" Johnson devinant qu'il l'a écrit lui-même
aaronman
8
@Cole "Cole9" Johnson: Ceci est une simplification excessive du code réel, je l'ai juste tapé dans le navigateur, il peut donc avoir des fautes de frappe et / ou ne pas se compiler pour différentes raisons. Le code dans la réponse est juste là pour présenter comment l'effacement de type est / peut être mis en œuvre, ce n'est clairement pas un code de qualité de production.
David Rodríguez - dribeas
2
@MooingDuck: Je pense que les lambdas sont copiables (5.1.2 / 19), mais ce n'est pas la question, plutôt si la sémantique de std::functionserait correcte si l'objet interne était copié, et je ne pense pas que ce soit le cas (pensez à un lambda qui capture une valeur et qui est mutable, stocké dans a std::function, si l'état de la fonction a été copié, le nombre de copies de l' std::functionintérieur d'un algorithme standard pourrait entraîner des résultats différents, ce qui n'est pas souhaité.
David Rodríguez - dribeas
1
@ MiklósHomolya: J'ai testé avec g ++ 4.8 et l'implémentation copie l'état interne. Si l'entité appelable est suffisamment grande pour nécessiter une allocation dynamique, alors la copie de std::functiondéclenchera une allocation.
David Rodríguez - dribeas
4
L'état partagé @ DavidRodríguez-dribeas serait indésirable, car l'optimisation des petits objets signifierait que vous passeriez d'un état partagé à un état non partagé à un seuil de taille déterminé par la version du compilateur et du compilateur (car l'optimisation des petits objets bloquerait l'état partagé). Cela semble problématique.
Yakk - Adam Nevraumont
22

La réponse de @David Rodríguez - dribeas est bonne pour démontrer l'effacement de type mais pas assez car l'effacement de type inclut également la manière dont les types sont copiés (dans cette réponse, l'objet fonction ne sera pas constructible par copie). Ces comportements sont également stockés dans lefunction objet, en plus des données du foncteur.

L'astuce, utilisée dans l'implémentation STL d'Ubuntu 14.04 gcc 4.8, consiste à écrire une fonction générique, à la spécialiser avec chaque type de foncteur possible et à les convertir en un type de pointeur de fonction universel. Par conséquent, les informations de type sont effacées .

J'ai bricolé une version simplifiée de cela. J'espère que ça aidera

#include <iostream>
#include <memory>

template <typename T>
class function;

template <typename R, typename... Args>
class function<R(Args...)>
{
    // function pointer types for the type-erasure behaviors
    // all these char* parameters are actually casted from some functor type
    typedef R (*invoke_fn_t)(char*, Args&&...);
    typedef void (*construct_fn_t)(char*, char*);
    typedef void (*destroy_fn_t)(char*);

    // type-aware generic functions for invoking
    // the specialization of these functions won't be capable with
    //   the above function pointer types, so we need some cast
    template <typename Functor>
    static R invoke_fn(Functor* fn, Args&&... args)
    {
        return (*fn)(std::forward<Args>(args)...);
    }

    template <typename Functor>
    static void construct_fn(Functor* construct_dst, Functor* construct_src)
    {
        // the functor type must be copy-constructible
        new (construct_dst) Functor(*construct_src);
    }

    template <typename Functor>
    static void destroy_fn(Functor* f)
    {
        f->~Functor();
    }

    // these pointers are storing behaviors
    invoke_fn_t invoke_f;
    construct_fn_t construct_f;
    destroy_fn_t destroy_f;

    // erase the type of any functor and store it into a char*
    // so the storage size should be obtained as well
    std::unique_ptr<char[]> data_ptr;
    size_t data_size;
public:
    function()
        : invoke_f(nullptr)
        , construct_f(nullptr)
        , destroy_f(nullptr)
        , data_ptr(nullptr)
        , data_size(0)
    {}

    // construct from any functor type
    template <typename Functor>
    function(Functor f)
        // specialize functions and erase their type info by casting
        : invoke_f(reinterpret_cast<invoke_fn_t>(invoke_fn<Functor>))
        , construct_f(reinterpret_cast<construct_fn_t>(construct_fn<Functor>))
        , destroy_f(reinterpret_cast<destroy_fn_t>(destroy_fn<Functor>))
        , data_ptr(new char[sizeof(Functor)])
        , data_size(sizeof(Functor))
    {
        // copy the functor to internal storage
        this->construct_f(this->data_ptr.get(), reinterpret_cast<char*>(&f));
    }

    // copy constructor
    function(function const& rhs)
        : invoke_f(rhs.invoke_f)
        , construct_f(rhs.construct_f)
        , destroy_f(rhs.destroy_f)
        , data_size(rhs.data_size)
    {
        if (this->invoke_f) {
            // when the source is not a null function, copy its internal functor
            this->data_ptr.reset(new char[this->data_size]);
            this->construct_f(this->data_ptr.get(), rhs.data_ptr.get());
        }
    }

    ~function()
    {
        if (data_ptr != nullptr) {
            this->destroy_f(this->data_ptr.get());
        }
    }

    // other constructors, from nullptr, from function pointers

    R operator()(Args&&... args)
    {
        return this->invoke_f(this->data_ptr.get(), std::forward<Args>(args)...);
    }
};

// examples
int main()
{
    int i = 0;
    auto fn = [i](std::string const& s) mutable
    {
        std::cout << ++i << ". " << s << std::endl;
    };
    fn("first");                                   // 1. first
    fn("second");                                  // 2. second

    // construct from lambda
    ::function<void(std::string const&)> f(fn);
    f("third");                                    // 3. third

    // copy from another function
    ::function<void(std::string const&)> g(f);
    f("forth - f");                                // 4. forth - f
    g("forth - g");                                // 4. forth - g

    // capture and copy non-trivial types like std::string
    std::string x("xxxx");
    ::function<void()> h([x]() { std::cout << x << std::endl; });
    h();

    ::function<void()> k(h);
    k();
    return 0;
}

Il y a aussi quelques optimisations dans la version STL

  • le construct_fetdestroy_f sont mélangés en un pointeur de fonction (avec un paramètre supplémentaire qui dit quoi faire) comme pour sauver quelques octets
  • les pointeurs bruts sont utilisés pour stocker l'objet foncteur, avec un pointeur de fonction dans un union , de sorte que lorsqu'un functionobjet est construit à partir d'un pointeur de fonction, il sera stocké directement dans l' unionespace plutôt que dans l' espace du tas

Peut-être que l'implémentation STL n'est pas la meilleure solution car j'ai entendu parler d'une implémentation plus rapide . Cependant, je pense que le mécanisme sous-jacent est le même.

neuront
la source
20

Pour certains types d'arguments ("si la cible de f est un objet appelable passé via reference_wrapperou un pointeur de fonction"), std::functionle constructeur de n'autorise aucune exception, donc l'utilisation de la mémoire dynamique est hors de question. Dans ce cas, toutes les données doivent être stockées directement dans lestd::function objet.

Dans le cas général (y compris le cas lambda), l'utilisation de la mémoire dynamique (via l'allocateur standard ou un allocateur passé au std::functionconstructeur) est autorisée comme l'implémentation le juge opportun. Le standard recommande que les implémentations n'utilisent pas de mémoire dynamique si cela peut être évité, mais comme vous le dites à juste titre, si l'objet fonction (pas l' std::functionobjet, mais l'objet qui est enveloppé à l'intérieur) est suffisamment grand, il n'y a aucun moyen de l'empêcher, puisquestd::function a une taille fixe.

Cette autorisation de lever des exceptions est accordée à la fois au constructeur normal et au constructeur de copie, ce qui autorise assez explicitement les allocations de mémoire dynamique pendant la copie. Pour les mouvements, il n'y a aucune raison pour laquelle la mémoire dynamique serait nécessaire. La norme ne semble pas l'interdire explicitement, et ne le peut probablement pas si le déplacement peut appeler le constructeur de déplacement du type de l'objet encapsulé, mais vous devriez pouvoir supposer que si l'implémentation et vos objets sont sensés, le déplacement ne provoquera pas toutes allocations.


la source
-6

An std::functionsurcharge le operator()fait d'en faire un objet foncteur, les lambda fonctionnent de la même manière. Il crée essentiellement une structure avec des variables membres accessibles à l'intérieur de la operator()fonction. Donc, le concept de base à garder à l'esprit est qu'un lambda est un objet (appelé foncteur ou objet fonction) et non une fonction. La norme dit de ne pas utiliser de mémoire dynamique si cela peut être évité.

Aaronman
la source
1
Comment des lambdas arbitrairement grands peuvent-ils s'intégrer dans une taille fixe std::function? Telle est la question clé ici.
Miklós Homolya
2
@aaronman: Je garantis que chaque std::functionobjet a la même taille et ne correspond pas à la taille des lambdas contenus.
Mooing Duck
5
@aaronman de la même manière que chaque std::vector<T...> objet a une taille fixe (copiletime) indépendante de l'instance d'allocateur réelle / du nombre d'éléments.
sehe
3
@aaronman: Eh bien, peut-être que vous devriez trouver une question de stackoverflow qui répond à la façon dont std :: function est implémentée de manière à contenir des lambdas de taille arbitraire: P
Mooing Duck
1
@aaronman: Lorsque l' entité appelable est définie, en construction, en affectation ... std::function<void ()> f;pas besoin d'allouer là, std::function<void ()> f = [&]() { /* captures tons of variables */ };très probablement alloue. std::function<void()> f = &free_function;n'attribue probablement pas non plus ...
David Rodríguez - dribeas