Czy można napisać zbyt wiele twierdzeń?


33

Jestem wielkim fanem pisania assertczeków w kodzie C ++ jako sposobu na złapanie przypadków podczas programowania, które nie mogą się zdarzyć, ale zdarzają się z powodu błędów logicznych w moim programie. Jest to ogólnie dobra praktyka.

Zauważyłem jednak, że niektóre funkcje, które piszę (które są częścią złożonej klasy) mają 5+ twierdzeń, co wydaje się być potencjalną złą praktyką programistyczną, jeśli chodzi o czytelność i łatwość konserwacji. Myślę, że nadal jest świetny, ponieważ każdy wymaga ode mnie myślenia o warunkach wstępnych i końcowych funkcji, a one naprawdę pomagają wykrywać błędy. Chciałem jednak po prostu to przedstawić i zapytać, czy istnieją lepsze paradygmaty wychwytywania błędów logicznych w przypadkach, gdy konieczna jest duża liczba kontroli.

Komentarz Emacsa : Ponieważ Emacs jest moim IDE do wyboru, mam nieco wyszarzyć stwierdzenia, które pomagają zmniejszyć poczucie bałaganu, które mogą zapewnić. Oto, co dodaję do mojego pliku .emacs:

; gray out the "assert(...)" wrapper
(add-hook 'c-mode-common-hook
  (lambda () (font-lock-add-keywords nil
    '(("\\<\\(assert\(.*\);\\)" 1 '(:foreground "#444444") t)))))

; gray out the stuff inside parenthesis with a slightly lighter color
(add-hook 'c-mode-common-hook
  (lambda () (font-lock-add-keywords nil
    '(("\\<assert\\(\(.*\);\\)" 1 '(:foreground "#666666") t)))))

3
Muszę przyznać, że od czasu do czasu przychodzi mi na myśl pytanie. Chciałbym poznać opinie innych na ten temat.
Captain Sensible

Odpowiedzi:


45

Widziałem setki błędów, które zostałyby rozwiązane szybciej, gdyby ktoś napisał więcej twierdzeń, i ani jednego, który można by rozwiązać szybciej, pisząc mniej .

[C] czy [zbyt wiele stwierdzeń] może być złą praktyką programistyczną, jeśli chodzi o czytelność i łatwość konserwacji [?]

Być może problemem może być czytelność - chociaż z mojego doświadczenia wynika, że ​​ludzie, którzy piszą dobre stwierdzenia, również piszą czytelny kod. I nigdy nie przeszkadza mi, że początek funkcji zaczyna się od bloku twierdzeń, aby sprawdzić, czy argumenty nie są śmieciami - po prostu wstaw za nim pustą linię.

Z mojego doświadczenia wynika, że łatwość konserwacji jest zawsze poprawiana przez twierdzenia, tak jak w testach jednostkowych. Aserty zapewniają sprawdzanie poprawności, czy kod jest używany zgodnie z jego przeznaczeniem.


1
Dobra odpowiedź. Dodałem także opis do pytania o to, jak poprawić czytelność w Emacsie.
Alan Turing

2
„z mojego doświadczenia wynika, że ​​ludzie, którzy piszą dobre stwierdzenia, piszą także czytelny kod” << doskonały punkt. Czytelność kodu zależy od indywidualnego programisty, podobnie jak technik, którymi jest i nie wolno mu używać. Widziałem, że dobre techniki stają się nieczytelne w niewłaściwych rękach, a nawet to, co większość uważa za złe, staje się doskonale jasne, nawet eleganckie, dzięki właściwemu wykorzystaniu abstrakcji i komentowania.
Greg Jackson

Miałem kilka awarii aplikacji, które zostały spowodowane błędnymi stwierdzeniami. Widziałem więc błędy, które nie istniałyby, gdyby ktoś (ja) napisał mniej stwierdzeń.
CodesInChaos

@CodesInChaos Prawdopodobnie, pomijając literówki, wskazuje to na błąd w sformułowaniu problemu - to znaczy, że błąd był w projekcie, stąd niezgodność między twierdzeniami i (innym) kodem.
Lawrence

12

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_MEDIUMa MY_ASSERT_HIGHzamiast standardowej biblioteki za „jeden rozmiar dla wszystkich” assertmakro 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_LIMITaby wybrać, jakie twierdzenia powinny uczynić go plikiem wykonywalnym. Stałe MY_ASSERT_COST_NONEi MY_ASSERT_COST_ALLnie odpowiadają żadnym makrom asercyjnym i powinny być używane jako wartości dla MY_ASSERT_COST_LIMITw 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_failedlub __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_failedcoś, na przykład w __builtin_trapcelu 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, -O3i -march=nativeflagi 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_2ndktóre dodaje się __attribute__ ((hot))do. my::assertion_failedUznano 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ę assertinstrukcji 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 classnietrywialne 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 privatefunkcji 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 consti noexceptzgodnie 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 ifinstrukcji zawierających wczesne returns 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::vectordział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::mallocnie returnjesteś 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 throwzgł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.


