Kann man zu viele Aussagen schreiben?
Nun, natürlich ist es das. [Stellen Sie sich hier ein abscheuliches Beispiel vor.] Wenn Sie jedoch die im Folgenden beschriebenen Richtlinien anwenden, sollten Sie in der Praxis keine Probleme haben, diese Grenze zu überschreiten. Ich bin auch ein großer Fan von Behauptungen und verwende sie nach diesen Grundsätzen. Ein Großteil dieser Ratschläge bezieht sich nicht auf Behauptungen, sondern nur auf die allgemein anerkannten Regeln der Technik.
Beachten Sie den Aufwand für Laufzeit und Binärdaten
Behauptungen sind großartig, aber wenn sie Ihr Programm inakzeptabel verlangsamen, wird es entweder sehr ärgerlich sein oder Sie werden sie früher oder später deaktivieren.
Ich möchte die Kosten einer Behauptung im Verhältnis zu den Kosten der Funktion messen, in der sie enthalten ist. Betrachten Sie die folgenden zwei Beispiele.
// 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();
}
Die Funktion selbst ist eine O (1) -Operation, aber die Zusicherungen berücksichtigen den O ( n ) -Overhead. Ich glaube nicht, dass Sie möchten, dass solche Überprüfungen nur unter ganz besonderen Umständen durchgeführt werden.
Hier ist eine andere Funktion mit ähnlichen Behauptungen.
// 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_)));
}
Die Funktion selbst ist eine O ( n ) -Operation, daher tut es viel weniger weh, einen zusätzlichen O ( n ) -Overhead für die Zusicherung hinzuzufügen . Eine Funktion um einen kleinen (in diesem Fall wahrscheinlich weniger als 3) konstanten Faktor zu verlangsamen, ist etwas, das wir uns normalerweise in einem Debug-Build leisten können, aber möglicherweise nicht in einem Release-Build.
Betrachten Sie nun dieses Beispiel.
// 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();
}
Während sich viele Menschen mit dieser O (1) -Aussage wahrscheinlich viel wohler fühlen als mit den beiden O ( n ) -Aussagen im vorherigen Beispiel, sind sie meiner Ansicht nach moralisch gleichwertig. Jeder fügt Overhead in der Reihenfolge der Komplexität der Funktion selbst hinzu.
Schließlich gibt es die "wirklich billigen" Behauptungen, die von der Komplexität der Funktion, in der sie enthalten sind, dominiert werden.
// 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;
}
Hier haben wir zwei O (1) -Aussagen in einer O ( n ) -Funktion. Es ist wahrscheinlich kein Problem, diesen Overhead auch in Release-Builds beizubehalten.
Bedenken Sie jedoch, dass asymptotische Komplexitäten nicht immer eine angemessene Schätzung liefern, da es sich in der Praxis immer um Eingabegrößen handelt, die durch einige endliche konstante und konstante Faktoren begrenzt sind, die von „Big- O “ verborgen werden .
Nun haben wir verschiedene Szenarien identifiziert. Was können wir dagegen tun? Ein (wahrscheinlich zu) einfacher Ansatz wäre die Befolgung einer Regel wie „Verwenden Sie keine Aussagen, die die Funktion dominieren, in der sie enthalten sind.“ Während dies für einige Projekte möglicherweise funktioniert, benötigen andere möglicherweise einen differenzierteren Ansatz. Dies könnte durch Verwendung verschiedener Zusicherungsmakros für die verschiedenen Fälle geschehen.
#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;
}
Sie können nun die drei Makros verwendet MY_ASSERT_LOW
, MY_ASSERT_MEDIUM
und MY_ASSERT_HIGH
anstelle der Standard - Bibliothek „one size fits all“ assert
Makro für Behauptungen , die von beherrscht werden, weder dominiert noch dominiert und die Komplexität ihrer Funktion enthält , dominiert sind. Beim Erstellen der Software können Sie das Präprozessorsymbol vordefinieren, MY_ASSERT_COST_LIMIT
um auszuwählen, welche Art von Zusicherungen es in die ausführbare Datei aufnehmen soll. Die Konstanten MY_ASSERT_COST_NONE
und MY_ASSERT_COST_ALL
entsprechen keinen Assert-Makros und dienen als Werte für MY_ASSERT_COST_LIMIT
, um alle Assertions zu aktivieren bzw. zu deaktivieren.
Wir gehen hier von der Annahme aus, dass ein guter Compiler keinen Code für generiert
if (false_constant_expression && run_time_expression) { /* ... */ }
und transformieren
if (true_constant_expression && run_time_expression) { /* ... */ }
in
if (run_time_expression) { /* ... */ }
was ich heutzutage für eine sichere Annahme halte.
Wenn Sie den obigen Code optimieren möchten, sollten Sie compilerspezifische Anmerkungen wie " __attribute__ ((cold))
on" my::assertion_failed
oder " __builtin_expect(…, false)
on" berücksichtigen !(CONDITION)
, um den Aufwand für übergebene Zusicherungen zu verringern. In Release-Builds können Sie auch in Betracht ziehen, den Funktionsaufruf auf my::assertion_failed
durch etwas __builtin_trap
zu ersetzen, das den Platzbedarf verringert , wenn Sie eine Diagnosemeldung verlieren.
Diese Art von Optimierungen ist wirklich nur bei extrem billigen Zusicherungen (wie dem Vergleichen von zwei Ganzzahlen, die bereits als Argumente angegeben sind) in einer Funktion relevant, die selbst sehr kompakt ist, ohne die zusätzliche Größe der durch das Einbeziehen aller Nachrichtenzeichenfolgen akkumulierten Binärdatei zu berücksichtigen.
Vergleichen Sie, wie dieser Code
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;
}
wird in die folgende Assembly kompiliert
_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:
während der folgende Code
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
gibt diese Versammlung
_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:
Womit ich mich viel wohler fühle. (Beispiele wurden mit GCC 5.3.0 getestet mit der -std=c++14
, -O3
und -march=native
Fahnen auf 4.3.3-2-ARCH x86_64 GNU / Linux. Nicht in den oben genannten Schnipsel gezeigt sind die Erklärungen test::positive_difference_1st
und test::positive_difference_2nd
die ich hinzugefügt die __attribute__ ((hot))
zu. my::assertion_failed
Erklärt wurde , mit __attribute__ ((cold))
.)
Legen Sie in der von ihnen abhängigen Funktion die Voraussetzungen fest
Angenommen, Sie haben die folgende Funktion mit dem angegebenen Vertrag.
/**
* @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;
Anstatt zu schreiben
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
Schreiben Sie diese Logik an jeder Aufrufstelle einmal in die Definition von 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;
}
und nenne es ohne weiteres.
const auto frequency = count_letters(text, letter);
Dies hat folgende Vorteile.
- Sie müssen den Assertionscode nur einmal schreiben. Da der eigentliche Zweck von Funktionen darin besteht, dass sie - oftmals mehrmals - aufgerufen werden, sollte dies die Gesamtzahl der
assert
Anweisungen in Ihrem Code verringern .
- Es hält die Logik, die die Voraussetzungen überprüft, nahe an der Logik, die von ihnen abhängt. Ich denke, das ist der wichtigste Aspekt. Wenn Ihre Clients Ihre Benutzeroberfläche missbrauchen, kann nicht davon ausgegangen werden, dass sie die Behauptungen korrekt anwenden. Daher ist es besser, wenn die Funktion sie darüber informiert.
Der offensichtliche Nachteil ist, dass Sie den Quellort der Anrufstelle nicht in die Diagnosemeldung aufnehmen können. Ich glaube, dass dies ein kleines Problem ist. Ein guter Debugger sollte es Ihnen ermöglichen, die Ursache der Vertragsverletzung auf bequeme Weise nachzuvollziehen.
Dasselbe gilt für „spezielle“ Funktionen wie überladene Operatoren. Wenn ich Iteratoren schreibe, gebe ich ihnen normalerweise - wenn die Art des Iterators dies zulässt - eine Member-Funktion
bool
good() const noexcept;
das erlaubt zu fragen, ob es sicher ist, den Iterator zu dereferenzieren. (Natürlich kann in der Praxis fast immer nur garantiert werden, dass es nicht sicher ist, den Iterator zu dereferenzieren. Aber ich glaube, Sie können mit einer solchen Funktion immer noch viele Fehler finden.) Anstatt meinen gesamten Code zu verunreinigen Wenn der Iterator mit assert(iter.good())
Anweisungen verwendet wird, möchte ich lieber eine einzelne assert(this->good())
Zeile als erste Zeile operator*
in die Implementierung des Iterators einfügen.
Wenn Sie die Standardbibliothek verwenden, müssen Sie deren Prüfungen in Debugbuilds aktivieren, anstatt die Voraussetzungen im Quellcode manuell festzulegen. Sie können noch komplexere Prüfungen durchführen, beispielsweise testen, ob der Container, auf den sich ein Iterator bezieht, noch vorhanden ist. ( Weitere Informationen finden Sie in der Dokumentation zu libstdc ++ und libc ++ (in Arbeit).)
Faktor gemeinsame Bedingungen aus
Angenommen, Sie schreiben ein lineares Algebra-Paket. Viele Funktionen haben komplizierte Voraussetzungen, und wenn sie verletzt werden, entstehen häufig falsche Ergebnisse, die nicht sofort als solche erkennbar sind. Es wäre sehr gut, wenn diese Funktionen ihre Voraussetzungen erfüllen würden. Wenn Sie eine Reihe von Prädikaten definieren, die Ihnen bestimmte Eigenschaften einer Struktur mitteilen, werden diese Aussagen viel besser lesbar.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
Es werden auch nützlichere Fehlermeldungen angezeigt.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
hilft viel mehr als sagen
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
Wo müsste man sich zuerst den Quellcode im Kontext ansehen, um herauszufinden, was tatsächlich getestet wurde.
Wenn Sie eine class
mit nicht-trivialen Invarianten haben, ist es wahrscheinlich eine gute Idee, diese von Zeit zu Zeit zu bestätigen, wenn Sie mit dem internen Status in Konflikt geraten sind und sicherstellen möchten, dass Sie das Objekt bei der Rückkehr in einem gültigen Status belassen.
Zu diesem Zweck fand ich es nützlich, eine private
Member-Funktion zu definieren , die ich herkömmlicherweise aufrufe class_invaraiants_hold_
. Angenommen std::vector
, Sie haben eine Neuimplementierung durchgeführt (da wir alle wissen, dass sie nicht gut genug ist), könnte sie eine Funktion wie diese haben.
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;
}
Beachten Sie ein paar Dinge dazu.
- Die Prädikatfunktion selbst ist
const
und noexcept
gemäß der Richtlinie, dass Aussagen keine Nebenwirkungen haben dürfen. Wenn es sinnvoll ist, erklären Sie es auch constexpr
.
- Das Prädikat selbst behauptet nichts. Es soll innerhalb von Behauptungen aufgerufen werden , wie z
assert(this->class_invariants_hold_())
. Auf diese Weise können wir sicher sein, dass bei der Kompilierung von Zusicherungen kein Laufzeit-Overhead anfällt.
- Der Kontrollfluss innerhalb der Funktion ist in mehrere
if
Anweisungen mit einem frühen return
s und keinem großen Ausdruck unterteilt. Dies macht es einfach, die Funktion in einem Debugger zu durchlaufen und herauszufinden, welcher Teil der Invariante beschädigt war, wenn die Behauptung ausgelöst wurde.
Behaupte nicht dumme Dinge
Manche Dinge machen einfach keinen Sinn, sich zu behaupten.
auto numbers = std::vector<int> {};
numbers.push_back(14);
numbers.push_back(92);
assert(numbers.size() == 2); // silly
assert(!numbers.empty()); // silly and redundant
Diese Behauptungen machen den Code nicht einmal ein bisschen lesbarer oder einfacher zu überlegen. Jeder C ++ - Programmierer sollte sicher genug sein, wie es std::vector
funktioniert, um sicherzugehen, dass der obige Code korrekt ist, wenn er nur angeschaut wird. Ich sage nicht, dass Sie sich niemals auf die Größe eines Containers festlegen sollten. Wenn Sie Elemente mithilfe eines nicht trivialen Kontrollflusses hinzugefügt oder entfernt haben, kann eine solche Behauptung hilfreich sein. Wenn jedoch nur wiederholt wird, was in dem oben beschriebenen Nicht-Assertions-Code geschrieben wurde, wird kein Wert gewonnen.
Stellen Sie auch nicht sicher, dass die Bibliotheksfunktionen korrekt funktionieren.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Wenn Sie der Bibliothek so wenig vertrauen, sollten Sie stattdessen eine andere Bibliothek verwenden.
Wenn andererseits die Dokumentation der Bibliothek nicht 100% eindeutig ist und Sie durch Lesen des Quellcodes Vertrauen in ihre Verträge gewinnen, ist es sehr sinnvoll, auf diesen „abgeleiteten Vertrag“ zu vertrauen. Wenn es in einer zukünftigen Version der Bibliothek kaputt geht, werden Sie es schnell bemerken.
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());
Dies ist besser als die folgende Lösung, die Ihnen nicht sagt, ob Ihre Annahmen korrekt waren.
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();
}
Behauptungen nicht missbrauchen, um Programmlogik zu implementieren
Behauptungen sollten immer nur verwendet werden, um Fehler aufzudecken , die es wert sind, Ihre Anwendung sofort zu beenden. Sie sollten nicht verwendet werden, um andere Zustände zu verifizieren, selbst wenn die entsprechende Reaktion auf diesen Zustand auch darin bestehen würde, sofort zu beenden.
Schreiben Sie deshalb folgendes…
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…stattdessen.
assert(server_reachable());
Auch niemals Behauptungen verwenden , um nicht vertrauenswürdige Eingaben zu bestätigen oder überprüfen, ob std::malloc
tat nicht return
Sie die nullptr
. Selbst wenn Sie wissen, dass Sie Behauptungen selbst in Release-Builds niemals deaktivieren werden, teilt eine Behauptung dem Leser mit, dass sie etwas überprüft, das immer wahr ist, vorausgesetzt, das Programm ist fehlerfrei und hat keine sichtbaren Nebenwirkungen. Wenn dies nicht die Art von Nachricht ist, die Sie kommunizieren möchten, verwenden Sie einen alternativen Fehlerbehandlungsmechanismus, z. B. throw
eine Ausnahme. Wenn Sie es bequem finden, einen Makro-Wrapper für Ihre Nicht-Assertions-Checks zu haben, schreiben Sie einen. Nennen Sie es einfach nicht "behaupten", "annehmen", "verlangen", "sicherstellen" oder so ähnlich. Ihre interne Logik könnte dieselbe sein wie für assert
, außer dass sie natürlich niemals kompiliert wird.
Mehr Informationen
Ich fand die Rede John Lakos Defensive Programmierung Done Right , da bei CppCon'14 ( 1 st Teil , 2 nd Teil sehr) erleuchtet. Er hat die Idee, die aktivierten Behauptungen anzupassen und auf fehlgeschlagene Ausnahmen noch weiter zu reagieren als in dieser Antwort.