Dedukcja typu klasy szablonowej


2018-08-08, 01:47

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.



Wojciech Razik

Programista C++ z wieloletnim stażem. Uwielbia czytać standard C++ przed snem, na co dzień tworzy oprogramowanie do robota. Jego drugą pasją jest hejtowanie JSa.

Profil na LinkedIn
Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.