Modismo Pimpl sin usar la asignación de memoria dinámica

13 minutos de lectura

avatar de usuario de erelender
erendero

queremos usar el idioma pimpl para ciertas partes de nuestro proyecto. Estas partes del proyecto también resultan ser partes donde la asignación de memoria dinámica está prohibida y esta decisión no está bajo nuestro control.

Entonces, lo que estoy preguntando es, ¿existe una forma limpia y agradable de implementar el idioma pimpl sin asignación de memoria dinámica?

Editar
Aquí hay algunas otras limitaciones: plataforma integrada, estándar C ++ 98, sin bibliotecas externas, sin plantillas.

  • ¿Cuál es el punto de espinilla sin asignación dinámica? El uso principal de pimpl es hacer que la vida útil de los objetos dinámicos sea manejable. Si no tiene problemas de administración de por vida, simplemente pase la referencia al objeto estático/de ámbito de pila directamente.

    – Chris Becke

    7 de febrero de 2011 a las 13:55

  • Creo que el uso principal de pimpl es ocultar los detalles de implementación, de ahí el nombre “puntero al idioma de implementación”.

    – erelender

    7 de febrero de 2011 a las 13:58

  • @Chris: no necesitamos pimpl para administrar la vida útil de los objetos. Simplemente use un puntero inteligente (o escriba el objeto para seguir el idioma RAII en primer lugar). pimpl se trata de ocultar las partes internas de una clase.

    – jalf

    7 de febrero de 2011 a las 14:29

  • ¿Cómo puede alguien con 23.000 representantes malinterpretar un modismo básico tan atrozmente?

    – subrayado_d

    06/09/2016 a las 22:14

  • @FantasticMrFox Es perfectamente justo que alguien no sepa qué es. Pero entonces no deberían publicar afirmaciones falsas sobre para qué sirve.

    – subrayado_d

    24 de abril de 2018 a las 9:28

Avatar de usuario de Matthieu M.
Matthieu M.

Advertencia: el código aquí solo muestra el aspecto de almacenamiento, es un esqueleto, no se ha tenido en cuenta ningún aspecto dinámico (construcción, copia, movimiento, destrucción).

Sugeriría un enfoque utilizando la nueva clase C++ 0x aligned_storageque está diseñado precisamente para tener almacenamiento en bruto.

// header
class Foo
{
public:
private:
  struct Impl;

  Impl& impl() { return reinterpret_cast<Impl&>(_storage); }
  Impl const& impl() const { return reinterpret_cast<Impl const&>(_storage); }

  static const size_t StorageSize = XXX;
  static const size_t StorageAlign = YYY;

  std::aligned_storage<StorageSize, StorageAlign>::type _storage;
};

En la fuente, luego implementa un control:

struct Foo::Impl { ... };

Foo::Foo()
{
  // 10% tolerance margin
  static_assert(sizeof(Impl) <= StorageSize && StorageSize <= sizeof(Impl) * 1.1,
                "Foo::StorageSize need be changed");
  static_assert(StorageAlign == alignof(Impl),
                "Foo::StorageAlign need be changed");
  /// anything
}

De esta forma, aunque tendrás que cambiar la alineación inmediatamente (si es necesario), el tamaño solo cambiará si el objeto cambia demasiado.

Y obviamente, dado que el cheque está en tiempo de compilación, no te lo puedes perder 🙂

