Na dzisiejszych zajęciach zajmiemy się szablonami (template’ami) klas oraz funkcji (od C++14 istnieją także szablony zmiennych, ale zaznajomienie się z nimi pozostawiamy dla chętnych). Template’y stanowią fundament C++ oraz są głównym powodem, dla którego język ten nie jest tylko “C z klasami” (choć stwierdzenie to można znaleźć w wielu miejscach w sieci). Ich obecność pozwala na pisanie generycznego kodu o maksymalnie szerokiej gamie zastosowań. Przykładem takiego podejścia jest sama biblioteka standardowa (STL - Standard Template Library), w której nie znajdziemy prawie żadnych funkcji i klas, lecz szablony funkcji i klas. Szablony definiujemy zgodnie z następującą składnią:
template< /* lista parametrów */ >
// Tutaj normalna definicja klasy/funkcji/aliasu/obiektu(C++14), wewnątrz której korzystamy z parametrów
Dalej możemy korzystać ze zdefiniowego szablonu w następujący sposób:
/* nazwa szablonu */ < /* konkretne argumenty zgodne z rodzajem zadeklarowanych parametrów */ >
// Powyższa linijka jest nazwą klasy/funkcji/etc., z której możemy korzystać jak z każdej innej klasy/funkcji/etc.
Dzięki zastosowaniu template’ów możemy zdefiniować ciało danej klasy/funkcji/etc. tylko raz, a następnie instancjonować dany szablon dla dowolnych (zgodnych z deklaracją) parametrów, w zależności od potrzeby. Podkreślmy, że klasą/funkcją/etc. jest dopiero instancja szablonu, nie sam szablon. Proces instancjonowania template’ów odbywa się w czasie kompilacji, także możemy mieć pewność, że wykorzystanie tej funkcjonalności języka nie pociąga za sobą żadnego kosztu w wydajności programu. Pełne wprowadzenie do tego tematu czytelnik może znaleźć np. tutaj.
Zanim napiszemy pierwszy szablon, powiedzmy, jakie może on w ogóle mieć rodzaje parametrów. Ich pełną listę możemy oczywiście znaleźć w dokumentacji, tutaj ograniczymy się do dwóch najważniejszych: typów oraz parametrów niebędących typami (tłumaczenie z angielskiego jest niestety mało wdzięczne).
Szablony w C++ mogą być sparametryzowanane typami, zgodnie ze składnią
Zamiast typename
możemy zamiennie użyć
class
, typename
jest jednak zgodne z
powszechną konwencją. Wszędzie, gdzie w definicji danego
sparametryzowanego bytu występuje typ, możemy teraz użyć T
.
Możemy zatem użyć T
m.in. jako:
Wyobraźmy sobie teraz, że mamy napisać funkcję, która przyjmuje 2
liczby i zwraca ich sumę. Gdyby nie template’y, musielibyśmy pisać
osobną funkcję dla int
ów, double
’i,
float
ów, bool
i itd. Teraz wystarczy, że
napiszemy jeden szablon, sparametryzowany typem argumentu i odpowiednio
go zainstancjonujemy (jak dowiemy się niebawem jawne podanie parametrów
szablonu nie będzie konieczne). Przykład ten stanowi jedynie wierzchołek
góry lodowej zastosowań szablonów w C++.
Parametrem szablonu może być także byt inny niż typ. W języku angielskim mówimy non-type template parameter, w dalszej części tego tekstu korzystać będziemy właśnie ze skrótu NTTP. NTTP mogą być:
int
, char
,
bool
, etc.)enum
)std::nullptr_t
(poza zakresem tej instrukcji)NTTP dają szablonowi dostęp do wartości liczbowych (lub im podobnym)
w czasie kompilacji. W konsekwencji w pełni legalna jest np.
statyczna alokacja pamięci uzależniona od liczby całkowitej będącej
parametrem szablonu (int tab[N]
jest w tym kontekście
dopuszczalne).
Możemy teraz napisać nasz pierwszy szablon klasy. Zacznijmy od rzeczy trywialnych.
Napisz szablon klasy Para
, który trzyma 2 obiekty typu,
którym jest sparametryzowany.
Szablonu Para
możemy teraz użyć wszędzie tam, gdzie
potrzebujemy trzymać razem parę obiektów pewnego typu.
Dodaj do szablonu klasy Para
metodę suma
,
która zwraca sumę trzymanych obiektów (użyj operatora
+
)
Widzimy już pierwszy problem towarzyszący szablonom - aby napisany
kod był poprawny, dla typu, którym zainstancjonujemy szablon, musi
istnieć zdefiniowany poprawnie operator +
. W czasie pisania
szablonu nie mamy kontroli nad typem, który zostanie do tego
użyty1. Na szczęście wszystkie błędy tego typu zostaną
wykryte w czasie kompilacji.
Przećwiczmy także klasy sparametryzowane NTTP.
Napisz szablon klasy TablicaPar
sparametryzowany 1 typem
oraz 1 NTTP typu unsigned int
. Niech przechowuje ona
statycznie zaalokowaną tablicę obiektów typu Para<T>
o długości N
, gdzie T
i N
to
parametry klasy TablicaPar<T, N>
.
Przeciąż dla klasy TablicaPar
operator []
tak, aby umożliwić indeksowanie po trzymanej tablicy.
Przećwicz działanie napisanych szablonów na typie double
(np. wypełnij tablicę a następnie policz sumę wszystkich trzymanych par
liczb). Teraz wykonaj to samo zadanie dla typu int
. W ilu
miejscach musiałaś/musiałeś zmodyfikować kod?
Jeżeli chcemy, aby nasz szablon zachowywał się w szczególny sposób dla jakiejś grupy parametrów, możemy dodać do niego specjalizację. Klasy specjalizujemy wg. następującego schematu:
// Definicja
template</* parametry */>
class Klasa { /* ... */ };
// Specjalizacja
template</* lista parametrów specjalizacji, w szczególności może być pusta */>
class Klasa</* konkretne typy/wartości/etc. wynikające z parametrów specjalizacji */>
{ /* ... */ };
Pokażmy to na konkretnym przykładzie:
template <typename T>
struct S {
void print() { puts("Szablon ogólny"); }
};
template <>
struct S<double> {
void print() { puts("Specjalizacja dla double"); }
};
Skopiuj powyższy kod i stwórz w funkcji main
obiekty
typu S<int>
i S<double>
.
Zweryfikuj, że metoda print
działa zgodnie z
oczekiwaniami.
Nie musimy jednak specjalizować klas dla konkretnych parametrów. Zwróćmy uwagę, że sama specjalizacja także posiada listę parametrów, którą możemy wykorzystać. Na przykład:
// Ogólna definicja
template <typename T>
struct S { /* ... */ };
// Specjalizacja dla wskaźników
template <typename T>
struct S<T*> { /* ... */ };
// Specjalizacja dla referencji
template <typename T>
struct S<T&> { /* ... */ };
Zauważmy też, że nie musimy podawać ogólnej definicji szablonu, wystarczy ogólna deklaracja. W takim wypadku, gdy spróbujemy zainstancjonować szablon dla parametrów, które nie pasują do żadnej z jego specjalizacji, nasz program się nie skompiluje. Jest to swego rodzaju sposób nakładania więzów na szablony (choć niezbyt elegancki, vide przypis1). Możemy także postąpić odwrotnie: deklarując specjalizację bez definicji “wyłączamy” ją.
Zadeklaruj specjalizację dla klasy TablicaPar
, która
“wyłączy” puste tablice (czyli klasy TablicaPar<T, 0>
dla każdego T
).
1 Nie jest to do końca prawda, istnieją sposoby nakładania
więzów na typy, którymi parametryzujemy szablon. Zainteresowane osoby
odsyłamy do haseł: SFINAE (dawniej), if constexpr
(C++17)
oraz koncepty (concepts, C++20). Techniki te wykraczają jednak
poza zakres bieżących zajęć.
Szablony funkcji definiujemy zgodnie z tym samym schematem, co szablony klas. Główną różnicę stanowi możliwość dedukcji typów argumentów2 (opisana niżej). Siłą szablonu funkcji jest fakt, że można wykorzystać jego parametry jako typy argumentów (lub wartości zwracanej). Możemy zatem napisac w jednym miejscu dowolnie skomplikowaną implementację pewnego algorytmu działającego na argumentach nie konkretnego typu, ale całej rodziny typów, spełniającej jakieś minimalne założenia tej implementacji. Na przykład, pisząc funkcję
jesteśmy przy jej pomocy w stanie dodać 2 obiekty każdego typu
należacego do rodziny typów, dla których zdefiniowany jest operator
+
zwrcający obiekt tego samego typu co jego argumenty.
Działa więc ona równie dobrze dla typu double
, jak dla typu
Wektor2D
z pierwszego laboratorium. Jest to swego rodzaju
statyczny polimorfizm - mamy wspólny interfejs dla różnych klas. Jeżeli
spróbujemy zainstancjonować szablon z typem niespełniającym naszych
założeń, nasz program się nie skompiluje.
Napisz funkcję iloczyn
, która przyjmuje tablicę typu,
którym jest sparametryzowana oraz liczbę całkowitą będącą rozmiarem
tablicy. Niech zwraca ona iloczyn elementów tej tablicy, liczony
operatorem *
. Zastanów się, jakie założenia czynisz na
temat typu tablicy?
Napisaną powyżej funkcję możemy zawołać np. w następujący sposób:
W drugiej linijce jawne podanie parametru funkcji
iloczyn
jest niepotrzebne. C++ jest statycznie typowany, a
zatem podanie tab
jako argumentu jednoznacznie determinuje
parametr, z jakim ma zostać zainstancjonowany szablon.
Napisz wolnostojącą funkcję sumaPary
, która przyjmuje
parę (w znaczeniu szablonu Para
napisanego wyżej) obiektów
typu, którym jest sparametryzowana i zwraca ich sumę (użyj metody
suma
). Stwórz parę liczb całkowitych i policz ich sumę przy
użyciu funkcji sumaPary
. Ile razy musiałaś/musiałeś użyć
słowa kluczowego int
? Dzięki dedukcji typów argumentów
odpowiedź powinna wynosić 1!
2 Od C++17 istnieje dedukcja parametrów typu obiektu na podstawie typu argumentów jego konstruktora (CTAD), jednak temat ten wykracza poza zakres bieżącego kursu.
W tej części instrukcji pokażemy działanie kilku podstawowych
szablonów biblioteki standardowej. Pierwsze 4 dotyczą tzw. smart
pointers, czyli klas, które pozwalają nam korzystać ze wskaźników w
prostszy i bezpieczniejszy sposób: std::unique_ptr
i
std::shared_ptr
(z nagłówka memory
). Istnieje
także 3. rodzaj smart pointera - std::weak_ptr
- lecz
zaznajomienie się z nim pozostawiamy dla chętnych. Dalej poznamy
std::variant
, std::get
i
std::visit
(z nagłówka variant
), które pozwolą
nam drastycznie uprościć kod z zajęć dotyczących polimorfizmu. Dodajmy,
że celem tego rozdziału nie jest nauczenie czytelnika każdego niuansu
omawianych szablonów (po takowe odsyłamy do dokumentacji), tylko
przedstawienie ich filozofii i podstaw użytkowania, tak, aby w
przyszłości czytelnik wiedział po jakie rozwiązanie sięgnąć w obliczu
konkretnego problemu. W tym rozdziale nie zawieramy także zadań
dotyczących omawianych szablonów. Zamiast tego, po jego przeczytaniu
polecamy przystąpić do wykonywania projektu nr 1, do zaliczenia którego
potrzebne będzie wykorzystanie szablonów omówionych poniżej.
std::unique_ptr
Klasa std::unique_ptr<T>
to smart pointer
(“inteligentny wskaźnik”) posiadający wyłączną własność
nad zasobem typu T
i niszczący ten zasób w swoim
destruktorze (zakres życia zasobu jest ograniczony zakresem życia smart
pointera). Wypunktujmy najważniejsze cechy tego szablonu:
std::unique_ptr<T>
przyjmuje obiekt typu T*
i zarządza zasobem, na który
wskazuje podany wskaźnik. Od C++14 nie korzystamy z tego konstruktora,
lecz zamiast tego z funkcji std::make_unique<T>
.std::unique_ptr
posiada konstruktor domyślny, który
tworzy obiekt, który niczym nie zarządza.std::unique_ptr
ma usunięty konstruktor kopiujący i
kopiujący operator przypisania.std::unique_ptr
ma dobrze zdefiniowany konstruktor
przenoszący i przenoszący operator przypisania. Te dwie metody specjalne
“przejmują” zasób, którym zarządzał argument konstruktora/operatora
przenoszącego.std::unique_ptr
posiada zdefiniowane operatory
*
oraz ->
, które działają analogicznie jak
dla zwykłego wskaźnika.std::unique_ptr
niszczy zasób, którym dany
obiekt zarządza.Szablon klasy std::unique_ptr
także posiada
specjalizację dla typów będących tablicami
(std::unique_ptr<T[]>
), która reprezentuje wyłączną
własność nad tablicą obiektów. Działa ona nieco inaczej niż
ogólny szablon:
std::unique_ptr<T[]>
nie ma przeciążonych
operatorów *
i ->
. Zamiast nich posiada
operator []
, który pozwala na indeksowanie po tablicy,
którą zarządza.std::unique_ptr<T[]>
niszczy trzymane zasoby przy
użyciu delete[]
, a nie delete
(poprawnie usuwa
każdy element tablicy).Wymieniowne powyżej cechy pozwalają nam korzystać z obiektów
std::unique_ptr
dokładnie tak samo, jak z wbudowanych
wskaźników, nie musimy się za to martwić o zwalnianie pamięci. Dodatkowo
mamy pewność, że nigdy nie wykonamy nieumyślnej kopii zasobu, ani nie
spróbujemy odnieść się do zasobu, który został zniszczony. Warto też
zaznaczyć, że dynamiczny polimorfizm opisany w instrukcji nr 3 działa w
niezmienionej formie dla std::unique_ptr
! W konsekwencji,
jeżeli mamy istenijący kod, w którym korzystamy z wbudowanych
wskaźników, to możemy zamienić deklarację wszystkich T*
na
std::unique_ptr<T>
oraz usunąć wszystkie zawołania
operatora delete
(pod warunkiem, że wbudowane wskaźniki
reprezentowały wyłączną własność). Taka operacja pozwoli nam skrócić kod
(nie musimy wołać delete
) oraz zagwarantuje nam jego
poprawność (nigdy nie zapomnimy już zwolnić pamięci, próba kopiowania
wskaźników teraz kończy się błędem kompilacji). Przyjrzyjmy się, jak
może to wyglądać. Rozważmy następujący kod:
bool warunek = sprawdzWarunek();
Baza* wsk_baza;
if (warunek)
wsk_baza = new Pochodna1{};
else
wsk_baza = new Pochodna2{};
wsk_baza->metodaWirtualna();
delete wsk_baza;
Możemy go przepisać jako:
bool warunek = sprawdzWarunek();
std::unique_ptr<Baza> wsk_baza; // konstruktor domyślny
if (warunek)
wsk_baza = std::unique_ptr<Pochodna1>{new Pochodna1{}};
else
wsk_baza = std::unique_ptr<Pochodna2>{new Pochodna2{}};
wsk_baza->metodaWirtualna(); // działa dzięki przeciązeniu operatora ->
// nie musimy pamiętać o wołaniu delete, robi to za nas destruktor!
W tym przykładzie widzimy, że
std::unique_ptr<KlasaPochodna>
jest konwertowalny na
std::unique_ptr<KlasaBazowa>
.
std::make_unique
W powyższym przykładzie, mało eleganckie mogą wydawać się linijki, w
któych tworzymy std::unique_ptr<PochodnaX>
i
przypisujemy je do wsk_baza
. Szczęśliwie, od standardu
C++14, mamy do dyspozycji szablon funkcji std::make_unique
.
std::make_unique<T>(argumenty...)
konstruuje na
stercie obiekt typu T
przy użyciu podanych
argumentów3, a następnie zwraca
std::unique_ptr<T>
do tego obiektu. Efektywnie woła
on za nas operator new
. W konsekwencji, linijkę
możemy zamienić na
co jest niewątpliwie zwięźlejsze i prostsze w zrozumieniu.
std::make_unique
jest jednym z szablonów funkcji, przy
użyciu których nie używamy dedukcji typów, lecz zawsze jawnie podajemy
parametr szablonu funkcji. Jest to bardzo logiczne - nie jesteśmy w
stanie na podstawie typów argumentów stwierdzić typu obiektu, którego
konstruktor chcemy zawołać. Wiele klas może mieć konstruktory, które
przyjmują dany zestaw typów!
Powyżej omówiliśmy 2 typy inteligentnych wskaźników:
std::unique_ptr
reprezentujący wyłączną własność oraz
std::shared_ptr
reprezentujący własność współdzieloną.
Jeżeli różnice między nimi nie są w pełni jasne, odsyłamy czytelnika np.
do tego nagrania. Poprawne
ich wykorzystanie pozwala na wyeliminowanie wycieków pamięci poprzez
automatyzację (do pewnego stopnia) zarządzania zasobami. Dzięki
pomocniczym funkcjom std::make_unique
i
std::make_shared
możemy więc sformułować następującą zasadę
programowania w C++:
Nigdy nie wołaj bezpośrednio operatorów new
i
delete
Znając te narzędzia warto też wiedzieć, kiedy po nie sięgać. Temat ten jest omówiony bardzo dokładnie np. w tym nagraniu (jest to półtoragodzinny wykład, także wymieniamy je jako materiał nadprogramowy). Decydując po jakie rozwiązanie sięgnąć, powinniśmy kierować się następującą hierarchią:
std::make_unique
) i zarządzmy
nim przez std::unique_ptr
.std::shared_ptr
sięgamy dopiero wtedy, gdy
std::unique_ptr
nie jest wystarczający.Uwaga: std::unique_ptr
nadal możemy podawać do funkcji
przy pomocy referencji. Konieczność korzystania z
std::shared_ptr
objawia się głównie w programach
wielowątkowych (zasób współdzielony przez więcej niż jeden wątek, jest
automatycznie niszczony gdy wszystkie wątki zakończą pracę) lub w
strukturach danych będących grafami (dany wierzchołek może mieć więcej
niż jednego rodzica).
std::variant
Cofnijmy się na chwilę do rozważań o dynamicznym polimorfizmie z poprzedniej instrukcji. Celem stosowania kombinacji dziedziczenia i metod wirtualnych była praca z obiektem, którego typ był tak jakby zmienny w czasie wykonania programu. Mając wskaźnik do klasy bazowej, mogliśmy, na podstawie np. wartości wpisanych z klawiatury, decydować na obiekt którego typu pochodnego będzie wskazywał. Rozwiązanie to było jednak obarczone następującymi problemami:
virtual
, szczególnie przy
destruktorzeOdpowiedzią na te problemy jest dodany w standardzie C++17 szablon
std::variant
. Wprowadza on do XXI wieku koncepcję unii
typów, znaną jeszcze z C (choć zapewnie nie z kursu informatyki na
wydziale MEiL). Szablon ten wygląda następująco:
Instancja klasy std::variant<T1, T2,...>
w danym
momencie trzyma obiekt dokładnie jednego z typów T1
,
T2
, itd. Poniżej będziemy nieformalnie odnosić się do tego
ciągu typów jako “paczki typów wariantu”. Wypunktujmy jego najważniejsze
cechy:
std::variant
nigdy
nie dokonuje dynamicznej alokacji dodatkowej pamięciT1
,
T2
,… plus pewna (mała) stała wartość (np. w kompilatorze
gcc
jest to 8B)ale już nie
gdyż wartość 3.14
jest typu double
(a
dokładniej double&&
), konwersja na
float
nie jest tu dopuszczalna. Jeżeli chcemy jawnie
wymusić typ obiektu, który ma trzymać wariant, możemy użyć 5.
przeciążenia konstruktora z
dokumentacji.
T1
)size_t index()
, która zwraca indeks
(liczony od 0) trzymanego obecnie typu z podanej paczki typów wariantu.
Np.:std::variant<int double> v1{42};
std::variant<int double> v2{42.};
std::cout << v1.index() << ' ' << v2.index();
wydrukuje 0 1
. Z tej metody nie korzystamy jednak zbyt
często (po prostu nie ma takiej potrzeby, nie ze względu na jakieś dobre
praktyki). - dostęp do obiektu trzymanego przez wariant odbywa się przez
std::get
i std::visit
, opisane poniżej
std::get
4Mamy dany obiekt typu std::variant<T1, T2,...> v
,
który wiemy, że trzyma w danej chwili obiekt typu T2
.
Możemy uzyskać dostęp do tego obiektu dostęp na 2 różne sposoby:
T2
występuje w
paczce typów wariantu dokładnie raz)Jeżeli v
nie trzymałby w danej chwili wartości typu
T2
, operacja rzuci wyjątek. O wyjątkach dowiemy się więcej
na późniejszym laboratorium, na chwilę obecną powiedzmy jedynie, że
próba dostępu do wartości trzymanej przez wariant przez niepoprawny typ
spowoduje zakończenie pracy naszego programu w trybie awaryjnym. Dodajmy
też, że std::get
zwraca referencję do trzymanego
obiektu, także nie musimy wykonywać jego kopii. Jeżeli chcielibyśmy to
zrobić, możemy oczywiście zawołać po prostu:
std::visit
Poznana dotychczas funkcjonalność pozwala nam na napisanie wizytatora
wariantu (spokojnie, jest to dużo prostsze niż w przypadku wirtualnego
polimorfizmu). Jeżeli mamy wariant sparametryzowany paczką
T1
, T2
,… i wiemy, że każdy z typów należących
do tej paczki ma metodę drukuj
, możemy napisać następującą
funkcję:
void drukujWariant(const std::variant<T1, T2,...>& v)
{
if (v.index() == 0)
std::get<0>(v).drukuj();
else if (v.index() == 1)
std::get<1>(v).drukuj();
// itd ...
}
Funkcja ta jest bardzo konkretnym wizytatorem, który woła metodę
drukuj
obiektu trzymanego przez wariant. Podobnie jak w
przypadku wirtualnego polimorfizmu, chcielibyśmy teraz uogólnić ideę
wizytowania, tzn. stworzyć uniwersalny mechanizm, przy użyciu którego
możliwe jest zawołanie dowolnej zdefiniowanej przez siebie funkcji,
która obsłuży w odpowiedni sposób różne możliwe obiekty trzymane przez
wariant (spoiler alert: taki mechanizm dostarcza biblioteka standardowa,
spróbujemy jednak najpierw stworzyć go sami, aby zrozumieć, jak działa).
Tutaj ujawni się esencja wygody (tak, wygody, nie skomplikowania), którą
mogą zapewnić nam template’y.
Zanim przejdziemy do przypadku wariantu, zastanówmy się nad zagadnieniem przekazywania funkcji jako argumentów innych funkcji. W języku C służyły do tego wskaźniki do funkcji, które były jednak niewygodne oraz cechowały się dość mało intuicyjną składnią. Aby zobaczyć, jak rozwiązujemy to zagadnienie w C++, pochylmy się nad następującym przykładem. Chcielibyśmy napisać szablon funkcji, która przyjmie argument “wołalny” (ang. callable) oraz drugi argument dowolnego typu, a następnie podaje drugi argument do wywołania pierwszego argumentu. Mówiąc prościej, chcielibyśmy przyjąć obiekt funkcjo-podobny oraz jego argument i wywołać tę (tak jakby) funkcję z tym argumentem. Dzięki template’om, możemy w trywialny sposób zapisać taką abstrakcję:
Pomijamy rozważania dotyczące przyjmowania argumentów jako referencje i wykonywania kopii, gdyż nie to jest tutaj istotne. Mając taki szablon, możemy teraz napisać:
Dzięki dedukcji typów nie musimy się przejmować, czym jest tak
naprawdę drukuj
podany jako argument do
zawolaj
. Maszyneria template’ów martwi się o to za nas, a
my możemy spędzić nasz czas na rzeczach bardziej produktywnych niż
przypominanie sobie składni wskaźników do funkcji z języka C (bo to
właśnie ta funkcjonalność jest przez nas wykorzystana w powyższym
przykładzie). Kłopoty pojawią się, gdy funkcja drukuj
będzie miała więcej niż jedno przeciążenie. Nie będzie wtedy
jednoznaczne, które znich ma zostać podane do funkcji (czytelnik może
sprawdzić to samodzielnie). Zamiast tego, możemy podać obiekt,
który posiada przeciążenia operatora nawiasów okrągłych dla wszystkich
potrzebnych typów. Konkretnie:
struct Drukarka
{
void operator()(int i) { std::cout << "int: " << i << '\n'; }
void operator()(double d) { std::cout << "double: " << d << '\n'; }
};
Teraz możemy zawołać:
Drukarka d;
zawolaj(d, 42);
zawolaj(d, 1.);
// Lub zwięźlej:
// zawolaj(Drukarka{}, 42);
// zawolaj(Drukarka{}, 1.);
Idea reprezentacji operacji przez obiekty ze zdefiniowanym operatorem
()
(tzw. obiekty funkcyjne lub funktory) zostanie
rozwinięta na laboratorium dotyczącym algorytmów STL, powróćmy teraz
jednak do wizytacji wariantu.
Wykorzystując opisany wyżej chwyt, możemy napisać szablon ogólnego
wizytatora konkretnego wariantu
std::variant<int, double>
(ponownie pomijamy
rozważania nt. referencji i kopiowania):
template <typename Wizytator_t>
void wizytuj(Wizytator_t wizytator, std::variant<int, double> wariant)
{
unsigned int index = wariant.index();
if (index == 0)
wizytator(std::get<0>(wariant));
else if (index == 1)
wizytator(std::get<1>(wariant));
}
Podkreślmy, że próba ominięcia drzewa decyzyjnego skończy się błędem kompilacji
gdyż argumenty template’ów muszą zostać określone w czasie
kompilacji, a operacja wariant.index()
jest z natury rzeczy
sprawdzana w czasie wykonania programu. Zobaczmy jak możemy wykorzystać
ten szablon:
std::variant<int, double> v{1.};
wizytuj(Drukarka{}, v);
// wydrukuje "double: 1"
v = 42;
wizytuj(Drukarka{}, v);
// wydrukuje "int: 42"
Jeżeli zdefiniujemy inny obiekt funkcyjny, możemy postąpić zgodnie z
tym samym schematem! Mamy więc ogólną metodę dostępu do wariantu
std::variant<double, int>
.
Ogólną metodę dostępu do dowolnego wariantu zapewnia nam szablon
funkcji std::visit
. Jest on sparametryzowany nie tylko
typem funktora, ale także typem samego wariantu. Dzięki temu możemy w
sposób analogiczny do tego zobrazowanego wyżej wizytować obiekt każdej
klasy stworzonej przez zainstancjonowanie szablonu
std::variant
. Możemy więc przepisać kod z przykładu
jako:
std::variant<int, double> v{1.};
std::visit(Drukarka{}, v);
// wydrukuje "double: 1"
v = 42;
std::visit(Drukarka{}, v);
// wydrukuje "int: 42"
Ponownie widzimy, że nawet tak skomplikowana funkcjonalność jak
std::visit
(pod maską ma ona dużo meta-programowania) może
być przez nas wykorzystana w prosty sposób, a wszystko dzięki dedukcji
parametrów z typów argumentów oraz bibliotece standardowej.
std::variant
std::variant
daje nam możliwość trzymania różnych typów
w jednym obiekciestd::visit
virtual
Na koniec zobaczmy, jak przepisać wizytator kształtów z poprzedniego laboratorium.
#include <iostream>
#include <string>
#include <variant>
// Uproszczona klasa koło
class Kolo
{
public:
Kolo() : r{0} {}
Kolo(double r_) : r{r_} {}
void id() { std::cout << "Jestem kołem o polu " << 3.14 * r * r << '\n'; };
private:
double r;
};
// Uproszczona klasa kwadrat
class Kwadrat
{
public:
Kwadrat() : a{0} {}
Kwadrat(double a_) : a{a_} {}
void id() { std::cout << "Jestem kwadratem o polu " << a * a << '\n'; }
private:
double a;
};
// Wizytujący funktor, pokażemy jak ominąć jego definicję na lab 6
struct WizytatorKsztaltu
{
void operator()(Kolo k) { k.id(); }
void operator()(Kwadrat k) { k.id(); }
};
int main()
{
std::variant<Kwadrat, Kolo> v;
std::string s;
std::cin >> s;
double d;
std::cin >> d;
if (s == "kwadrat")
v = Kwadrat{d};
else if (s == "kolo")
v = Kolo{d};
else
{
std::cout << "Nie rozpoznano kształtu\n";
return 1; // wartość inna niż 0 oznacza błąd programu
}
std::visit(WizytatorKsztaltu{}, v);
}
3 Mechanizm, który pozwala definiować szablony dla nieznanej a priori liczby parametrów wykracza poza zakres tego kursu. Zainteresowani mogą szukać hasła variadic templates.
4 W bibliotece standardowej są co najmniej 3 różne
szablony funkcji std::get
. W tym przypadku mowa o szablonie
std::get(std::variant)
, ale są także
std::get(std::array)
i std::get(std::tuple)
.
Służą one jednak do dostępu do klas, które leżą poza zakresem tego kursu
(ze względu na ograniczenia czasowe, nie wysoki stopień skomplikowania
std::tuple
i std::array
).