¿Cuál es el patrón para una interfaz segura en C ++?

22

Nota: el siguiente es el código C ++ 03, pero esperamos pasar a C ++ 11 en los próximos dos años, por lo que debemos tenerlo en cuenta.

Estoy escribiendo una guía (para novatos, entre otros) sobre cómo escribir una interfaz abstracta en C ++. Leí los dos artículos de Sutter sobre el tema, busqué en Internet ejemplos y respuestas e hice algunas pruebas.

¡Este código NO debe compilarse!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Todos los comportamientos anteriores encuentran la fuente de su problema en el corte : la interfaz abstracta (o la clase no hoja en la jerarquía) no debe ser construible ni copiable / asignable, INCLUSO si la clase derivada puede serlo.

0ª Solución: la interfaz básica

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Esta solución es simple y algo ingenua: falla todas nuestras restricciones: puede ser construida por defecto, construida por copia y asignada por copia (ni siquiera estoy seguro sobre los constructores de movimientos y la asignación, pero todavía tengo 2 años para calcular fuera).

  1. No podemos declarar el destructor virtual puro porque necesitamos mantenerlo en línea, y algunos de nuestros compiladores no digerirán métodos virtuales puros con el cuerpo vacío en línea.
  2. Sí, el único punto de esta clase es hacer que los implementadores sean prácticamente destructibles, lo cual es un caso raro.
  3. Incluso si tuviéramos un método virtual puro adicional (que es la mayoría de los casos), esta clase aún sería asignable por copia.

Entonces no ...

1ra Solución: boost :: no copiable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Esta solución es la mejor, porque es simple, clara y C ++ (sin macros)

El problema es que todavía no funciona para esa interfaz específica porque VirtuallyConstructible todavía se puede construir por defecto .

  1. No podemos declarar el destructor puro virtual porque necesitamos mantenerlo en línea, y algunos de nuestros compiladores no lo digerirán.
  2. Sí, el único punto de esta clase es hacer que los implementadores sean prácticamente destructibles, lo cual es un caso raro.

Otro problema es que las clases que implementan la interfaz no copiable deben declarar / definir explícitamente el constructor de copia y el operador de asignación si necesitan tener esos métodos (y en nuestro código, tenemos clases de valores a las que nuestro cliente todavía puede acceder a través de interfaces).

Esto va en contra de la Regla de Cero, que es a donde queremos ir: si la implementación predeterminada es correcta, entonces deberíamos poder usarla.

Segunda solución: ¡protégelos!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

Este patrón sigue las restricciones técnicas que teníamos (al menos en el código de usuario): MyInterface no se puede construir por defecto, no se puede construir con copia y no se puede asignar con copia.

Además, no impone restricciones artificiales para implementar clases , que luego son libres de seguir la Regla de Cero, o incluso declarar algunos constructores / operadores como "= predeterminado" en C ++ 11/14 sin problema.

Ahora, esto es bastante detallado, y una alternativa sería usar una macro, algo así como:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

El protegido debe permanecer fuera de la macro (porque no tiene alcance).

Correctamente "espacio de nombres" (es decir, prefijado con el nombre de su empresa o producto), la macro debe ser inofensiva.

Y la ventaja es que el código se factoriza en una fuente, en lugar de ser copiado y pegado en todas las interfaces. Si el constructor de movimiento y la asignación de movimiento se desactivan explícitamente de la misma manera en el futuro, este sería un cambio muy leve en el código.

Conclusión

  • ¿Estoy paranoico de querer que el código esté protegido contra cortes en las interfaces? (Creo que no, pero nunca se sabe ...)
  • ¿Cuál es la mejor solución entre las anteriores?
  • ¿Hay otra solución mejor?

Recuerde que este es un patrón que servirá como guía para los novatos (entre otros), por lo que una solución como: "Cada caso debe tener su implementación" no es una solución viable.

Recompensa y resultados

Le otorgé la recompensa a Coredump por el tiempo dedicado a responder las preguntas y la relevancia de las respuestas.

Mi solución al problema probablemente irá a algo así:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... con la siguiente macro:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

