¿Se puede implementar una variación de pimpl sin penalización de rendimiento?

9

Uno de los problemas de pimpl es la penalización de rendimiento de usarlo (asignación de memoria adicional, miembros de datos no contiguos, indirecciones adicionales, etc.). Me gustaría proponer una variación en el lenguaje de pimpl que evitará estas penalizaciones de rendimiento a expensas de no obtener todos los beneficios de pimpl. La idea es dejar a todos los miembros de datos privados en la clase misma y mover solo los métodos privados a la clase pimpl. El beneficio en comparación con el pimpl básico es que la memoria permanece contigua (sin indirección adicional). Los beneficios en comparación con no usar pimpl son:

  1. Oculta las funciones privadas.
  2. Puede estructurarlo para que todas estas funciones tengan un enlace interno y permitan que el compilador lo optimice de manera más agresiva.

Entonces, mi idea es hacer que el pimpl herede de la clase en sí (suena un poco loco, lo sé, pero tengan paciencia conmigo). Se vería algo así:

En el archivo Ah:

class A
{
    A();
    void DoSomething();
protected:  //All private stuff have to be protected now
    int mData1;
    int mData2;
//Not even a mention of a PImpl in the header file :)
};

En el archivo A.cpp:

#define PCALL (static_cast<PImpl*>(this))

namespace //anonymous - guarantees internal linkage
{
struct PImpl : public A
{
    static_assert(sizeof(PImpl) == sizeof(A), 
                  "Adding data members to PImpl - not allowed!");
    void DoSomething1();
    void DoSomething2();
    //No data members, just functions!
};

void PImpl::DoSomething1()
{
    mData1 = bar(mData2); //No Problem: PImpl sees A's members as it's own
    DoSomething2();
}

void PImpl::DoSomething2()
{
    mData2 = baz();
}

}
A::A(){}

void A::DoSomething()
{
    mData2 = foo();
    PCALL->DoSomething1(); //No additional indirection, everything can be completely inlined
}

Hasta donde veo, no hay absolutamente ninguna penalización de rendimiento al usar este vs no pimpl y algunas posibles ganancias de rendimiento y una interfaz de archivo de encabezado más limpia. Una desventaja que esto tiene frente al pimpl estándar es que no puede ocultar los miembros de datos, por lo que los cambios en esos miembros de datos aún desencadenarán una recopilación de todo lo que depende del archivo de encabezado. Pero a mi modo de ver, es obtener ese beneficio o el beneficio de rendimiento de tener a los miembros contiguos en la memoria (o hacer este truco- "Por qué el intento n. ° 3 es deplorable"). Otra advertencia es que si A es una clase con plantilla, la sintaxis se vuelve molesta (ya sabes, no puedes usar mData1 directamente, debes hacer esto-> mData1, y debes comenzar a usar el nombre de tipo y quizás las palabras clave de plantilla para los tipos dependientes y tipos con plantilla, etc.). Sin embargo, otra advertencia es que ya no puede usar private en la clase original, solo miembros protegidos, por lo que no puede restringir el acceso desde ninguna clase heredada, no solo el pimpl. Lo intenté pero no pude solucionar este problema. Por ejemplo, intenté convertir el pimpl en una clase de plantilla de amigo con la esperanza de que la declaración de amigo sea lo suficientemente amplia como para permitirme definir la clase de pimpl real en un espacio de nombres anónimo, pero eso simplemente no funciona. Si alguien tiene alguna idea de cómo mantener la privacidad de los miembros de datos y aún permitir que una clase de pimpl heredada definida en un espacio de nombres anónimo acceda a ellos, ¡realmente me gustaría verlo! Eso eliminaría mi reserva principal de usar esto.

Sin embargo, creo que estas advertencias son aceptables para los beneficios de lo que propongo.

Intenté buscar en línea alguna referencia a este modismo de "pimpl solo de función" pero no pude encontrar nada. Estoy realmente interesado en lo que la gente piensa sobre esto. ¿Hay otros problemas con esto o razones por las que no debería usar esto?

