Każdy programista prędzej czy później musi zmierzyć się z sytuacjami, w których coś idzie nie po jego myśli. A to użytkownik wprowadza nieprawidłowe dane, system operacyjny odmawia dostępu do pliku, albo połączenie z siecią nagle się urywa. Bez odpowiednich mechanizmów kontrolowania takich sytuacji aplikacje stają się kruche i podatne na awarie. To prowadzi do frustracji użytkowników naszego projektu.
C++ oferuje wiele narzędzi do zarządzania błędami, od klasycznych kodów zwracanych przez funkcje, przez wskaźniki null
, aż po bardziej zaawansowane podejścia, takie jak wyjątki, std::optional
czy std::expected
. Każde z nich ma swoje mocne strony, ale też wymaga zrozumienia i odpowiedniego zastosowania. W tym artykule przyjrzymy się różnym technikom obsługi błędów w C++, przeanalizujemy ich wady i zalety, a także pokażemy, jak wybrać najlepsze podejście do konkretnych problemów. Jeśli chcesz, aby Twój kod był nie tylko poprawny, ale też czytelny i łatwy w utrzymaniu, czytaj dalej!
Klasyczne motody obsługi błędów
Klasyczna obsługa błędów w C++ to podejście, które wielu programistów poznaje na samym początku swojej przygody z tym językiem. Najpopularniejszą metodą jest zwracanie kodów błędów przez funkcje, np. prostych liczb całkowitych, które wskazują, czy operacja zakończyła się sukcesem, czy nie. Funkcja może zwrócić 0
, aby oznaczyć powodzenie, lub wartość ujemną, gdy coś poszło nie tak. Innym rozwiązaniem jest stosowanie specjalnych wartości, takich jak nullptr
czy magiczne liczby, które sygnalizują nietypowy stan.
Spójrzmy na ten przykład
// funkcja zwraca wynik lub -1 przy niepowodzeniu
int indeksZnaku(const char* str, char znak) {
if (str == nullptr) {
return -1; // Obsługa przypadku null
}
for (int i = 0; str[i] != '\0'; i++) {
if (str[i] == znak) {
return i; // Zwraca indeks, jeśli znajdzie znak
}
}
return -1; // -1 oznacza, że znak nie został znaleziony
}
int main()
{
int pos = indeksZnaku("abcd", 'c');
if(pos != -1) {
// znaleziono na pozycji 2 (liczone od zera)
} else {
// nie znaleziono znaku lub wystapil błąd
}
pos = indeksZnaku(nullptr, 'c'); // zwraca -1, null na wejsciu
pos = indeksZnaku("xyz", 'c'); // zwraca -1, nie znaleziono
return 0;
}
i podobny przykład z zastosowaniem kodów błędów
enum ErrorCode {
NO_ERROR,
BAD_INPUT,
NOT_FOUND,
};
// funkcja zwraca status operacji, a wynik zapisuje poprzez referencje
ErrorCode indeksZnaku(const char* str, char znak, int& wynik) {
if (str == nullptr) {
return BAD_INPUT; // Obsługa przypadku null
}
for (int i = 0; str[i] != '\0'; i++) {
if (str[i] == znak) {
wynik = i;
return NO_ERROR;
}
}
return NOT_FOUND; // znak nie został znaleziony
}
int main()
{
int wynik;
ErrorCode status = indeksZnaku("xyf", 'd', wynik);
if(status != NO_ERROR) {
// pojawil sie blad, mozemy zareagowac
}
return 0;
}
Warto zauwazyć, że przykład drugi jest czytelniejszy i bardziej intuicyjny.
Choć te techniki są intuicyjne i łatwe do zaimplementowania, mają swoje wady, ponieważ zmuszają programistów do ciągłego sprawdzania zwracanych wartości, co zaciemnia kod i utrudnia śledzenie logiki. Co gorsza, jeden pominięty warunek może doprowadzić do poważnych błędów, które trudno jest zdiagnozować. Mimo to klasyczne podejście wciąż znajduje swoje zastosowanie, zwłaszcza w systemach embedded, gdzie priorytetem jest prostota.
Błędy systemowe
W systemach operacyjnych typu Unix/Linux wiele funkcji systemowych, takich jak operacje na plikach, sygnalizuje błędy poprzez zwracanie specjalnych wartości (np. -1
lub NULL
) oraz ustawienie zmiennej globalnej errno
, która przechowuje kod błędu. Programista powinien sprawdzać te wartości i odpowiednio reagować na występujące problemy. W C można dodatkowo wykorzystać funkcję perror()
lub strerror()
do uzyskania opisu błędu. Poniżej znajduje się przykład prostej obsługi błędu otwarcia pliku.
#include <iostream>
#include <cerrno>
#include <cstring>
int main() {
FILE *fp = fopen("dane.txt", "r");
if (fp == NULL) {
std::cerr << "Błąd otwarcia pliku: " << strerror(errno)) << "\n";
return 1;
}
// operacje na pliku
fclose(fp); // zamkniecie pliku
return 0;
}
W tym przykładzie, jeżeli plik dane.txt
nie istnieje lub nie można go otworzyć, program wypisze komunikat błędu i zakończy działanie z kodem 1
. Zwróć uwagę, na te 3 elementy:
std::cerr
– jest to obiekt globalny, który identyfikuje strumień błędów, możemy go użyć do wyświetlania błędów na konsoli.errno
– zmienna globalna, która przechowuje kod błędu ostatnio wywołanej operacji systemowej, jej typ toint
strerror()
to to funkcja, która przetłumaczy nam kod błędu zerrno
na postać tekstową, bardziej czytelną dla użytkownika.
Wyjątki (exceptions)
Wyjątki w C++ to odmienny mechanizm obsługi błędów, który znacząco różni się od tradycyjnych kodów błędów. W przeciwieństwie do nich, wyjątki pozwalają oddzielić logikę aplikacji od obsługi problemów. Prowadzi to do bardziej czytelnego i zwięzłego kodu.
Podstawową zaletą wyjątków jest to, że programista nie musi za każdym razem sprawdzać zwracanego kodu błędu i na niego reagować. Jeśli w którejś funkcji wystąpi błąd i zostanie zgłoszony wyjątek, wykonywanie kodu jest natychmiast przerywane i przenoszone do najbliższego bloku catch
. Dzięki temu mamy pewność, że dalszy kod nie bedzie przetwarzany z błędnymi danymi. Dla mechanizmu wyjątków zostały zarezerwowane trzy słowa kluczowe:
throw
– zgłoszenie wyjątkutry
– blok kodu, w którym spodziewamy się wyjątku (logika podstawowa)catch
– blok kodu obsługi błędu
Oto przykład.
#include <iostream>
#include <stdexcept>
int oblicz(int licznik, int mianownik) {
if (mianownik == 0) throw std::runtime_error("Dzielenie przez zero!");
return licznik / mianownik;
}
void wyswietl(int wynik) {
std::cout << "Wynik obliczeń: " << wynik << "\n";
}
void przetwarzaj(int a, int b) {
int wynik = oblicz(a, b); // tutaj może zostać zgłoszony wyjątek
wyswietl(wynik); // wtedy ta funkcja się nie wykona
}
int main() {
try {
przetwarzaj(10, 0);
} catch (const std::exception& e) { // przechwytujemy wyjątek, nawet z wnetrza funkcji
std::cerr << "Blad: " << e.what() << "\n";
}
std::cout << "Program kontynuuje dzialanie.\n";
return 0;
}
Wyjątki pozwalają także przekazywać bardziej szczegółowe informacje o błędzie w postaci obiektów, co ułatwia diagnozowanie i naprawę problemów. Biblioteka standardowa dostarcza nam kilka typów wyjątków ogólnego przeznaczenia, ale nic nie stoi na przeszkodzie, aby zdefiniowac własny typ wyjątku. Dobrą praktyką jest wówczas dziedziczenie z klasy std::exception
. Poniżej lista kilku popularnych wyjątków zdefiniowany w nagłówku <stdexcept>
std::exception
– klasa bazowa wszystkich wyjatkówstd::logic_error
– sygnalizuje błąd logiczny programustd::runtime_error
– sygnalizuje najczęściej błędny stan systemustd::domain_error
– funkcja otrzymała błedny argument (dziedziczy z std::logic_error)std::out_of_range
– indeks tablicy poza zakresem (dziedziczy z std::logic_error)std::bad_alloc
– wyrzucony przez operatornew
gdy brakuje pamięci
Choć obsługa wyjątków jest wygodna, to jednak rzadko są stosowane w środowisku embedded. Główną przyczyną tego stanu rzeczy jest fakt, że pod spodem jest to całkiem skomplikowana maszyneria, która powoduje, że kod wynikowy jest po prostu duży, a w tym środowisku zwykle mamy ograniczone zasoby, zwłaszcza jeśli chodzi zarówno o pamięć jak i moc obliczeniową. Nie mnej jednak warto je znać.
Nowoczesne metody obsługi błędów
W nowszych edycjach standardu C++ zostało zaimplementowanych jeszcze kilka innych mechanizmów obsługi błędów, są to m.in. std::optional
, std::variant
i std::expected
. Charakterystyczne głównie dla dogmatu programowania funkcyjnego. Nie będę ich tu omawiał szczegółowo, bo jest to materiał znacznie bardziej zaawansowany. Kto jest ciekawy to sobie wygugluje. Z pewności kiedyś spróbuję je omówić na łamach tego bloga.
Podsumowanie
Obsługa błędów w C++ jest elastyczna, ale wymaga odpowiedzialnego podejścia. Poprawne wykorzystanie wyjątków pozwala na czytelne oddzielenie logiki normalnego działania programu od obsługi sytuacji wyjątkowych. Stosując dobre praktyki – takie jak przechwytywanie wyjątków przez referencję, dziedziczenie po std::exception
, czy tworzenie własnych typów błędów – można pisać bardziej niezawodny i odporny kod. Niezależnie od wybranego mechanizmu, najważniejsze jest, aby błędy były wykrywane, komunikowane i obsługiwane w sposób przewidywalny i konsekwentny.