Idioma de Pimpl vs interfaz de clase virtual pura

118

Me preguntaba qué haría que un programador eligiera el idioma de Pimpl o la clase virtual pura y la herencia.

Entiendo que el idioma pimpl viene con una indirección adicional explícita para cada método público y la sobrecarga de creación de objetos.

La clase virtual pura, por otro lado, viene con indirección implícita (vtable) para la implementación heredada y entiendo que no hay gastos generales de creación de objetos.
EDITAR : Pero necesitaría una fábrica si crea el objeto desde el exterior

¿Qué hace que la clase virtual pura sea menos deseable que el idioma pimpl?

Arkaitz Jiménez
fuente
3
Gran pregunta, solo quería hacer lo mismo. Véase también boost.org/doc/libs/1_41_0/libs/smart_ptr/sp_techniques.html
Frank

Respuestas:

60

Al escribir una clase de C ++, es apropiado pensar si va a ser

  1. Un tipo de valor

    Copia por valor, la identidad nunca es importante. Es apropiado que sea una clave en un std :: map. Por ejemplo, una clase de "cadena", una clase de "fecha" o una clase de "número complejo". "Copiar" instancias de tal clase tiene sentido.

  2. Un tipo de entidad

    La identidad es importante. Siempre se pasa por referencia, nunca por "valor". A menudo, no tiene sentido "copiar" instancias de la clase en absoluto. Cuando tiene sentido, un método de "clonación" polimórfica suele ser más apropiado. Ejemplos: una clase de Socket, una clase de base de datos, una clase de "política", cualquier cosa que sea un "cierre" en un lenguaje funcional.

Tanto pImpl como la clase base abstracta pura son técnicas para reducir las dependencias del tiempo de compilación.

Sin embargo, solo uso pImpl para implementar tipos de valor (tipo 1), y solo a veces cuando realmente quiero minimizar el acoplamiento y las dependencias en tiempo de compilación. A menudo, no vale la pena molestarse. Como señala correctamente, hay más sobrecarga sintáctica porque tiene que escribir métodos de reenvío para todos los métodos públicos. Para las clases de tipo 2, siempre uso una clase base abstracta pura con métodos de fábrica asociados.

Paul Hollingsworth
fuente
6
Consulte el comentario de Paul de Vrieze a esta respuesta . Pimpl y Pure Virtual difieren significativamente si está en una biblioteca y desea intercambiar su .so / .dll sin reconstruir el cliente. Los clientes se vinculan a las interfaces de pimpl por su nombre, por lo que mantener firmas de métodos antiguos es suficiente. OTOH en un caso abstracto puro, se vinculan efectivamente por índice vtable, por lo que reordenar métodos o insertar en el medio romperá la compatibilidad.
SnakE
1
Solo puede agregar (o reordenar) métodos en una interfaz de clase Pimpl para mantener la comparabilidad binaria. Lógicamente hablando, todavía has cambiado la interfaz y parece un poco dudosa. La respuesta aquí es un equilibrio sensato que también puede ayudar con las pruebas unitarias mediante la "inyección de dependencia"; pero la respuesta siempre depende de los requisitos. Los escritores de bibliotecas de terceros (a diferencia de usar una biblioteca en su propia organización) pueden preferir mucho el Pimpl.
Spacen Jasset
31

Pointer to implementationgeneralmente se trata de ocultar detalles de implementación estructural. Interfacesse trata de crear instancias de diferentes implementaciones. Realmente sirven para dos propósitos diferentes.

D.Shawley
fuente
13
no necesariamente, he visto clases que almacenan múltiples pimpls dependiendo de la implementación deseada. A menudo, esto es decir una impl de win32 vs una impl de linux de algo que debe implementarse de manera diferente por plataforma.
Doug T.
14
Pero puede usar una interfaz para desacoplar los detalles de implementación y ocultarlos
Arkaitz Jimenez
6
Si bien puede implementar pimpl usando una interfaz, a menudo no hay razón para desacoplar los detalles de implementación. Entonces no hay razón para volverse polimórfico. El motivo de pimpl es mantener los detalles de implementación fuera del alcance del cliente (en C ++ para mantenerlos fuera del encabezado). Puede hacer esto usando una base / interfaz abstracta, pero generalmente es una exageración innecesaria.
Michael Burr
10
¿Por qué es exagerado? Quiero decir, ¿es más lento el método de interfaz que el de pimpl? Puede haber razones lógicas, pero desde el punto de vista práctico, diría que es más fácil hacerlo con una interfaz abstracta
Arkaitz Jimenez
1
Yo diría que la interfaz / clase base abstracta es la forma "normal" de hacer las cosas y permite realizar pruebas más fáciles mediante burlas
Paulm
28