ACTUALIZAR:

He encontrado esta propuesta que más o menos trata de lograr exactamente lo que soy, pero lo hace cambiando el estándar. Estoy completamente de acuerdo con esa propuesta y espero que se convierta en el estándar (no sé nada de ese proceso, así que no tengo idea de la probabilidad de que eso suceda). Prefiero tener esto posible a través de un mecanismo de lenguaje incorporado. La propuesta también explica los beneficios de lo que estoy tratando de lograr mucho mejor que yo. Tampoco tiene el problema de romper la encapsulación como lo ha hecho mi sugerencia (privado -> protegido). Aún así, hasta que esa propuesta se convierta en el estándar (si eso sucede), creo que mi sugerencia hace posible obtener esos beneficios, con las advertencias que mencioné.

ACTUALIZACIÓN2:

Una de las respuestas menciona LTO como una posible alternativa para obtener algunos de los beneficios (optimizaciones más agresivas, supongo). No estoy realmente seguro de qué sucede exactamente en varios pases de optimización del compilador, pero tengo un poco de experiencia con el código resultante (uso gcc). Simplemente poner los métodos privados en la clase original obligará a aquellos a tener un enlace externo.

Podría estar equivocado aquí, pero la forma en que lo interpreto es que el optimizador de tiempo de compilación no puede eliminar la función incluso si todas sus instancias de llamada están completamente integradas dentro de esa TU. Por alguna razón, incluso LTO se niega a deshacerse de la definición de la función, incluso si parece que todas las instancias de llamada en todo el binario vinculado están en línea. Encontré algunas referencias que indican que es porque el vinculador no sabe si de alguna manera llamarás a la función usando punteros de función (aunque no entiendo por qué el vinculador no puede darse cuenta de que la dirección de ese método nunca se toma )

Este no es el caso si utiliza mi sugerencia y coloca esos métodos privados en un pimpl dentro de un espacio de nombres anónimo. Si se alinean, las funciones NO aparecerán en (con -O3, que incluye -finline-functions) el archivo de objeto.

Según tengo entendido, el optimizador, al decidir si se debe incorporar o no una función, tiene en cuenta su impacto en el tamaño del código. Entonces, usando mi sugerencia, estoy haciendo un poco "más barato" para que el optimizador incorpore esos métodos privados.

dcmm88
fuente
El uso de PCALLes un comportamiento indefinido. No puede convertir un Aa PImply usarlo a menos que el objeto subyacente sea realmente de tipo PImpl. Sin embargo, a menos que me equivoque, los usuarios solo crearán objetos de tipo A.
BeeOnRope

Respuestas:

8

Los puntos de venta del patrón Pimpl son:

  • encapsulación total: no hay miembros de datos (privados) mencionados en el archivo de encabezado del objeto de interfaz.
  • estabilidad: hasta que rompa la interfaz pública (que en C ++ incluye miembros privados), nunca tendrá que volver a compilar el código que depende del objeto de la interfaz. Esto hace que el Pimpl sea un gran patrón para las bibliotecas que no desean que sus usuarios vuelvan a compilar todo el código en cada cambio interno.
  • polimorfismo e inyección de dependencia: la implementación o el comportamiento del objeto de interfaz se pueden intercambiar fácilmente en tiempo de ejecución, sin necesidad de volver a compilar el código dependiente. Genial si necesitas burlarte de algo para una prueba unitaria.

