¿Cómo se implementa la función std ::?

98

De acuerdo con las fuentes que he encontrado, una expresión lambda es esencialmente implementada por el compilador creando una clase con el operador de llamada de función sobrecargado y las variables referenciadas como miembros. Esto sugiere que el tamaño de las expresiones lambda varía, y dadas suficientes referencias variables, ese tamaño puede ser arbitrariamente grande .

An std::functiondebe tener un tamaño fijo , pero debe poder envolver cualquier tipo de invocables, incluidas las lambdas del mismo tipo. ¿Cómo se implementa? Si std::functionutiliza internamente un puntero a su destino, ¿qué sucede cuando la std::functioninstancia se copia o se mueve? ¿Hay alguna asignación de montón involucrada?

Miklós Homolya
fuente
2
Investigué la implementación de gcc / stdlib de hace std::functionun tiempo. Es esencialmente una clase de identificador para un objeto polimórfico. Se crea una clase derivada de la clase base interna para contener los parámetros, asignados en el montón; luego, el puntero a esto se mantiene como un subobjeto de std::function. Creo que utiliza el recuento de referencias como std::shared_ptrpara manejar la copia y el movimiento.
Andrew Tomazos
4
Tenga en cuenta que las implementaciones pueden usar magia, es decir, depender de extensiones del compilador que no están disponibles para usted. De hecho, esto es necesario para algunos rasgos de tipo. En particular, los trampolines son una técnica conocida que no está disponible en C ++ estándar.
MSalters

Respuestas:

78

La implementación de std::functionpuede diferir de una implementación a otra, pero la idea central es que usa borrado de tipo. Si bien hay varias formas de hacerlo, puede imaginar que una solución trivial (no óptima) podría ser así (simplificada para el caso específico de std::function<int (double)>por simplicidad):

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); }
// ...
};

En este enfoque simple, el functionobjeto almacenaría solo unique_ptra en un tipo base. Para cada funtor diferente utilizado con elfunction , se crea un nuevo tipo derivado de la base y se crea una instancia de un objeto de ese tipo de forma dinámica. El std::functionobjeto es siempre del mismo tamaño y asignará el espacio necesario para los diferentes functores del montón.

En la vida real existen diferentes optimizaciones que proporcionan ventajas de rendimiento pero complicarían la respuesta. El tipo podría usar optimizaciones de objetos pequeños, el despacho dinámico se puede reemplazar por un puntero de función libre que toma el functor como argumento para evitar un nivel de indirección ... pero la idea es básicamente la misma.


Con respecto a la cuestión de cómo se std::functioncomportan las copias del , una prueba rápida indica que se realizan copias del objeto invocable interno, en lugar de compartir el estado.

// 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)
}

La prueba indica que f2obtiene una copia de la entidad invocable, en lugar de una referencia. Si la entidad invocable fuera compartida por los diferentes std::function<>objetos, la salida del programa habría sido 5, 6, 7.

David Rodríguez - dribeas
fuente
@Cole "Cole9" Johnson suponiendo que lo escribió él mismo
Aaronman
8
@Cole "Cole9" Johnson: Esta es una simplificación excesiva del código real, simplemente lo escribí en el navegador, por lo que podría tener errores tipográficos y / o fallar al compilar por diferentes razones. El código en la respuesta está ahí para presentar cómo se puede implementar el borrado de tipo, esto claramente no es un código de calidad de producción.
David Rodríguez - dribeas
2
@MooingDuck: Creo que las lambdas son copiables (5.1.2 / 19), pero esa no es la cuestión, sino si la semántica de std::functionsería correcta si se copiara el objeto interno, y no creo que ese sea el caso. (piense en una lambda que captura un valor y es mutable, almacenada dentro de un std::function, si el estado de la función se copió, la cantidad de copias de std::functiondentro de un algoritmo estándar podría dar como resultado resultados diferentes, lo cual no es deseado.
David Rodríguez - dribeas
1
@ MiklósHomolya: Probé con g ++ 4.8 y la implementación copia el estado interno. Si la entidad invocable es lo suficientemente grande como para requerir una asignación dinámica, entonces la copia std::functionactivará una asignación.
David Rodríguez - dribeas
4
El estado compartido de @ DavidRodríguez-dribeas sería indeseable, porque la optimización del objeto pequeño significaría que pasaría del estado compartido al estado no compartido en un compilador y un umbral de tamaño determinado por la versión del compilador (ya que la optimización del objeto pequeño bloquearía el estado compartido). Eso parece problemático.
Yakk - Adam Nevraumont
22

La respuesta de @David Rodríguez: dribeas es buena para demostrar el borrado de tipo, pero no lo suficientemente bueno, ya que el borrado de tipo también incluye cómo se copian los tipos (en esa respuesta, el objeto de función no se podrá copiar). Estos comportamientos también se almacenan en el functionobjeto, además de los datos de functor.

El truco, utilizado en la implementación de STL de Ubuntu 14.04 gcc 4.8, es escribir una función genérica, especializarla con cada tipo de functor posible y convertirlos en un tipo de puntero de función universal. Por lo tanto, se borra la información de tipo .

He improvisado una versión simplificada de eso. Espero que ayude

#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;
}