El idioma pimpl le ayuda a reducir las dependencias y los tiempos de compilación, especialmente en aplicaciones grandes, y minimiza la exposición del encabezado de los detalles de implementación de su clase a una unidad de compilación. Los usuarios de su clase ni siquiera deberían tener que ser conscientes de la existencia de una espinilla (¡excepto como un puntero críptico del que no están al tanto!).

Las clases abstractas (virtuales puros) es algo que sus clientes deben tener en cuenta: si intenta usarlas para reducir el acoplamiento y las referencias circulares, debe agregar alguna forma de permitirles crear sus objetos (por ejemplo, a través de métodos o clases de fábrica, inyección de dependencia u otros mecanismos).

Pontus Gagge
fuente
17

Estaba buscando una respuesta para la misma pregunta. Después de leer algunos artículos y practicar , prefiero usar "interfaces de clase virtual pura" .

  1. Son más sencillos (esta es una opinión subjetiva). El lenguaje de Pimpl me hace sentir que estoy escribiendo código "para el compilador", no para el "próximo desarrollador" que leerá mi código.
  2. Algunos marcos de prueba tienen soporte directo para simular clases virtuales puras
  3. Es cierto que necesitas que una fábrica sea accesible desde el exterior. Pero si desea aprovechar el polimorfismo: eso también es "pro", no una "estafa". ... y un simple método de fábrica no duele tanto

El único inconveniente ( estoy tratando de investigar sobre esto ) es que el idioma de pimpl podría ser más rápido

  1. cuando las llamadas de proxy están en línea, mientras que la herencia necesariamente necesita un acceso adicional al objeto VTABLE en tiempo de ejecución
  2. la huella de memoria de la clase de proxy público pimpl es más pequeña (puede hacer optimizaciones fácilmente para intercambios más rápidos y otras optimizaciones similares)
Ilias Bartolini
fuente
21
También recuerde que al usar la herencia, introduce una dependencia en el diseño de vtable. Para mantener ABI, ya no puede cambiar las funciones virtuales (agregar al final es algo seguro, si no hay clases secundarias que agreguen sus propios métodos virtuales).
Paul de Vrieze
1
^ Este comentario aquí debería ser pegajoso.
CodeAngry
10

¡Odio las espinillas! Hacen la clase fea y no legible. Todos los métodos se redirigen al grano. Nunca ves en los encabezados, qué funcionalidades tiene la clase, por lo que no puedes refactorizarla (ej. Simplemente cambiar la visibilidad de un método). La clase se siente como "embarazada". Creo que usar iterfaces es mejor y realmente suficiente para ocultar la implementación al cliente. Puede dejar que una clase implemente varias interfaces para mantenerlas delgadas. ¡Uno debería preferir las interfaces! Nota: No necesita la clase de fábrica. Es relevante que los clientes de la clase se comuniquen con sus instancias a través de la interfaz adecuada. La ocultación de métodos privados me parece una extraña paranoia y no veo razón para esto ya que tenemos interfaces.

Masha Ananieva
fuente
1
Hay algunos casos en los que no puede utilizar interfaces virtuales puras. Por ejemplo, cuando tiene algún código heredado y tiene dos módulos que necesita separar sin tocarlos.
AlexTheo
Como @Paul de Vrieze señaló a continuación, pierde compatibilidad ABI al cambiar los métodos de la clase base, porque tiene una dependencia implícita de la vtable de la clase. Depende del caso de uso, si esto es un problema.
H. Rittich
"La ocultación de métodos privados me parece una extraña paranoia" ¿No te permite eso ocultar las dependencias y, por lo tanto, minimizar el tiempo de compilación si cambia una dependencia?
pooya13
Tampoco entiendo cómo las fábricas son más fáciles de refactorizar que pImpl. ¿No abandona la "interfaz" en ambos casos y cambia la implementación? En Factory tienes que modificar un archivo .hy un archivo .cpp y en pImpl tienes que modificar uno .hy dos archivos .cpp pero eso es todo y generalmente no necesitas modificar el archivo cpp de la interfaz de pImpl.
pooya13
8

