Wyjątki i funkcje składowe
Do tego momentu tutoriala widziałeś tylko wyjątki używane w funkcjach nieczłonkowskich. Jednak wyjątki są równie przydatne w funkcjach składowych, a jeszcze bardziej w przypadku przeciążonych operatorów. Rozważ następujący przeciążony operator [] jako część prostej klasy tablicy liczb całkowitych:
int& IntArray::operator[](const int index)
{
return m_data[index];
}Chociaż ta funkcja będzie działać świetnie, jeśli indeks jest prawidłowym indeksem tablicy, tej funkcji bardzo brakuje dobrego sprawdzania błędów. Moglibyśmy dodać instrukcję potwierdzenia, aby upewnić się, że indeks jest prawidłowy:
int& IntArray::operator[](const int index)
{
assert (index >= 0 && index < getLength());
return m_data[index];
}Teraz, jeśli użytkownik przekaże nieprawidłowy indeks, program spowoduje błąd potwierdzenia. Niestety, ponieważ przeciążeni operatorzy mają specyficzne wymagania co do liczby i typu parametrów, które mogą przyjmować i zwracać, nie ma elastyczności w przekazywaniu kodów błędów lub wartości logicznych do obiektu wywołującego do obsługi. Ponieważ jednak wyjątki nie zmieniają sygnatury funkcji, można je tutaj świetnie wykorzystać. Oto przykład:
int& IntArray::operator[](const int index)
{
if (index < 0 || index >= getLength())
throw index;
return m_data[index];
}Teraz, jeśli użytkownik przekaże nieprawidłowy indeks, operator[] zgłosi wyjątek int.
Gdy konstruktory zawiodą
Konstruktory to kolejny obszar klas, w którym wyjątki mogą być bardzo przydatne. Jeśli z jakiegoś powodu konstruktor musi zakończyć się niepowodzeniem (np. użytkownik przekazał nieprawidłowe dane wejściowe), po prostu zgłoś wyjątek, aby wskazać, że utworzenie obiektu nie powiodło się. W takim przypadku konstrukcja obiektu zostaje przerwana, a wszystkie składowe klasy (które zostały już utworzone i zainicjowane przed wykonaniem treści konstruktora) zostają w zwykły sposób zniszczone.
Jednakże destruktor klasy nigdy nie jest wywoływany (ponieważ obiekt nigdy nie zakończył konstrukcji). Ponieważ destruktor nigdy się nie wykonuje, nie można na nim polegać, jeśli chodzi o wyczyszczenie przydzielonych już zasobów.
Prowadzi to pytanie, co powinniśmy zrobić, jeśli przydzieliliśmy zasoby w naszym konstruktorze, a przed zakończeniem działania konstruktora wystąpi wyjątek. Jak możemy zapewnić, że zasoby, które już przydzieliliśmy, zostaną odpowiednio uporządkowane? Jednym ze sposobów byłoby zawinięcie dowolnego kodu, który może zakończyć się niepowodzeniem w bloku try, użycie odpowiedniego bloku catch w celu przechwycenia wyjątku i przeprowadzenia niezbędnego czyszczenia, a następnie ponowne zgłoszenie wyjątku (temat omówimy w lekcji 27.6 -- Ponowne zgłaszanie wyjątków). Powoduje to jednak duży bałagan i łatwo się pomylić, szczególnie jeśli klasa alokuje wiele zasobów.
Na szczęście istnieje lepszy sposób. Korzystając z faktu, że elementy klasy są niszczone nawet w przypadku niepowodzenia konstruktora, jeśli alokację zasobów dokonasz wewnątrz elementów klasy (a nie w samym konstruktorze), wówczas elementy te będą mogły po sobie posprzątać, gdy ulegną zniszczeniu.
Oto przykład:
#include <iostream>
class Member
{
public:
Member()
{
std::cerr << "Member allocated some resources\n";
}
~Member()
{
std::cerr << "Member cleaned up\n";
}
};
class A
{
private:
int m_x {};
Member m_member;
public:
A(int x) : m_x{x}
{
if (x <= 0)
throw 1;
}
~A()
{
std::cerr << "~A\n"; // should not be called
}
};
int main()
{
try
{
A a{0};
}
catch (int)
{
std::cerr << "Oops\n";
}
return 0;
}Wypisuje:
Member allocated some resources Member cleaned up Oops
W powyższym programie, gdy klasa A zgłosi wyjątek, wszystkie elementy klasy A zostaną zniszczone. Wywoływany jest destruktor m_member, zapewniający możliwość oczyszczenia wszystkich przydzielonych mu zasobów.
Jest to jeden z powodów, dla których RAII (omówiony w lekcji 19.3 -- Destructors) jest tak bardzo zalecany — nawet w wyjątkowych okolicznościach klasy implementujące RAII są w stanie po sobie posprzątać.
Jednak utworzenie niestandardowej klasy, takiej jak Member, aby zarządzanie alokacją zasobów nie jest efektywne. Na szczęście standardowa biblioteka C++ zawiera klasy zgodne z RAII do zarządzania typowymi typami zasobów, takimi jak pliki (std::fstream, omówione w lekcji 28.6 — Podstawowe operacje we/wy plików) i pamięć dynamiczna (std::unique_ptr i inne inteligentne wskaźniki omówione w 22.1 -- Wprowadzenie do inteligentnych wskaźników i semantyki ruchów).
Na przykład zamiast tego:
class Foo
private:
int* ptr; // Foo will handle allocation/deallocationZrób to:
class Foo
private:
std::unique_ptr<int> ptr; // std::unique_ptr will handle allocation/deallocationW pierwszym przypadku, jeśli konstruktor Foo zawiedzie po alokacji pamięci dynamicznej przez ptr, Foo będzie odpowiedzialny za czyszczenie, co może być trudne.W drugim przypadku, jeśli Konstruktor Foo zawiódłby po przydzieleniu przez ptr pamięci dynamicznej, destruktor ptr wykonałby i zwrócił tę pamięć do systemu. Foo nie musi wykonywać żadnego jawnego czyszczenia, gdy obsługa zasobów jest delegowana do elementów zgodnych z RAII!
Klasy wyjątków
Jednym z głównych problemów związanych z używaniem podstawowych typów danych (takich jak int) jako typów wyjątków jest to, że są one z natury niejasne wyjątek oznacza, że w bloku try znajduje się wiele instrukcji lub wywołań funkcji.
// Using the IntArray overloaded operator[] above
try
{
int* value{ new int{ array[index1] + array[index2]} };
}
catch (int value)
{
// What are we catching here?
}Jeśli w tym przykładzie mamy przechwycić wyjątek typu int, co nam to tak naprawdę mówi? Czy jeden z indeksów tablicy był poza dopuszczalnym zakresem? Czy operator+ spowodował przepełnienie liczby całkowitej? Czy operator new zawiódł, ponieważ zabrakło mu pamięci? Niestety w tym przypadku nie ma prostego sposobu na ujednoznacznienie. Chociaż możemy zgłosić wyjątek const char*, aby rozwiązać problem identyfikacji CO poszło źle, to nadal nie daje nam możliwości obsługi wyjątków z różnych źródeł w różny sposób.
Jednym ze sposobów rozwiązania tego problemu jest użycie klas wyjątków. Klasa wyjątków jest po prostu normalną klasą zaprojektowaną specjalnie do zgłaszania wyjątków. Zaprojektujmy prostą klasę wyjątków do użycia z naszą klasą IntArray:
#include <string>
#include <string_view>
class ArrayException
{
private:
std::string m_error;
public:
ArrayException(std::string_view error)
: m_error{ error }
{
}
const std::string& getError() const { return m_error; }
};Oto pełny program wykorzystujący to. class:
#include <iostream>
#include <string>
#include <string_view>
class ArrayException
{
private:
std::string m_error;
public:
ArrayException(std::string_view error)
: m_error{ error }
{
}
const std::string& getError() const { return m_error; }
};
class IntArray
{
private:
int m_data[3]{}; // assume array is length 3 for simplicity
public:
IntArray() {}
int getLength() const { return 3; }
int& operator[](const int index)
{
if (index < 0 || index >= getLength())
throw ArrayException{ "Invalid index" };
return m_data[index];
}
};
int main()
{
IntArray array;
try
{
int value{ array[5] }; // out of range subscript
}
catch (const ArrayException& exception)
{
std::cerr << "An array exception occurred (" << exception.getError() << ")\n";
}
}Używając takiej klasy, możemy sprawić, że wyjątek zwróci opis zaistniałego problemu, który zapewni kontekst tego, co poszło nie tak. A ponieważ ArrayException jest swoim własnym, unikalnym typem, możemy w szczególności wychwytywać wyjątki zgłaszane przez klasę tablicy i traktować je inaczej niż inne wyjątki, jeśli chcemy.
Pamiętaj, że procedury obsługi wyjątków powinny wyłapywać obiekty wyjątków klasy przez referencję, a nie przez wartość. Uniemożliwia to kompilatorowi utworzenie kopii wyjątku w miejscu, gdzie jest przechwytywany, co może być kosztowne, gdy wyjątek jest obiektem klasy, i zapobiega dzieleniu obiektu na kawałki w przypadku pochodnych klas wyjątków (o czym porozmawiamy za chwilę). Generalnie należy unikać przechwytywania wyjątków przez wskaźnik, chyba że masz ku temu konkretny powód.
Najlepsza praktyka
Wyjątki typu podstawowego można wychwytywać według wartości, ponieważ są tanie w kopiowaniu.
Wyjątki typu klasy powinny być wychwytywane przez (const) referencję, aby zapobiec kosztownym kopiowanie i wycinanie.
Wyjątki i dziedziczenie
Ponieważ możliwe jest zgłaszanie klas jako wyjątków, a klasy można wyprowadzać z innych klas, musimy rozważyć, co się stanie, gdy użyjemy klas odziedziczonych jako wyjątków. Jak się okazuje, procedury obsługi wyjątków nie tylko dopasowują klasy określonego typu, ale także klasy wywodzące się z tego konkretnego typu. Rozważ następujące kwestie! przykład:
#include <iostream>
class Base
{
public:
Base() {}
};
class Derived: public Base
{
public:
Derived() {}
};
int main()
{
try
{
throw Derived();
}
catch (const Base& base)
{
std::cerr << "caught Base";
}
catch (const Derived& derived)
{
std::cerr << "caught Derived";
}
return 0;
} W powyższym przykładzie zgłaszamy wyjątek typu Derived. Jednak wynik tego programu jest następujący:
caught Base
Co się stało?
Po pierwsze, jak wspomniano powyżej, klasy pochodne będą przechwytywane przez procedury obsługi typu podstawowego. Ponieważ Derived wywodzi się z Base, Derived jest bazą (posiadają relację jest-jest). Po drugie, gdy C++ próbuje znaleźć procedurę obsługi zgłoszonego wyjątku, robi to sekwencyjnie. W związku z tym pierwszą rzeczą, jaką robi C++, jest sprawdzenie, czy obsługa wyjątków dla Base pasuje do wyjątku pochodnego. Ponieważ Derived jest bazą, odpowiedź brzmi „tak” i wykonuje blok catch dla typu Base! Blok catch dla Derived nigdy nie jest nawet testowany w tym przypadku.
Aby ten przykład działał zgodnie z oczekiwaniami, musimy odwrócić kolejność bloków catch:
#include <iostream>
class Base
{
public:
Base() {}
};
class Derived: public Base
{
public:
Derived() {}
};
int main()
{
try
{
throw Derived();
}
catch (const Derived& derived)
{
std::cerr << "caught Derived";
}
catch (const Base& base)
{
std::cerr << "caught Base";
}
return 0;
} W ten sposób program obsługi Derived jako pierwszy będzie mógł przechwycić obiekty typu Derived (zanim moduł obsługi Base będzie mógł to zrobić). Obiekty typu Base nie będą pasować do procedury obsługi Derived (Derived to-a Base, ale Base nie jest Derived), w związku z czym „przejdą” do procedury obsługi Base.
Reguła
Procedury obsługi pochodnych klas wyjątków powinny być wymienione przed procedurami obsługi klas bazowych.
Możliwość wykorzystania procedury obsługi do wychwytywania wyjątków typów pochodnych przy użyciu procedury obsługi dla klasy bazowej okazuje się niezwykle przydatna przydatne.
std::exception
Wiele klas i operatorów w bibliotece standardowej generuje klasy wyjątków w przypadku niepowodzenia. Na przykład operator new może zgłosić std::bad_alloc, jeśli nie jest w stanie przydzielić wystarczającej ilości pamięci. Niepowodzenie dynamic_cast spowoduje wyrzucenie std::bad_cast. I tak dalej. Począwszy od C++20, istnieje 28 różnych klas wyjątków, które można zgłosić, a w każdym kolejnym standardzie językowym dodawane są kolejne.
Dobrą wiadomością jest to, że wszystkie te klasy wyjątków wywodzą się z jednej klasy o nazwie std::exception (zdefiniowanej w nagłówku <exception>). std::exception to mała klasa interfejsu zaprojektowana, aby służyć jako klasa bazowa dla wszelkich wyjątków zgłaszanych przez standardową bibliotekę C++.
W większości przypadków, gdy biblioteka standardowa zgłasza wyjątek, nie obchodzi nas, czy jest to zła alokacja, zła obsada, czy coś innego. Obchodzi nas tylko to, że coś katastrofalnego poszło nie tak i teraz nasz program eksploduje. Dzięki std::exception możemy skonfigurować procedurę obsługi wyjątków, która przechwytuje wyjątki typu std::exception, co zakończy się przechwytywaniem std::exception i wszystkich pochodnych wyjątków w jednym miejscu. To proste!
#include <cstddef> // for std::size_t
#include <exception> // for std::exception
#include <iostream>
#include <limits>
#include <string> // for this example
int main()
{
try
{
// Your code using standard library goes here
// We'll trigger one of these exceptions intentionally for the sake of the example
std::string s;
s.resize(std::numeric_limits<std::size_t>::max()); // will trigger a std::length_error or allocation exception
}
// This handler will catch std::exception and all the derived exceptions too
catch (const std::exception& exception)
{
std::cerr << "Standard exception: " << exception.what() << '\n';
}
return 0;
}Na maszynie autora powyższy program wypisuje:
Standard exception: string too long
Powyższy przykład powinien być całkiem prosty. Jedyną rzeczą wartą odnotowania jest to, że std::exception ma wirtualną funkcję składową o nazwie what() , która zwraca ciąg znaków opisujący wyjątek w stylu C. Większość klas pochodnych zastępuje funkcję what() w celu zmiany komunikatu. Pamiętaj, że ten ciąg znaków jest przeznaczony wyłącznie do opisu tekstu — nie używaj go do porównań, ponieważ nie ma gwarancji, że będzie taki sam we wszystkich kompilatorach.
Czasami będziemy chcieli inaczej obsłużyć określony typ wyjątku. W tym przypadku możemy dodać procedurę obsługi dla tego konkretnego typu, a wszystkie pozostałe „przejdą” do procedury obsługi podstawowej. Rozważ:
try
{
// code using standard library goes here
}
// This handler will catch std::length_error (and any exceptions derived from it) here
catch (const std::length_error& exception)
{
std::cerr << "You ran out of memory!" << '\n';
}
// This handler will catch std::exception (and any exception derived from it) that fall
// through here
catch (const std::exception& exception)
{
std::cerr << "Standard exception: " << exception.what() << '\n';
}W tym przykładzie wyjątki typu std::length_error zostaną przechwycone przez pierwszą procedurę obsługi i tam obsługiwane. Wyjątki typu std::exception i wszystkie inne klasy pochodne zostaną przechwycone przez drugą procedurę obsługi.
Takie hierarchie dziedziczenia pozwalają nam używać określonych procedur obsługi do celowania w określone pochodne klasy wyjątków lub używać procedur obsługi klas podstawowych do przechwytywania całej hierarchii wyjątków. Pozwala nam to na dużą kontrolę nad rodzajem wyjątków, które chcemy obsłużyć, jednocześnie zapewniając, że nie musimy wkładać zbyt wiele pracy, aby wychwycić „wszystko inne” w hierarchii.
Bezpośrednie użycie standardowych wyjątków
Nic nie zgłasza bezpośrednio std::exception i Ty też nie powinieneś. Możesz jednak swobodnie wrzucać inne standardowe klasy wyjątków do biblioteki standardowej, jeśli odpowiednio reprezentują one Twoje potrzeby. Listę wszystkich standardowych wyjątków można znaleźć na stronie cppreference.
std::runtime_error (zawartej jako część nagłówka stdexcept) jest popularnym wyborem, ponieważ ma ogólną nazwę, a jego konstruktor pobiera dostosowywalny komunikat:
#include <exception> // for std::exception
#include <iostream>
#include <stdexcept> // for std::runtime_error
int main()
{
try
{
throw std::runtime_error("Bad things happened");
}
// This handler will catch std::exception and all the derived exceptions too
catch (const std::exception& exception)
{
std::cerr << "Standard exception: " << exception.what() << '\n';
}
return 0;
}Wypisuje:
Standard exception: Bad things happened
Wyprowadzanie własnych klas ze std::exception lub std::runtime_error
Możesz oczywiście wyprowadzić własne klasy z std::exception i zastąp wirtualną funkcję członkowską what() const. Oto ten sam program, co powyżej, z wyjątkiem ArrayException wywodzącym się ze std::exception:
#include <exception> // for std::exception
#include <iostream>
#include <string>
#include <string_view>
class ArrayException : public std::exception
{
private:
std::string m_error{}; // handle our own string
public:
ArrayException(std::string_view error)
: m_error{error}
{
}
// std::exception::what() returns a const char*, so we must as well
const char* what() const noexcept override { return m_error.c_str(); }
};
class IntArray
{
private:
int m_data[3] {}; // assume array is length 3 for simplicity
public:
IntArray() {}
int getLength() const { return 3; }
int& operator[](const int index)
{
if (index < 0 || index >= getLength())
throw ArrayException("Invalid index");
return m_data[index];
}
};
int main()
{
IntArray array;
try
{
int value{ array[5] };
}
catch (const ArrayException& exception) // derived catch blocks go first
{
std::cerr << "An array exception occurred (" << exception.what() << ")\n";
}
catch (const std::exception& exception)
{
std::cerr << "Some other std::exception occurred (" << exception.what() << ")\n";
}
}Zauważ, że funkcja wirtualna what() ma specyfikator noexcept (co oznacza, że funkcja sama obiecuje nie zgłaszać wyjątków). Dlatego też nasze zastąpienie powinno mieć także specyfikator noexcept.
Ponieważ std::runtime_error ma już możliwości obsługi ciągów znaków, jest to również popularna klasa bazowa dla pochodnych klas wyjątków. std::runtime_error może przyjmować parametr łańcuchowy w stylu C lub parametr const std::string& .
Oto ten sam przykład wywodzący się z std::runtime_error:
#include <exception> // for std::exception
#include <iostream>
#include <stdexcept> // for std::runtime_error
#include <string>
class ArrayException : public std::runtime_error
{
public:
// std::runtime_error takes either a null-terminated const char* or a const std::string&.
// We will follow their lead and take a const std::string&
ArrayException(const std::string& error)
: std::runtime_error{ error } // std::runtime_error will handle the string
{
}
// no need to override what() since we can just use std::runtime_error::what()
};
class IntArray
{
private:
int m_data[3]{}; // assume array is length 3 for simplicity
public:
IntArray() {}
int getLength() const { return 3; }
int& operator[](const int index)
{
if (index < 0 || index >= getLength())
throw ArrayException("Invalid index");
return m_data[index];
}
};
int main()
{
IntArray array;
try
{
int value{ array[5] };
}
catch (const ArrayException& exception) // derived catch blocks go first
{
std::cerr << "An array exception occurred (" << exception.what() << ")\n";
}
catch (const std::exception& exception)
{
std::cerr << "Some other std::exception occurred (" << exception.what() << ")\n";
}
}To od Ciebie zależy, czy chcesz utworzyć własne samodzielne klasy wyjątków, użyć standardowych klas wyjątków, czy też wyprowadzić własne klasy wyjątków ze std::exception lub std::runtime_error. Wszystkie są prawidłowymi podejściami, w zależności od celów.
Czas istnienia wyjątków
Gdy zgłaszany jest wyjątek, zgłaszanym obiektem jest zazwyczaj zmienna tymczasowa lub lokalna, która została przydzielona na stosie. Jednakże proces obsługi wyjątków może zakończyć działanie funkcji, powodując zniszczenie wszystkich zmiennych lokalnych funkcji. Jak więc zgłaszany obiekt wyjątku przetrwa odwijanie stosu?
Gdy zostanie zgłoszony wyjątek, kompilator tworzy kopię obiektu wyjątku w jakiejś części nieokreślonej pamięci (poza stosem wywołań) zarezerwowanej do obsługi wyjątków. W ten sposób obiekt wyjątku zostanie utrwalony niezależnie od tego, czy i ile razy stos jest rozwijany. Wyjątek będzie istniał do czasu jego obsłużenia.
Oznacza to, że rzucane obiekty muszą być generalnie kopiowalne (nawet jeśli stos nie jest faktycznie rozwinięty). Inteligentne kompilatory mogą zamiast tego wykonać ruch lub całkowicie pominąć kopię w określonych okolicznościach.
Wskazówka
Obiekty wyjątków muszą być kopiowalne.
Oto przykład pokazujący, co się dzieje, gdy próbujemy rzucić obiekt Derived, którego nie można skopiować:
#include <iostream>
class Base
{
public:
Base() {}
};
class Derived : public Base
{
public:
Derived() {}
Derived(const Derived&) = delete; // not copyable
};
int main()
{
Derived d{};
try
{
throw d; // compile error: Derived copy constructor was deleted
}
catch (const Derived& derived)
{
std::cerr << "caught Derived";
}
catch (const Base& base)
{
std::cerr << "caught Base";
}
return 0;
}Gdy ten program zostanie skompilowany, kompilator zgłosi skargę, że konstruktor kopiujący Derived jest niedostępny i zatrzyma się kompilacja.
Obiekty wyjątków nie powinny przechowywać wskaźników ani odwołań do obiektów alokowanych na stosie. Jeśli zgłoszony wyjątek powoduje rozwinięcie stosu (powodując zniszczenie obiektów przydzielonych do stosu), te wskaźniki lub odniesienia mogą pozostać niezwiązane.

