W lekcji 9.4 — Wykrywanie i obsługa błędów, omówiliśmy przypadki, w których funkcja napotyka błąd, z którym sama nie jest w stanie sobie poradzić. Rozważmy na przykład funkcję, która oblicza i zwraca wartość:
int doIntDivision(int x, int y)
{
return x / y;
}Jeśli obiekt wywołujący przekaże wartość, która jest semantycznie niepoprawna (np. y = 0), ta funkcja nie może obliczyć wartości do zwrócenia (ponieważ dzielenie przez 0 jest matematycznie niezdefiniowane). Co w takim przypadku robimy? Ponieważ funkcje obliczające wyniki nie powinny powodować żadnych skutków ubocznych, funkcja ta nie jest w stanie rozsądnie rozwiązać samego błędu. W takich przypadkach typową rzeczą jest wykrycie przez funkcję błędu, a następnie przekazanie go z powrotem do wywołującego, aby mógł sobie z tym poradzić w jakiś sposób odpowiedni dla programu.
W poprzedniej lekcji omówiliśmy dwa różne sposoby, aby funkcja zwróciła błąd z powrotem do wywołującego:
- Funkcja zwracająca void zwraca zamiast tego wartość bool (wskazującą sukces lub niepowodzenie).
- Funkcja zwracająca wartość wartość wskaźnikowa (specjalna wartość, która nie występuje w zbiorze możliwych wartości, które funkcja może w przeciwnym razie zwrócić) wskazująca błąd.
Jako przykład tej drugiej funkcji reciprocal() postępująca po niej funkcja zwraca wartość 0.0 (która w innym przypadku nigdy nie mogłaby wystąpić), jeśli użytkownik przekaże semantycznie niepoprawny argument dla x:
#include <iostream>
// The reciprocal of x is 1/x, returns 0.0 if x=0
double reciprocal(double x)
{
if (x == 0.0) // if x is semantically invalid
return 0.0; // return 0.0 as a sentinel to indicate an error occurred
return 1.0 / x;
}
void testReciprocal(double d)
{
double result { reciprocal(d) };
std::cout << "The reciprocal of " << d << " is ";
if (result != 0.0)
std::cout << result << '\n';
else
std::cout << "undefined\n";
}
int main()
{
testReciprocal(5.0);
testReciprocal(-4.0);
testReciprocal(0.0);
return 0;
}Chociaż jest to dość atrakcyjne rozwiązanie, istnieje wiele potencjalnych wady:
- Programista musi wiedzieć, jakiej wartości wskaźnikowej używa funkcja do wskazania błędu (a wartość ta może się różnić dla każdej funkcji zwracającej błąd tą metodą).
- Inna wersja tej samej funkcji może używać innej wartości wskaźnikowej.
- Ta metoda nie działa w przypadku funkcji, w których wszystkie możliwe wartości wskaźnikowe są prawidłowymi wartościami zwrotnymi.
Rozważ naszą doIntDivision() funkcję powyżej. Jaką wartość może zwrócić, jeśli użytkownik przekaże 0 for y? Nie możemy użyć 0, ponieważ 0 dzielenia przez cokolwiek, co daje 0 jako prawidłowego wyniku. W rzeczywistości nie ma wartości, które moglibyśmy zwrócić, a które nie mogłyby wystąpić w sposób naturalny.
Więc co mamy zrobić?
Najpierw możemy wybrać jakąś (miejmy nadzieję) nietypową wartość zwracaną jako nasz wskaźnik i użyć jej do wskazania błędu:
#include <limits> // for std::numeric_limits
// returns std::numeric_limits<int>::lowest() on failure
int doIntDivision(int x, int y)
{
if (y == 0)
return std::numeric_limits<int>::lowest();
return x / y;
}std::numeric_limits<T>::lowest() to funkcja, która zwraca najbardziej ujemną wartość dla typu T. Jest to odpowiednik funkcji std::numeric_limits<T>::max() (która zwraca największą dodatnią wartość dla typu T), którą przedstawiliśmy w lekcji 9.5 — std::cin i obsługa nieprawidłowych danych wejściowych.
W powyższym przykładzie, jeśli doIntDivision() nie można kontynuować, zwracamy std::numeric_limits<int>::lowest(), która zwraca najbardziej ujemną wartość int z powrotem do wywołującego, aby wskazać, że funkcja się nie powiodła.
Chociaż to w większości działa, to ma dwie wady:
- Za każdym razem, gdy wywołujemy tę funkcję, musimy przetestować wartość zwracaną pod kątem równości z
std::numeric_limits<int>::lowest(), aby sprawdzić, czy się nie udało. To jest rozwlekłe i brzydkie. - To przykład problemu półpredykatu: jeśli użytkownik wywoła
doIntDivision(std::numeric_limits<int>::lowest(), 1), zwrócony wynikstd::numeric_limits<int>::lowest()będzie niejednoznaczny co do tego, czy funkcja się powiodła, czy nie. Może to stanowić problem, ale nie musi, w zależności od tego, jak funkcja jest faktycznie używana, ale jest to kolejna rzecz, o którą musimy się martwić i kolejny potencjalny sposób, w jaki błędy mogą wkraść się do naszego programu.
Po drugie, moglibyśmy zrezygnować ze zwracanych wartości do zwracania błędów i zastosować inny mechanizm (np. wyjątki). Jednakże wyjątki mają swoje własne komplikacje i koszty wydajności i mogą nie być odpowiednie lub pożądane. To prawdopodobnie przesada w przypadku czegoś takiego.
Po trzecie, moglibyśmy zrezygnować ze zwracania pojedynczej wartości i zamiast tego zwrócić dwie wartości: jedną (typu bool), który wskazuje, czy funkcja się powiodła, i drugi (o żądanym typie zwrotu), który przechowuje rzeczywistą wartość zwracaną (jeśli funkcja powiodła się) lub wartość nieokreśloną (jeśli funkcja się nie powiodła). Jest to prawdopodobnie najlepsza opcja ze wszystkich.
Przed C++17 wybranie tej drugiej opcji wymagało samodzielnego wdrożenia. I chociaż C++ zapewnia wiele sposobów, aby to zrobić, każde samodzielne podejście nieuchronnie doprowadzi do niespójności i błędów.
Zwrócenie std::optional
C++17 wprowadza std::optional, który jest typem szablonu klasy, który implementuje wartość opcjonalną. Oznacza to, że a std::optional<T> może mieć wartość typu T lub nie. Możemy to wykorzystać do wdrożenia trzeciej opcji powyżej:
#include <iostream>
#include <optional> // for std::optional (C++17)
// Our function now optionally returns an int value
std::optional<int> doIntDivision(int x, int y)
{
if (y == 0)
return {}; // or return std::nullopt
return x / y;
}
int main()
{
std::optional<int> result1 { doIntDivision(20, 5) };
if (result1) // if the function returned a value
std::cout << "Result 1: " << *result1 << '\n'; // get the value
else
std::cout << "Result 1: failed\n";
std::optional<int> result2 { doIntDivision(5, 0) };
if (result2)
std::cout << "Result 2: " << *result2 << '\n';
else
std::cout << "Result 2: failed\n";
return 0;
}Wypisuje:
Result 1: 4 Result 2: failed
Użycie std::optional jest całkiem prosta. Możemy skonstruować std::optional<T> z wartością lub bez:
std::optional<int> o1 { 5 }; // initialize with a value
std::optional<int> o2 {}; // initialize with no value
std::optional<int> o3 { std::nullopt }; // initialize with no valueAby sprawdzić, czy a std::optional ma wartość, możemy wybrać jedną z następujących opcji:
if (o1.has_value()) // call has_value() to check if o1 has a value
if (o2) // use implicit conversion to bool to check if o2 has a valueAby uzyskać wartość z a std::optional, możemy wybrać jedną z następujących opcji:
std::cout << *o1; // dereference to get value stored in o1 (undefined behavior if o1 does not have a value)
std::cout << o2.value(); // call value() to get value stored in o2 (throws std::bad_optional_access exception if o2 does not have a value)
std::cout << o3.value_or(42); // call value_or() to get value stored in o3 (or value `42` if o3 doesn't have a value)Zauważ to std::optional ma składnię użycia, która jest zasadniczo identyczna ze składnią wskaźnik:
| Zachowanie | Wskaźnik | std::optional |
|---|---|---|
| Nie przechowuj wartości | zainicjuj/przypisz {} lub std::nullptr | zainicjuj/przypisz {} lub std::nullopt |
| Zatrzymaj wartość | zainicjuj/przypisz adres | zainicjuj/przypisz wartość |
| Sprawdź, czy ma wartość | ukryta konwersja na bool | ukryta konwersja na bool lub has_value() |
| Pobierz wartość | dereferencja | dereferencja lub value() |
Jednak semantycznie wskaźnik i a std::optional są zupełnie różne.
- Wskaźnik ma semantyka odniesienia, co oznacza, że odwołuje się do innego obiektu, a przypisanie kopiuje wskaźnik, a nie obiekt. Jeśli zwrócimy wskaźnik według adresu, wskaźnik zostanie skopiowany z powrotem do osoby wywołującej, a nie do wskazywanego obiektu. Oznacza to, że nie możemy zwrócić obiektu lokalnego według adresu, ponieważ skopiujemy adres tego obiektu z powrotem do obiektu wywołującego, a następnie obiekt zostanie zniszczony, pozostawiając zwrócony wskaźnik w zawieszeniu.
- A
std::optionalma semantykę wartości, co oznacza, że faktycznie zawiera jej wartość, a przypisanie kopiuje wartość. Jeśli zwrócimystd::optionalby wartość,std::optional(wraz z zawartą wartością) zostanie skopiowana z powrotem do obiektu wywołującego. Oznacza to, że możemy zwrócić wartość funkcji z powrotem do osoby wywołującej za pomocąstd::optional.
Mając to na uwadze, przyjrzyjmy się, jak działa nasz przykład. Nasz doIntDivision() zwraca teraz std::optional<int> zamiast int. Jeśli w treści funkcji wykryjemy błąd, zwracamy {}, co domyślnie zwraca a std::optional niezawierający żadnej wartości. Jeśli mamy wartość, zwracamy tę wartość, która niejawnie zwraca a std::optional zawierający tę wartość.
W main(), używamy niejawnej konwersji na wartość bool, aby sprawdzić, czy zwrócony std::optional ma wartość, czy nie. Jeśli tak, usuwamy referencję do obiektu std::optional , aby uzyskać wartość. Jeśli tak nie jest, wykonujemy nasz warunek błędu. To wszystko!
Za i przeciw zwracaniu std::optional
Zwrócenie std::optional jest to przydatne z wielu powodów:
- Użycie
std::optionalskutecznie dokumentuje, że funkcja może zwracać wartość lub nie. - Nie musimy pamiętać, która wartość jest zwracana jako wskaźnik.
- Składnia używania
std::optionaljest wygodna i intuicyjny.
Zwrócenie std::optional ma kilka wad:
- Musimy upewnić się, że
std::optionalzawiera wartość, zanim ją uzyskamy. Jeśli usuniemy odwołanie dostd::optionalnie zawierającej wartości, otrzymamy niezdefiniowane zachowanie. std::optionalnie umożliwia przekazania informacji o przyczynie niepowodzenia funkcji.
Jeśli funkcja nie musi zwracać dodatkowych informacji o przyczynie niepowodzenia (aby lepiej zrozumieć awarię, albo rozróżnić różne rodzaje awarii), std::optional jest doskonałym wyborem dla funkcji, które mogą zwracać wartość lub niepowodzenie.
Najlepsza praktyka
Zwróć std::optional (zamiast wartości wskaźnikowej) dla funkcji, które mogą zakończyć się niepowodzeniem, chyba że funkcja musi zwrócić dodatkowe informacje o przyczynie niepowodzenia.
Powiązana treść
std::expected (wprowadzony w C++23) został zaprojektowany do obsługi przypadku, w którym funkcja może zwrócić oczekiwaną wartość lub nieoczekiwany kod błędu. Zobacz std::expected reference .
Użycie std::optional jako opcjonalny parametr funkcji
W lekcji 12.11 — Przekazywanie adresu (część 2). Omówiliśmy, w jaki sposób można użyć przekazywania adresu, aby umożliwić funkcji przyjęcie „opcjonalnego” argumentu (tzn. osoba wywołująca może albo przekazać nullptr , aby reprezentować „brak argumentu” lub obiekt). Jednakże wadą tego podejścia jest to, że argument inny niż nullptr musi być wartością (aby jego adres mógł zostać przekazany do funkcji).
Być może nie jest to zaskakujące (biorąc pod uwagę nazwę), std::optional jest alternatywnym sposobem, aby funkcja zaakceptowała opcjonalny argument (który jest używany tylko jako parametr wewnętrzny). Zamiast tego:
#include <iostream>
void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(&userid); // we know the user's ID now
return 0;
}Możesz to zrobić:
#include <iostream>
#include <optional>
void printIDNumber(std::optional<const int> id = std::nullopt)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(userid); // we know the user's ID now
printIDNumber(62); // we can also pass an rvalue
return 0;
}To podejście ma dwie zalety:
- Efektywnie dokumentuje, że parametr jest opcjonalny.
- Możemy przekazać wartość (ponieważ
std::optionalutworzy kopię).
Jednakże std::optional tworzy kopię swojego argumentu, staje się to problematyczne, gdy T jest typem drogim do skopiowania (jak std::string). W przypadku normalnych parametrów funkcji obeszliśmy ten problem, tworząc parametr a const lvalue reference, aby nie została wykonana kopia. Niestety, od wersji C++23 std::optional nie obsługuje odniesień.
Dlatego zalecamy używanie std::optional<T> jako opcjonalnego parametru tylko wtedy, gdy T zwykle jest przekazywana przez wartość. W przeciwnym razie użycie const T*.
Dla zaawansowanych czytelników
Chociaż std::optional nie obsługuje bezpośrednio odniesień, możesz użyć std::reference_wrapper (które omówimy w lekcji 17.5 -- Tablice odniesień poprzez std::reference_wrapper), aby naśladować odniesienie. Przyjrzyjmy się jak wygląda powyższy program z std::string id i std::reference_wrapper:
#include <functional> // for std::reference_wrapper
#include <iostream>
#include <optional>
#include <string>
struct Employee
{
std::string name{}; // expensive to copy
int id;
};
void printEmployeeID(std::optional<std::reference_wrapper<Employee>> e=std::nullopt)
{
if (e)
std::cout << "Your ID number is " << e->get().id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printEmployeeID(); // we don't know the Employee yet
Employee e { "James", 34 };
printEmployeeID(e); // we know the Employee's ID now
return 0;
}I dla porównania wersją wskaźnikową:
#include <iostream>
#include <string>
struct Employee
{
std::string name{}; // expensive to copy
int id;
};
void printEmployeeID(const Employee* e=nullptr)
{
if (e)
std::cout << "Your ID number is " << e->id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printEmployeeID(); // we don't know the Employee yet
Employee e { "James", 34 };
printEmployeeID(&e); // we know the Employee's ID now
return 0;
}Te dwa programy są prawie identyczne. Twierdzilibyśmy, że ten pierwszy nie jest bardziej czytelny ani łatwiejszy w utrzymaniu niż drugi i nie warto wprowadzać do programu dwóch dodatkowych typów.
W wielu przypadkach przeciążenie funkcji zapewnia lepsze rozwiązanie:
#include <iostream>
#include <string>
struct Employee
{
std::string name{}; // expensive to copy
int id;
};
void printEmployeeID()
{
std::cout << "Your ID number is not known.\n";
}
void printEmployeeID(const Employee& e)
{
std::cout << "Your ID number is " << e.id << ".\n";
}
int main()
{
printEmployeeID(); // we don't know the Employee yet
Employee e { "James", 34 };
printEmployeeID(e); // we know the Employee's ID now
printEmployeeID( { "Dave", 62 } ); // we can even pass rvalues
return 0;
}Najlepsza praktyka
Preferuj std::optional w przypadku opcjonalnych typów zwracanych wartości.
Preferuj przeciążanie funkcji dla opcjonalnych parametrów funkcji (jeśli to możliwe). W przeciwnym razie użyj std::optional<T> dla opcjonalnych argumentów, gdy T zwykle jest przekazywana przez wartość. Kopiowanie przysługi const T* gdy T jest kosztowne.