Para este efecto, el Pimpl clásico consta de tres partes:

  • Una interfaz para el objeto de implementación, que debe ser pública, y utilizar métodos virtuales para la interfaz:

    class IFrobnicateImpl
    {
    public:
        virtual int frobnicate(int) const = 0;
    };

    Se requiere que esta interfaz sea estable.

  • Un objeto de interfaz que representa la implementación privada. No tiene que usar métodos virtuales. El único miembro permitido es un puntero a la implementación:

    class Frobnicate
    {
        std::unique_ptr<IFrobnicateImpl> _impl;
    public:
        explicit Frobnicate(std::unique_ptr<IFrobnicateImpl>&& impl = nullptr);
        int frobnicate(int x) const { return _impl->frobnicate(x); }
    };
    
    ...
    
    Frobnicate::Frobnicate(std::unique_ptr<IFrobnicateImpl>&& impl /* = nullptr */)
    : _impl(std::move(impl))
    {
        if (!_impl)
            _impl = std::make_unique<DefaultImplementation>();
    }

    El archivo de encabezado de esta clase debe ser estable.

  • Al menos una implementación

El Pimpl luego nos compra una gran estabilidad para una clase de biblioteca, a costa de una asignación de almacenamiento dinámico y un envío virtual adicional.

¿Cómo se compara tu solución?

  • Elimina la encapsulación. Como sus miembros están protegidos, cualquier subclase puede meterse con ellos.
  • Elimina la estabilidad de la interfaz. Cada vez que cambie sus miembros de datos, y ese cambio es solo una refactorización, tendrá que volver a compilar todo el código dependiente.
  • Elimina la capa de despacho virtual, evitando el intercambio fácil de la implementación.

Entonces, para cada objetivo del patrón Pimpl, no logra cumplir este objetivo. Por lo tanto, no es razonable llamar a su patrón una variación del Pimpl, es mucho más una clase ordinaria. En realidad, es peor que una clase ordinaria porque sus variables miembro son privadas. Y debido a ese reparto que es un punto flagrante de fragilidad.

Tenga en cuenta que el patrón Pimpl no siempre es óptimo: hay una compensación entre la estabilidad y el polimorfismo, por un lado, y la compacidad de la memoria, por el otro. Es semánticamente imposible que un lenguaje tenga ambos (sin la compilación JIT). Entonces, si está micro optimizando la compactación de la memoria, claramente el Pimpl no es una solución adecuada para su caso de uso. Probablemente también dejará de usar la mitad de la biblioteca estándar, ya que estas horribles clases de cadenas y vectores implican asignaciones de memoria dinámica ;-)

amon
fuente
Sobre su última nota sobre el uso de cadenas y vectores, está muy cerca de la verdad :). Entiendo que pimpl tiene beneficios que mi sugerencia simplemente no proporciona. Sin embargo, tiene beneficios que se presentan con mayor elocuencia en el enlace que agregué en la ACTUALIZACIÓN. Teniendo en cuenta que el rendimiento juega un papel muy importante en mi caso de uso, ¿cómo compararía mi sugerencia con no utilizar pimpl en absoluto? Porque para mi caso de uso no es mi sugerencia vs pimpl, es mi sugerencia vs no-pimpl en absoluto, ya que pimpl tiene costos de rendimiento en los que no quiero incurrir.
dcmm88
1
@ dcmm88 Si el rendimiento es su objetivo número 1, entonces, obviamente, cosas como la calidad del código y la encapsulación son un juego justo. Lo único que mejora su solución sobre las clases normales es evitar una recompilación cuando cambian las firmas de métodos privados. En mi libro, eso no es demasiado, pero si esta es una clase fundamental en una base de código grande, la implementación extraña podría valer la pena. Consulte este útil gráfico XKCD para determinar cuánto tiempo de desarrollo puede dedicar a acortar los tiempos de compilación . Dependiendo de su salario, actualizar su computadora puede ser más barato.
amon
1
También permite que las funciones privadas tengan un enlace interno, lo que permite una optimización más agresiva.
dcmm88
1
Parece que expone la interfaz pública de la clase pimpl en el archivo de encabezado de la clase original. AFAUI, la idea es esconderse tanto como sea posible. Por lo general, de la forma en que veo que se implementa pimpl, el archivo de encabezado solo tiene una declaración hacia adelante de la clase pimpl y el cpp tiene la definición completa de la clase pimpl. Creo que la capacidad de intercambio viene con un patrón de diseño diferente: el patrón de puente. Por lo tanto, no estoy seguro de que sea justo decir que mi sugerencia no lo proporciona, cuando pimpl generalmente tampoco lo hace.
dcmm88
Perdón por el último comentario. De hecho, tienes respuesta bastante grande, y el punto está al final, así que no me di cuenta
BЈовић
3

Para mí, las ventajas no superan las desventajas.

Ventajas :

Puede acelerar la compilación, ya que guarda una reconstrucción si solo han cambiado las firmas de métodos privados. Pero la reconstrucción es necesaria si las firmas de métodos públicos o protegidos o los miembros de datos privados han cambiado, y es raro que tenga que cambiar las firmas de métodos privados sin tocar ninguna de estas otras opciones.

Puede permitir optimizaciones de compilador más agresivas, pero LTO debería permitir muchas de las mismas optimizaciones (al menos, creo que puede hacerlo; no soy un gurú de la optimización de compiladores), además de algunas más, y puede hacerse estándar y automático.

Desventajas

Mencionó un par de desventajas: la imposibilidad de usar privado y las complejidades con las plantillas. Sin embargo, para mí, la mayor desventaja es que es simplemente incómodo: un estilo de programación poco convencional, con saltos de estilo Pimpl no bastante estándar entre la interfaz y la implementación, que no será familiar para los futuros mantenedores o nuevos miembros del equipo, y que puede estar mal respaldado por herramientas (ver, por ejemplo, este error GDB ).

Las preocupaciones estándar sobre la optimización se aplican aquí: ¿Ha medido que las optimizaciones dan una mejora significativa a su rendimiento? ¿Mejoraría su rendimiento al hacer esto o al tomarse el tiempo necesario para mantenerlo e invertirlo en perfiles de puntos de acceso, mejora de algoritmos, etc.? Personalmente, prefiero elegir un estilo de programación claro y directo, suponiendo que libere tiempo para hacer optimizaciones específicas. Pero esa es mi perspectiva para los tipos de código en los que trabajo: para su dominio problemático, las compensaciones pueden ser diferentes.

Nota al margen : permisos privados con pimpl solo de método

Preguntó acerca de cómo permitir miembros privados con su sugerencia de pimpl de solo método. Desafortunadamente, considero que el pimpl solo por método es una especie de hack, pero si ha decidido que las ventajas superan a las desventajas, entonces también podría aceptar el hack.

Ah

#ifndef A_impl
#define A_impl private
#endif

class A
{
public:
    A();
    void DoSomething();
A_impl:
    int mData1;
    int mData2;
};

A.cpp:

#define A_impl public
#include "A.h"
Josh Kelley
fuente
Me da un poco de vergüenza decirlo, pero me gusta su sugerencia privada #define A_impl :). Tampoco soy un gurú de la optimización, pero tengo algo de experiencia con LTO y jugar con él fue lo que realmente me llevó a pensar en esto. Actualizaré mi pregunta con la información que tengo sobre cómo usarla y por qué todavía no proporciona el beneficio completo que proporciona mi método solo pimpl.
dcmm88
@ dcmm88 - Gracias por la información sobre LTO. No es algo con lo que tenga mucha experiencia.
Josh Kelley
1

Puede usar std::aligned_storagepara declarar almacenamiento para su pimpl en la clase de interfaz.

class A
{
std::aligned_storage< 128 > _storage;
public:
  A();
};

En la implementación, puede construir su clase Pimpl en el lugar _storage:

class Pimpl
{
  int _some_data;
};

A::A()
{
  ::new(&_storage) Pimpl();
  // accessing Pimpl: *(Pimpl*)(_storage);
}

A::~A()
{
  ((Pimpl*)(_storage))->~Pimpl(); // calls destructor for inline pimpl
}
Fabio
fuente
Olvidé mencionar que necesitaría aumentar el tamaño de almacenamiento de vez en cuando, forzando así la recompilación. A menudo podrá exprimir muchos cambios que no desencadenan recompilaciones, si le da un poco de holgura al almacenamiento.
Fabio
0

No, no puede implementarlo sin una penalización de rendimiento. PIMPL es, por su propia naturaleza, una penalización de rendimiento, ya que está aplicando una indirecta en tiempo de ejecución.

Por supuesto, esto depende exactamente de lo que desea indirectamente. Parte de la información simplemente no es utilizada por el consumidor, como exactamente lo que pretende poner en sus 64 bytes de 4 bytes alineados. Pero otra información es, como el hecho de que desea 64 bytes de 4 bytes alineados para su objeto.

Los PIMPL genéricos sin penalizaciones de rendimiento no existen y nunca existirán. Es la misma información que le niega a su usuario que desea utilizar para optimizar. Si se lo das, tu IMPL no se abstrae; si se lo niegas, no pueden optimizar. No puedes tenerlo en ambos sentidos.

DeadMG
fuente
Proporcioné una posible sugerencia de "pimpl parcial" que afirmo que no tiene penalización de rendimiento y posibles ganancias de rendimiento. Puede que te moleste que lo llame por algo relacionado con pimpl, pero estoy ocultando algunos de los detalles de implementación (los métodos privados). Podría llamarlo ocultamiento de implementación de método privado, pero elegí llamarlo una variación de pimpl, o pimpl solo de método.
dcmm88
0

Con el debido respeto y no con la intención de matar esta emoción, no veo ningún beneficio práctico que sirva desde una perspectiva de tiempo de compilación. Muchos de los beneficios pimplsse obtendrán al ocultar detalles de tipo definidos por el usuario. Por ejemplo:

struct Foo
{
    Bar data;
};

... en tal caso, el costo más alto de la compilación proviene del hecho de que, para definir Foo, debemos conocer los requisitos de tamaño / alineación de Bar(lo que significa que debemos exigir recursivamente la definición de Bar).

Si no está ocultando los miembros de datos, se pierde uno de los beneficios más significativos desde una perspectiva de tiempo de compilación. También hay un código de aspecto potencialmente peligroso allí, pero el encabezado no se aclara y el archivo fuente se vuelve más pesado con más funciones de reenvío, por lo que es probable que aumente, en lugar de disminuir, los tiempos de compilación en general.

Encabezados más ligeros es la clave

Para obtener una disminución en los tiempos de compilación, desea poder mostrar una técnica que dé como resultado un encabezado dramáticamente más liviano (generalmente al permitirle no recurrir recursivamente a #includeotros encabezados porque oculta detalles que ya no requieren ciertas struct/classdefiniciones). Ahí es donde genuino pimplspuede tener un efecto significativo, rompiendo cadenas en cascada de inclusiones de encabezado y produciendo encabezados mucho más independientes con todos los detalles privados ocultos.

Maneras más seguras

Si de todos modos desea hacer algo como esto, también será mucho más simple usar un frienddefinido en su archivo de origen en lugar de uno que herede la misma clase que en realidad no crea con trucos de puntero para invocar métodos un objeto desinstalado, o simplemente use funciones independientes con enlace interno dentro del archivo fuente que reciben los parámetros apropiados para hacer el trabajo necesario (cualquiera de estos al menos podría permitirle ocultar algunos métodos privados del encabezado para un ahorro muy trivial en tiempos de compilación y un poco de margen de maniobra para evitar la compilación en cascada).

Asignador fijo

Si desea el tipo de pimpl más barato, el truco principal es utilizar un asignador fijo. Especialmente cuando se agregan pimpls a granel, la mayor causa de muerte es la pérdida de localidad espacial y las fallas de página obligatorias adicionales al acceder al pimpl por primera vez. Al preasignar agrupaciones de memoria que agrupan la memoria en los pimpls que se asignan y devuelven la memoria a la agrupación en lugar de liberarla en la desasignación, el costo de una gran cantidad de instancias de pimpl disminuye drásticamente. Sin embargo, aún no es gratuito desde el punto de vista del rendimiento, pero es mucho más barato y mucho más amigable con la caché / página.


fuente