Si no tiene acceso a las funciones de C++0x, hay equivalentes en el espacio de nombres TR1 para aligned_storage y alignof y hay macros implementaciones de static_assert.

  • @Gart: cualquier cambio en el tamaño de Foo introduce una incompatibilidad binaria, que es lo que estamos tratando de evitar aquí. Por lo tanto, necesita Tamaño de almacenamiento ser superior a sizeof(Impl) y estable, por lo que probablemente lo sobredimensionará un poco para poder agregar campos a Impl mas tarde. Sin embargo, es posible que se exceda demasiado y termine con un objeto muy grande por… nada, así que le sugiero que verifique que tampoco termine con un objeto demasiado grande, utilizando este margen del 10%.

    – Matthieu M.

    27 de agosto de 2012 a las 16:06

  • necesitaba llamar new( &_storage )Impl(); en el constructor para que los miembros de Pimpl se inicialicen correctamente.

    – Máquina de supervivencia

    11/07/2014 a las 20:55

  • yo también necesitaba llamar reinterpret_cast< Impl* >( &_storage )->~Impl(); en el destructor para evitar pérdidas de memoria.

    – Máquina de supervivencia

    01/04/2015 a las 19:14

  • Para refutar “Por qué el intento n.º 3 es deplorable” de Sutter gotw.ca/gotw/028.htm (que es anterior a C ++ 11, creo): 1. Me ocupé de la alineación (y podría hacerlo mejor usando std::align para permitir que el valor se compense en el búfer) 2. Fragilidad: ahora es fácil hacerlo estáticamente seguro. 3. Costo de mantenimiento: hay casos en los que el tamaño no cambiará pero los encabezados requeridos son costosos. 4. Espacio desperdiciado: A veces no me importa. 5. Lo dejaré sin respuesta. Mi punto es que tengo algunas clases que quiero como miembros de tipos de vocabulario pero que atraen encabezados enormes. Esto podría arreglar eso; los módulos también pueden hacerlo.

    – ben

    17 de noviembre de 2020 a las 13:35

  • @Ben: De hecho, los módulos deberían dejar obsoletos los aspectos de “Cortafuegos de compilación” de PIMPL y, por lo tanto, InlinePimpl… Sin embargo, todavía no están allí, así que creo que su implementación puede servirle bien mientras tanto 🙂

    – Matthieu M.

    17 de noviembre de 2020 a las 13:58

pimpl se basa en punteros y puede configurarlos en cualquier lugar donde se asignen sus objetos. También puede ser una tabla estática de objetos declarados en el archivo cpp. El punto principal de pimpl es mantener las interfaces estables y ocultar la implementación (y sus tipos usados).

  • Creo que este es el mejor enfoque para nuestro caso, pero no creo que sea agradable y limpio como el grano estándar.

    – erelender

    7 de febrero de 2011 a las 14:20

  • En mi humilde opinión, el único inconveniente de este enfoque es que debe acordar una cantidad máxima de objetos de ese tipo por adelantado/en el momento de la compilación. Para todos los demás aspectos que se me ocurren, se alcanzan los objetivos de pimpl.

    – jdehaan

    7 febrero 2011 a las 17:39

  • Tener que decidir de antemano el número máximo de objetos no es un error, es una característica. Es uno de los fundamentos principales detrás de las reglas que prohíben la asignación de memoria dinámica. Haz esto y nunca te quedarás sin memoria. Y nunca tendrá que preocuparse por montones fragmentados.

    – sbass

    7 de febrero de 2011 a las 17:59

  • Buen punto sbass para enfatizar eso, mi formulación fue un poco negativa con respecto a este aspecto. +1

    – jdehaan

    7 febrero 2011 a las 21:05

Ver El idioma rápido de la espinilla y La alegría de las espinillas sobre el uso de un asignador fijo junto con el modismo pimpl.

  • Creo que escribir un asignador fijo pierde el punto de “no usar memoria dinámica”. Puede que no requiera una asignación de memoria dinámica, pero requiere una gestión de memoria dinámica, que creo que no es diferente a anular nuevo y eliminar globalmente.

    – erelender

    7 de febrero de 2011 a las 14:11


Si puede usar boost, considere boost::optional<>. Esto evita el costo de la asignación dinámica, pero al mismo tiempo, su objeto no se construirá hasta que lo considere necesario.

Una forma sería tener un char[] matriz en su clase. Hágalo lo suficientemente grande para que quepa su Impl, y en su constructor, cree una instancia de su Impl en su arreglo, con una ubicación nueva: new (&array[0]) Impl(...).

También debe asegurarse de no tener ningún problema de alineación, probablemente al tener su char[] array un miembro de un sindicato. Esto:

union {
char array[xxx];
int i;
double d;
char *p;
};