4
Assertions are great, but ... you will turn them off sooner or later.- Mam nadzieję, że wcześniej, tak jak przed wysłaniem kodu. Rzeczy, które muszą spowodować, że program zginie w produkcji, powinny być częścią „prawdziwego” kodu, a nie twierdzeń.
Blrfl

4

Uważam, że z czasem piszę mniej stwierdzeń, ponieważ wiele z nich to „działa kompilator”, a „działa biblioteka”. Gdy zaczniesz myśleć o tym, co dokładnie testujesz, podejrzewam, że napiszesz mniej stwierdzeń.

Na przykład metoda, która (powiedzmy) dodaje coś do kolekcji, nie powinna wymagać stwierdzenia, że ​​kolekcja istnieje - jest to na ogół albo warunek wstępny klasy, która jest właścicielem komunikatu, albo błąd krytyczny, który powinien wrócić do użytkownika . Więc sprawdź to raz, bardzo wcześnie, a następnie załóż to.

Zapewnienia są dla mnie narzędziem do debugowania i zazwyczaj używam ich na dwa sposoby: znajdowanie błędu na moim biurku (i nie są one sprawdzane. Cóż, być może ten jeden klucz może być); i znajdowanie błędu na biurku klienta (i oni się meldują). Za każdym razem używam asercji głównie do generowania śladu stosu po wymuszeniu wyjątku jak najwcześniej. Należy pamiętać, że asercje używane w ten sposób mogą łatwo prowadzić do heisenbugs - błąd może nigdy nie wystąpić w kompilacji debugowania z włączonymi asercjami .


4
Nie rozumiem, o czym mówisz: „jest to albo warunek wstępny klasy, która jest właścicielem wiadomości, albo błąd krytyczny, który powinien wrócić do użytkownika. Więc sprawdź to raz, bardzo wcześnie, a następnie załóż to. ” Do czego używasz twierdzeń, jeśli nie do weryfikacji swoich założeń?
5gon12eder

4

Za mało twierdzeń: powodzenia w zmianie tego kodu, który jest pełen ukrytych założeń.

Zbyt wiele stwierdzeń: może prowadzić do problemów z czytelnością i potencjalnie zapachu kodu - czy klasa, funkcja i interfejs API są zaprojektowane, gdy mają tak wiele założeń zawartych w instrukcjach asercji?

Mogą istnieć również twierdzenia, które tak naprawdę nie sprawdzają niczego ani nie sprawdzają rzeczy takich jak ustawienia kompilatora w każdej funkcji: /

Celuj w najsłodsze miejsce, ale nie mniej (jak ktoś już powiedział: „więcej” twierdzeń jest mniej szkodliwych niż posiadanie ich za mało lub niech Bóg nam pomoże - nic).


3

Byłoby wspaniale, gdybyś mógł napisać funkcję Assert, która odwoływałaby się tylko do boolowskiej metody CONST, w ten sposób masz pewność, że twoje aserty nie wywołują skutków ubocznych, zapewniając, że do testowania assert zostanie użyta metoda boolean const

wyciągnęłoby to trochę z czytelności, zwłaszcza, że ​​nie sądzę, że nie można przypisać lambda (w c ++ 0x) jako stałej do jakiejś klasy, co oznacza, że ​​nie można do tego używać lambda

przesada, jeśli mnie zapytasz, ale jeśli zacznę widzieć pewien poziom zanieczyszczenia z powodu twierdzeń, będę ostrożny wobec dwóch rzeczy:

  • upewniając się, że nie występują żadne skutki uboczne w twierdzeniu (dostarczone przez konstrukcję, jak wyjaśniono powyżej)
  • wydajność podczas testowania programistycznego; można temu zaradzić, dodając poziomy (np. rejestrowanie) do funkcji potwierdzenia; dzięki czemu można wyłączyć niektóre twierdzenia z kompilacji programistycznej w celu poprawy wydajności

2
Cholera jasna, lubisz słowo „pewne” i jego pochodne. Liczę 8 zastosowań.
Casey Patton

tak, przepraszam, mam tendencję do klęczenia na słowach zdecydowanie za dużo - naprawiono, dziękuję
lurscher

2

