È possibile scrivere troppi asserti?
Certamente. [Immagina un esempio odioso qui.] Tuttavia, applicando le linee guida dettagliate di seguito, non dovresti avere problemi a spingere quel limite in pratica. Sono anche un grande fan delle affermazioni e le uso secondo questi principi. Gran parte di questo consiglio non è speciale per le asserzioni, ma solo una buona pratica ingegneristica generale applicata ad esse.
Tieni a mente il tempo di esecuzione e il footprint binario
Le asserzioni sono ottime, ma se rendono il programma inaccettabilmente lento, sarà o molto fastidioso o le spegnerai prima o poi.
Mi piace valutare il costo di un'asserzione rispetto al costo della funzione in cui è contenuta. Considera i seguenti due esempi.
// 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 stessa funzione è un'operazione O (1) ma le asserzioni spiegano O ( n spese generali ). Non credo che vorresti che tali controlli fossero attivi se non in circostanze molto speciali.
Ecco un'altra funzione con asserzioni simili.
// 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 stessa funzione è un'operazione O ( n ), quindi fa molto meno per aggiungere un'ulteriore O ( n overhead ) per l'asserzione. Rallentare una funzione di un piccolo fattore costante (in questo caso, probabilmente inferiore a 3) è qualcosa che di solito possiamo permetterci in una build di debug ma forse non in una build di rilascio.
Ora considera questo esempio.
// 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();
}
Mentre molte persone probabilmente saranno molto più a loro agio con questa affermazione O (1) che con le due O ( n asserzioni ) nell'esempio precedente, secondo me sono moralmente equivalenti. Ognuno aggiunge overhead sull'ordine della complessità della funzione stessa.
Infine, ci sono le affermazioni "davvero economiche" che sono dominate dalla complessità della funzione in cui sono contenute.
// 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;
}
Qui, abbiamo due asserzioni O (1) in una funzione O ( n ). Probabilmente non sarà un problema mantenere questo sovraccarico anche nelle build di rilascio.
Tieni presente, tuttavia, che le complessità asintotiche non sempre danno una stima adeguata perché in pratica, abbiamo sempre a che fare con dimensioni di input limitate da alcuni fattori e costanti finiti nascosti da "Big- O " potrebbe non essere trascurabile.
Quindi ora abbiamo identificato diversi scenari, cosa possiamo fare al riguardo? Un approccio (probabilmente troppo semplice) sarebbe quello di seguire una regola come "Non usare asserzioni che dominano la funzione in cui sono contenute". Mentre potrebbe funzionare per alcuni progetti, altri potrebbero aver bisogno di un approccio più differenziato. Ciò potrebbe essere fatto utilizzando diverse macro di asserzioni per i diversi casi.
#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;
}
Ora puoi usare le tre macro MY_ASSERT_LOW
, MY_ASSERT_MEDIUM
e MY_ASSERT_HIGH
invece della macro "one size size all" della libreria standard assert
per affermazioni che sono dominate, né dominate né dominanti e dominanti la complessità della loro funzione di contenimento, rispettivamente. Quando si crea il software, è possibile pre-definire il simbolo del pre-processore MY_ASSERT_COST_LIMIT
per selezionare quale tipo di asserzioni dovrebbe trasformarlo nell'eseguibile. Le costanti MY_ASSERT_COST_NONE
e MY_ASSERT_COST_ALL
non corrispondono a nessuna macro di asserzione e sono pensate per essere utilizzate come valori MY_ASSERT_COST_LIMIT
al fine di attivare o disattivare tutte le asserzioni rispettivamente.
Facciamo affidamento sul presupposto che un buon compilatore non genererà alcun codice per
if (false_constant_expression && run_time_expression) { /* ... */ }
e trasformare
if (true_constant_expression && run_time_expression) { /* ... */ }
in
if (run_time_expression) { /* ... */ }
che credo sia un presupposto sicuro al giorno d'oggi.
Se stai per modificare il codice sopra, prendi in considerazione le annotazioni specifiche del compilatore come __attribute__ ((cold))
on my::assertion_failed
o __builtin_expect(…, false)
on !(CONDITION)
per ridurre il sovraccarico delle asserzioni passate. Nelle build di rilascio, puoi anche considerare di sostituire la chiamata di funzione my::assertion_failed
con qualcosa di simile __builtin_trap
per ridurre l'impronta digitale nell'inconveniente di perdere un messaggio diagnostico.
Questi tipi di ottimizzazioni sono realmente rilevanti solo in asserzioni estremamente economiche (come il confronto di due numeri interi che sono già indicati come argomenti) in una funzione che è di per sé molto compatta, non considerando la dimensione aggiuntiva del binario accumulato incorporando tutte le stringhe di messaggio.
Confronta come questo codice
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;
}
viene compilato nel seguente assieme
_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:
mentre il seguente codice
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
dà questo assemblaggio
_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 cui mi sento molto più a mio agio. (Gli esempi sono stati testati con GCC 5.3.0 usando il -std=c++14
, -O3
e -march=native
flag su 4.3.3-2-ARCH x86_64 GNU / Linux. Non mostrati nei frammenti di cui sopra sono le dichiarazioni di test::positive_difference_1st
e con test::positive_difference_2nd
cui ho aggiunto il __attribute__ ((hot))
. È my::assertion_failed
stato dichiarato __attribute__ ((cold))
.)
Asserire i presupposti nella funzione che dipende da essi
Supponiamo di avere la seguente funzione con il contratto specificato.
/**
* @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;
Invece di scrivere
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
in ciascun sito di chiamata, inserire quella logica una volta nella definizione di 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 chiamalo senza ulteriori indugi.
const auto frequency = count_letters(text, letter);
Questo ha i seguenti vantaggi.
- Devi solo scrivere il codice di asserzione una volta. Poiché lo scopo stesso delle funzioni è che vengono chiamate, spesso più di una volta, ciò dovrebbe ridurre il numero complessivo di
assert
istruzioni nel codice.
- Mantiene la logica che controlla i presupposti vicino alla logica che dipende da essi. Penso che questo sia l'aspetto più importante. Se i tuoi clienti abusano della tua interfaccia, non si può presumere che applichino correttamente le asserzioni, quindi è meglio che la funzione gli dica.
L'ovvio svantaggio è che non verrà visualizzata la posizione di origine del sito di chiamata nel messaggio di diagnostica. Credo che questo sia un problema minore. Un buon debugger dovrebbe essere in grado di farti risalire all'origine della violazione del contratto in modo conveniente.
Lo stesso pensiero si applica a funzioni "speciali" come gli operatori sovraccarichi. Quando scrivo iteratori, di solito, se la natura dell'iteratore lo consente, conferisco loro una funzione membro
bool
good() const noexcept;
ciò consente di chiedere se è sicuro di dereferenziare l'iteratore. (Naturalmente, in pratica, è quasi sempre possibile solo garantire che non sarà sicuro dereferenziare l'iteratore. Ma credo che tu possa ancora catturare molti bug con una tale funzione.) Invece di sporcare tutto il mio codice che utilizza l'iteratore con le assert(iter.good())
dichiarazioni, preferirei mettere un singolo assert(this->good())
come prima riga dell'implementazione operator*
dell'iteratore.
Se stai usando la libreria standard, invece di affermare manualmente le sue precondizioni nel tuo codice sorgente, attiva i loro controlli nelle build di debug. Possono eseguire controlli ancora più sofisticati come verificare se esiste ancora il contenitore a cui fa riferimento un iteratore. (Vedi la documentazione per libstdc ++ e libc ++ (work in progress) per maggiori informazioni.)
Fattore condizioni comuni fuori
Supponiamo di scrivere un pacchetto di algebra lineare. Molte funzioni avranno precondizioni complicate e la loro violazione spesso causerà risultati errati che non sono immediatamente riconoscibili come tali. Sarebbe molto utile se queste funzioni affermassero i loro presupposti. Se si definiscono un insieme di predicati che indicano determinate proprietà di una struttura, tali asserzioni diventano molto più leggibili.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
Fornirà anche messaggi di errore più utili.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
aiuta molto di più di, diciamo
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
dove dovresti prima andare a guardare il codice sorgente nel contesto per capire cosa è stato effettivamente testato.
Se hai a class
con invarianti non banali, è probabilmente una buona idea far valere di tanto in tanto quando hai incasinato lo stato interno e vuoi assicurarti di lasciare l'oggetto in uno stato valido al ritorno.
A tale scopo, ho trovato utile definire una private
funzione membro che chiamo convenzionalmente class_invaraiants_hold_
. Supponiamo che tu std::vector
stia implementando nuovamente (perché sappiamo tutti che non è abbastanza buono.), Potrebbe avere una funzione come questa.
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;
}
Nota alcune cose a riguardo.
- La stessa funzione predicata è
const
e noexcept
, conformemente alla linea guida, che le asserzioni non devono avere effetti collaterali. Se ha senso, dichiaralo anche tu constexpr
.
- Il predicato non afferma nulla in sé. È pensato per essere chiamato all'interno di asserzioni, come
assert(this->class_invariants_hold_())
. In questo modo, se le asserzioni vengono compilate, possiamo essere certi che non si verifichino costi generali di runtime.
- Il flusso di controllo all'interno della funzione è suddiviso in più
if
istruzioni con le prime return
s anziché un'espressione di grandi dimensioni. Ciò semplifica il passaggio attraverso la funzione in un debugger e consente di scoprire quale parte dell'invariante è stata interrotta in caso di attivazione dell'asserzione.
Non far valere cose sciocche
Alcune cose non hanno senso affermare.
auto numbers = std::vector<int> {};
numbers.push_back(14);
numbers.push_back(92);
assert(numbers.size() == 2); // silly
assert(!numbers.empty()); // silly and redundant
Queste affermazioni non rendono il codice un po 'più leggibile o più facile da ragionare. Ogni programmatore C ++ dovrebbe essere abbastanza sicuro di come std::vector
funziona per essere sicuro che il codice sopra riportato sia corretto semplicemente guardandolo. Non sto dicendo che non si dovrebbe mai far valere le dimensioni di un contenitore. Se hai aggiunto o rimosso elementi usando un flusso di controllo non banale, una simile affermazione può essere utile. Ma se si ripete semplicemente ciò che è stato scritto nel codice di asserzione appena sopra, non si ottiene alcun valore.
Inoltre, non affermare che le funzioni della libreria funzionino correttamente.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Se ti fidi della libreria così poco, è meglio considerare di utilizzare un'altra libreria.
D'altra parte, se la documentazione della biblioteca non è chiara al 100% e si acquisisce fiducia sui suoi contratti leggendo il codice sorgente, ha molto senso affermare quel "contratto inferito". Se viene rotto in una versione futura della libreria, noterai 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());
Questo è meglio della seguente soluzione che non ti dirà se i tuoi presupposti erano corretti.
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();
}
Non abusare delle affermazioni per implementare la logica del programma
Le asserzioni dovrebbero essere usate sempre e solo per scoprire bug degni di uccidere immediatamente l'applicazione. Non dovrebbero essere utilizzati per verificare qualsiasi altra condizione anche se la reazione appropriata a tale condizione sarebbe anche quella di uscire immediatamente.
Pertanto, scrivi questo ...
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…invece di quello.
assert(server_reachable());
Inoltre non usare mai le asserzioni per convalidare l'input non attendibile o verificare che std::malloc
invece no return
la nullptr
. Anche se sai che non disattiverai mai le asserzioni, anche nelle build di rilascio, un'asserzione comunica al lettore che controlla qualcosa che è sempre vero dato che il programma è privo di bug e non ha effetti collaterali visibili. Se questo non è il tipo di messaggio che si desidera comunicare, utilizzare un meccanismo alternativo di gestione degli errori, come ad esempio throw
un'eccezione. Se ritieni conveniente disporre di un wrapper di macro per i controlli di asserzione, procedi scrivendone uno. Basta non chiamarlo "asserire", "assumere", "richiedere", "garantire" o qualcosa del genere. La sua logica interna potrebbe essere la stessa di assert
, tranne per il fatto che non è mai stata compilata, ovviamente.
Maggiori informazioni
Ho trovato parlare di Giovanni Lakos' Programmazione difensiva Done Right , dato a CppCon'14 ( 1 ° parte , 2 ° parte ) molto illuminante. Prende l'idea di personalizzare quali affermazioni sono abilitate e come reagire alle eccezioni fallite anche più di quanto abbia fatto in questa risposta.