Esta es una solución viable para mi problema por las siguientes razones:

  • Esta clase no se puede instanciar (los constructores están protegidos)
  • Esta clase puede ser prácticamente destruida.
  • Esta clase se puede heredar sin imponer restricciones indebidas a las clases heredadas (por ejemplo, la clase heredada podría ser copiable por defecto)
  • El uso de la macro significa que la "declaración" de la interfaz es fácilmente reconocible (y buscable), y su código se factoriza en un solo lugar, lo que facilita su modificación (un nombre con el prefijo adecuado eliminará los conflictos de nombres indeseables)

Tenga en cuenta que las otras respuestas dieron información valiosa. Gracias a todos los que lo intentaron.

Tenga en cuenta que supongo que todavía puedo poner otra recompensa por esta pregunta, y valoro las respuestas de iluminación lo suficiente como para que si vea una, abra una recompensa solo para asignarla a esa respuesta.

paercebal
fuente
55
¿No puedes simplemente usar funciones virtuales puras en la interfaz? virtual void bar() = 0;¿por ejemplo? Eso evitaría que su interfaz sea instanciada.
Morwenn
@ Morwenn: Como se dijo en la pregunta, eso resolvería el 99% de los casos (apunto al 100% si es posible). Incluso si elegimos ignorar el 1% faltante, tampoco resolvería el corte de la asignación. Entonces, no, esta no es una buena solución.
paercebal
@Morwenn: ¿En serio? ... :-D ... Primero escribí esta pregunta en StackOverflow, y luego cambié de opinión justo antes de enviarla. ¿Crees que debería eliminarlo aquí y enviarlo a SO?
paercebal
Si tengo razón, todo lo que necesita es virtual ~VirtuallyDestructible() = 0una herencia virtual de clases de interfaz (solo con miembros abstractos). Puede omitir eso VirtuallyDestructible, probablemente.
Dieter Lücking
55
@paercebal: si el compilador se ahoga en clases virtuales puras, entonces pertenece a la basura. Una interfaz real es, por definición, puramente virtual.
Nadie

Respuestas:

13

La forma canónica de crear una interfaz en C ++ es darle un destructor virtual puro. Esto asegura que

  • No se pueden crear instancias de la clase de interfaz en sí, porque C ++ no le permite crear una instancia de una clase abstracta. Esto se encarga de los requisitos no construibles (tanto por defecto como por copia).
  • Llamar deletea un puntero a la interfaz hace lo correcto: llama al destructor de la clase más derivada para esa instancia.

solo tener un destructor virtual puro no impide la asignación de una referencia a la interfaz. Si necesita que eso también falle, debe agregar un operador de asignación protegida a su interfaz.

Cualquier compilador de C ++ debería poder manejar una clase / interfaz como esta (todo en un archivo de encabezado):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Si tiene un compilador que se ahoga en esto (lo que significa que debe ser anterior a C ++ 98), entonces su opción 2 (que tiene constructores protegidos) es una buena segunda opción.

El uso boost::noncopyableno es aconsejable para esta tarea, ya que envía el mensaje de que todas las clases en la jerarquía no se pueden copiar y, por lo tanto, puede crear confusión para los desarrolladores más experimentados que no estarían familiarizados con sus intenciones de usarlo de esta manera.

Bart van Ingen Schenau
fuente
If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Esta es la raíz de mi problema. Los casos en los que necesito una interfaz para admitir la asignación deben ser realmente raros. Por otro lado, los casos en los que quiero pasar una interfaz por referencia (los casos en que NULL no es aceptable) y, por lo tanto, quieren evitar un no-op o cortar esa compilación son mucho mayores.
paercebal
Como el operador de asignación nunca debe llamarse, ¿por qué le das una definición? Como un aparte, ¿por qué no hacerlo private? Además, es posible que desee tratar con default- y copy-ctor.
Deduplicador el
5

¿Soy paranoico ...

  • ¿Estoy paranoico de querer que el código esté protegido contra cortes en las interfaces? (Creo que no, pero nunca se sabe ...)

¿No es este un problema de gestión de riesgos?

  • ¿Temes que se pueda introducir un error relacionado con el corte?
  • ¿Crees que puede pasar desapercibido y provocar errores irrecuperables?
  • ¿En qué medida estás dispuesto a ir para evitar cortar?

Mejor solución

  • ¿Cuál es la mejor solución entre las anteriores?

Su segunda solución ("hacerlos protegidos") se ve bien, pero tenga en cuenta que no soy un experto en C ++.
Al menos, los usos inválidos parecen ser reportados correctamente como erróneos por mi compilador (g ++).

Ahora, ¿necesitas macros? Yo diría "sí", porque aunque no diga cuál es el propósito de la directriz que está escribiendo, supongo que esto es para imponer un conjunto particular de mejores prácticas en el código de su producto.

Para ese propósito, las macros pueden ayudar a detectar cuándo las personas aplican efectivamente el patrón: un filtro básico de confirmaciones puede decirle si se utilizó la macro:

  • si se usa, es probable que se aplique el patrón y, lo que es más importante, que se aplique correctamente (solo verifique que haya una protectedpalabra clave),
  • si no se usa, puede intentar investigar por qué no fue así.

Sin macros, debe inspeccionar si el patrón es necesario y está bien implementado en todos los casos.

Mejor solución

  • ¿Hay otra solución mejor?

Rebanar en C ++ no es más que una peculiaridad del lenguaje. Como está escribiendo una guía (especialmente para novatos), debe centrarse en la enseñanza y no solo en enumerar las "reglas de codificación". Debes asegurarte de explicar realmente cómo y por qué ocurre el corte, junto con ejemplos y ejercicios (no reinventes la rueda, inspírate en libros y tutoriales).

Por ejemplo, el título de un ejercicio podría ser " ¿Cuál es el patrón para una interfaz segura en C ++ ?"

Por lo tanto, su mejor movimiento sería asegurarse de que sus desarrolladores de C ++ entiendan lo que sucede cuando se produce el corte. Estoy convencido de que si lo hacen, no cometerán tantos errores en el código como temerías, incluso sin aplicar formalmente ese patrón en particular (pero aún puedes hacerlo, las advertencias del compilador son buenas).

Sobre el compilador

Tu dices :

No tengo poder para elegir compiladores para este producto,

A menudo la gente dirá "No tengo derecho a hacer [X]" , "Se supone que no debo hacer [Y] ..." , ... porque piensan que esto no es posible, y no porque intentado o preguntado

Probablemente sea parte de la descripción de su trabajo dar su opinión sobre cuestiones técnicas; si realmente crees que el compilador es la opción perfecta (o única) para tu dominio problemático, entonces úsalo. Pero también dijo que "los destructores virtuales puros con implementación en línea no son el peor punto de estrangulamiento que he visto" ; Según tengo entendido, el compilador es tan especial que incluso los desarrolladores de C ++ expertos tienen dificultades para usarlo: su compilador heredado / interno ahora es una deuda técnica, y usted tiene el derecho (¿el deber?) de discutir ese problema con otros desarrolladores y gerentes .

Intente evaluar el costo de mantener el compilador frente al costo de usar otro:

  1. ¿Qué te trae el compilador actual que nadie más puede hacer?
  2. ¿Se puede compilar fácilmente el código de su producto utilizando otro compilador? ¿Por qué no ?

No conozco tu situación, y de hecho, probablemente tengas razones válidas para estar vinculado a un compilador específico.
Pero en el caso de que esto sea simplemente inercia, la situación nunca evolucionará si usted o sus compañeros de trabajo no informan problemas de productividad o deudas técnicas.

volcado de memoria
fuente
Am I paranoid...: "Haga que sus interfaces sean fáciles de usar correctamente y difíciles de usar incorrectamente". He probado ese principio particular cuando alguien informó que uno de mis métodos estáticos fue, por error, usado incorrectamente. El error producido no parecía estar relacionado, y un ingeniero tardó varias horas en encontrar la fuente. Este "error de interfaz" está a la par de asignar una referencia de interfaz a otra. Entonces, sí, quiero evitar ese tipo de error. Además, en C ++, la filosofía es capturar tanto como sea posible en el momento de la compilación, y el lenguaje nos da ese poder, así que vamos con él.
paercebal
Best solution: Estoy de acuerdo. . . Better solution: Esa es una respuesta increíble. Trabajaré en ello ... Ahora, sobre Pure virtual classes: ¿Qué es esto? ¿Una interfaz abstracta C ++? (¿clase sin estado y solo métodos virtuales puros?). ¿Cómo esta "clase virtual pura" me protegió contra el corte? (los métodos virtuales puros harán que la creación de instancias no se compile, pero la asignación de copias lo hará, y la asignación de movimiento también lo hará IIRC).
paercebal
About the compiler: Estamos de acuerdo, pero nuestros compiladores están fuera de mi alcance de responsabilidad (no es que me impida hacer comentarios sarcásticos ... :-p ...). No divulgaré los detalles (ojalá pudiera), pero está relacionado con razones internas (como conjuntos de pruebas) y razones externas (por ejemplo, vinculación de clientes con nuestras bibliotecas). Al final, cambiar la versión del compilador (o incluso parchearlo) NO es una operación trivial. Y mucho menos reemplazar un compilador roto con un gcc reciente.
paercebal
@paercebal gracias por tus comentarios; sobre clases virtuales puras, tienes razón, no resuelve todas tus restricciones (eliminaré esta parte). Entiendo la parte de "error de interfaz" y cómo es útil detectar errores en tiempo de compilación: pero usted preguntó si es paranoico, y creo que el enfoque racional es equilibrar su necesidad de verificaciones estáticas con la probabilidad de que ocurra el error. Buena suerte con el compilador :)
coredump
1
No soy un fanático de las macros, especialmente porque las pautas están dirigidas (también) a los hombres jóvenes. Con demasiada frecuencia, he visto personas a las que se les dieron herramientas "prácticas" para aplicarlas a ciegas y nunca entender lo que realmente estaba sucediendo. Llegan a creer que lo que hace la macro debe ser lo más complicado porque su jefe pensó que sería demasiado difícil para ellos hacerlo. Y debido a que la macro solo existe en su empresa, ni siquiera pueden hacer una búsqueda en la web, mientras que para una guía documentada qué funciones de los miembros declarar y por qué, podrían hacerlo.
5gon12eder
2

El problema del corte es uno, pero ciertamente no el único, que se presenta cuando expone una interfaz polimórfica en tiempo de ejecución a sus usuarios. Piense en punteros nulos, administración de memoria, datos compartidos. Ninguno de estos se resuelve fácilmente en todos los casos (los punteros inteligentes son geniales, pero ni siquiera son una bala de plata). De hecho, desde su publicación, no parece que esté tratando de resolver el problema del corte, sino que evite que los usuarios hagan copias. Todo lo que necesita hacer para ofrecer una solución al problema de corte es agregar una función de miembro de clonación virtual. Creo que el problema más profundo al exponer una interfaz polimórfica en tiempo de ejecución es que obliga a los usuarios a tratar con la semántica de referencia, que es más difícil de razonar que la semántica de valor.

La mejor manera que conozco para evitar estos problemas en C ++ es usar el tipo de borrado . Esta es una técnica en la que oculta una interfaz polimórfica en tiempo de ejecución, detrás de una interfaz de clase normal. Esta interfaz de clase normal tiene una semántica de valor y se encarga de todo el "desastre" polimórfico detrás de las pantallas. std::functiones un excelente ejemplo de borrado de tipo.

Para obtener una excelente explicación de por qué exponer la herencia a sus usuarios es malo y cómo la eliminación de tipos puede ayudar a corregir eso, vea estas presentaciones de Sean Parent:

La herencia es la clase base del mal (versión corta)

Valor semántico y polimorfismo basado en conceptos (versión larga; más fácil de seguir, pero el sonido no es excelente)

D Drmmr
fuente
0

No eres paranoico Mi primera tarea profesional como programador de C ++ resultó en cortes y fallas. Sé de los demás. No hay muchas buenas soluciones para esto.

Dadas las restricciones de su compilador, la opción 2 es la mejor. En lugar de hacer una macro, que sus nuevos programadores verán como extraña y misteriosa, sugeriría un script o herramienta para generar automáticamente el código. Si sus nuevos empleados utilizarán un IDE, debería poder crear una herramienta de "Nueva interfaz MYCOMPANY" que le pedirá el nombre de la interfaz y creará la estructura que está buscando.

Si sus programadores están usando la línea de comando, entonces use cualquier lenguaje de script disponible para crear el script NewMyCompanyInterface para generar el código.

He usado este enfoque en el pasado para patrones de código comunes (interfaces, máquinas de estado, etc.). Lo bueno es que los nuevos programadores pueden leer el resultado y comprenderlo fácilmente, reproduciendo el código necesario cuando necesitan algo que no se puede generar.

Las macros y otros enfoques de metaprogramación tienden a ofuscar lo que está sucediendo, y los nuevos programadores no aprenden lo que está sucediendo 'detrás del telón'. Cuando tienen que romper el patrón, están tan perdidos como antes.

Ben
fuente