Dedukcja typu klasy szablonowej
Na początku przygody z C++, szablony uznawane są za “czarną magię”. Wydają się być bardzo trudne w zrozumieniu, i przysparzają kłopotu nawet doświadczonym programistom. Całe szczęście, wraz z kolejnymi wersjami standardu, metaprogramowanie jest coraz łatwiejsze. Spróbujemy się przyjrzeć jednemu z dodatków do C++17, który może sprawić, że nasz kod będzie jeszcze bardziej czytelny.
std::pair i std::tuple
Nic tak nie irytuje programistów, jak pisanie nieeleganckiego kodu. Weźmy na przykład tworzenie obiektów klas std::pair lub std::tuple:
std::pair<int, double> some_pair(3, 2.0);
// or
auto another_pair = std::make_pair(3, 2.0); // std::pair<int, double>
W pierwszym przypadku, musimy wprost napisać typ, w drugim natomiast musimy użyć nieeleganckiego auto i metody pomocniczej make_pair (lub make_tuple). Całe szczęście, w C++17, tworzenie std::pair wygląda tak, jak powinno wyglądać od momentu pojawienia się pary w bibliotece standardowej:
std::pair some_pair(3, 4.0); // std::pair<int, double>
Spróbujmy zdefiniować własną klasę, która pokaże nam w jaki sposób działa dedukcja typu klasy.
Problem
Chcemy stworzyć strukturę, która będzie przechowywała dowolny typ wraz z opisem. Pierwsze, co przychodzi nam do głowy, to prosty template:
template<class T>
struct LabeledType {
T val_;
std::string label_;
};
Aby stworzyć obiekt takiej klasy, przed C++17, musimy albo podać wprost typ:
LabeledType<int> labeled_int {0, "some_int"};
Albo możemy użyć metody, która zwróci nam obiekt odpowiedniego typu (analogicznie do make_pair i make_tuple):
template<typename T>
auto make_labeled(T val, std::string description) {
return LabeledType<T>{val, description};
}
// ...
auto labeled_double = make_labeled(4.3, "some_double");
Jak już wspomnieliśmy, w C++17 powyższa metoda nie jest potrzebna. Wystarczy zadeklarować konstruktor i na jego podstawie kompilator sam wydedukuje typ T:
template<class T>
struct LabeledType {
T val_;
std::string label_;
LabeledType(const T& v, std::string label) : val_(v), label_(std::move(label)) {
std::cout << __PRETTY_FUNCTION__ << "\n";
}
};
// somewhere in the code
LabeledType with_double{42.0, "doubled"}; // OK in C++17
LabeledType with_int{42, "inted"}; // OK in C++17
Makro __PRETTY_FUNCTION__ pozwala na zobaczenie, jaki typ został wydedukowany. Na ekranie pojawi się więc:
LabeledType<double>::LabeledType(const T &, std::string) [T = double]
LabeledType<int>::LabeledType(const T &, std::string) [T = int]
Powyższa implementacja posiada wadę. A co jeśli, spróbujemy wywołać:
LabeledType with_constchar{"cpp", "polska"};
Wydedukowanym typem będzie char[4], kod więc się nawet nie skompiluje. Problem możemy rozwiązać na kilka sposobów.
Pierwszym może być zmiana argumentu konstruktora tak, aby była przekazywana kopia a nie stała referencja. Załóżmy jednak, że z jakiegoś powodu zależy nam na zachowaniu typu konstruktora. W takim przypadku, możemy zadeklarować tzw. “deduction guide”, czyli możemy wprost przekazać kompilatorowi, w jaki sposób powinien zostać wydedukowany typ. Powiedzmy, że w naszym wypadku, gdy natrafimy na łańcuch znaków, chcemy by zmienna w klasie była typu std::string. Wystarczy wtedy zadeklarować poza ciałem klasy:
LabeledType(const char*, std::string) -> LabeledType<std::string>;
Jak widzimy, deduction guide deklarowany jest przy użyciu strzałki, nie jest to żadna funkcja i nie posiada żadnego ciała.
Kolejnym sposobem rozwiązania naszego problemu, może być usunięcie deklaracji konstruktora i włączenia dedukcji dla agregate initialization. Przypomnijmy, od C++11 mając strukturę:
struct S {
int x_;
int y_;
};
Możemy stworzyć jej obiekt korzystając z inicjalizacji klamrami:
S s {42, 0};
Aby zrobić to samo dla naszej klasy LabeledType, możemy zadeklarować deduction guide dla wszystkich typów, używając szablonu:
template<typename T>
LabeledType(T, std::string) -> LabeledType<T>;
Należy tylko pamiętać, że nie deklarujemy konstruktora, więc poniższa konstrukcja się nie skompiluje:
LabeledType foo(4, 5.0); // not compiling
LabeledType foo{4, 5.0}; // OK, uses agregate initialization
Nic nie stoi na przeszkodzie, aby użyć połączyć to z deduction guide zdefiniowanym wcześniej. Pełny kod może więc wyglądać następująco:
#include <iostream>
template<class T>
struct LabeledType {
T val_;
std::string label_;
};
template<typename T>
LabeledType(T, std::string) -> LabeledType<T>;
LabeledType(const char*, std::string) -> LabeledType<std::string>;
int main() {
LabeledType with_const_char {"cpp", "polska"};
LabeledType with_int {3, "polska"};
static_assert(std::is_same<LabeledType<std::string>, decltype(with_const_char)>());
static_assert(std::is_same<LabeledType<int>, decltype(with_int)>());
}
Interaktywny przykład znajdziecie tutaj: https://godbolt.org/g/aQScvJ
Podsumowanie
Metaprogramowanie jest niewątpliwie jedną z trudniejszych rzeczy w języku C++. Wraz jednak z jego rozwojem, jest coraz więcej funkcji ułatwiających pisanie ładnego kodu, bez zbędnych duplikacji. Wiąże się to niestety z nową składnią, która dodaje “kolejną rzecz do zapamiętania” i sprawia, że język ma kolejne “corner case’y”.
Z drugiej jednak strony, szablony używane są głównie w bibliotekach, a zdecydowanie większość z nas jest ich użytkownikami, a nie twórcami. A jako użytkownicy, dzięki dedukcji typu klasy, możemy pisać coraz czytelniejszy kod.