PImpl
"Pointer to implementation" o "pImpl" es una técnica de programación de C++ que elimina los detalles de implementación de una clase de su representación de objeto colocándolos en una clase separada, a la que se accede a través de un puntero opaco:
// -------------------- // interface (widget.h) struct widget { // miembros públicos private: struct impl; // declaración anticipada de la clase de implementación // Un ejemplo de implementación: ver abajo para otras opciones de diseño y compensaciones std::experimental::propagate_const< // envoltorio de puntero con reenvío de const std::unique_ptr< // puntero opaco de propiedad única impl>> pImpl; // a la clase de implementación declarada anticipadamente }; // --------------------------- // implementation (widget.cpp) struct widget::impl { // detalles de implementación };
Esta técnica se utiliza para construir interfaces de bibliotecas C++ con ABI estable y para reducir las dependencias en tiempo de compilación.
Contenidos |
Explicación
Debido a que los miembros de datos privados de una clase participan en su representación de objeto, afectando el tamaño y el diseño, y debido a que las funciones miembro privadas de una clase participan en la resolución de sobrecarga (que tiene lugar antes de la verificación de acceso a miembros), cualquier cambio en esos detalles de implementación requiere recompilar todos los usuarios de la clase.
pImpl elimina esta dependencia de compilación; los cambios en la implementación no causan recompilación. En consecuencia, si una biblioteca utiliza pImpl en su ABI, las versiones más recientes de la biblioteca pueden cambiar la implementación manteniendo la compatibilidad ABI con versiones anteriores.
Compensaciones
Las alternativas al patrón pImpl son
- implementación inline: los miembros privados y los miembros públicos son miembros de la misma clase.
- clase abstracta pura (fábrica OOP): los usuarios obtienen un puntero único a una clase base ligera o abstracta, los detalles de implementación están en la clase derivada que sobrescribe sus funciones miembro virtuales.
Cortafuegos de compilación
En casos simples, tanto pImpl como el método de fábrica eliminan la dependencia en tiempo de compilación entre la implementación y los usuarios de la interfaz de la clase. El método de fábrica crea una dependencia oculta en la vtable, por lo que reordenar, agregar o eliminar funciones miembro virtuales rompe la ABI. El enfoque pImpl no tiene dependencias ocultas, sin embargo, si la clase de implementación es una especialización de plantilla de clase, se pierde el beneficio del cortafuegos de compilación: los usuarios de la interfaz deben observar toda la definición de la plantilla para instanciar la especialización correcta. Un enfoque de diseño común en este caso es refactorizar la implementación de manera que evite la parametrización, este es otro caso de uso para las C++ Core Guidelines:
- T.61 No sobreparametrice miembros y
- T.84 Use una implementación central no basada en plantillas para proporcionar una interfaz estable de ABI .
Por ejemplo, la siguiente plantilla de clase no utiliza el tipo
T
en su miembro privado o en el cuerpo de
push_back
:
template<class T> class ptr_vector { std::vector<void*> vp; public: void push_back(T* p) { vp.push_back(p); } };
Por lo tanto, los miembros privados pueden transferirse a la implementación tal cual, y
push_back
puede reenviar a una implementación que tampoco utiliza
T
en la interfaz:
// --------------------- // header (ptr_vector.hpp) #include <memory> class ptr_vector_base { struct impl; // does not depend on T std::unique_ptr<impl> pImpl; protected: void push_back_fwd(void*); void print() const; // ... see implementation section for special member functions public: ptr_vector_base(); ~ptr_vector_base(); }; template<class T> class ptr_vector : private ptr_vector_base { public: void push_back(T* p) { push_back_fwd(p); } void print() const { ptr_vector_base::print(); } }; // ----------------------- // source (ptr_vector.cpp) // #include "ptr_vector.hpp" #include <iostream> #include <vector> struct ptr_vector_base::impl { std::vector<void*> vp; void push_back(void* p) { vp.push_back(p); } void print() const { for (void const * const p: vp) std::cout << p << '\n'; } }; void ptr_vector_base::push_back_fwd(void* p) { pImpl->push_back(p); } ptr_vector_base::ptr_vector_base() : pImpl{std::make_unique<impl>()} {} ptr_vector_base::~ptr_vector_base() {} void ptr_vector_base::print() const { pImpl->print(); } // --------------- // user (main.cpp) // #include "ptr_vector.hpp" int main() { int x{}, y{}, z{}; ptr_vector<int> v; v.push_back(&x); v.push_back(&y); v.push_back(&z); v.print(); }
Salida posible:
0x7ffd6200a42c 0x7ffd6200a430 0x7ffd6200a434
Sobrecarga en tiempo de ejecución
- Sobrecarga de acceso: En pImpl, cada llamada a una función miembro privada se redirige a través de un puntero. Cada acceso a un miembro público realizado por un miembro privado se redirige a través de otro puntero. Ambas redirecciones cruzan límites de unidades de traducción y por lo tanto solo pueden ser optimizadas mediante optimización en tiempo de enlace. Nótese que la fábrica OO requiere redirección entre unidades de traducción para acceder tanto a datos públicos como a detalles de implementación, y ofrece aún menos oportunidades para el optimizador en tiempo de enlace debido al despacho virtual.
- Sobrecarga de espacio: pImpl añade un puntero al componente público y, si algún miembro privado necesita acceder a un miembro público, se añade otro puntero al componente de implementación o se pasa como parámetro para cada llamada al miembro privado que lo requiera. Si se admiten asignadores personalizados con estado, la instancia del asignador también debe almacenarse.
- Sobrecarga de gestión de ciclo de vida: pImpl (así como la fábrica OO) sitúan el objeto de implementación en el montón, lo que impone una sobrecarga significativa en tiempo de ejecución durante la construcción y destrucción. Esto puede compensarse parcialmente mediante asignadores personalizados, ya que el tamaño de asignación para pImpl (pero no para la fábrica OO) se conoce en tiempo de compilación.
Por otro lado, las clases pImpl son compatibles con movimiento; refactorizar una clase grande como pImpl movible puede mejorar el rendimiento de algoritmos que manipulan contenedores que almacenan dichos objetos, aunque el pImpl movible tiene una fuente adicional de sobrecarga en tiempo de ejecución: cualquier función miembro pública que esté permitida en un objeto después de ser movido y necesite acceso a la implementación privada incurre en una verificación de puntero nulo.
|
Esta sección está incompleta
Razón: ¿Microbenchmark?) |
Carga de mantenimiento
El uso de pImpl requiere una unidad de traducción dedicada (una biblioteca exclusiva de cabeceras no puede usar pImpl), introduce una clase adicional, un conjunto de funciones de reenvío y, si se utilizan asignadores, expone el detalle de implementación del uso de asignadores en la interfaz pública.
Dado que los miembros virtuales son parte del componente de interfaz de pImpl, simular un pImpl implica simular únicamente el componente de interfaz. Un pImpl testeable normalmente está diseñado para permitir una cobertura completa de pruebas a través de la interfaz disponible.
Implementación
Como el objeto del tipo de interfaz controla la duración del objeto del tipo de implementación, el puntero a implementación es usualmente std::unique_ptr .
Debido a que std::unique_ptr requiere que el tipo apuntado sea un tipo completo en cualquier contexto donde se instancie el deleter, las funciones miembro especiales deben ser declaradas por el usuario y definidas fuera de línea, en el archivo de implementación, donde la clase de implementación está completa.
Porque cuando una función miembro const llama a una función a través de un puntero a miembro no const, se llama a la sobrecarga no const de la función de implementación, el puntero debe envolverse en std::experimental::propagate_const o equivalente.
Todos los miembros de datos privados y todas las funciones miembro no virtuales privadas se colocan en la clase de implementación. Todos los miembros públicos, protegidos y virtuales permanecen en la clase de interfaz (consulte GOTW #100 para la discusión de las alternativas).
Si algún miembro privado necesita acceder a un miembro público o protegido, se puede pasar una referencia o puntero a la interfaz como parámetro a la función privada. Alternativamente, la referencia inversa puede mantenerse como parte de la clase de implementación.
Si se pretende admitir asignadores no predeterminados para la asignación del objeto de implementación, puede utilizarse cualquiera de los patrones habituales de conciencia del asignador, incluyendo el parámetro de plantilla del asignador con valor predeterminado std::allocator y el argumento del constructor de tipo std::pmr::memory_resource* .
Notas
|
Esta sección está incompleta
Razón: nota la conexión con el polimorfismo de valor-semántica |
Ejemplo
Demuestra un pImpl con propagación de const, con referencia inversa pasada como parámetro, sin conciencia de asignador, y con movimiento habilitado sin comprobaciones en tiempo de ejecución:
// ---------------------- // interface (widget.hpp) #include <experimental/propagate_const> #include <iostream> #include <memory> class widget { class impl; std::experimental::propagate_const<std::unique_ptr<impl>> pImpl; public: void draw() const; // public API that will be forwarded to the implementation void draw(); bool shown() const { return true; } // public API that implementation has to call widget(); // even the default ctor needs to be defined in the implementation file // Note: calling draw() on default constructed object is UB explicit widget(int); ~widget(); // defined in the implementation file, where impl is a complete type widget(widget&&); // defined in the implementation file // Note: calling draw() on moved-from object is UB widget(const widget&) = delete; widget& operator=(widget&&); // defined in the implementation file widget& operator=(const widget&) = delete; }; // --------------------------- // implementation (widget.cpp) // #include "widget.hpp" class widget::impl { int n; // private data public: void draw(const widget& w) const { if (w.shown()) // this call to public member function requires the back-reference std::cout << "drawing a const widget " << n << '\n'; } void draw(const widget& w) { if (w.shown()) std::cout << "drawing a non-const widget " << n << '\n'; } impl(int n) : n(n) {} }; void widget::draw() const { pImpl->draw(*this); } void widget::draw() { pImpl->draw(*this); } widget::widget() = default; widget::widget(int n) : pImpl{std::make_unique<impl>(n)} {} widget::widget(widget&&) = default; widget::~widget() = default; widget& widget::operator=(widget&&) = default; // --------------- // user (main.cpp) // #include "widget.hpp" int main() { widget w(7); const widget w2(8); w.draw(); w2.draw(); }
Salida:
drawing a non-const widget 7 drawing a const widget 8
|
Esta sección está incompleta
Razón: describir otra alternativa más — "fast PImpl". La principal diferencia es que la memoria para la implementación se reserva en un miembro de datos que es un array-C opaco (dentro de la definición de la clase PImpl), mientras que en el archivo cpp esa memoria se asigna (mediante
reinterpret_cast
o placement-
new
) a la estructura de implementación. Este enfoque tiene sus propios pros y contras, en particular, un
pro
obvio es que no hay asignación adicional, siempre que se haya reservado suficiente memoria inicialmente en el
diseño
de la clase PImpl. (Mientras que entre los
contras
está la reducción de la amigabilidad con el movimiento.)
|
Enlaces externos
| 1. | GotW #28 : The Fast Pimpl Idiom. |
| 2. | GotW #100 : Compilation Firewalls. |
| 3. | The Pimpl Pattern - what you should know. |