por ejemplo, se asegurará de que la alineación de array[0] será adecuado para un int, doble o un puntero.

  • +1: Estaba escribiendo una publicación más larga, pero esto es básicamente todo. Podría escribir un segundo proyecto que obtenga el tamaño de las clases impl y los instrumentos que se encuentran en las clases contenedoras, por lo que no necesita realizar un seguimiento manual de cada cambio.

    – Preocupado binario

    7 de febrero de 2011 a las 13:45


  • no estoy seguro de que los miembros del sindicato sean suficientes para garantizar la alineación

    – Gregorio Pakosz

    7 de febrero de 2011 a las 13:47

  • Ese enfoque requiere que mantengamos el tamaño de la matriz de caracteres siempre que cambie la implementación (y puede cambiar con frecuencia en algunos lugares). Además, no podemos hacerlo grande para el futuro porque la memoria es escasa.

    – erelender

    7 de febrero de 2011 a las 13:48

  • @erelender: sin embargo, podría hacerse como una simple tarea de preprocesamiento. Compile el archivo que define la clase “interna” en un pequeño programa de prueba que devuelve su tamaño y luego escriba ese tamaño en la definición de la clase pimpl. Alternativamente, se podría usar una afirmación estática como lo sugiere @Matthieu M. para alertarlo si el “tamaño previsto es demasiado pequeño, por lo que el código no se compilará a menos que se elija un tamaño válido.

    – jalf

    7 febrero 2011 a las 14:31


  • Él union truco no es necesario ahora que std::aligned_storage existe (que podría usarlo internamente, pero ehh, lo que sea). Pero un problema más fundamental aquí es cómo dijiste “será adecuado para un int, doble o un puntero”. Para punteros, solo se garantizará que su ejemplo esté alineado adecuadamente para char* puntero. Recuerde que no es necesario que los punteros a diferentes tipos tengan los mismos tamaños (o representaciones, etc.)

    – subrayado_d

    6 sep 2016 a las 22:18

El objetivo de usar pimpl es ocultar la implementación de su objeto. Esto incluye el Talla del verdadero objeto de implementación. Sin embargo, esto también hace que sea incómodo evitar la asignación dinámica: para reservar suficiente espacio de pila para el objeto, debe saber qué tan grande es el objeto.

La solución típica es, de hecho, utilizar la asignación dinámica y pasar la responsabilidad de asignar suficiente espacio a la implementación (oculta). Sin embargo, esto no es posible en su caso, por lo que necesitaremos otra opción.

Una de esas opciones es usar alloca(). Esta función poco conocida asigna memoria en la pila; la memoria se liberará automáticamente cuando la función salga de su alcance. Esto no es C++ portátilsin embargo, muchas implementaciones de C++ lo admiten (o una variación de esta idea).

Tenga en cuenta que debe asignar sus objetos pimpl’d usando una macro; alloca() debe invocarse para obtener la memoria necesaria directamente de la función propietaria. Ejemplo:

// Foo.h
class Foo {
    void *pImpl;
public:
    void bar();
    static const size_t implsz_;
    Foo(void *);
    ~Foo();
};

#define DECLARE_FOO(name) \
    Foo name(alloca(Foo::implsz_));

// Foo.cpp
class FooImpl {
    void bar() {
        std::cout << "Bar!\n";
    }
};

Foo::Foo(void *pImpl) {
    this->pImpl = pImpl;
    new(this->pImpl) FooImpl;
}

Foo::~Foo() {
    ((FooImpl*)pImpl)->~FooImpl();
}

void Foo::Bar() {
    ((FooImpl*)pImpl)->Bar();
}

// Baz.cpp
void callFoo() {
    DECLARE_FOO(x);
    x.bar();
}

Esto, como puede ver, hace que la sintaxis sea bastante incómoda, pero logra un análogo de grano.

Si puede codificar el tamaño del objeto en el encabezado, también existe la opción de usar una matriz de caracteres:

class Foo {
private:
    enum { IMPL_SIZE = 123; };
    union {
        char implbuf[IMPL_SIZE];
        double aligndummy; // make this the type with strictest alignment on your platform
    } impl;
// ...
}

Esto es menos puro que el enfoque anterior, ya que debe cambiar los encabezados siempre que cambie el tamaño de la implementación. Sin embargo, le permite usar la sintaxis normal para la inicialización.

También podría implementar una pila de sombra, es decir, una pila secundaria separada de la pila normal de C++, específicamente para contener objetos pImpl’d. Esto requiere una gestión muy cuidadosa, pero, debidamente envuelto, debería funcionar. Este tipo de está en la zona gris entre la asignación dinámica y estática.

// One instance per thread; TLS is left as an exercise for the reader
class ShadowStack {
    char stack[4096];
    ssize_t ptr;
public:
    ShadowStack() {
        ptr = sizeof(stack);
    }