Hay un problema muy real con las bibliotecas compartidas que el idioma pimpl elude perfectamente y que los virtuales puros no pueden: no se pueden modificar / eliminar de forma segura los miembros de datos de una clase sin obligar a los usuarios de la clase a recompilar su código. Eso puede ser aceptable en algunas circunstancias, pero no, por ejemplo, para las bibliotecas del sistema.

Para explicar el problema en detalle, considere el siguiente código en su biblioteca / encabezado compartido:

// header
struct A
{
public:
  A();
  // more public interface, some of which uses the int below
private:
  int a;
};

// library 
A::A()
  : a(0)
{}

El compilador emite código en la biblioteca compartida que calcula la dirección del entero que se inicializará para que sea un cierto desplazamiento (probablemente cero en este caso, porque es el único miembro) del puntero al objeto A que sabe que es this.

En el lado del usuario del código, new Aprimero asignará sizeof(A)bytes de memoria, luego pasará un puntero a esa memoria al A::A()constructor como this.

Si en una revisión posterior de su biblioteca decide eliminar el número entero, hacerlo más grande, más pequeño o agregar miembros, habrá una discrepancia entre la cantidad de memoria que el código del usuario asigna y las compensaciones que espera el código del constructor. El resultado probable es un bloqueo, si tiene suerte; si tiene menos suerte, su software se comporta de manera extraña.

Al hacer pimpl'ing, puede agregar y eliminar miembros de datos de forma segura a la clase interna, ya que la asignación de memoria y la llamada al constructor ocurren en la biblioteca compartida:

// header
struct A
{
public:
  A();
  // more public interface, all of which delegates to the impl
private:
  void * impl;
};

// library 
A::A()
  : impl(new A_impl())
{}

Todo lo que necesita hacer ahora es mantener su interfaz pública libre de miembros de datos que no sean el puntero al objeto de implementación, y está a salvo de esta clase de errores.

Editar: tal vez debería agregar que la única razón por la que estoy hablando del constructor aquí es que no quería proporcionar más código; la misma argumentación se aplica a todas las funciones que acceden a los miembros de datos.


fuente
4
En lugar de void *, creo que es más tradicional declarar hacia adelante la clase de implementación:class A_impl *impl_;
Frank Krueger
9
No entiendo, no debes declarar miembros privados en una clase virtual pura que pretendes usar como interfaz, la idea es mantener la clase probaly abstracta, sin tamaño, solo métodos virtuales puros, no veo nada no se puede hacer a través de bibliotecas compartidas
Arkaitz Jimenez
@Frank Krueger: Tienes razón, estaba siendo un vago. @Arkaitz Jimenez: Ligero malentendido; si tiene una clase que solo contiene funciones virtuales puras, entonces no tiene mucho sentido hablar de bibliotecas compartidas. Por otro lado, si se trata de bibliotecas compartidas, proxenetismo en sus clases públicas puede ser prudente por la razón descrita anteriormente.
10
Esto es simplemente incorrecto. Ambos métodos le permiten ocultar el estado de implementación de sus clases, si convierte la otra clase en una clase de "base abstracta pura".
Paul Hollingsworth
10
La primera oración en su anser implica que los virtuales puros con un método de fábrica asociado de alguna manera no le permiten ocultar el estado interno de la clase. Eso no es cierto. Ambas técnicas te permiten ocultar el estado interno de la clase. La diferencia es cómo lo ve el usuario. pImpl le permite seguir representando una clase con semántica de valor pero también ocultar el estado interno. El método de fábrica Pure Abstract Base Class + le permite representar tipos de entidad y también le permite ocultar el estado interno. Esto último es exactamente cómo funciona COM. El capítulo 1 de "Essential COM" tiene una gran discusión sobre esto.
Paul Hollingsworth
6

