Obsługa błędów a std::optional
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
dlafopen
) - 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³:
- PDF P0709 R0 - Zero - overhead deterministic exceptions: Throwing values - nowy proposal od Herb’a Sutter’a
- P0157R0 - Handling Disappointment in C++
- Wpisy od Simon’a Brand’a:
- Vittorio Romeo: why choose sum types over exceptions?
- foonathan::blog() - Exceptions vs expected: Let’s find a compromise
- r/cpp Status of the std::expected
A tutaj prezentacja z Meeting C++ 2017 o std::expected
:
https://www.youtube.com/watch?v=JfMBLx7qE0I
¹, ², ³ - Treci pozostawione w formie oryginalnej