Obsługa błędów a std::optional


2019-01-24, 01:21

W moich poprzednich wpisach w serii C++ STL poruszyłem tematy związane z tym, jak używać std::optional. Ten opakowujący (nazywany również słownikowym) typ jest użyteczny zawsze wtedy, kiedy potrzebujemy wyrazić coś, co może nie posiadać stanu: coś co może być “puste”. Na przykład możemy zwrócić std::nullopt aby poinformować, że że mamy do czynienia z błędem… ale czy aby na pewno to jest najlepszy wybór?

Rzeczywisty problem

Spójrzmy na przykład:

struct SelectionData
{
    bool anyCivilUnits { false };
    bool anyCombatUnits { false };
    int numAnimating { 0 };
};

std::optional<SelectionData> 
CheckSelection(const ObjSelection &objList)
{
    if (!objList.IsValid())
        return { };

    SelectionData out;

    // skanowanie...

    return {out};
}

Ten kod pochodzi z mojego wcześniejszego wpisu o refactoringu z std::optional.

Podstawowym jego pomysłem jest podejmowanie operacji skanowania zaznaczenia w poszukiwaniu “obiektów cywilnych”, “obiektów bojowych” lub ilości animowanych obiektów, kiedy zaznaczenie jest prawidłowe. Kiedy skanowanie zostaje zakończone, możemy skonstruować obiekt SelectionData i opakować go używając std::optional. Jeżeli zaznaczenie nie jest gotowe, wtedy zwracamy nullopt - czyli pusty optional.

Nasz kod wygląda dobrze, ale możemy zadać następujące pytanie: co z obsługą błędów?

Problemem związanym z std::optional jest to, że tracimy informacje o błedach. Ta funkcja zwraca wartość lub coś pustego, więc nie możemy powiedzieć, że coś poszło źle. W przypadku tej funkcji mogliśmy wyjść wcześniej tylko wtedy, kiedy zaznaczenie było nieprawidłowe. Jednakże przy nieco bardziej skomplikowanych przykładach moglibyśmy mieć kilka takich powodów.

Jak myślicie? Czy jest to poprawne użycie std::optional?

Postarajmy się znaleźć odpowiedź na to pytanie.

Obsługa błędów

Jak możecie przypuszczać, istnieje wiele sposobów na obsługę błędów. Mamy różne rodzaje błędów, więc nasze zagadnienie staje się bardziej skomplikowane.

W C++ mamy dwie możliwości:

  • użyć kodu błędu / wartości specjalnej
  • rzucić wyjątek

Oczywiście z kilkoma różnymi wariacjami:

  • zwrócenie kodu błędu wraz ze zwróceniem obliczonej wartości stosując parametr wyjściowy
  • zwrócenie unikalnej wartości dla obliczonego resultatu aby wskazać błąd (jak -1, npos)
  • rzucić wyjątek - od kiedy wyjątki są uważane za ciężkie i posiadający niemały narzut, są one stosowane oszczędnie w wielu projektach
    • dodatkowo musimy podjąć decyzję, co należy rzucić stosując wyjątek
  • zwrócić parę <value, error_code>
  • zwrócić wariant / dyskryminowaną unię <value, error>
  • ustawić specjalny, globalny obiekt błędu (jak errno dla fopen) - często stosowane w API języka C
  • pozostałe… ?

W kilku dokumentach i artykułach widziałem fajny termin “rozczarowanie” (dissapointment), który odnosi się do wszystkich rodzajów błędów i “problemów”, które kod mógł wygenerować.

Rozróżniamy kilka rodzajów “rozczarowań”:

  • System operacyjny
  • Poważny
  • Znaczący
  • Normalny
  • Nieznaczny
  • Spodziewany / prawdopodobny

Co więcej, możemy patrzeć na obsługę błędów pod kątem wydajności. Chcielibyśmy, aby to było szybkie oraz aby nie było dodatkowego narzutu wykonania. Na przykład wyjątki są traktowane jako “ciężkie” i zazwyczaj nie są używane w niskopoziomowym kodzie.

Gdzie nam pasuje używanie std::optional?

Myślę, że wraz z std::optional otrzymujemy kolejne narzędzie, które może polepszyć nasz kod.

