W typowych sytuacjach, jedyne co nas interesuje to to czy dana funkcja się wykonała czy wystąpił błąd — dwa scenariusze, stąd bool
jako rezultat wystarczy. Natomiast dodatkowe informacje mogą być odkładane i korzysta się z nich wtedy kiedy potrzeba (np. w WinAPI za pomocą GetLastError
lub w SDL za pomocą SDL_GetError
). Te funkcje zwracają stringi z komunikatami, natomiast false
lub liczbowy kod błędu (np. -1
) jako rezultat oznacza, że dana funkcja się wyłożyła i można samodzielnie zdecydować co robić dalej — znów zwrócić false
i wyjść z funkcji, albo obsłużyć błąd i ukryć problem (funkcja niżej w call stacku się o tym problemie nie dowie).
W moim kodzie (silnika, nad którym pracuję), nie ma znaczenia jak duży jest call stack i w której funkcji potrzeba obsłużyć błąd a w której nie. Jeśli któraś funkcja się wykłada, to zwraca False
i każda funkcja niższego rzędu (niżej w call stacku) może na to zareagować lub błąd olać (o ile można olać błąd, to zależy od kontekstu). Jeśli w którymkolwiek miejscu będę potrzebował zareagować na błąd, to sobie dodam/rozwinę if
a i to wszystko. Jeśli bym używał wyjątków, to zamiast rozwijania ifa
, dodałbym blok try except
z obsługą błędu, a potem puścił wyjątek dalej (za pomocą raise
), jeśli funkcje niżej w call stacku mają ten wyjątek również dostać.
Nie ma więc żadnego problemu ani w reagowaniu na błędy, ani w kontroli przepływu sterowania, ani też z przekazywaniem informacji o błędach nawet na sam dół call stacku. No i nie ma też problemu w rozbudowywaniu kodu, dodając obsługę błędów w tych funkcjach, które jej jeszcze nie mają. To jest standardowa kontrola błędów, znana i stosowana od kilkudziesięciu lat, łącznie z systemowym API (np. Win32 API). Wprowadzenie wyjątków pozwoliło olać if
y, olać sprawdzanie czegokolwiek, bo w razie czego pojawi się wyjątek i on tak sobie będzie leciał po call stacku w dół, aż którać metoda go złapie i obsłuży. W skrócie — jebnie to jebnie, co mnie to obchodzi. Niestety takie olewactwo ma swoje wady, o których pisałem wcześniej.
Jeśli mam metodę, w której coś tak się wykonuje, ale nie ma w niej try except
, to nie wiadomo czy ona może walnąć wyjątkiem czy nie, nie wiadomo też czy metody, które są w niej wywoływane mogą to zrobić czy nie. Tak więc nie wiadomo, czy przez tę metodę może wyjątek przelecieć czy nie może. W przypadku klasycznej obsługi błędów, wystarczy popatrzeć na instrukcje warunkowe — są testy danych/rezultatów funkcji to znaczy, że coś może pójść nie tak (tym bardziej, jeśli w środku kodu są return
y lub exit
y, jeśli chodzi o Pascala).
Dam przykład z mojego silnika. W głównym bloku kodu jest wywoływana funkcja z menedżera kursorów, której zadaniem jest załadowanie kursorów z pliku binarnego (zwraca wartość logiczną). W niej następuje próba otwarcia pliku — jeśli coś pójdzie nie tak (np. nie ma pliku lub jest zablokowany do odczytu), to zwraca False
i tyle. Jeśli plik jest, woła funkcję odczytującą dane ze strumienia pliku. W tej funkcji wołane są kolejne — odczyt sygnatury pliku oraz sanych kursorów. Sygnatury nie ma lub jest zła? Zwraca False
i tyle roboty. Sygnatura jest prawidłowa, zaczyna czytać dane kursorów — ich liczbę oraz dane. Nieprawidłowa liczba kursorów? Zwraca False
i przerywa ładowanie. Liczba jest prawidłowa, to wołane są kolejne funkcje — call stack puchnie. Odczyt danych pojedynczego kursora to kolejne funkcje — odczyt i test sygnatury, dane nagłówka, następnie dane poszczególnych typów kursorów (nazywam je ”kształtami"). Nagłowek nieprawidłowy, bo danych brakuje lub nie przechodzą walidacji? Zwracam False
i przerywam ładowanie. Odczyt kształtów to kolejne funkcjie — nagłówek, liczba klatek, atlas, kolejne wywołania dokładane do call stacku. Coś pójdzie nie tak to funkcja zwraca False
.
Call stack ładowania kursorów z pliku wygląda tak:
Game_CursorsReadFromFile()
Game_CursorsReadFromStream()
Game_CursorsReadFromStreamSignature()
Game_CursorsReadFromStreamCursors()
Game_CursorReadFromStream()
Game_CursorReadFromStreamSignature()
Game_CursorReadFromStreamHeader()
Game_CursorReadFromStreamShapes()
Game_CursorShapeReadFromStream()
Game_CursorShapeReadFromStreamHeader()
Game_CursorShapeReadFromStreamFrames()
Game_CursorShapeReadFromStreamAtlas()
Game_StreamReadTexture() // to mój wrapper maskujący bug w SDL-u
Zauważ, że nieważne ile jest wywoływanych funkcji w funkcjach, bo na każdym etapie jeśli coś pójdzie nie tak, to funkcja zwraca False
(plus dodaje informacje do pliku logu, jeśli to istotny problem) i przerywa swoje działanie. Funkcja niżej w call stacku widzi, że dostała zwrotkę w formie False
, więc sama też zwraca False
i przerywa działanie. I tak dalej i tak dalej, informacja o błędzie przemieszcza się aż na sam spód call stacku.
W wielu z tych funkcji, po wykryciu jakiegokolwiek problemu z odczytem danych z pliku, następuje cleanup, opcjonalny skok za pomocą goto
do kodu logującego problem w pliku logu oraz zwrócenie False
. Ów zwrócony False
zawsze jest sprawdzany i jeśli wykryty, następuje cała drabinka exit
ów z False
'ami, aż do samego spodu call stacku.
Jeśli będę potrzebował dodać jakikolwiek kod reagowania na błędy, dodam go w konkretnej funkcji, bez żadnego problemu — ot podłączę się pod if
a lub go dodam. Jeśli będę potrzebował ukryć problem, to zamiast exit
z False
, obsłużę błąd i zwrócę True
, a jeśli nie, to przekażę False
dalej. Te False
'y czy liczbowe kody błędów lecą po call stacku tak samo jak wyjątki, tyle że niczego przede mną nie ukrywają i nie obniżają wydajności kodu wynikowego.
Taki kod jest niezwykle czytelny, wszystko jest jawne i da się go łatwo rozwijać, mam pełną kontrolę nad błędami.