Comprobación de condiciones previas o no

8

He estado buscando una respuesta sólida a la pregunta de si se deben realizar o no verificaciones de tiempo de ejecución para validar las entradas con el fin de garantizar que un cliente se haya apegado a su final del acuerdo en el diseño por contrato. Por ejemplo, considere un constructor de clase simple:

class Foo
{
public:
  Foo( BarHandle bar )
  {
    FooHandle handle = GetFooHandle( bar );
    if( handle == NULL ) {
      throw std::exception( "invalid FooHandle" );
    }
  }
};

Yo diría en este caso que un usuario no debe intentar construir un Foosin un válido BarHandle. No parece correcto verificar que barsea ​​válido dentro del Fooconstructor. Si simplemente documento que Fooel constructor requiere un válido BarHandle , ¿no es suficiente? ¿Es esta una forma adecuada de hacer cumplir mi condición previa en el diseño por contrato?

Hasta ahora, todo lo que he leído tiene opiniones encontradas sobre esto. Parece que el 50% de las personas diría que verifica que bares válido, el otro 50% diría que no debería hacerlo, por ejemplo, considere un caso en el que el usuario verifique que BarHandlees correcto, pero una segunda verificación (e innecesaria) también se está haciendo dentro del Fooconstructor de.

void.pointer
fuente

Respuestas:

10

No creo que haya una sola respuesta para esto. Creo que lo principal que es necesario es la coherencia, ya sea que imponga todas las condiciones previas en una función o no intente imponer ninguna de ellas. Desafortunadamente, eso es bastante raro: lo que generalmente sucede es que, en lugar de pensar en las condiciones previas y hacerlas cumplir, los programadores agregan fragmentos de código para hacer cumplir las condiciones previas cuya violación causó fallas durante las pruebas, pero con frecuencia dejan abiertas otras posibilidades que pueden causar fallas, pero no surgió en las pruebas.

En muchos casos, es bastante razonable proporcionar dos capas: una para uso "interno" que no intenta hacer cumplir ninguna condición previa, y luego una segunda para uso "externo" que solo impone condiciones previas, luego invoca la primera.

Sin embargo, creo que es mejor hacer cumplir las condiciones previas en el nodo de origen, no solo documentarlas. Una excepción o afirmación es mucho más difícil de ignorar que la documentación y es mucho más probable que permanezca sincronizado con el resto del código.

Jerry Coffin
fuente
En principio, estoy de acuerdo con su último párrafo. Aunque eso ahora significa que hay tres cosas que deben mantenerse sincronizadas; ¡la documentación, las afirmaciones en sí mismas y los casos de prueba que prueban que las afirmaciones están haciendo su trabajo (si crees en tales cosas)!
Oliver Charlesworth el
@OliCharlesworth: Sí, crea una tercera cosa para mantenerse sincronizado, pero establece una (la aplicación en el código fuente) como la que generalmente se confía cuando hay desacuerdo. De lo contrario, generalmente no lo sabes.
Jerry Coffin
2
@JerryCoffin Podría verificar si fooes NULL, pero ser NULL no es la única forma en que foopodría ser inválido. Por ejemplo, ¿qué pasa con -1 elenco a FooHandle? No puedo verificar todas las formas posibles en que el identificador podría ser inválido. NULL es una opción obvia y es algo que normalmente se verifica, pero no es una verificación concluyente. ¿Qué recomendarías aquí?
void.pointer
@RobertDailey: en última instancia, es casi imposible asegurarse contra todos los posibles abusos, especialmente cuando / si el casting se involucra. Con el casting, el usuario puede subvertir esencialmente cualquier cosa que pueda verificar. Lo que más enfatizo es la diferencia entre 1) suponer que los parámetros son buenos y agregar comprobaciones de las cosas que salen mal en las pruebas, y 2) descubrir las condiciones previas con la mayor precisión posible y aplicarlas tan bien como pueda .
Jerry Coffin
@JerryCoffin ¿En qué se diferencia esto de la Programación Defensiva, que generalmente no se considera "algo bueno"? La mayoría de las veces, las técnicas de programación defensiva como esta y muchas otras que he visto no son muy pragmáticas. Es un diseño hecho para combatir los malos hábitos de codificación de sus compañeros de trabajo u otras cosas en lugar de centrarse en la funcionalidad práctica y la implementación de sus métodos. Solo veo que esto se sale de las manos fácilmente como un hábito que agrega lógica extra de placa de caldera en todas las funciones de clase. Se podría pensar que las pruebas unitarias eliminan la necesidad de estas verificaciones previas.
void.pointer
4