Wersja z użyciem std::optional

Jak zauważyłem kilkukrotnie, std::optional powinno być głównie używane w kontekście typów nullable.

Za dokumentacją boost::optional: Kiedy używać Optional¹

It is recommended to use optional<T> in situations where there is exactly one, clear (to all parties) reason for having no value of type T, and where the lack of value is as natural as having any regular value of T.

Można również stwierdzić, że skoro optional dodaje wartość “null” do naszego typu, to jesteśmy blisko używania wskaźników wraz z wartością nullptr. Na przykład: widziałem wiele kodu, gdzie prawidłowy wskaźnik został zwrócony w przypadku sukcesu, a nullptr w przypadku wystąpienia błędu.

TreeNode* FindNode(TheTree* pTree, string_view key)
{
    // find...
    if (found)
        return pNode;

    return nullptr;
}

Lub jeśli spojrzymy na kilka funkcji z języka C:

FILE * pFile = nullptr;
pFile = fopen ("temp.txt","w");
if (pFile != NULL)
{
    fputs ("fopen example",pFile);
    fclose (pFile);
}

Nawet w C++ STL zwracamy npos w przypadku niepowodzenia przeszukiwania stringa. Zatem zamiast nullptr używają one specjalnych wartości sygnalizujących błąd.

std::string s = "test";
if(s.find('a') == std::string::npos)
    std::cout << "no 'a' in 'test'\n";

Myślę, że powyższy przykład - ten z npos, możemy śmiało przepisać używając std::optional. Kiedykolwiek mamy do czynienia z funkcją, która oblicza coś, a jej wynik może być pusty - wtedy użycie std::optional jest odpowiednią ścieżką.

Kiedy inny developer widzi deklarację jak poniżej:

std::optional<Object> PrepareData(inputs...);

Jest dla niego jasne, że Object może czasami nie zostać obliczony. To jest znacznie lepsze niż:

// zwraca nullptr jeśli liczenie nie udało się! sprawdź to!
Object* PrepareData(inputs...);

O ile wersja z std::optional może wyglądać lepiej, to obsługa błędów ciągle wygląda trochę słabo.

Co powiecie o innych sposobach?

Równie dobrze, jeśli chcielibyście przesłać więcej informacji związanych z ‘rozczarowaniami‘, to możecie pomyśleć o std::variant<Result, Error_Code> albo o nowym proposalu Expected<T, E>, który opakowuje wartość kodem błędu. Po stronie wywołującej możemy zbadać powód powstałego niepowodzenia:

// poglądowy przykład std::expected
std::expected<Object, error_code> PrepareData(inputs...);

// wywołanie:
auto data = PrepareData(...);
if (data)
    use(*data);
else
    showError(data.error());

Kiedy operujemy na wartości opcjonalnej, musimy sprawdzić czy wartość została zwrócona. Bardzo lubię styl funkcyjny, który propaguje Simon Brand, dzięki któremu możemy zmienić kod:

std::optional<image_view> get_cute_cat (image_view img) {
    auto cropped = find_cat(img);
    if (!cropped) {
      return std::nullopt;
    }

    auto with_sparkles = make_eyes_sparkle(*with_tie);
    if (!with_sparkles) {
      return std::nullopt;
    }

    return add_rainbow(make_smaller(*with_sparkles));
}

na

tl::optional<image_view> get_cute_cat (image_view img) {
    return find_cat(img)
           .and_then(make_eyes_sparkle)
           .map(make_smaller)
           .map(add_rainbow);
}

Więcej informacji na ten tema znajdziecie we wpisie: Functional exceptionless error-handling with optional and expected

Nowy proposal

Kiedy pisałem ten artykuł, Herb Sutter opublikował zupełnie nowy wpis na podobny temat:

PDF P0709 R0 - Zero - overhead deterministic exceptions: Throwing values.

Temat ten będzie podejmowany podczas następnego spotkania C++ ISO Meeting w Rapperswil na początku czerwca 2018.

Herb Sutter omawia, jakie są obecnie opcje dla obsługi błędów oraz jakie są ich zalety i wady. Największą rzeczą, która znajduje się w jego proposalu jest nowy mechanizm obsługi wyjątków²

This proposal aims to marry the best of exceptions and error codes: to allow a function to declare that it throws values of a statically known type, which can then be implemented exactly as efficiently as a return value. Throwing such values behaves as if the function returned union{R;E;}+bool where on success the function returns the normal return value R and on err or the function returns the error value type E, both in the same return channel including using the same registers. The discriminant can use an unused CPU flag or a register.

Na przykład:

string func() throws // nowy keyword! nie "throw" :)
{
    if (flip_a_coin()) 
        throw arithmetic_error::something;

    return “xyzzy”s + “plover”; // jakikolwiek dynamiczny wyjątek
                                // jest tłumaczony do postaci błędu
}

int main() {
    try {
        auto result = func();
        cout << “success, result is: ” << result;
    }
    catch(error err) { // łapanie przez wartość jest OK
        cout << “failed, error is: ” << err.error();
    }
}

Ogólnie rzecz biorąc, ta propozycja celuje w składnię wzorowaną na wyjątkach przy zachowaniu zerowego obciążenia i bezpieczeństwa typu.

Spójność i prostota

Wierzę, że o ile mamy mnóstwo opcji i wariacji związanych z obsługą błędów, to kluczem tutaj jest “spójność“.

Jeśli macie jeden projekt, który używa 10-ciu metod obsługi błędów, to może być ciężko programistom tworzyć nowy kod, ponieważ nie będą oni pamiętali, które rozwiązanie powinno być stosowane.

Prawdopodobnie nie jest możliwe aby trzymać się jednej wersji: w miejscach krytycznych dla wydajności stosowanie wyjątków nie będzie możliwe, zarówno jak typy opakowujące (takie jak optional, variant czy expected), które mają dodatkowy narzut dla wydajności. Trzymanie się minimalnej ilości odpowiednich narzędzi jest drogą idealną.

Inną myślą w tym temacie jest to, jak bardzo wasz kod jest jasny i prosty. Kiedy mamy relatywnie krótkie funkcje, które robią tylko jedną rzecz, to wtedy jest bardzo łatwo przedstawiać rozczarowania - jako, że mamy tylko jedną czy dwie opcje. Jeżeli natomiast wasza metoda jest długa, z wieloma odpowiedzialnościami, to wtedy będzie można zastosować całkiem nową złożoność błędów.

Utrzymywanie kodu prostym pomoże miejscom wywołującym na obsługę wyniku w klarowny sposób.

Podsumowanie

W tym wpisie przejrzalem kilka opcji do obsługi błędów (lub rozczarowań) w naszym kodzie. Spojrzeliśmy również w przyszłość, kiedy wspominałem o nowym proposalu “Zero-overhead deterministic exceptions”, który zaproponował Herb Sutter.

Gdzie pasuje std::optional?

Pozwala nam on na wyrażenie typów null-owych. Jeżeli więc macie kod, który zwraca specjalną wartość do wyrażenia niepowodzenia wykonywanej operacji, to możecie w takim razie pomyśleć o opakowaniu tej wartości w std::optional. Kluczową rzeczą tutaj jest to, że optional nie przekazuje powodu niepowodzenia, zatem w dalszym ciągu musimy posiłkować się innymi mechanizmami.

Z std::optional zyskujemy nowe narzędzie, które wyraża nasze intencje. Kluczowym tutaj jest to, jak zwykle, aby być spójnym i pisać prosty kod, który nie wprowadza zamieszania wśród innych programistów.

Jaka jest Wasza opinia na temat używania std::optional w obsłudze błędów?
Czy widzicie inne możliwości w swoim kodzie?

Koniecznie zobaczcie poprzedni wpis w serii: Jak używać std::optional z C++17

Tutaj macie kilka innych wpisów, które mogą Wam pomóc³:

A tutaj prezentacja z Meeting C++ 2017 o std::expected:

https://www.youtube.com/watch?v=JfMBLx7qE0I


¹, ², ³ - Treci pozostawione w formie oryginalnej



Bartłomiej Filipek

Programista i pasjonat C++ z ponad 11-letnim doświadczeniem. Bloguje od wielu lat, głównie o naszym ulubionym języku programowania. Autor ksiązki C++17 In Detail.

Blog Bartka
Profil na LinkedIn
Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.