web-dev-qa-db-fra.com

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

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 compte tenu du nombre suffisant de variables de référence, cette taille peut être arbitrairement grande.

Un std::function devrait avoir un taille fixe, mais il doit pouvoir envelopper n'importe quel type de callables, y compris tous les lambdas du même type. Comment est-il mis en œuvre? Si std::function utilise en interne un pointeur sur sa cible, puis que se passe-t-il lorsque le std::function l'instance est copiée ou déplacée? Y a-t-il des allocations de tas impliquées?

91
Miklós Homolya

L'implémentation de std::function Peut différer d'une implémentation à l'autre, mais l'idée centrale 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 ceci (simplifiée pour le cas spécifique de std::function<int (double)> pour des raisons 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'objet function ne stockerait qu'un unique_ptr Dans un type 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'objet std::function Est toujours de la même taille et allouera l'espace nécessaire aux différents foncteurs du tas.

Dans la vie réelle, 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.


En ce qui concerne le comportement des copies de std::function, 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 que f2 Obtient une copie de l'entité appelable, plutôt qu'une référence. Si l'entité appelable était partagée par les différents objets std::function<>, La sortie du programme aurait été 5, 6, 7.

Pour certains types d'arguments ("si la cible de f est un objet appelable passé via reference_wrapper Ou un pointeur de fonction"), le constructeur de std::function Interdit toutes les exceptions, donc l'utilisation de la mémoire dynamique est hors du question. Dans ce cas, toutes les données doivent être stockées directement à l'intérieur de l'objet std::function.

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 constructeur std::function) Est autorisée selon l'implémentation. La norme 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'objet std::function, Mais l'objet qui est encapsulé à l'intérieur) est suffisamment grand, il y a aucun moyen de l'empêcher, car std::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 dynamiques de mémoire pendant la copie. Pour les déplacements, il n'y a aucune raison pour que la mémoire dynamique soit nécessaire. La norme ne semble pas l'interdire explicitement, et probablement pas si le mouvement 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 sensibles, le déplacement ne causera pas toutes allocations.

19
user743382

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 façon 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 l'objet function, 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'en ai bricolé une version simplifiée. J'espère que cela vous 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_f et destroy_f sont mélangés dans un pointeur de fonction (avec un paramètre supplémentaire qui indique quoi faire) pour économiser quelques octets
  • des pointeurs bruts sont utilisés pour stocker l'objet functor, ainsi qu'un pointeur de fonction dans un union, de sorte que lorsqu'un objet function est construit à partir d'un pointeur de fonction, il sera stocké directement dans le union plutôt que de l'espace de tas

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

17
neuront