Pisałem w C # o wiele więcej niż w C ++, ale oba języki nie są zbyt daleko od siebie. W .Net używam Asserts dla warunków, które nie powinny się zdarzyć, ale często rzucam wyjątki, gdy nie ma możliwości kontynuacji. Debuger VS2010 pokazuje mi wiele dobrych informacji na temat wyjątku, bez względu na to, jak zoptymalizowana jest wersja Release. Dobrym pomysłem jest także dodanie testów jednostkowych, jeśli możesz. Czasami rejestrowanie jest również dobrym pomysłem na pomoc w debugowaniu.

Czy może być więc zbyt wiele twierdzeń? Tak. Wybór pomiędzy Przerwij / Zignoruj ​​/ Kontynuuj 15 razy w ciągu jednej minuty staje się denerwujący. Wyjątek zostaje zgłoszony tylko raz. Trudno jest oszacować punkt, w którym jest zbyt wiele twierdzeń, ale jeśli twoje twierdzenia spełniają rolę twierdzeń, wyjątków, testów jednostkowych i rejestrowania, coś jest nie tak.

Zastrzegłbym twierdzenia dla scenariuszy, które nie powinny się zdarzyć. Możesz początkowo nadmiernie potwierdzać, ponieważ asercje są szybsze do napisania, ale później ponownie uwzględnij kod - zamień niektóre z nich w wyjątki, niektóre w testy itp. Jeśli masz wystarczająco dużo dyscypliny, aby wyczyścić każdy komentarz do zrobienia, pozostaw komentuj obok każdego, który planujesz przerobić, i NIE ZAPOMNIJ, aby zająć się TODO później.


Jeśli kod nie powiedzie się 15 twierdzeń na minutę, myślę, że wiąże się to z większym problemem. Asercje nigdy nie powinny uruchamiać się w kodzie bezbłędnym, a robią to, powinny zabić aplikację, aby zapobiec dalszym uszkodzeniom, lub wpuścić cię do debuggera, aby zobaczyć, co się dzieje.
5gon12eder

2

Chcę z tobą pracować! Ktoś, kto dużo pisze, assertsjest fantastyczny. Nie wiem, czy istnieje coś takiego jak „za dużo”. O wiele bardziej powszechne są dla mnie osoby, które piszą za mało i ostatecznie wpadają na sporadyczny śmiertelny problem UB, który pojawia się tylko podczas pełni księżyca, który można łatwo odtworzyć wielokrotnie za pomocą prostego assert.

Komunikat o błędzie

Jedyną rzeczą, o której mogę myśleć, jest osadzenie informacji o awarii w assertprzypadku, gdy jeszcze tego nie robisz, na przykład:

assert(n >= 0 && n < num && "Index is out of bounds.");

W ten sposób możesz już nie mieć poczucia, że ​​masz ich zbyt wiele, jeśli jeszcze tego nie robiłeś, ponieważ teraz twoje twierdzenia odgrywają większą rolę w dokumentowaniu założeń i warunków wstępnych.

Skutki uboczne

Oczywiście assertmożna faktycznie nadużywać i wprowadzać błędy, takie jak:

assert(foo() && "Call to foo failed!");

... jeśli foo()wywołuje skutki uboczne, powinieneś być bardzo ostrożny, ale jestem pewien, że już jesteś tym, który zapewnia bardzo liberalnie („doświadczony aserter”). Mamy nadzieję, że twoja procedura testowania jest równie dobra, jak staranna dbałość o założenia.

Szybkość debugowania

Podczas gdy szybkość debugowania powinna zasadniczo znajdować się na dole naszej listy priorytetów, pewnego razu skończyłem tak dużo w bazie kodu, zanim uruchomienie kompilacji debugowania za pomocą debugera było ponad 100 razy wolniejsze niż wydanie.

Było tak przede wszystkim dlatego, że miałem takie funkcje:

vec3f cross_product(const vec3f& lhs, const vec3f& rhs)
{
    return vec3f
    (
        lhs[1] * rhs[2] - lhs[2] * rhs[1],
        lhs[2] * rhs[0] - lhs[0] * rhs[2],
        lhs[0] * rhs[1] - lhs[1] * rhs[0]
    );
}

... gdzie każde pojedyncze wezwanie operator[]wykonałoby stwierdzenie sprawdzające granice. Skończyło się na tym, że niektóre z tych krytycznych pod względem wydajności zostały zastąpione niebezpiecznymi ekwiwalentami, które nie zapewniają drastycznego przyspieszenia debugowania kompilacji przy niewielkim koszcie tylko bezpieczeństwa na poziomie szczegółowości implementacji, i tylko dlatego, że zaczynała się jego szybkość bardzo zauważalnie obniża produktywność (sprawienie, że szybsze debugowanie przeważa nad kosztem utraty kilku twierdzeń, ale tylko dla funkcji takich jak ta funkcja produktu krzyżowego, która była używana na najbardziej krytycznych, mierzonych ścieżkach, a nie operator[]ogólnie).

