12.15 — std::opcjonalny

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 wynik std::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 value

Aby 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 value

Aby 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:

ZachowanieWskaźnikstd::optional
Nie przechowuj wartościzainicjuj/przypisz {} lub std::nullptrzainicjuj/przypisz {} lub std::nullopt
Zatrzymaj wartośćzainicjuj/przypisz adreszainicjuj/przypisz wartość
Sprawdź, czy ma wartośćukryta konwersja na boolukryta konwersja na bool lub has_value()
Pobierz wartośćdereferencjadereferencja 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::optional ma semantykę wartości, co oznacza, że ​​faktycznie zawiera jej wartość, a przypisanie kopiuje wartość. Jeśli zwrócimy std::optional by 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::optional skutecznie 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::optional jest wygodna i intuicyjny.

Zwrócenie std::optional ma kilka wad:

  • Musimy upewnić się, że std::optional zawiera wartość, zanim ją uzyskamy. Jeśli usuniemy odwołanie do std::optional nie zawierającej wartości, otrzymamy niezdefiniowane zachowanie.
  • std::optional nie 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:

  1. Efektywnie dokumentuje, że parametr jest opcjonalny.
  2. Możemy przekazać wartość (ponieważ std::optional utworzy 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.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
42 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze