Refactoring z std::optional
Jest wiele sytuacji, kiedy potrzebujemy wyrazić coś “opcjonalnego” - obiekt, który może posiadać wartość, lub nie. Mamy kilka możliwości implementacji tego przypadku, ale wraz ze standardem C++17 przychodzi najbardziej przydatna droga: std::optional
. Dzisiaj przygotowałem jeden przypadek refactoringu, który może nauczyć nas, jak stosować tą funkcję standardu C++17.
Wstęp
Najpierw zagłębmy się w kod.
Mamy funkcję, która pobiera obiekt klasy ObjSelection
, który jest reprezentacją na przykład aktualnie wykonanego przez kursor myszy zaznaczenia. Funkcja ta skanuje to zaznaczenie oraz znajduje liczbę animowanych obiektów, liczbę jednostek cywilnych oraz bojowych.
Oto nasz kod:
class ObjSelection
{
public:
bool IsValid() const { return true; }
// więcej kodu...
};
bool CheckSelectionVer1(
const ObjSelection &objList,
bool *pOutAnyCivilUnits,
bool *pOutAnyCombatUnits,
int *pOutNumAnimating
);
Jak możemy zauważyć, przyjmujemy głównie parametry wyjściowe (w formie wskaźników) oraz zwracamy true
lub false
aby wskazać sukces operacji (na przykład zaznaczenie mogło być nieprawidłowe).
Pominę na chwilę implementaję tej funkcji. Poniżej mamy natomiast kod ją wywołujący:
ObjSelection sel;
bool anyCivilUnits { false };
bool anyCombatUnits {false};
int numAnimating { 0 };
if (CheckSelectionVer1(sel, &anyCivilUnits, &anyCombatUnits, &numAnimating))
{
// ...
}
Dlaczego ta funkcja nie jest idealna?
Możemy mieć kilka uwag do tego kodu:
- Przyjżyjmy się bliżej kodowi wywołującemu: musimy utworzyć wszystkie zmienne, które przetrzymują informację zwrotną. Jeżeli będziemy chcieli wywołać tą funkcję w wielu miejscach, powstanie nam sporo powtórzeń kodu.
- Patametry wyjściowe: Core Guidelines sugerują, aby ich nie stosować.
- Jeżeli stosujemy wskaźniki proste, to zawsze musimy sprawdzać ich ważność
- Co w sytuacji, kiedy chcemy rozszerzyć tą funkcję? Co, jeśli będziemy potrzebowali dodać kolejny parametr wyjściowy?
Coś więcej?
Jak możemy to zrefaktoryzować?
Zmotywowany przez Core Guidelines oraz nowe funkcje C++17 mam zamiar wykonać następujące kroki:
- Zamiast parametrów wyjściowych użyję
tuple
, które mogą być zwrócone - Zastąpię
tuple
całkiem osobną strukturą, a następnie zredukujętuple
dopair
. - Użyję
std::optional
aby wyrazić możliwe błędy.
Tuple
Pierwszym krokiem jest zamiana parametrów wyjściowych na tuple
oraz zwrócenie ich przez funkcję.
Nawiązując do F.21: To return multiple “out” values, prefer returning a tuple or struct¹:
A return value is self-documenting as an “output-only” value. Note that C++ does have multiple return values, by convention of using a tuple (including pair), possibly with the extra convenience of tie at the call site.
Po tej zmianie kod wygląda następująco:
std::tuple<bool, bool, bool, int>
CheckSelectionVer2(const ObjSelection &objList)
{
if (!objList.IsValid())
return {false, false, false, 0};
// zmienne lokalne:
int numCivilUnits = 0;
int numCombat = 0;
int numAnimating = 0;
// skanowanie...
return {true, numCivilUnits > 0, numCombat > 0, numAnimating };
}
Odrobinę lepiej… prawda?
- Nie ma potrzeby sprawdzania ważności wskaźników prostych
- Kod jest nieco bardziej ekspresywny
Co więcej, po stronie kodu wywołującego możemy użyć Dowiązań strukturowych w celu opakowania tuple
:
auto [ok, anyCivil, anyCombat, numAnim] = CheckSelectionVer2(sel);
if (ok)
{
// ...
}
Niestety, ta opcja nie jest idealna. Myślę, że łatwo jest zapomnieć kolejność danych w tuple
. Był nawet jeden artykuł na ten temat: : Smelly std::pair and std::tuple.
Co więcej, w dalszym ciągu mamy problem z możliwością rozszerzania funkcji. Jeżeli będziemy chcieli dodać kolejny parametr wyjściowy, musimy w takim razie zadbać zarówno o tuple
, jak i o wszystkie miejsca wywołujące tą funkcję.
Dlatego proponuję kolejny krok: wykorzystać strukturę (co jest także rekomendowane przez Core Guidelines).
Oddzielna struktura
Zwracane przez naszą funkcję danesą ze sobą powiązane. Dlatego dobrym pomysłem jest spakować je w strukturę o nazwie SelectionData
.
struct SelectionData
{
bool anyCivilUnits { false };
bool anyCombatUnits { false };
int numAnimating { 0 };
};
Następnie możemy przepisać naszą funkcję:
std::pair<bool, SelectionData> CheckSelectionVer3(const ObjSelection &objList)
{
SelectionData out;
if (!objList.IsValid())
return {false, out};
// skanowanie...
return {true, out};
}
W miejscu wywołania funkcji:
if (auto [ok, selData] = CheckSelectionVer3(sel); ok)
{
// ...
}
Aby wydzielić flagę informującą nas o sukcesiem użyłem std::pair
, ponieważ nie jest ona częścią nowej struktury.
Główną zaletą, którą tutaj mamy jest to, że nasz kod jest strukturą logiczną, oraz to, że jest łatwo rozszerzalny. Jeżeli będziemy chcieli dodać nowy parametr, wystarczy jedynie rozszerzyć strukturę.
Ale czy std::pair<bool, MyType>
nie jest podobne do std::optional
?
std::optional
Z cppreference - std::optional²:
The class template std::optional manages an optional contained value, i.e. a value that may or may not be present.
A common use case for optional is the return value of a function that may fail. As opposed to other approaches, such as std::pair<T,bool>, optional handles expensive-to-construct objects well and is more readable, as the intent is expressed explicitly.
Wygląda na to, że std::optional
w naszej sytuacji jest idealnym wyborem. Możemy usunąć zmienną ok
i skorzystać z semantyki std::optional
.
Dla przypomnienia std::optional
został dodany w standardzie C++17 (zobacz mój opis), ale przed C++17 możemy użyć boost::optional
, ponieważ są one tożsame.
Nowa wersja kodu:
std::optional<SelectionData> CheckSelection(const ObjSelection &objList)
{
if (!objList.IsValid())
return { };
SelectionData out;
// skanowanie...
return {out};
}
Miejsce wywołania funkcji:
if (auto ret = CheckSelection(sel); ret.has_value())
{
// dostęp przez *ret lub ret->
// ret->numAnimating
}
Jakie są zalety kodu w wersji z użyciem std::optional
?
- Czysta i ekspresywna forma
- Efektywność: implementacje
std::optional
nie zezwalają na dodatkowe magazynowanie, jak dynamiczna alokacja pamięci dla przechowywanej wartości. Przechowywana wartość powinna być przydzielana w tym samym regionie cooptional
, odpowiednio wyrównanym dla typu T. - Nie przejmujemy się dodatkowymi alokacjami pamięci.
Wersja optional
jest dla mnie numerem 1.
Kod
Możesz pobawić się tym kodem, skompilować go i eksperymentować:
#include <iostream>
#include <tuple>
#include <optional>
class ObjSelection
{
public:
bool IsValid() const { return true; }
};
bool CheckSelectionVer1(const ObjSelection &objList, bool *pOutAnyCivilUnits, bool *pOutAnyCombatUnits, int *pOutNumAnimating)
{
if (!objList.IsValid())
return false;
// zmienne lokalne:
int numCivilUnits = 0;
int numCombat = 0;
int numAnimating = 0;
// skanowanie...
// ustawianie wartości:
if (pOutAnyCivilUnits)
*pOutAnyCivilUnits = numCivilUnits > 0;
if (pOutAnyCombatUnits)
*pOutAnyCombatUnits = numCombat > 0;
if (pOutNumAnimating)
*pOutNumAnimating = numAnimating;
return true;
}
std::tuple<bool, bool, bool, int> CheckSelectionVer2(const ObjSelection &objList)
{
if (!objList.IsValid())
return {false, false, false, 0};
// zmienne lokalne:
int numCivilUnits = 0;
int numCombat = 0;
int numAnimating = 0;
// skanowanie...
return {true, numCivilUnits > 0, numCombat > 0, numAnimating };
}
struct SelectionData
{
bool anyCivilUnits { false };
bool anyCombatUnits { false };
int numAnimating { 0 };
};
std::pair<bool, SelectionData> CheckSelectionVer3(const ObjSelection &objList)
{
SelectionData out;
if (!objList.IsValid())
return {false, out};
// skanowanie...
return {true, out};
}
std::optional<SelectionData> CheckSelectionVer4(const ObjSelection &objList)
{
if (!objList.IsValid())
return { };
SelectionData out;
// skanowanie...
return {out};
}
int main()
{
ObjSelection sel;
bool anyCivilUnits = false;
bool anyCombatUnits = false;
int numAnimating = 0;
if (CheckSelectionVer1(sel, &anyCivilUnits, &anyCombatUnits, &numAnimating))
std::cout << "ok...\n";
auto [ok, anyCivilVer2, anyCombatVer2, numAnimatingVer2] = CheckSelectionVer2(sel);
if (ok)
std::cout << "ok...\n";
auto retV4 = CheckSelectionVer4(sel);
if (retV4.has_value())
std::cout << "ok...\n";
}
Podsumowanie
W tym wpisie mogliśmy zobaczyć jak można zrefaktoryzować wiele brzydko wyglądających parametrów wyjściowych do postaci std::optional
. Opcjonalny wrapper czysto wyraża to, że wszystkie wyliczane wartości mogą nie być dostępne. Dodatkowo, przedstawiłem, w jaki sposób można opakować te parametry w oddzielną strukturę. Posiadanie jednej wydzielonej struktury pozwala nam na łatwe rozszerzanie kodu, pozostawiając strukturę logiczną nieruszoną.
Z drugiej strony, nowa implementacja pomija jeden ważny aspekt: obsługę błędów. Na tą chwilę nie potrzebujemy wiedzieć, dlaczego wartość nie została obliczona. W wersji gdzie używalśmy std::pair
mieliśmy możliwość zwrócenia kodu błędu wskazującego jego powód.
Tutaj coś, co znalazłem w dokumentacji boost-a³:
It is recommended to use optional<T> in situations where there is exactly one, clear (to all parties) reason for having no value of type T, and where the lack of value is as natural as having any regular value of T
Innymi słowy, wersja std::optional
wygląda ok tylko wtedy, kiedy chcemy wyrazić to, że nieprawidłowe zaznaczenie jest w aplikacji normalną sytuacją. To świetny temat na następny wpis :) Zastanawiam się nad lepszymi miejscami, gdzie możemy użyć std::optional
.
Jak Wy zrefaktorowalibyście pierwszą wersję kodu?
Wolicie zwracać tuple
, czy może korzystać ze struktur?
Zachęcam również do przeczytania: Jak używać std::optional z C++17
Tutaj kilka artykułów, które pomogły mi przy pisaniu tego posta:
- Andrzej’s C++ blog: Efficient optional values
- Andrzej’s C++ blog: Ref-qualifiers
- Clearer interfaces with optional<T> - Fluent C++
¹, ², ³ - Treść zostawiona w formie oryginalnej