También hay algunas optimizaciones en la versión STL.

  • el construct_fy destroy_fse mezclan en un puntero de función (con un parámetro adicional que le dice qué hacer) como para guardar algunos bytes
  • Los punteros en bruto se utilizan para almacenar el objeto de función, junto con un puntero de función en a union, de modo que cuando un functionobjeto se construye a partir de un puntero de función, se almacenará directamente en el unionespacio en lugar de en el montón

Quizás la implementación de STL no sea la mejor solución, ya que he oído hablar de una implementación más rápida . Sin embargo, creo que el mecanismo subyacente es el mismo.

neurona
fuente
20

Para ciertos tipos de argumentos ("si el objetivo de f es un objeto invocable pasado a través de reference_wrappero un puntero de función"), std::functionel constructor no permite ninguna excepción, por lo que el uso de memoria dinámica está fuera de discusión. Para este caso, todos los datos deben almacenarse directamente dentro del std::functionobjeto.

En el caso general (incluido el caso lambda), std::functionse permite el uso de memoria dinámica (a través del asignador estándar o un asignador pasado al constructor) según lo considere la implementación. El estándar recomienda que las implementaciones no usen memoria dinámica si se puede evitar, pero como bien dice, si el objeto de función (no el std::functionobjeto, sino el objeto que se envuelve dentro de él) es lo suficientemente grande, no hay forma de evitarlo, ya que std::functiontiene un tamaño fijo.

Este permiso para lanzar excepciones se otorga tanto al constructor normal como al constructor de copia, que también permite de manera bastante explícita asignaciones de memoria dinámica durante la copia. Para los movimientos, no hay ninguna razón por la que sea necesaria la memoria dinámica. El estándar no parece prohibirlo explícitamente, y probablemente no pueda hacerlo si el movimiento podría llamar al constructor de movimiento del tipo del objeto envuelto, pero debería poder asumir que si tanto la implementación como sus objetos son sensibles, el movimiento no causará cualquier asignación.


fuente
-6

An lo std::functionsobrecarga operator()convirtiéndolo en un objeto functor, lambda funciona de la misma manera. Básicamente, crea una estructura con variables miembro a las que se puede acceder dentro de la operator()función. Entonces, el concepto básico a tener en cuenta es que una lambda es un objeto (llamado functor u objeto de función), no una función. El estándar dice que no se use memoria dinámica si se puede evitar.

Aaronman
fuente
1
¿Cómo es posible que las lambdas arbitrariamente grandes encajen en un tamaño fijo std::function? Esa es la pregunta clave aquí.
Miklós Homolya
2
@aaronman: Te garantizo que todos los std::functionobjetos tienen el mismo tamaño y no son del tamaño de las lambdas contenidas.
Mooing Duck
5
@aaronman de la misma manera que cada std::vector<T...> objeto tiene un tamaño fijo (copiletime) independiente de la instancia del asignador real / número de elementos.
sehe
3
@aaronman: Bueno, tal vez debería encontrar una pregunta de stackoverflow que responda cómo se implementa std :: function de manera que pueda contener lambdas de tamaño arbitrario: P
Mooing Duck
1
@aaronman: cuando se establece la entidad invocable , en construcción, asignación ... std::function<void ()> f;no es necesario asignar allí, lo std::function<void ()> f = [&]() { /* captures tons of variables */ };más probable es que asigne. std::function<void()> f = &free_function;probablemente tampoco asigna ...
David Rodríguez - dribeas