    ~ShadowStack() {
        assert(ptr == sizeof(stack));
    }

    void *alloc(size_t sz) {
        if (sz % 8) // replace 8 with max alignment for your platform
            sz += 8 - (sz % 8);
        if (ptr < sz) return NULL;
        ptr -= sz;
        return &stack[ptr];
    }

    void free(void *p, size_t sz) {
        assert(p == stack[ptr]);
        ptr += sz;
        assert(ptr < sizeof(stack));
    }
};
ShadowStack theStack;

Foo::Foo(ShadowStack *ss = NULL) {
    this->ss = ss;
    if (ss)
        pImpl = ss->alloc(sizeof(FooImpl));
    else
        pImpl = new FooImpl();
}

Foo::~Foo() {
    if (ss)
        ss->free(pImpl, sizeof(FooImpl));
    else
        delete ss;
}

void callFoo() {
    Foo x(&theStack);
    x.Foo();
}

Con este enfoque, es fundamental asegurarse de NO utilizar la pila de sombra para los objetos en los que el objeto contenedor está en el montón; esto violaría la suposición de que los objetos siempre se destruyen en el orden inverso al de la creación.

  • +1: Estaba escribiendo una publicación más larga, pero esto es básicamente todo. Podría escribir un segundo proyecto que obtenga el tamaño de las clases impl y los instrumentos que se encuentran en las clases contenedoras, por lo que no necesita realizar un seguimiento manual de cada cambio.

    – Preocupado binario

    7 de febrero de 2011 a las 13:45


  • no estoy seguro de que los miembros del sindicato sean suficientes para garantizar la alineación

    – Gregorio Pakosz

    7 de febrero de 2011 a las 13:47

  • Ese enfoque requiere que mantengamos el tamaño de la matriz de caracteres siempre que cambie la implementación (y puede cambiar con frecuencia en algunos lugares). Además, no podemos hacerlo grande para el futuro porque la memoria es escasa.

    – erelender

    7 de febrero de 2011 a las 13:48

  • @erelender: sin embargo, podría hacerse como una simple tarea de preprocesamiento. Compile el archivo que define la clase “interna” en un pequeño programa de prueba que devuelve su tamaño y luego escriba ese tamaño en la definición de la clase pimpl. Alternativamente, se podría usar una afirmación estática como lo sugiere @Matthieu M. para alertarlo si el “tamaño previsto es demasiado pequeño, por lo que el código no se compilará a menos que se elija un tamaño válido.

    – jalf

    7 febrero 2011 a las 14:31


  • Él union truco no es necesario ahora que std::aligned_storage existe (que podría usarlo internamente, pero ehh, lo que sea). Pero un problema más fundamental aquí es cómo dijiste “será adecuado para un int, doble o un puntero”. Para punteros, solo se garantizará que su ejemplo esté alineado adecuadamente para char* puntero. Recuerde que no es necesario que los punteros a diferentes tipos tengan los mismos tamaños (o representaciones, etc.)

    – subrayado_d

    6 sep 2016 a las 22:18

avatar de usuario de scx
scx

Una técnica que he usado es un envoltorio pImpl no propietario. Esta es una opción muy especial y no es tan segura como el pimpl tradicional, pero puede ayudar si el rendimiento es una preocupación. Puede requerir una nueva arquitectura para que sea más funcional, como API.

Puede crear una clase pimpl que no sea propietaria, siempre que pueda (algo) garantizar que el objeto pimpl de la pila sobrevivirá al contenedor.

por ej.

/* header */
struct MyClassPimpl;
struct MyClass {
    MyClass(MyClassPimpl& stack_object); // Initialize wrapper with stack object.

private:
    MyClassPimpl* mImpl; // You could use a ref too.
};


/* in your implementation code somewhere */

void func(const std::function<void()>& callback) {
    MyClassPimpl p; // Initialize pimpl on stack.

    MyClass obj(p); // Create wrapper.

    callback(obj); // Call user code with MyClass obj.
}

El peligro aquí, como la mayoría de los contenedores, es que el usuario almacena el contenedor en un ámbito que sobrevivirá a la asignación de la pila. Úselo bajo su propio riesgo.

¿Ha sido útil esta solución?