Wprowadzenie do problemu
Szablony (ang. templates) mogą nieść za sobą pewien niepokojący wydźwięk – dla niektórych są one wciąż zagadką-wyzwaniem, do którego zwyczajnie podchodzić się nie da, dla innych są narzędziem, które należy stosować z zachowaniem szczególnej ostrożności, dla jeszcze innych są one nieodzownym elementem codziennej pracy przy kodzie, a dla pozostałego ułamka procenta fanatyków C++ są one placem zabaw z nieskończonymi (dosłownie) możliwościami.
Interesującym jest to, iż można znaleźć solidne argumenty dla istnienia i podejść każdej z grup wymienionych powyżej, ale nie jest to rzecz, o której będziemy tutaj mówić.
Załóżmy sytuację, gdzie mamy funkcję, która sumuje wszystkie elementy z jakiejś kolekcji. Dla dobra i prostoty przykładu posłużymy się najpopularniejszym kontenerem w języku:
double accumulate(const std::vector<double>& vec) {
double sum = 0.0;
for (const double element : vec) {
sum += element;
}
return sum;
}
Funkcja działa, przechodzi testy i code-review. Jej użycie jest tak proste, jak tylko może być:
int main() {
const auto vector = std::vector<double>{3.14, 2.71, 42.0};
std::cout << accumulate(vector);
}
Co oczywiście drukuje nam na ekranie 47.85
.
Warto jednak zatrzymać się przy tym kodzie i postawić pewne pytania:
- Dlaczego funkcja nazywa się
accumulate
? - Czemu zakładamy, że korzystamy zawsze z
std::vector
? - Dlaczego zakładamy, że korzystamy zawsze z
double
? - Dlaczego początkową wartością jest
0
? Czy zawsze w przypadku braku elementów powinniśmy sygnalizować, że suma to0
?
Domyślna wartość, do której sumujemy wszystkie elementy
Odpowiedzmy sobie na te wszystkie pytania, ale w odwrotnej kolejności. Zatem:
Dlaczego początkową wartością jest
0
? Czy zawsze w przypadku braku elementów powinniśmy sygnalizować, że suma to0
?
Jest to, być może, najmniej ważny element warty uwagi. O ile suma zerowej liczby elementów to 0 z matematycznego punktu widzenia (zobacz: ang. additive identity), to warto się zastanowić, czy zawsze tak jest. Ale co konkretnie oznacza „zawsze”? Przecież, ewidentnie, zawsze mamy takie założenia w matematyce. Czemu by w kodzie miało być inaczej?
Do tego jeszcze wrócimy, lecz naprawmy ten (potencjalnie) wyimaginowany problem. Dajmy użytkownikowi możliwość sprecyzowania początkowej wartości, która dodatkowo będzie zwrócona w przypadku braku elementów:
double accumulate(const std::vector<double>& vec, const double& init) {
double sum = init;
for (const double element : vec) {
sum += element;
}
return sum;
}
Użycie wciąż jest trywialne, w końcu wymagamy tylko podania początkowej wartości:
int main() {
const auto vector = std::vector<double>{3.14, 2.71, 42.0};
std::cout << accumulate(vector, 0.0);
}
Nie tylko pamiętajmy, ale i rozumiejmy dobre praktyki przyjmowania argumentów
Zwróćmy jeszcze uwagę na to, jak przekazujemy argumenty do naszej funkcji. std::vector
przekazujemy przez const&
(stała referencja), ponieważ nie tylko nie chcemy kopiować całego zbioru danych (zapobiega temu użycie referencji – &
), ale też nie chcemy nic w tym zbiorze zmieniać, stąd const
. No bo po co zmieniać, skoro tylko sumujemy elementy, prawda?
Podobnie robimy z drugim argumentem. Przekazywanie przez const&
jest zwykle dobrą praktyką, lecz w tym przypadku nie jest ona preferowana. Dlaczego? Ponieważ przekazywanie tak małych obiektów (tak, obiektów – według standardu C++ zmienna typu prymitywnego też jest nazywana obiektem) przez stałą referencję nie niesie ze sobą żadnych zysków. Lepiej jest ją zwyczajnie skopiować.
Co więcej – jeżeli pozbędziemy się zarówno modyfikatora &
jak i const
, to będziemy mogli skrócić kod do następującej postaci:
double accumulate(const std::vector<double>& vec, double init) {
for (const double element : vec) {
init += element;
}
return init;
}
Którego użycie dalej pozostaje takie samo.
(Nie)szablonowe myślenie
Przejdźmy zatem do kolejnego pytania:
Dlaczego zakładamy, że korzystamy zawsze z
double
?
No właśnie – dlaczego? Co jeżeli ktoś by chciał zsumować elementy z wektora przechowującego int
y, float
y czy long long
i? Pierwszym rozwiązaniem, które przychodzi na myśl, jest użycie przeciążania (ang. overloading). Szybko natomiast widać, dlaczego to rozwiązanie jest nieskalowalne, czyli jest zwyczajnie złe w kontekście długiej pracy przy kodzie:
double accumulate(const std::vector<double>& vec, double init) {
for (const double element : vec) {
init += element;
}
return init;
}
long long accumulate(const std::vector<long long>& vec, long long init) {
for (const long long element : vec) {
init += element;
}
return init;
}
float accumulate(const std::vector<float>& vec, float init) {
for (const float element : vec) {
init += element;
}
return init;
}
int accumulate(const std::vector<int>& vec, int init) {
for (const int element : vec) {
init += element;
}
return init;
}
Duplikacja, duplikacja i, znowu, duplikacja. Każdy programista albo wie, że duplikacja kodu jest zła, albo jeszcze nie wie, choć podświadomie to przeczuwa, a za niedługo się dowie. Przyjrzyjmy się dokładnie tym funkcjom. Mają one takie same:
- nazwy
- nazwy zmiennych
- ciała
Czym się zatem różnią? Tylko i wyłącznie typami, z którymi pracują. Jedna korzysta z int
a, druga z float
a, inna z long long
a. Spróbujmy zrobić ogólny… szablon (?!) takiej funkcji. Jakiś wzorzec, można by powiedzieć. Coś, co będzie wyglądało tak samo, jak każde z tych przeciążeń funkcji. Skoro różnią się tylko i wyłącznie w typach, z którymi pracują, to spróbujmy sobie wyobrazić, że mamy jakiś nowy, ogólny typ, który jest zamiennikiem dowolnego innego. Spróbujmy sobie wyobrazić, że możemy powiedzieć „ja nie wiem, jaki to powinien być typ, więc wstawię tutaj jakąś literę lub inny symbol i zakładam, że można te literę zastąpić int
em, double
m, float
em lub dowolnym innym typem”.
Taki kod wyglądałby następująco:
T accumulate(const std::vector<T>& vec, T init) {
for (const T element : vec) {
init += element;
}
return init;
}
Zmiana jest więc trywialna – wzięliśmy jedno z przeciążeń funkcji pokazanych wyżej i zamieniliśmy wszystkie typy na jakąś losowo wybraną literę – ja wybrałem T
.
(Już)szablonowe myślenie
Co teraz? Teraz, niestety, mamy błąd kompilacji. Nie ma takiego typu jak T
a kompilator nie wie, że chodzi nam o to, aby ta litera zastępowała dowolny typ, czyli była placeholderem.
Ale spokojnie. Nie trafiliśmy na ślepy zaułek. Każdy nasz ruch miał jakiś cel – jedyny obecny problem jest taki, że kompilator (zgodnie z tym, co powiedzieliśmy przed chwilą) nie wie, o co nam chodzi. Nie wie, co oznacza T
. Powiedzmy mu zatem, że T
jest jakimś zamiennikiem na dowolny typ. Taki manewr zmieni naszą funkcję w szablon funkcji:
template <typename T>
T accumulate(const std::vector<T>& vec, T init) {
for (const T element : vec) {
init += element;
}
return init;
}
Od razu widać pewne znaczące różnice. Plus jest taki, że one wszystkie sprowadzają się do jednej, dodatkowej linii kodu przez naszą funkcją. Pojawiły się dwa nowe słowa kluczowe: template
oraz typename
. Ich znaczenia są bardzo proste:
template
oznacza, że mamy do czynienia z szablonem. Nic dodać, nic ująć. Zawsze, kiedy chcemy napisać jakiś szablon, musi na początku pojawić się słowo kluczowetemplate
1.typename
, po którym następuje jakiś identyfikator (ja wybrałem literę T), mówi, że będziemy wprowadzać jakiś placeholder dla typu, czyli coś, co może być traktowane jako dowolny typ.
Ogromnym plusem jest to, że użycie wciąż się nie zmienia:
int main() {
const auto vector = std::vector<double>{3.14, 2.71, 42.0};
std::cout << accumulate(vector, 0.0);
}
Na razie nie przejmujmy się, jak dokładnie działa ten mechanizm. Widać, że osiągnęliśmy to, co chcieliśmy
Zwróćmy jeszcze uwagę, że mając tylko tę jedną definicję accumulate()
, możemy wywołać ją z wieloma różnymi typami, co prezentuje rozszerzony main()
poniżej:
int main() {
const auto vector1 = std::vector<double>{3.14, 2.71, 42.0};
const auto vector2 = std::vector<int>{1, 2, -5};
const auto vector3 = std::vector<long long>{30ll, 40ll, 50ll};
std::cout << accumulate(vector1, 0.0) << ' ';
std::cout << accumulate(vector2, 0) << ' ';
std::cout << accumulate(vector3, 0ll);
}
Kod ten drukuje wartości 47.85 -2 120
.
Rozumiejmy to, co robimy
Jak widać, stworzyliśmy szablon, który wygląda jak funkcja, która może działać z praktycznie dowolnym typem (o tym za chwilę). Nie interesowały nas szczegóły dotyczące tego, jaki to typ – kod zawsze był taki sam. Szablony pozwalają nam zaadaptować generyczne zachowanie (zachowanie w znaczeniu funkcji) i zaaplikować je do wielu typów.
Warto też zwrócić uwagę, że kod ten zadziała dla każdego typu, który ma zdefiniowane odpowiednie operacje. Jakie? Takie, których używamy w funkcji. Jest to kopiowanie (do argumentu funkcji i iterowania) i operator +=
. Możemy zatem rozszerzyć przykład o std::string
, który również wspiera obydwie te operacje:
int main() {
const auto vector = std::vector<std::string>{
std::string("world "),
std::string("of "),
std::string("templates "),
};
std::cout << accumulate(vector, std::string(""));
}
Oczywistym jest, że jeżeli przekażemy niekopiowalny typ lub taki, który nie ma zdefiniowanego operatora +=
, to nasz szablon zwyczajnie się nie skompiluje.
Powyższy kod wyświetla tekst world of templates
. Korzystając z faktu, że zmodyfikowaliśmy już wcześniej kod i możemy precyzować dowolną wartość początkową, możemy powyższy przykład zamienić na taki, który wydrukuje Hello world of templates
, w następujący sposób:
int main() {
const auto vector = std::vector<std::string>{
std::string("world "),
std::string("of "),
std::string("templates "),
};
std::cout << accumulate(vector, std::string("Hello ")); // <= zmiana
}
Widać już, dlaczego moglibyśmy chcieć precyzować początkową wartość. Czasami chcemy zacząć od czegoś innego niż od „pustego” elementu (lub zera, w przypadku liczb).
Praktyka czyni… zwyczaje
Zanim przejdziemy do odpowiadania na dalsze pytania postawione na początku artykułu, spójrzmy na kilka dodatkowych przykładów tworzenia szablonów funkcji. Warto zapamiętać, że są to szablony funkcji, a nie funkcje. Nie będziemy się jednak koncentrować na tym, co to dokładnie znaczy. Po prostu warto mieć te informację z tyłu głowy.
Warto również mieć świadomość, do jakich celów najlepiej używać szablonów. Przykład accumulate()
jest odpowiedni, ponieważ jest to zachowanie, które działa tak samo (z wysokopoziomowego punktu widzenia) dla każdego typu. Wiele typów chcemy ze sobą sumować – stąd motywacja do użycia takiego przykładu. Kolejnym przykładem jakiegoś zachowania, które często chcemy móc definiować dla dowolnego typu, jest zamienianie wartości. Załóżmy, że mamy dwie zmienne i chcemy zamienić ich wartości:
int main() {
int x = 10;
int y = 20;
int tmp = x;
x = y;
y = tmp;
// x oraz y maja tutaj zamienione wartosci
}
Ponownie zwróćmy uwagę na to, że, niezależnie od typu, algorytm zamieniania wartości dwóch zmiennych jest zawsze taki sam2:
int main() {
long x = 10;
long y = 20;
long tmp_long = x;
x = y;
y = tmp_long;
std::string s1 = "abc";
std::string s2 = "def";
std::string tmp_str = s1;
s1 = s2;
s2 = tmp_str;
}
Czyli:
- tworzymy dodatkową zmienną będącą kopią jednej z wartości
- nadpisujemy zmienną, która została skopiowana
- nadpisujemy zmienną użytą do poprzedniego nadpisania za pomocą
Tak więc nieważne, czy to int
, long
, std::string
, czy std::vector<std::vector<char>>
, zamiana dwóch zmiennych o tym samym typie może być zaimplementowana zawsze na tej samej zasadzie.
Napiszmy zatem szablon, który będzie robił właśnie to:
template <typename T>
void swap(T& left, T& right) {
T tmp = left;
left = right;
right = tmp;
}
Nic nas tutaj nie powinno zaskoczyć. Zaczynamy od słowa kluczowego template
, ponieważ mamy do czynienia z szablonem. Chcemy, aby ten kod działał dla dowolnego typu (który spełnia dane wymagania – tutaj wymagamy, aby można było go skopiować (linijka 3) i nadpisać (linijka 4 i 5)).
Użycie jest standardowo, wciąż intuicyjne:
int main() {
long x = 10;
long y = 20;
swap(x, y);
double d1 = 3.14;
double d2 = 2.73;
swap(d1, d2);
}
Dalsze aplikacje szablonowych praktyk
Zostawmy już szablon swap()
i wróćmy do naszego głównego przykładu z accumulate()
. Odpowiedzmy sobie zatem na kolejne pytanie:
Dlaczego zakładamy, że korzystamy zawsze z
std::vector
?
Jest to, poniekąd, bliźniacze pytanie do poprzedniego. Zdecydowaliśmy się (z jakiegoś arbitralnego powodu) na użycie std::vector
oraz na użycie double
. Jak wiadomo, ograniczanie tak ogólnego algorytmu tylko do jednego typu wartości (double
) nie było odpowiednim rozwiązaniem.
Dlaczego zatem ograniczamy się tylko do jednego typu kontenera? Ewidentnie przecież widać, że dokładnie taki sam kod zadziała dla, przykładowo, std::set
:
int main() {
const auto ints = std::set<int>{1, 5, 3, 0, 8};
int init = 0;
for (const int element : ints) {
init += element;
}
std::cout << init;
}
Widzimy tutaj duplikację – przecież algorytm jest identyczny jak nasz accumulate()
. Przyjrzyjmy się mu jeszcze raz:
template <typename T>
T accumulate(const std::vector<T>& vec, T init) {
for (const T element : vec) {
init += element;
}
return init;
}
Niestety, nasz accumulate()
nie przyjmie zmiennej typu std::set
, nawet jeżeli będziemy akceptować dowolny typ wartości w środku kontenera. To dlatego, że o ile może on pracować z std::vector
em przechowującym obiekty dowolnego typu, to nie może pracować on z std::set
em – typ argumentu by się nie zgadzał. Nie będzie on też akceptował std::deque
czy std::list
. Gdyby tylko istniał jakiś sposób, aby napisać funkcję, która będzie akceptowała dowolny typ (który udostępnia jakieś zdefiniowane funkcjonalności, takie jak przechodzenie po jego elementach)…
Chwileczkę, przecież znamy takie narzędzie – od tego są szablony! Zamiast mówić, że pobieramy dowolny std::vector
, pobierzmy dowolny kontener. Skąd wiemy, że użytkownik zawsze przekaże kontener, jeżeli powiemy, że akceptujemy cokolwiek? Nie wiemy, ale to nam nie przeszkadza. Jeżeli ktoś przekaże nie-kontener, to kod się zwyczajnie nie skompiluje. Tak samo, jakbyśmy przekazali do naszego accumulate()
jakiś std::vector
przechowujący dane, które nie mają zdefiniowanego operatora +=
. Z reguły nie przejmujemy się takimi rzeczami.
Dalsza implementacja szablonowych praktyk
Zdefiniujmy zatem szablon, który będzie mógł pracować na:
- dowolnym kontenerze
- dowolnym typie danych, które przechowuje ten kontener
Zwróćmy uwagę na podwójne użycie terminu „dowolnym”. Wcześniej chcieliśmy pracować na dowolnym typie, tak więc skorzystaliśmy z szablonu, który wprowadzał jeden placeholder (typename T
). Teraz potrzebujemy dwóch. Zatem wprowadźmy dwa placeholdery (dwa osobne typename
’y):
template <typename C, typename T>
T accumulate(const C& container, T init) {
for (const T element : container) {
init += element;
}
return init;
}
Zatrzymajmy się na chwilę. Przeanalizujmy kod powyżej. Nie zmieniło się wcale tak dużo, prawda?
Nie ma już nigdzie std::vector
. Jest za to C
. Co to jest C
? Widząc poprzednią linijkę wiemy, że jest to typename
, czyli placeholder. Zakładamy, że będzie to dowolny kontener, który będzie przechowywał obiekty typu T
. Dlaczego obiekty typu T
? Spójrzmy na drugi argument, na typ zwracany oraz na typ, którego używamy do iteracji w pętli – widać, że oczekujemy pracy z obiektami typu T
przy iteracji poprzez obiekt typu C
. Do tego też typu wszystko sumujemy i to zwracamy.
Niektórzy mogą się zacząć zastanawiać, jaką mamy gwarancję, że zmienna container
na pewno zawiera obiekty typu T
. Nie mamy. Istnieją techniki, które wprowadzą taki wymóg, ale na razie nie są nam potrzebne. Generalnie jeżeli ktoś przekaże niepoprawne typy (np. spróbuje zsumować kontener double
’i do pojedynczego std::string
), to dostanie błąd kompilacji.
Warto też zapamiętać, że takich typename
’ów może być wiele. Wszystko zależy od tego, czego potrzebujemy i co próbujemy osiągnąć.
Spójrzmy na przykłady użycia tego szablonu:
int main() {
const auto set_of_ints = std::set<int>{
1, 2, 3, 4, 5
};
const auto vec_of_doubles = std::vector<double>{
3.14, 2.71, 42.0
};
const auto list_of_strings = std::list<std::string>{
std::string("world "),
std::string("of "),
std::string("templates "),
};
std::cout << accumulate(set_of_ints, 0) << ' '
<< accumulate(vec_of_doubles, 0.0) << ' '
<< accumulate(list_of_strings, std::string(" "));
}
Efektem uruchomienia tego kodu będzie wyświetlenie tekstu: 15 47.85 world of templates
.
Globalne aplikacje szablonowych praktyk
Widać już, jak imponujące możliwości dają nam szablony. Jeden szablon, jeden algorytm, a wspiera pracę z tak wieloma kombinacjami typów.
Na (prawie) koniec odpowiedzmy sobie na ostatnie pytanie:
Dlaczego funkcja nazywa się
accumulate
?
Powód jest tak naprawdę tylko jeden – ponieważ tak nazywa ją C++. W standardowej bibliotece, w nagłówku <numeric>
, mamy dostępny szablon funkcji std::accumulate()
. Nie jest on identyczny jak nasz, ponieważ nasz przyjmuje cały kontener, a wersja ze standardowej biblioteki przyjmuje dwa iteratory i trochę inaczej przechodzi przez wszystkie elementy sumując je. Co to jest iterator? To temat na zupełnie inny. Uchylając tylko rąbka tajemnicy, można wspomnieć, że wykorzystanie iteratorów odpowiada na kolejne, niezadane w tym artkule pytanie – dlaczego zakładamy, że zawsze sumujemy wszystkie elementy z danego kontenera?
Podsumowanie aplikacji szablonowych praktyk
Podsumowując, udało nam się zasymulować trochę zaadaptowaną część biblioteki standardowej. W C++ aż roi się od szablonów – nic dziwnego. Pozwalają one na wspieranie generycznych funkcjonalności dla wszystkich typów, które by były zainteresowane taką funkcjonalnością. Szablony to jeden z najważniejszych i najpotężniejszych elementów C++. Może udało nam się poznać trochę więcej niż tylko „wierzchołek góry lodowej”, ale mimo wszystko jeszcze długa droga przed tymi, którzy chcą się stać ekspertami w używaniu tej funkcjonalności.
Nie ma jednak podstaw do obaw – cała reszta wiedzy o szablonach nie jest trudniejsza. Wszystko jest kwestią odpowiedniego przeniesienia się na wyższy poziom abstrakcji, poczytaniu o ułatwiających pracę z szablonami dodatkowych funkcjonalnościach (np. if constexpr
, type traits czy fold expressions3) i w ostateczności skompilowaniu kodu i inspekcji generowania instancji szablonów.
Ostatnia korekta
Na koniec chciałbym wrócić do jednej ze wspomnianych wcześniej kwestii i postawić nowy problem – tym razem taki, którego nie rozwiążemy.
Przytoczmy jeszcze raz ostateczną wersję szablonu accumulate()
, który napisaliśmy:
template <typename C, typename T>
T accumulate(const C& container, T init) {
for (const T element : container) {
init += element;
}
return init;
}
Chciałbym wykorzystać resztki skupienia, jakie jeszcze mamy, aby przyjrzeć się dwóm fragmentom: init
oraz element
. Oryginalnie korzystaliśmy z const&
do przekazywania początkowej wartości. Były to obiekty typu int
. Potem był to double
i long long
. W każdym z tych przypadków prawdziwe było to, o czym napisałem wcześniej – lepiej było skorzystać z kopiowania.
Nie jest to jednak uniwersalne. Weźmy pod uwagę chociażby std::string
, którego użyliśmy wcześniej w przykładzie działania. W przypadku iteracji po kontenerze, element
będzie po kolei kopią każdego obiektu z kolekcji. Czy tego chcemy? Absolutnie nie. Kopiowanie int
a czy double
’a do takich operacji jest jak najbardziej wskazane, ale std::string
a już przecież nie!
Co zatem zrobić? Mimo wszystko jednak warto wziąć pod uwagę relatywne koszty pesymizacji przekazywania małych obiektów przez const&
i porównać je z oszczędnościami płynącymi z optymalizacji wynikającej z unikania kopii przy pracy z większymi obiektami. Te pierwsze może zoptymalizować kompilator, a tego drugiego już nie do końca.
Optymalnie byłoby, gdybyśmy mogli warunkowo korzystać z const&
lub ze zwykłego kopiowania, zależnie od wielkości obiektu. Można próbować osiągać to za pomocą odpowiedniego metaprogramowania, ale jest to wciąż otwarty problem wśród programistów C++.
Oczywiście nie wspominam nawet o semantykach przenoszenia (które by były dodatkową optymalizacją), bo nie jest to tematem artykułu. Chciałem się skupić na szablonach, a nie na optymalnym ich implementowaniu. Pamiętajmy zatem, że w programowaniu (a zwłaszcza w C++) praktycznie wszystko ma nie tyle co drugie, ale też często trzecie lub nawet czwarte dno. Chłońmy wiedzę, ale nie zapominajmy o tym, że doświadczenie jest ważne i czasem jest nie do zastąpienia. Do usłyszenia następnym razem, przy kolejnym wpisie na blogu.
Przypisy
1Tak było przez 16 lub 22 lata – zależy, jak kto patrzy. W C++11 dodano lambdy, które wraz ze standardem C++14 otrzymały możliwość bycia generycznymi (ang. generic lambdas). To był pierwszy raz, kiedy mogliśmy pisać coś, co efektywnie stawało się szablonem, ale nie musieliśmy pisać słowa kluczowego template
. W C++20 sprawa wygląda jeszcze inaczej – możemy pisać auto
nie tylko jako placeholder argumentu lambdy, ale też i zwykłej funkcji, tak więc technicznie rzecz biorąc informacja, którą podałem, jest nieprawdziwa, ale bycie całkowicie poprawnym wprowadziłoby tutaj, moim zdaniem, większy chaos.
2Oczywiście dla niektórych typów nie będzie to optymalny sposób zamian wartości. Niemniej jednak przypadek ogólny zawsze wygląda tak samo.
3Postanowiłem nie tłumaczyć tych dwóch pojęć (type traits i fold expressions), ponieważ tak rzadko spotykałem się z ich tłumaczeniami, że obawiam się, że wprowadzenie ich by skutkowało niepokojąco dużym zamieszaniem. Słyszałem o tłumaczeniach type traits jako cechy typów i fold expressions jako wyrażenia harmonijne, ale… no właśnie…