Es una pregunta muy difícil, porque hay varios conceptos diferentes:

  • Exactitud
  • Documentación
  • Actuación

Sin embargo, esto es principalmente un artefacto de una falla de tipo , en este caso. La nulidad se aplica mejor mediante restricciones de tipo, porque el compilador realmente las verifica. Aún así, dado que no todo se puede capturar en un sistema de tipos, especialmente en C ++, la pregunta en sí misma todavía vale la pena.


Personalmente, creo que la corrección y la documentación son primordiales. Ser rápido e incorrecto es inútil. Ser rápido y estar solo mal a veces es un poco mejor, pero tampoco aporta mucho a la mesa.

Sin embargo, el rendimiento puede ser crítico en algunas partes de los programas, y algunas comprobaciones pueden ser bastante extensas (es decir, demostrar que un gráfico dirigido tiene todos sus nodos accesibles y co-accesibles). Por lo tanto, votaría por un enfoque dual.

Principio uno: Fail Fast . Este es un principio rector en la programación defensiva en general, que aboga por la detección de errores en la etapa más temprana posible. Añadiría Fail Hard a la ecuación.

if (not bar) { abort(); }

Desafortunadamente, en un entorno de producción, fallar mucho no es necesariamente la mejor solución. En este caso, una excepción específica puede ayudar a salir de allí a toda prisa, y dejar que algún manejador de alto nivel se dé cuenta y aborde el caso fallido de manera adecuada (lo más probable es iniciar sesión y seguir adelante con un nuevo caso).

Sin embargo, esto no aborda el problema de las pruebas costosas . En los puntos calientes, esas pruebas pueden costar demasiado. En este caso, es razonable habilitar solo la prueba en las compilaciones DEBUG.

Esto nos deja con una solución agradable y simple:

  • SOFT_ASSERT(Cond_, Text_)
  • DEBUG_ASSERT(Cond_, Text_)

Donde las dos macros se definen así:

 #ifdef NDEBUG
 #  define SOFT_ASSERT(Cond_, Text_)                                                \
        while (not (Cond_)) { throw Exception(Text_, __FILE__, __LINE__); }
 #  define DEBUG_ASSERT(Cond_, Text_) while(false) {}
 #else // NDEBUG
 #  define SOFT_ASSERT(Cond_, Text_)                                                \
        while (not (Cond_)) {                                                       \
            std::cerr << __FILE__ << '#' << __LINE__ << ": " << Text_ << std::endl; \
            abort();                                                                \
        }
 #  define DEBUG_ASSERT(Cond_, Text_) SOFT_ASSERT(Cond_, Text_)
 #endif // NDEBUG
Matthieu M.
fuente
0

Una cita que he escuchado sobre esto es:

"Sea conservador en lo que hace y liberal en lo que acepta".

Lo que se reduce a seguir los contratos para los argumentos cuando se llaman funciones, y verificar todas las entradas antes de actuar cuando se escriben funciones.

En última instancia, depende del dominio. Si está haciendo una API de sistema operativo, será mejor que verifique cada entrada, no confíe en que todos los datos entrantes sean válidos antes de comenzar a actuar sobre ella. Si está haciendo una biblioteca para que otros la usen, continúe, deje que el usuario se atornille (OpenGL viene a su mente primero por alguna razón desconocida).

EDITAR: en el sentido OO, parece haber dos enfoques: uno que dice que un objeto nunca debe estar malformado (todos sus invariantes deben ser verdaderos) durante todo el tiempo que un objeto es accesible, y otro enfoque que dice que tiene un constructor que no establece todos los invariantes, luego establece algunos valores más y tiene una segunda función de inicialización que finaliza init.

Me gusta más el primero, ya que no requiere conocimientos mágicos ni depende de la documentación actual para saber qué partes de inicialización no hace el constructor.

luego
fuente
Para dicha inicialización, utilizo un objeto generador de precios que contiene datos de inicialización parcial y luego crea un objeto "útil" completamente inicializado.
user470365