No debemos olvidar que la herencia es un vínculo más fuerte y más estrecho que la delegación. También tomaría en cuenta todas las cuestiones planteadas en las respuestas dadas al decidir qué modismos de diseño emplear para resolver un problema en particular.

Sam
fuente
3

Aunque está ampliamente cubierto en las otras respuestas, tal vez pueda ser un poco más explícito sobre un beneficio de pimpl sobre las clases base virtuales:

Un enfoque de pimpl es transparente desde el punto de vista del usuario, lo que significa que, por ejemplo, puede crear objetos de la clase en la pila y usarlos directamente en contenedores. Si intenta ocultar la implementación utilizando una clase base virtual abstracta, deberá devolver un puntero compartido a la clase base desde una fábrica, lo que complica su uso. Considere el siguiente código de cliente equivalente:

// Pimpl
Object pi_obj(10);
std::cout << pi_obj.SomeFun1();

std::vector<Object> objs;
objs.emplace_back(3);
objs.emplace_back(4);
objs.emplace_back(5);
for (auto& o : objs)
    std::cout << o.SomeFun1();

// Abstract Base Class
auto abc_obj = ObjectABC::CreateObject(20);
std::cout << abc_obj->SomeFun1();

std::vector<std::shared_ptr<ObjectABC>> objs2;
objs2.push_back(ObjectABC::CreateObject(13));
objs2.push_back(ObjectABC::CreateObject(14));
objs2.push_back(ObjectABC::CreateObject(15));
for (auto& o : objs2)
    std::cout << o->SomeFun1();
Superfly Jon
fuente
2

A mi entender, estas dos cosas tienen propósitos completamente diferentes. El propósito del modismo de la espinilla es básicamente darle una idea de su implementación para que pueda hacer cosas como intercambios rápidos por una especie.

El propósito de las clases virtuales está más en la línea de permitir el polimorfismo, es decir, tiene un puntero desconocido a un objeto de un tipo derivado y cuando llama a la función x siempre obtiene la función correcta para cualquier clase a la que el puntero base realmente apunte.

Realmente manzanas y naranjas.

Robert S. Barnes
fuente
Estoy de acuerdo con las manzanas / naranjas. Pero parece que usa pImpl para un funcional. Mi objetivo es principalmente la ocultación de información y técnicas de construcción.
xtofl
2

El problema más molesto del idioma pimpl es que hace extremadamente difícil mantener y analizar el código existente. Por lo tanto, al usar pimpl, paga con el tiempo y la frustración del desarrollador solo para "reducir las dependencias y los tiempos de compilación y minimizar la exposición del encabezado de los detalles de implementación". Decide tú mismo, si realmente vale la pena.

Especialmente los "tiempos de compilación" son un problema que puede resolver mejorando el hardware o utilizando herramientas como Incredibuild (www.incredibuild.com, también ya incluida en Visual Studio 2017), sin afectar así el diseño de su software. El diseño del software debe ser generalmente independiente de la forma en que se construye el software.

Trantor
fuente
También paga con el tiempo del desarrollador cuando los tiempos de construcción son de 20 minutos en lugar de 2, por lo que es un poco equilibrado, un sistema de módulo real ayudaría mucho aquí.
Arkaitz Jimenez
En mi humilde opinión, la forma en que se construye el software no debería influir en el diseño interno en absoluto. Este es un problema completamente diferente.
Trantor
2
¿Qué hace que sea difícil de analizar? Un montón de llamadas en un archivo de implementación que se reenvían a la clase Impl no suena difícil.
mabraham
2
Imagine implementaciones de depuración en las que se utilizan pimpl e interfaces. A partir de una llamada en el código de usuario A, rastrea hasta la interfaz B, salta a la clase C con picos para finalmente comenzar a depurar la clase de implementación D ... Cuatro pasos hasta que puedas analizar lo que realmente sucede. Y si todo se implementa en una DLL, posiblemente encontrará una interfaz C en algún lugar intermedio ....
Trantor
¿Por qué usaría una interfaz con pImpl cuando pImpl también puede hacer el trabajo de una interfaz? (es decir, puede ayudarlo a lograr la inversión de dependencia)
pooya13