É possível escrever muitas afirmações?
Bem, claro que é. [Imagine um exemplo desagradável aqui.] No entanto, ao aplicar as diretrizes detalhadas a seguir, você não deve ter problemas para empurrar esse limite na prática. Também sou um grande fã de afirmações e as uso de acordo com esses princípios. Muitos desses conselhos não são especiais para afirmações, mas apenas boas práticas gerais de engenharia aplicadas a elas.
Lembre-se do tempo de execução e da sobrecarga binária
As asserções são ótimas, mas se elas tornarem seu programa inaceitavelmente lento, será muito irritante ou você as desativará mais cedo ou mais tarde.
Gosto de avaliar o custo de uma asserção em relação ao custo da função em que ela está contida. Considere os dois exemplos a seguir.
// 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();
}
A função em si é uma operação O (1), mas as asserções são responsáveis pela sobrecarga de O ( n ). Não acho que você gostaria que essas verificações estivessem ativas, a menos que em circunstâncias muito especiais.
Aqui está outra função com asserções semelhantes.
// 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_)));
}
A função em si é uma operação O ( n ), portanto, é muito menos adicionar uma sobrecarga O ( n ) adicional para a asserção. Diminuir a velocidade de uma função por um fator constante pequeno (nesse caso, provavelmente menor que 3) é algo que geralmente podemos pagar em uma compilação de depuração, mas talvez não em uma compilação de versão.
Agora considere este exemplo.
// 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();
}
Embora muitas pessoas provavelmente se sintam muito mais confortáveis com essa asserção O (1) do que com as duas asserções O ( n ) do exemplo anterior, elas são moralmente equivalentes na minha opinião. Cada um deles adiciona uma sobrecarga na ordem da complexidade da própria função.
Finalmente, existem as afirmações "realmente baratas" que são dominadas pela complexidade da função em que estão contidas.
// 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;
}
Aqui, temos duas asserções O (1) em uma função O ( n ). Provavelmente não será um problema manter essa sobrecarga, mesmo nas versões de lançamento.
No entanto, lembre-se de que as complexidades assintóticas nem sempre fornecem uma estimativa adequada porque, na prática, estamos sempre lidando com tamanhos de entrada limitados por alguns fatores constantes e constantes finitos ocultos por "Big- O ", que podem muito bem não ser desprezíveis.
Então, agora que identificamos cenários diferentes, o que podemos fazer sobre eles? Uma abordagem (provavelmente também) fácil seria seguir uma regra como "Não use asserções que dominam a função em que estão contidas". Embora possa funcionar para alguns projetos, outros podem precisar de uma abordagem mais diferenciada. Isso pode ser feito usando macros de asserção diferentes para os 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;
}
Agora você pode usar as três macros e MY_ASSERT_LOW
, em vez da macro "tamanho único" da biblioteca padrão, para afirmações que são dominadas por, nem dominadas por, nem dominando e dominando, respectivamente, a complexidade de sua função de contenção. Ao criar o software, você pode pré-definir o símbolo do pré-processador para selecionar que tipo de asserções devem entrar no executável. As constantes e não correspondem a nenhuma macro de asserção e devem ser usadas como valores para ativar ou desativar todas as asserções, 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 contando com a suposição aqui de que um bom compilador não irá gerar nenhum código para
if (false_constant_expression && run_time_expression) { /* ... */ }
e transformar
if (true_constant_expression && run_time_expression) { /* ... */ }
para dentro
if (run_time_expression) { /* ... */ }
o que acredito ser uma suposição segura hoje em dia.
Se você estiver prestes a ajustar o código acima, considere anotações específicas do compilador, como __attribute__ ((cold))
on my::assertion_failed
ou __builtin_expect(…, false)
on, !(CONDITION)
para reduzir a sobrecarga das asserções passadas. Nas compilações de versão, você também pode substituir a chamada de função my::assertion_failed
por algo como __builtin_trap
reduzir a pegada com o inconveniente de perder uma mensagem de diagnóstico.
Esses tipos de otimizações são realmente relevantes apenas em asserções extremamente baratas (como comparar dois números inteiros que já são apresentados como argumentos) em uma função que é muito compacta, sem considerar o tamanho adicional do binário acumulado ao incorporar todas as sequências de mensagens.
Compare como esse 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;
}
é compilado no seguinte assembly
_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:
enquanto o código a seguir
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
dá essa montagem
_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:
com o qual me sinto muito mais confortável. (Os exemplos foram testados com o GCC 5.3.0 usando os sinalizadores -std=c++14
, -O3
e -march=native
4.3.3-2-ARCH x86_64 GNU / Linux. Não são mostrados nos trechos acima as declarações test::positive_difference_1st
e as test::positive_difference_2nd
quais eu adicionei o __attribute__ ((hot))
. my::assertion_failed
Foi declarado __attribute__ ((cold))
.)
Declarar pré-condições na função que depende delas
Suponha que você tenha a seguinte função com o 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;
Em vez de escrever
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
em cada local de chamada, coloque essa lógica uma vez na definição 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;
}
e chamá-lo sem mais delongas.
const auto frequency = count_letters(text, letter);
Isso tem as seguintes vantagens.
- Você só precisa escrever o código de asserção uma vez. Como o objetivo das funções é que elas sejam chamadas - geralmente mais de uma vez - isso deve reduzir o número geral de
assert
instruções no seu código.
- Ele mantém a lógica que verifica as pré-condições próximas à lógica que depende delas. Eu acho que esse é o aspecto mais importante. Se seus clientes usam mal sua interface, eles também não podem aplicar as asserções corretamente, portanto é melhor que a função informe a eles.
A desvantagem óbvia é que você não receberá o local de origem do site de chamada na mensagem de diagnóstico. Eu acredito que este é um problema menor. Um bom depurador deve permitir rastrear a origem da violação do contrato de maneira conveniente.
O mesmo pensamento se aplica às funções "especiais", como operadores sobrecarregados. Quando estou escrevendo iteradores, normalmente - se a natureza do iterador permitir - dou a eles uma função de membro
bool
good() const noexcept;
que permite perguntar se é seguro desreferenciar o iterador. (Obviamente, na prática, quase sempre é possível garantir que não será seguro desreferenciar o iterador. Mas acredito que você ainda pode detectar muitos bugs com essa função.) Em vez de desarrumar todo o meu código que usa o iterador com assert(iter.good())
instruções, prefiro colocar uma única assert(this->good())
como a primeira linha do operator*
na implementação do iterador.
Se você estiver usando a biblioteca padrão, em vez de afirmar manualmente suas pré-condições no seu código-fonte, ative suas verificações nas construções de depuração. Eles podem fazer verificações ainda mais sofisticadas, como testar se o contêiner ao qual um iterador se refere ainda existe. (Consulte a documentação para libstdc ++ e libc ++ (trabalho em andamento) para obter mais informações.)
Fatore condições comuns
Suponha que você esteja escrevendo um pacote de álgebra linear. Muitas funções terão pré-condições complicadas e violá-las frequentemente causará resultados errados que não são imediatamente reconhecíveis como tais. Seria muito bom se essas funções afirmassem suas pré-condições. Se você definir vários predicados que informam certas propriedades sobre uma estrutura, essas asserções se tornarão muito mais legíveis.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
Também fornecerá mensagens de erro mais úteis.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
ajuda muito mais do que, digamos
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
onde você primeiro deve procurar o código-fonte no contexto para descobrir o que foi realmente testado.
Se você possui class
invariantes não triviais, provavelmente é uma boa ideia afirmá-los de vez em quando quando você mexe com o estado interno e deseja garantir que você esteja deixando o objeto em um estado válido no retorno.
Para esse propósito, achei útil definir uma private
função de membro que eu chamo convencionalmente class_invaraiants_hold_
. Suponha que você estava reimplementando std::vector
(Como todos sabemos que não é bom o suficiente.), Ele pode ter uma função como essa.
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 algumas coisas sobre isso.
- A função do predicado em si é
const
e noexcept
, de acordo com a diretriz de que afirmações não devem ter efeitos colaterais. Se fizer sentido, também declare constexpr
.
- O predicado não afirma nada. Ele deve ser chamado de afirmações internas , como
assert(this->class_invariants_hold_())
. Dessa forma, se as asserções forem compiladas, podemos ter certeza de que não haverá custo adicional no tempo de execução.
- O fluxo de controle dentro da função é dividido em várias
if
instruções com return
s iniciais, em vez de uma expressão grande. Isso facilita percorrer a função em um depurador e descobrir qual parte da invariante foi quebrada se a asserção for acionada.
Não afirme coisas tolas
Algumas coisas simplesmente não fazem sentido 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
Essas afirmações não tornam o código nem um pouco mais legível ou mais fácil de raciocinar. Todo programador de C ++ deve ter certeza de como std::vector
funciona para garantir que o código acima esteja correto simplesmente olhando para ele. Não estou dizendo que você nunca deve afirmar o tamanho de um contêiner. Se você adicionou ou removeu elementos usando algum fluxo de controle não trivial, essa afirmação pode ser útil. Mas se apenas repetir o que foi escrito no código de não afirmação logo acima, não haverá valor agregado.
Além disso, não afirme que as funções da biblioteca funcionam corretamente.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Se você confia pouco na biblioteca, é melhor considerar usar outra biblioteca.
Por outro lado, se a documentação da biblioteca não estiver 100% clara e você ganhar confiança sobre seus contratos lendo o código-fonte, faz muito sentido afirmar sobre esse "contrato inferido". Se estiver quebrado em uma versão futura da biblioteca, você notará rapidamente.
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());
É melhor que a solução a seguir, que não informa se suas suposições estavam corretas.
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();
}
Não abuse das asserções para implementar a lógica do programa
As asserções devem ser usadas apenas para descobrir bugs que valham a pena matar imediatamente seu aplicativo. Eles não devem ser usados para verificar qualquer outra condição, mesmo se a reação apropriada a essa condição também for parar imediatamente.
Portanto, escreva isso…
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…ao invés disso.
assert(server_reachable());
Também nunca use asserções para validar entrada não confiável ou verificar std::malloc
se return
você não nullptr
. Mesmo se você souber que nunca desativará as asserções, mesmo nas versões de lançamento, uma asserção comunica ao leitor que verifica algo que é sempre verdadeiro, uma vez que o programa é livre de erros e não apresenta efeitos colaterais visíveis. Se esse não for o tipo de mensagem que você deseja comunicar, use um mecanismo alternativo de tratamento de erros, como throw
uma exceção. Se você achar conveniente ter um wrapper de macro para suas verificações sem asserções, continue escrevendo um. Apenas não chame isso de "afirmar", "assumir", "exigir", "garantir" ou algo assim. Sua lógica interna pode ser a mesma que para assert
, exceto que nunca é compilada, é claro.
Mais Informações
Eu encontrei talk John Lakos' Programação Defensiva feito direito , dado no CppCon'14 ( 1 st parte , 2 nd parte ) muito esclarecedor. Ele adota a ideia de personalizar quais asserções estão habilitadas e como reagir a exceções com falha ainda mais do que eu fiz nesta resposta.