¿Es posible escribir demasiadas afirmaciones?
Bueno, por supuesto que lo es. [Imagine un ejemplo desagradable aquí.] Sin embargo, aplicando las pautas detalladas a continuación, no debería tener problemas para superar ese límite en la práctica. También soy un gran admirador de las afirmaciones, y las uso de acuerdo con estos principios. Gran parte de este consejo no es especial para las afirmaciones, sino que solo se les aplica una buena práctica general de ingeniería.
Tenga en cuenta la sobrecarga de tiempo de ejecución y huella binaria en mente
Las afirmaciones son geniales, pero si hacen que su programa sea inaceptablemente lento, será muy molesto o los apagará tarde o temprano.
Me gusta medir el costo de una afirmación en relación con el costo de la función que contiene. Considere los siguientes dos ejemplos.
// Precondition: queue is not empty
// Invariant: queue is sorted
template <typename T>
const T&
sorted_queue<T>::max() const noexcept
{
assert(!this->data_.empty());
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
return this->data_.back();
}
La función en sí es una operación O (1), pero las aserciones representan la sobrecarga de O ( n ). No creo que le gustaría que tales controles estén activos a menos que en circunstancias muy especiales.
Aquí hay otra función con afirmaciones similares.
// Requirement: op : T -> T is monotonic [ie x <= y implies op(x) <= op(y)]
// Invariant: queue is sorted
// Postcondition: each item x in the queue is replaced by op(x)
template <typename T>
template <typename FuncT>
void
sorted_queue<T>::apply_monotonic_function(FuncT&& op)
{
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
std::transform(std::cbegin(this->data_), std::cend(this->data_),
std::begin(this->data_), std::forward<FuncT>(op));
assert(std::is_sorted(std::cbegin(this->data_), std::cend(this->data_)));
}
La función en sí es una operación O ( n ), por lo que duele mucho menos agregar una sobrecarga adicional O ( n ) para la aserción. Disminuir la velocidad de una función mediante un factor constante pequeño (en este caso, probablemente menos de 3) es algo que generalmente podemos permitirnos en una compilación de depuración, pero tal vez no en una compilación de lanzamiento.
Ahora considere este ejemplo.
// Precondition: queue is not empty
// Invariant: queue is sorted
// Postcondition: last element is removed from queue
template <typename T>
void
sorted_queue<T>::pop_back() noexcept
{
assert(!this->data_.empty());
return this->data_.pop_back();
}
Si bien muchas personas probablemente se sentirán mucho más cómodas con esta afirmación O (1) que con las dos afirmaciones O ( n ) en el ejemplo anterior, en mi opinión son moralmente equivalentes. Cada uno agrega una sobrecarga en el orden de la complejidad de la función misma.
Finalmente, están las afirmaciones "realmente baratas" que están dominadas por la complejidad de la función en la que están contenidas.
// Requirement: cmp : T x T -> bool is a strict weak ordering
// Precondition: queue is not empty
// Postcondition: if x is returned, then there is no y in the queue
// such that cmp(x, y)
template <typename T>
template <typename CmpT>
const T&
sorted_queue<T>::max(CmpT&& cmp) const
{
assert(!this->data_.empty());
const auto pos = std::max_element(std::cbegin(this->data_),
std::cend(this->data_),
std::forward<CmpT>(cmp));
assert(pos != std::cend(this->data_));
return *pos;
}
Aquí, tenemos dos aserciones O (1) en una función O ( n ). Probablemente no será un problema mantener esta sobrecarga incluso en las versiones de lanzamiento.
Sin embargo, tenga en cuenta que las complejidades asintóticas no siempre dan una estimación adecuada porque, en la práctica, siempre estamos tratando con tamaños de entrada limitados por algunos factores finitos constantes y constantes ocultos por "Big- O " que muy bien podrían no ser insignificantes.
Entonces, ahora que hemos identificado diferentes escenarios, ¿qué podemos hacer al respecto? Un enfoque (probablemente demasiado) fácil sería seguir una regla como “No use afirmaciones que dominen la función en la que están contenidas”. Si bien podría funcionar para algunos proyectos, otros podrían necesitar un enfoque más diferenciado. Esto podría hacerse utilizando diferentes macros de aserción para los diferentes casos.
#define MY_ASSERT_IMPL(COST, CONDITION) \
( \
( ((COST) <= (MY_ASSERT_COST_LIMIT)) && !(CONDITION) ) \
? ::my::assertion_failed(__FILE__, __LINE__, __FUNCTION__, # CONDITION) \
: (void) 0 \
)
#define MY_ASSERT_LOW(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_LOW, CONDITION)
#define MY_ASSERT_MEDIUM(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_MEDIUM, CONDITION)
#define MY_ASSERT_HIGH(CONDITION) \
MY_ASSERT_IMPL(MY_ASSERT_COST_HIGH, CONDITION)
#define MY_ASSERT_COST_NONE 0
#define MY_ASSERT_COST_LOW 1
#define MY_ASSERT_COST_MEDIUM 2
#define MY_ASSERT_COST_HIGH 3
#define MY_ASSERT_COST_ALL 10
#ifndef MY_ASSERT_COST_LIMIT
# define MY_ASSERT_COST_LIMIT MY_ASSERT_COST_MEDIUM
#endif
namespace my
{
[[noreturn]] extern void
assertion_failed(const char * filename, int line, const char * function,
const char * message) noexcept;
}
Ahora puede usar las tres macros y MY_ASSERT_LOW
, en lugar de la macro estándar de "un tamaño para todos" de la biblioteca estándar, para aserciones que están dominadas por, ni dominadas por, ni dominando y dominando la complejidad de su función de contención respectivamente. Cuando compila el software, puede predefinir el símbolo del preprocesador para seleccionar qué tipo de aserciones deberían convertirse en el ejecutable. Las constantes y no corresponden a ninguna macros de aserción y están destinadas a usarse como valores para activar o desactivar todas las aserciones, respectivamente.MY_ASSERT_MEDIUM
MY_ASSERT_HIGH
assert
MY_ASSERT_COST_LIMIT
MY_ASSERT_COST_NONE
MY_ASSERT_COST_ALL
MY_ASSERT_COST_LIMIT
Estamos confiando en el supuesto aquí de que un buen compilador no generará ningún código para
if (false_constant_expression && run_time_expression) { /* ... */ }
y transformar
if (true_constant_expression && run_time_expression) { /* ... */ }
dentro
if (run_time_expression) { /* ... */ }
lo cual creo que es una suposición segura hoy en día.
Si está a punto de modificar el código anterior, considere las anotaciones específicas del compilador como __attribute__ ((cold))
on my::assertion_failed
o __builtin_expect(…, false)
on !(CONDITION)
para reducir la sobrecarga de las afirmaciones aprobadas. En las versiones de lanzamiento, también puede considerar reemplazar la llamada a la función my::assertion_failed
con algo como __builtin_trap
reducir la huella por el inconveniente de perder un mensaje de diagnóstico.
Este tipo de optimizaciones solo son relevantes en aserciones extremadamente baratas (como comparar dos enteros que ya se dan como argumentos) en una función que es muy compacta, sin tener en cuenta el tamaño adicional del binario acumulado al incorporar todas las cadenas de mensajes.
Compara cómo este código
int
positive_difference_1st(const int a, const int b) noexcept
{
if (!(a > b))
my::assertion_failed(__FILE__, __LINE__, __FUNCTION__, "!(a > b)");
return a - b;
}
se compila en el siguiente ensamblado
_ZN4test23positive_difference_1stEii:
.LFB0:
.cfi_startproc
cmpl %esi, %edi
jle .L5
movl %edi, %eax
subl %esi, %eax
ret
.L5:
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $.LC0, %ecx
movl $_ZZN4test23positive_difference_1stEiiE12__FUNCTION__, %edx
movl $50, %esi
movl $.LC1, %edi
call _ZN2my16assertion_failedEPKciS1_S1_
.cfi_endproc
.LFE0:
mientras que el siguiente código
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
da esta asamblea
_ZN4test23positive_difference_2ndEii:
.LFB1:
.cfi_startproc
cmpl %esi, %edi
jle .L8
movl %edi, %eax
subl %esi, %eax
ret
.p2align 4,,7
.p2align 3
.L8:
ud2
.cfi_endproc
.LFE1:
con el que me siento mucho más cómodo. (Ejemplos se ensayaron con GCC 5.3.0 usando el -std=c++14
, -O3
y -march=native
banderas en 4.3.3-2-ARCH x86_64 GNU / Linux. No se muestra en los fragmentos anteriores son las declaraciones de test::positive_difference_1st
y test::positive_difference_2nd
que he añadido el __attribute__ ((hot))
al. my::assertion_failed
Fue declarado con __attribute__ ((cold))
.)
Afirmar condiciones previas en la función que depende de ellas
Suponga que tiene la siguiente función con el contrato especificado.
/**
* @brief
* Counts the frequency of a letter in a string.
*
* The frequency count is case-insensitive.
*
* If `text` does not point to a NUL terminated character array or `letter`
* is not in the character range `[A-Za-z]`, the behavior is undefined.
*
* @param text
* text to count the letters in
*
* @param letter
* letter to count
*
* @returns
* occurences of `letter` in `text`
*
*/
std::size_t
count_letters(const char * text, int letter) noexcept;
En lugar de escribir
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
en cada sitio de llamada, ponga esa lógica una vez en la definición de count_letters
std::size_t
count_letters(const char *const text, const int letter) noexcept
{
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
auto frequency = std::size_t {};
// TODO: Figure this out...
return frequency;
}
y llámalo sin más preámbulos.
const auto frequency = count_letters(text, letter);
Esto tiene las siguientes ventajas.
- Solo necesita escribir el código de aserción una vez. Dado que el propósito de las funciones es que se llamen, a menudo más de una vez, esto debería reducir el número total de
assert
declaraciones en su código.
- Mantiene la lógica que verifica las condiciones previas cerca de la lógica que depende de ellas. Creo que este es el aspecto más importante. Si sus clientes hacen un mal uso de su interfaz, tampoco se puede suponer que apliquen las afirmaciones correctamente, por lo que es mejor que la función les diga.
La desventaja obvia es que no obtendrá la ubicación de origen del sitio de llamada en el mensaje de diagnóstico. Creo que este es un problema menor. Un buen depurador debería permitirle rastrear el origen de la violación del contrato convenientemente.
El mismo pensamiento se aplica a las funciones "especiales" como los operadores sobrecargados. Cuando escribo iteradores, generalmente, si la naturaleza del iterador lo permite, les doy una función miembro
bool
good() const noexcept;
eso permite preguntar si es seguro desreferenciar el iterador. (Por supuesto, en la práctica, casi siempre solo es posible garantizar que no será seguro desreferenciar el iterador. Pero creo que aún puede detectar muchos errores con dicha función). En lugar de ensuciar todo mi código que usa el iterador con assert(iter.good())
declaraciones, prefiero poner un solo assert(this->good())
como la primera línea de la operator*
implementación del iterador.
Si está utilizando la biblioteca estándar, en lugar de afirmar manualmente sus precondiciones en su código fuente, active sus comprobaciones en las compilaciones de depuración. Pueden hacer comprobaciones aún más sofisticadas, como probar si el contenedor al que se refiere un iterador todavía existe. (Consulte la documentación de libstdc ++ y libc ++ (trabajo en progreso) para obtener más información).
Factorizar condiciones comunes fuera
Supongamos que está escribiendo un paquete de álgebra lineal. Muchas funciones tendrán precondiciones complicadas y violarlas a menudo causará resultados incorrectos que no son inmediatamente reconocibles como tales. Sería muy bueno si estas funciones afirmaran sus condiciones previas. Si define un grupo de predicados que le dicen ciertas propiedades sobre una estructura, esas afirmaciones se vuelven mucho más legibles.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
También le dará más mensajes de error útiles.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
ayuda mucho más que, digamos
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
donde primero tendrías que ir a mirar el código fuente en el contexto para descubrir lo que realmente se probó.
Si tiene una class
con invariantes no triviales, es probable que sea una buena idea afirmarlos de vez en cuando cuando se haya metido con el estado interno y quiera asegurarse de que está dejando el objeto en un estado válido al regresar.
Para este propósito, me pareció útil definir una private
función miembro a la que llamo convencionalmente class_invaraiants_hold_
. Suponga que está volviendo a implementar std::vector
(porque todos sabemos que no es lo suficientemente bueno), podría tener una función como esta.
template <typename T>
bool
vector<T>::class_invariants_hold_() const noexcept
{
if (this->size_ > this->capacity_)
return false;
if ((this->size_ > 0) && (this->data_ == nullptr))
return false;
if ((this->capacity_ == 0) != (this->data_ == nullptr))
return false;
return true;
}
Observe algunas cosas sobre esto.
- La función de predicado en sí es
const
y noexcept
, de acuerdo con la directriz, las afirmaciones no tendrán efectos secundarios. Si tiene sentido, también lo declara constexpr
.
- El predicado no afirma nada en sí mismo. Está destinado a ser llamado aserciones internas , como
assert(this->class_invariants_hold_())
. De esta manera, si las aserciones se compilan, podemos estar seguros de que no se incurre en gastos generales de tiempo de ejecución.
- El flujo de control dentro de la función se divide en varias
if
declaraciones con return
s temprana en lugar de una gran expresión. Esto facilita el paso a través de la función en un depurador y descubrir qué parte de la invariante se rompió si se dispara la aserción.
No afirmes sobre cosas tontas
Algunas cosas simplemente no tienen sentido para afirmar.
auto numbers = std::vector<int> {};
numbers.push_back(14);
numbers.push_back(92);
assert(numbers.size() == 2); // silly
assert(!numbers.empty()); // silly and redundant
Estas afirmaciones no hacen que el código sea aún más legible o más fácil de razonar. Todos los programadores de C ++ deben confiar lo suficiente en cómo std::vector
funciona para asegurarse de que el código anterior sea correcto simplemente al mirarlo. No estoy diciendo que nunca debas afirmar el tamaño de un contenedor. Si ha agregado o eliminado elementos utilizando un flujo de control no trivial, tal afirmación puede ser útil. Pero si simplemente repite lo que se escribió en el código de no aserción justo arriba, no se gana valor.
Tampoco afirme que las funciones de la biblioteca funcionan correctamente.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Si confías en la biblioteca tan poco, mejor considera usar otra biblioteca en su lugar.
Por otro lado, si la documentación de la biblioteca no es 100% clara y usted gana confianza sobre sus contratos leyendo el código fuente, tiene mucho sentido afirmar sobre ese "contrato inferido". Si está roto en una versión futura de la biblioteca, lo notará rápidamente.
auto w = widget {};
// After reading the source code, I have concluded that quantum mode is
// always off by default but this isn't documented anywhere.
assert(!w.quantum_mode_enabled());
Esto es mejor que la siguiente solución que no le dirá si sus suposiciones eran correctas.
auto w = widget {};
if (w.quantum_mode_enabled())
{
// I don't think that quantum mode is ever enabled by default but
// I'm not sure.
w.disable_quantum_mode();
}
No abuse de las afirmaciones para implementar la lógica del programa
Las afirmaciones solo deben usarse para descubrir errores que merezcan matar inmediatamente su aplicación. No deben usarse para verificar ninguna otra condición, incluso si la reacción apropiada a esa condición también fuera dejar de fumar inmediatamente.
Por lo tanto, escribe esto ...
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…en lugar de eso.
assert(server_reachable());
Además, nunca utilizar afirmaciones para validar la entrada de confianza o se compruebe que std::malloc
no lo hicieron return
que el nullptr
. Incluso si sabe que nunca desactivará las afirmaciones, incluso en las versiones de lanzamiento, una afirmación le comunica al lector que verifica algo que siempre es cierto dado que el programa está libre de errores y no tiene efectos secundarios visibles. Si este no es el tipo de mensaje que desea comunicar, utilice un mecanismo alternativo de manejo de errores, como throw
una excepción. Si le parece conveniente tener un contenedor macro para sus comprobaciones de no aserción, continúe escribiendo uno. Simplemente no lo llame "afirmar", "asumir", "exigir", "garantizar" o algo así. Su lógica interna podría ser la misma que para assert
, excepto que nunca se compila, por supuesto.
Más información
He encontrado charla John Lakos' programación defensiva bien hecha , dada en CppCon'14 ( 1 st parte , 2 ª parte ) muy ilustrativo. Él toma la idea de personalizar qué afirmaciones están habilitadas y cómo reaccionar ante excepciones fallidas aún más de lo que hice en esta respuesta.