Czy można napisać zbyt wiele twierdzeń?
Oczywiście, że tak. [Wyobraź sobie tutaj nieznośny przykład.] Jednak stosując się do poniższych wskazówek, nie powinieneś mieć problemów z przekraczaniem tego limitu w praktyce. Jestem także wielkim fanem twierdzeń i używam ich zgodnie z tymi zasadami. Wiele z tych rad nie dotyczy w szczególności twierdzeń, ale dotyczyło ich jedynie ogólnej dobrej praktyki inżynierskiej.
Pamiętaj o czasie pracy i obciążeniu binarnym
Asercje są świetne, ale jeśli spowodują, że twój program będzie zbyt wolny, będzie albo bardzo denerwujący, albo wcześniej lub później je wyłączysz.
Chciałbym oszacować koszt asercji w stosunku do kosztu funkcji, w której się ona zawiera. Rozważ następujące dwa przykłady.
// 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();
}
Sama funkcja jest operacją O (1), ale twierdzenia uwzględniają obciążenie ogólne O ( n ). Nie sądzę, byś chciał, aby takie kontrole były aktywne, chyba że w bardzo szczególnych okolicznościach.
Oto kolejna funkcja z podobnymi twierdzeniami.
// 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_)));
}
Sama funkcja jest operacją O ( n ), więc znacznie mniej boli dodanie dodatkowego obciążenia O ( n ) dla asercji. Spowolnienie funkcji o mały (w tym przypadku prawdopodobnie mniejszy niż 3) stały czynnik jest czymś, na co zwykle możemy sobie pozwolić w kompilacji debugowania, ale może nie w kompilacji wydania.
Teraz rozważ ten przykład.
// 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();
}
Chociaż wiele osób prawdopodobnie będzie o wiele wygodniej z tym twierdzeniem O (1) niż z dwoma twierdzeniami O ( n ) z poprzedniego przykładu, są one moim zdaniem moralnie równoważne. Każdy z nich dodaje narzut w kolejności złożoności samej funkcji.
Wreszcie istnieją „naprawdę tanie” twierdzenia, które są zdominowane przez złożoność funkcji, w których się znajdują.
// 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;
}
Tutaj mamy dwie asercje O (1) w funkcji O ( n ). Zapewne nie będzie problemu z utrzymaniem tego obciążenia nawet w kompilacjach wersji.
Należy jednak pamiętać, że asymptotyczne złożoności nie zawsze dają odpowiednie oszacowanie, ponieważ w praktyce zawsze mamy do czynienia z wielkościami wejściowymi ograniczonymi przez pewne skończone stałe i stałe czynniki ukryte przez „Big- O ”, które mogą być bardzo nieistotne.
Więc teraz zidentyfikowaliśmy różne scenariusze, co możemy z nimi zrobić? (Prawdopodobnie zbyt) łatwym podejściem byłoby przestrzeganie zasady, takiej jak: „Nie używaj twierdzeń, które dominują w funkcji, w której są zawarte”. Chociaż może to działać w przypadku niektórych projektów, inne mogą wymagać bardziej zróżnicowanego podejścia. Można to zrobić za pomocą różnych makr asercji dla różnych przypadków.
#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;
}
Teraz można korzystać z trzech makr MY_ASSERT_LOW
, MY_ASSERT_MEDIUM
a MY_ASSERT_HIGH
zamiast standardowej biblioteki za „jeden rozmiar dla wszystkich” assert
makro dla twierdzeń, które są zdominowane przez ani zdominowanych przez nie dominuje i dominuje złożoność ich funkcji zawierającej odpowiednio. Podczas budowania oprogramowania można wstępnie zdefiniować symbol preprocesora, MY_ASSERT_COST_LIMIT
aby wybrać, jakie twierdzenia powinny uczynić go plikiem wykonywalnym. Stałe MY_ASSERT_COST_NONE
i MY_ASSERT_COST_ALL
nie odpowiadają żadnym makrom asercyjnym i powinny być używane jako wartości dla MY_ASSERT_COST_LIMIT
w celu odpowiednio włączenia lub włączenia wszystkich asercji.
Opieramy się na założeniu, że dobry kompilator nie wygeneruje żadnego kodu
if (false_constant_expression && run_time_expression) { /* ... */ }
i przekształcić
if (true_constant_expression && run_time_expression) { /* ... */ }
w
if (run_time_expression) { /* ... */ }
które moim zdaniem jest obecnie bezpiecznym założeniem.
Jeśli masz zamiar dostosować powyższy kod, należy rozważyć adnotacje kompilatora specyficzne jak __attribute__ ((cold))
na my::assertion_failed
lub __builtin_expect(…, false)
na !(CONDITION)
zmniejszenie narzutu minęły twierdzeń. W kompilacjach wersji można również rozważyć zamianę wywołania funkcji na my::assertion_failed
coś, na przykład w __builtin_trap
celu zmniejszenia obciążenia, co utrudnia utratę komunikatu diagnostycznego.
Tego rodzaju optymalizacje są tak naprawdę istotne tylko w wyjątkowo tanich asercjach (takich jak porównywanie dwóch liczb całkowitych, które już podano jako argumenty) w funkcji, która sama w sobie jest bardzo zwarta, nie biorąc pod uwagę dodatkowego rozmiaru pliku binarnego nagromadzonego przez włączenie wszystkich ciągów komunikatów.
Porównaj jak ten kod
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;
}
jest wkompilowany w następujący zestaw
_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:
podczas gdy następujący kod
int
positive_difference_2nd(const int a, const int b) noexcept
{
if (__builtin_expect(!(a > b), false))
__builtin_trap();
return a - b;
}
daje to zgromadzenie
_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:
z czym czuję się znacznie bardziej komfortowo. (Przykłady testowano GCC 5.3.0 użyciu -std=c++14
, -O3
i -march=native
flagi 4.3.3-2-Arch x86_64 GNU / Linux. Nie pokazane na powyższych fragmentów jest zadeklarowane test::positive_difference_1st
, a test::positive_difference_2nd
które dodaje się __attribute__ ((hot))
do. my::assertion_failed
Uznano z __attribute__ ((cold))
).
Zapewnij warunki wstępne w funkcji, która zależy od nich
Załóżmy, że masz określoną funkcję z określoną umową.
/**
* @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;
Zamiast pisać
assert(text != nullptr);
assert((letter >= 'A' && letter <= 'Z') || (letter >= 'a' && letter <= 'z'));
const auto frequency = count_letters(text, letter);
w każdej witrynie wywołującej umieść tę logikę raz w definicji 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;
}
i nazywaj to bez zbędnych ceregieli.
const auto frequency = count_letters(text, letter);
Ma to następujące zalety.
- Musisz tylko raz napisać kod potwierdzający. Ponieważ głównym celem funkcji jest ich wywoływanie - często więcej niż jeden raz - powinno to zmniejszyć ogólną liczbę
assert
instrukcji w kodzie.
- Utrzymuje logikę, która sprawdza warunki wstępne blisko logiki, która zależy od nich. Myślę, że to najważniejszy aspekt. Jeśli twoi klienci niewłaściwie używają twojego interfejsu, nie można założyć, że poprawnie stosują asercje, więc lepiej, żeby funkcja im to powiedziała.
Oczywistą wadą jest to, że nie dostaniesz lokalizacji źródłowej strony wywołującej w komunikacie diagnostycznym. Uważam, że jest to drobny problem. Dobry debugger powinien być w stanie w wygodny sposób prześledzić pochodzenie naruszenia umowy.
To samo dotyczy „specjalnych” funkcji, takich jak przeciążone operatory. Kiedy piszę iteratory, zwykle - jeśli pozwala na to charakter iteratora - nadaję im funkcję członka
bool
good() const noexcept;
pozwala to zapytać, czy można bezpiecznie odrzucić iterator. (Oczywiście w praktyce prawie zawsze można jedynie zagwarantować, że nie będzie bezpiecznie wyłapywać iteratora. Ale uważam, że dzięki tej funkcji nadal można złapać wiele błędów.) Zamiast zaśmiecać cały mój kod który używa iteratora z assert(iter.good())
instrukcjami, wolałbym umieścić jeden assert(this->good())
jako pierwszy wiersz operator*
implementacji iteratora.
Jeśli używasz biblioteki standardowej, zamiast ręcznie sprawdzać jej warunki wstępne w kodzie źródłowym, włącz ich sprawdzanie w kompilacjach debugowania. Mogą wykonywać nawet bardziej skomplikowane kontrole, takie jak testowanie, czy kontener, do którego odwołuje się iterator, nadal istnieje. (Aby uzyskać więcej informacji, zobacz dokumentację libstdc ++ i libc ++ (prace w toku)).
Uwzględnij wspólne warunki
Załóżmy, że piszesz pakiet algebry liniowej. Wiele funkcji będzie miało skomplikowane warunki wstępne, a ich naruszenie często spowoduje nieprawidłowe wyniki, których nie można natychmiast rozpoznać. Byłoby bardzo dobrze, gdyby te funkcje spełniały swoje warunki wstępne. Jeśli zdefiniujesz kilka predykatów, które mówią ci pewne właściwości dotyczące struktury, te twierdzenia stają się znacznie bardziej czytelne.
template <typename MatrixT>
auto
cholesky_decompose(MatrixT&& m)
{
assert(is_square(m) && is_symmetric(m));
// TODO: Somehow decompose that thing...
}
Daje także bardziej przydatne komunikaty o błędach.
cholesky.hxx:357: cholesky_decompose: assertion failed: is_symmetric(m)
pomaga o wiele bardziej niż, powiedzmy
detail/basic_ops.hxx:1289: fast_compare: assertion failed: m(i, j) == m(j, i)
gdzie najpierw trzeba spojrzeć na kod źródłowy w kontekście, aby dowiedzieć się, co faktycznie zostało przetestowane.
Jeśli masz class
nietrywialne niezmienniki, prawdopodobnie dobrym pomysłem jest od czasu do czasu twierdzenie, że popsułeś stan wewnętrzny i chcesz upewnić się, że pozostawiasz obiekt w prawidłowym stanie po powrocie.
W tym celu uznałem za użyteczne zdefiniowanie private
funkcji składowej, którą konwencjonalnie nazywam class_invaraiants_hold_
. Załóżmy, że dokonałeś ponownej implementacji std::vector
(ponieważ wszyscy wiemy, że to nie wystarczy). Może mieć taką funkcję.
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;
}
Zwróć uwagę na kilka rzeczy na ten temat.
- Sama funkcja jest orzecznikiem
const
i noexcept
zgodnie z wytycznymi, które twierdzenia nie mają skutków ubocznych. Jeśli ma to sens, również to zadeklaruj constexpr
.
- Predykat sam niczego nie potwierdza. Ma to być nazywane wewnętrznymi twierdzeniami, takimi jak
assert(this->class_invariants_hold_())
. W ten sposób, jeśli asercje zostaną skompilowane, możemy być pewni, że nie zostaną narzucone żadne nakłady czasu wykonywania.
- Przepływ sterujący wewnątrz funkcji jest podzielony na wiele
if
instrukcji zawierających wczesne return
s zamiast dużego wyrażenia. Ułatwia to przejście przez funkcję w debuggerze i sprawdzenie, która część niezmiennika została uszkodzona, jeśli asercja zostanie uruchomiona.
Nie przejmuj się głupotami
Niektóre rzeczy po prostu nie mają sensu potwierdzać.
auto numbers = std::vector<int> {};
numbers.push_back(14);
numbers.push_back(92);
assert(numbers.size() == 2); // silly
assert(!numbers.empty()); // silly and redundant
Te twierdzenia nie czynią kodu nawet odrobinę bardziej czytelnym lub łatwiejszym do uzasadnienia. Każdy programista C ++ powinien być wystarczająco pewny, jak std::vector
działa, aby mieć pewność, że powyższy kod jest poprawny, po prostu patrząc na niego. Nie twierdzę, że nigdy nie powinieneś twierdzić o rozmiarze pojemnika. Jeśli dodałeś lub usunąłeś elementy za pomocą nietrywialnego przepływu sterowania, takie twierdzenie może być przydatne. Ale jeśli tylko powtórzy to, co zostało napisane powyżej w kodzie niepotwierdzającym, nie zyska żadnej wartości.
Nie twierdzę również, że funkcje biblioteczne działają poprawnie.
auto w = widget {};
w.enable_quantum_mode();
assert(w.quantum_mode_enabled()); // probably silly
Jeśli tak mało ufasz bibliotece, lepiej zamiast tego użyj innej biblioteki.
Z drugiej strony, jeśli dokumentacja biblioteki nie jest w 100% przejrzysta i zyskujesz pewność co do jej umów poprzez czytanie kodu źródłowego, sensowne jest twierdzenie o tej „wywnioskowanej umowie”. Jeśli zostanie zepsuty w przyszłej wersji biblioteki, szybko to zauważysz.
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());
Jest to lepsze niż poniższe rozwiązanie, które nie powie ci, czy twoje założenia były prawidłowe.
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();
}
Nie nadużywaj twierdzeń do implementacji logiki programu
Asercje powinny być zawsze wykorzystywane do wykrywania błędów, które są warte natychmiastowego zabicia twojej aplikacji. Nie należy ich używać do weryfikacji żadnego innego warunku, nawet jeśli odpowiednia reakcja na ten warunek byłaby również natychmiastowa.
Dlatego napisz to ...
if (!server_reachable())
{
log_message("server not reachable");
shutdown();
}
…zamiast tego.
assert(server_reachable());
Również nigdy nie używać do sprawdzania poprawności twierdzeń wejście niezaufane lub sprawdzić, czy std::malloc
nie return
jesteś nullptr
. Nawet jeśli wiesz, że nigdy nie wyłączysz asercji, nawet w kompilacjach wersji, asercja informuje czytelnika, że sprawdza coś, co jest zawsze prawdziwe, biorąc pod uwagę, że program jest wolny od błędów i nie ma widocznych efektów ubocznych. Jeśli nie jest to komunikat, który chcesz przekazać, użyj alternatywnego mechanizmu obsługi błędów, takiego jak throw
zgłoszenie wyjątku. Jeśli uznasz, że wygodnie jest mieć opakowanie makr do kontroli niepotwierdzania, napisz je. Po prostu nie nazywaj tego „twierdzeniem”, „zakładaniem”, „wymaganiem”, „zapewnieniem” lub czymś takim. Jego wewnętrzna logika może być taka sama jak assert
, z tym wyjątkiem, że oczywiście nigdy nie jest skompilowana.
Więcej informacji
Znalazłem John Lakos' talk Defensive Programowanie zrobione dobrze , zważywszy na CppCon'14 ( 1 st strony , 2 nd części ) bardzo wiecania. Pomysł dostosowywania włączanych asercji i reagowania na nieudane wyjątki jest jeszcze większy niż w tej odpowiedzi.