Zasada pojedynczej odpowiedzialności

Chociaż nie sądzę, abyś naprawdę mógł pomylić się z większą liczbą twierdzeń (przynajmniej daleko, o wiele lepiej jest pomylić się po stronie zbyt wielu niż za mało), same twierdzenia mogą nie stanowić problemu, ale mogą wskazywać na jeden.

Jeśli na przykład masz 5 asercji do pojedynczego wywołania funkcji, może to oznaczać zbyt wiele. Jego interfejs może mieć zbyt wiele warunków wstępnych i parametrów wejściowych, np. Uważam, że nie ma to związku z samym tematem tego, co stanowi zdrową liczbę twierdzeń (na które ogólnie odpowiadam, „im więcej, tym lepiej!”), Ale może to być możliwa czerwona flaga (lub bardzo możliwe, że nie).


1
Cóż, teoretycznie może być „zbyt wielu” twierdzeń, choć ten problem staje się oczywisty bardzo szybko: jeśli twierdzenie zajmuje znacznie więcej czasu niż funkcja funkcji. Trzeba przyznać, że nie pamiętam jeszcze, że odkryłem, że na wolności dominuje odwrotny problem.
Deduplicator

@Deduplicator Ach tak, napotkałem ten przypadek w tych krytycznych procedurach matematycznych wektorów. Chociaż zdecydowanie wydaje się o wiele lepiej pomylić się po stronie zbyt wielu niż zbyt niewielu!

-1

Dodanie kontroli do kodu jest bardzo rozsądne. W przypadku zwykłego asertu (wbudowanego w kompilator C i C ++) mój wzorzec użytkowania jest taki, że nieudane asercja oznacza błąd w kodzie, który należy naprawić. Trochę to interpretuję; jeśli spodziewam prośba internetowej, aby powrócić do stanu 200 i dochodzić do niego bez obsługi innych przypadków potem udało twierdzenie jest rzeczywiście pokazać błąd w kodzie, więc assert jest uzasadnione.

Więc kiedy ludzie mówią, że twierdzenie, że tylko sprawdza to, co robi kod, jest zbędne, to nie do końca tak. To twierdzenie sprawdza, co według nich robi kod, a sednem tego stwierdzenia jest sprawdzenie, czy założenie o braku błędu w kodzie jest prawidłowe. Aserta może również służyć jako dokumentacja. Jeśli założę, że po wykonaniu pętli i == n i nie jest to w 100% oczywiste z kodu, pomocne będzie „assert (i == n)”.

Lepiej mieć w swoim repertuarze coś więcej niż „zapewnić”, aby poradzić sobie z różnymi sytuacjami. Na przykład sytuacja, w której sprawdzam, czy coś się nie dzieje, co wskazywałoby na błąd, ale nadal pracuję nad tym warunkiem. (Na przykład, jeśli użyję pamięci podręcznej, mogę sprawdzić, czy nie wystąpiły błędy, a jeśli błąd wystąpi nieoczekiwanie, bezpiecznym rozwiązaniem może być wyrzucenie pamięci podręcznej. Chcę czegoś, co jest prawie twierdzeniem, które mówi mi podczas programowania i nadal pozwala mi kontynuować.

Innym przykładem jest sytuacja, w której nie spodziewam się, że coś się wydarzy, mam ogólne obejście, ale jeśli tak się stanie, chcę o tym wiedzieć i to zbadać. Znowu coś prawie jak twierdzenie, które powinno mi powiedzieć podczas rozwoju. Ale niezupełnie twierdzenie.

Zbyt wiele asercji: jeśli aser powoduje awarię programu, gdy jest on w rękach użytkownika, nie możesz mieć żadnych asercji, które ulegają awarii z powodu fałszywych negatywów.


-3

To zależy. Jeśli wymagania dotyczące kodu są jasno udokumentowane, to stwierdzenie powinno zawsze odpowiadać wymaganiom. W takim przypadku jest to dobra rzecz. Jeśli jednak nie ma wymagań lub źle napisanych wymagań, nowym programistom byłoby trudno edytować kod bez konieczności odwoływania się do testu jednostkowego za każdym razem, aby dowiedzieć się, jakie są wymagania.


3
wydaje się, że nie oferuje to nic istotnego w porównaniu z punktami przedstawionymi i wyjaśnionymi w poprzednich 8 odpowiedziach
komentuje
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.