11.14 Szablony funkcji

Często wiele funcji, jakie musimy definiować, jest do siebie bardzo podobnych — różnią się tylko typem argumentów, a czynności, które mają wykonać, są identyczne. Wyobraźmy sobie na przykład funkcję, która z podanej poprzez argument tablicy wybiera element największy. Jeśli chcemy takiej funkcji użyć dla tablicy int ów, dla tablicy liczb typu double, a potem dla tablicy obiektów klasy Osoba (zakładając, że jest w niej zdefiniowana relacja większości), to przyjdzie nam pisać identyczną w zasadzie funkcję trzy razy: typ parametru musi być jawnie zadeklarowany w definicji funkcji, więc nie można użyć tej samej funkcji dla tablicy osób i tablicy liczb. Jeśli okaże się, że potrzebujemy takiej funkcji również dla tablic obiektów klasy Zwierze, to trzeba będzie dodać odpowiednią funkcję i zrekompilować moduł definiujący te funkcje.

I właśnie w takich sytuacjach przychodzą nam z pomocą szablony. Tworzymy wzorzec (czyli szablon), według którego kompilator sam utworzy tyle wersji danej funkcji, ile będzie trzeba; wszystkie będą w zasadzie takie same, ale różnić się będą typami parametrów, wartości zwracanej i/lub zmiennych lokalnych definiowanych wewnątrz funkcji. Można będzie te funkcje wygenerować nawet dla typów, które w chwili pisania szablonu jeszcze w ogóle nie istniały!


Składnię szablonów funkcji omówimy na następującym przykładzie:


P81: tmplt.cpp     Szablon funkcji

      1.  #include <iostream>
      2.  #include <typeinfo>
      3.  using namespace std;
      4.  
      5.  template <class T1, typename T2>                      
      6.  int howmany(T1* arr, T2 mn, T2 mx, int size) {        
      7.      int count = 0;
      8.      for (int i = 0; i < size; ++i)
      9.          if (arr[i] > mn && arr[i] < mx) ++count;
     10.  
     11.      // test
     12.      cout << "T1=" << typeid(T1).name() << " "         
     13.           << "T2=" << typeid(T2).name() << " ";
     14.  
     15.      return count;
     16.  }
     17.  
     18.  int main() {
     19.      double mnd = 0, mxd = 10;
     20.      int    mni = 0, mxi = 10;
     21.      double tabd[] = {-2, -1, 2, 5, 7, 11};
     22.      int    tabi[] = {-2, -1, 2, 5, 7, 11};
     23.  
     24.      int ii = howmany(tabi,mni,mxi,6);                 
     25.      cout << "res=" << ii << endl;
     26.  
     27.      int id = howmany(tabi,mnd,mxd,6);                 
     28.      cout << "res=" << id << endl;
     29.  
     30.      int di = howmany(tabd,mni,mxi,6);                 
     31.      cout << "res=" << di << endl;
     32.  
     33.      int dd = howmany(tabd,mnd,mxd,6);                 
     34.      cout << "res=" << dd << endl << endl;
     35.  
     36.      int xx = howmany<double,double>(tabd,mni,mxi,6);  
     37.      cout << "res=" << xx << endl;
     38.  }

Słowo template () jest tu słowem kluczowym informującym kompilator, że to, co dalej nastąpi, będzie definicją wzorca funkcji (lub klasy), a nie definicją konkretnej funkcji. Po tym słowie, w nawiasach kątowych, występuje lista parametrów formalnych, oddzielonych przecinkami, każdy poprzedzony słowem kluczowym class lub typename – w tym kontekście te dwa słowa są synonimami, ale zalecane jest to drugie. Parametry formalne wzorca mogą mieć dowolne nazwy, choć często używa się w nich dużej litery T — w naszym przykładzie użyliśmy nazw T1T2. Teraz następuje definicja samego wzorca funkcji, w której używamy nazw parametrów formalnych (u nas T1T2) w miejscach, gdzie wymagana jest nazwa typu – w szczególności którejś z tych nazw moglibyśmy użyć do określenia typu zwracanego. Można też używać wyrażeń określających typy pochodne, jak T1& czy T2*.

Analizując kod wzorca widzimy, że jest to coś w rodzaju definicji funkcji howmany (), która pobiera wskaźnik do tablicy elementów typu T1 i zwraca ilość takich elementów tej tablicy, które są większe od mn a mniejsze od mx. Z kolei mnmx są typu T2. [Instrukcja nie jest tu potrzebna, ale została dodana, aby podczas wykonania wypisywał się aktualny typ skojarzony z  T1T2. Więcej na temat operatora typeid powiemy w rozdziale o RTTI .] Oczywiście, kompilator nie może skompilować wzorca jako funkcji, choćby dlatego, że nie ma typów o nazwach T1 czy T2. Zapamięta jednak, że taki wzorzec istnieje.

Jak możemy takiego wzorca użyć? W linii  wywołujemy funkcję howmany podając jako argumenty tablicę int ów oraz zmienne typu int jako mnmx. Kompilator zauważy, że funkcji howmany w ogóle nie ma, ale jest szablon o tej nazwie. Spróbuje zatem dopasować we wzorcu typy T1T2 tak, aby pasowały do typów argumentów wywołania. Zauważy, że jeśli T1 utożsamić z  intT2 również z  int, to wywołanie będzie dokładnie pasować do sygnatury szablonu. Dokona zatem podstawienia: T1 zostanie wszędzie w ciele wzorca zastąpione przez int i to samo dla T2. Nazywa się to konkretyzacją (ang. concretization) wzorca. Otrzymana w ten sposób funkcja zostanie skompilowana (i użyta w czasie wykonania); będzie ona miała postać

       int howmany(int* arr, int mn, int mx, int size) {
           //
           // ...
           //
       }
Gdyby w dalszej części programu znów pojawiło się wywołanie funkcji howmany z argumentami tego samego typu, to żadna konkretyzacja wzorca już nie zajdzie — teraz odpowiednia funkcja już będzie istnieć.

Przejdźmy teraz do linii . Tu wywołanie nie pasuje dokładnie do już istniejącej wersji funkcji, bo argumenty drugi i trzeci są tym razem typu double. Konkretyzacja zatem zajdzie jeszcze raz: teraz dokładne dopasowanie otrzymamy po utożsamieniu T1 int oraz T2 double. A zatem kompilator wygeneruje następne przeciążenie funkcji howmany, tym razem w postaci

       int howmany(int* arr, double mn, double mx, int size) {
           //
           // ...
           //
       }
Podobna sytuacja zajdzie przy wywołaniach z linii : w pierwszym przypadku dopasowanie będzie odpowiadać utożsamieniu T1 double oraz T2 int, natomiast w drugim T1 double oraz T2 double.

Zauważmy jeszcze składnię wywołania z linii . Tu jawnie (nawiasy kątowe za nazwą funkcji) zażądaliśmy podstawienia za pierwszy parametr formalny wzorca (T1) typu double i za drugi(T1) również typu double. Taka też wersja funkcji (w tym przypadku już utworzona wcześniej) zostanie użyta, mimo, że argumenty drugi i trzeci są typu int (oczywiście nic złego się nie stanie, bo konwersja int double jest konwersją standardową i nie powoduje żadnej straty informacji).

Wynik programu

    T1=i T2=i res=3
    T1=i T2=d res=3
    T1=d T2=i res=3
    T1=d T2=d res=3

    T1=d T2=d res=3
pokazuje, że istotnie utworzone zostały cztery przeciążone wersje funkcji howmany. Tym, że typ int został nazwany i, a  double to d nie należy się przejmować — są to zależne od kompilatora wewnętrzne nazwy (kody) typów.

Słowo kluczowe class na liście parametrów szablonu może sugerować, że T1 musi odpowiadać typowi zdefiniowanemu za pomocą klasy. Tak jednak nie jest: jak widzieliśmy z tego prostego przykładu, wartościami odpowiadającymi tym parametrom mogą być dowolne typy, również wbudowane. Dlatego bardziej chyba czytelne jest używanie w tym miejscu słowa kluczowego typename.


Przypatrzmy się teraz następującemu programowi:


P82: tmpl.cpp     Szablony funkcji

      1.  #include <iostream>
      2.  #include <typeinfo>
      3.  using namespace std;
      4.  
      5.  template <typename T>                          
      6.  T larger(T k1, T k2) {
      7.      cout << "T=" << typeid(T).name() << " ";   
      8.      return k1 < k2 ? k2 : k1;
      9.  }
     10.  
     11.  double larger(double k1, double k2) {          
     12.      cout << "Spec. double ";
     13.      return k1 < k2 ? k2 : k1;
     14.  }
     15.  
     16.  template<>                                     
     17.  short larger<short>(short k1, short k2) {
     18.      cout << "Spec. short ";
     19.      return k1 < k2 ? k2 : k1;
     20.  }
     21.  
     22.  template<>
     23.  long larger<long>(long k1, long k2) = delete;  
     24.  
     25.  int main() {
     26.      short s1 = 4, s2 = 5;
     27.  
     28.      cout << larger(1.5,2.5)    << endl;        
     29.      cout << larger(111,222)    << endl;        
     30.      cout << larger('a','d')    << endl;        
     31.      cout << larger<int>(s1,s2) << endl;        
     32.      cout << larger(30L,50L)    << endl;        
     33.  }

Definiujemy tu szablon funkcji larger (). Wzorzec zależy od jednego tylko parametru T. Funkcja opisywana wzorcem pobiera dwa argumenty tego samego typu, oznaczonego jako T, i zwraca przez wartość rezultat też typu T. Sama funkcja jest bardzo prosta: wartością zwracaną jest wartość większego z argumentów. Jak poprzednio, dodaliśmy linię () która wypisuje informację o aktualnym typie skojarzonym z  T (za pomocą operatora typeid dostępnego po dołączeniu pliku nagłówkowego typeinfo —  patrz rozdział o RTTI ).

Zwróćmy teraz uwagę na funkcję (nie wzorzec) larger typu double (). Jej sygnatura dokładnie odpowiada szablonowi po podstawieniu T double.

Podobnie dostarczamy też specyficznej wersji funkcji larger dla typu short (). Tym razem jednak jawnie poinformowaliśmy kompilator, że to co robimy jest konkretyzacją wzorca dla pewnego konkretnego typu (w tym przypadku short). Zauważmy składnię — puste nawiasy kątowe po słowie template i jawne wskazanie typu parametru wzorca przy nazwie funkcji. Ta forma jest bardziej wskazana, bo pozwala kompilatorowi sprawdzić czy to co robimy rzeczywiście jest poprawną składniowo konkretyzacją wzorca.

W końcu linia deklaruje konkretyzację naszego wzorca dla typu long jako nieistniejącą (=delete zamiast ciała funkcji — jest to konstrukcja wprowadzona w standardzie C++11).

Program się nie kompiluje:

    tmpl.cpp: In function ‘int main()’:
    tmpl.cpp:32:27: error: use of deleted function
            ‘T larger(T, T) [with T = long int]’
Przyczyna leży w ostatniej linii (). Funkcje usunięte (deleted) brane pod uwagę, kiedy wybierana jest najlepsza funkcja kandydująca; tu będzie nią funkcja powstała z konkretyzacji szablonu po podstawieniu T long, które daje dopasowanie dokładne. Wtedy dopiero kompilator „zda sobie sprawę”, że ta funkcja jest usunięta — żadna inna kandydatura nie będzie już rozważana i kompilacja zakończy sie niepowodzeniem.

Po wykomentowaniu ostatniej linii kompilacja przebiega pomyślnie i wykonanie daje:

    Spec. double 2.5
    T=i 222
    T=c d
    T=i 5
Zwróćmy uwagę na niektóre aspekty tego programu ilustrujące ogólne zasady używania szablonów funkcji: Nazwy typów pojawiające się w wydruku (i, c) są wewnętrznymi nazwami typów danych i mogą zależeć od implementacji (nie ma się co nimi przejmować, ważne jest, że możemy porównywać typy, a nie to, jak się nazywają).

Przypatrzmy się jeszcze definicji szablonu. W jego treści dla zmiennych k1k2 użyte jest porównanie za pomocą operatora '<'. Jeśli te zmienne są typu numerycznego, to oczywiście nie ma kłopotu: dla typów numerycznych operacja porównywania zdefiniowana jest „sama z siebie”. Dla innych typów, zdefiniowanych za pomocą klas przez użytkownika, takie porównanie może nie być określone — użycie takiego typu do konkretyzacji szablonu spowodowałoby błąd kompilacji (choć, jak się przekonamy, nawet dla własnych typów można określić działanie operatora porównania).


Jako przykład na bardziej realistyczne zastosowanie szablonów, w poniższym programie definiujemy wzorzec minmaxmed funkcji pobierającej tablicę elementów pewnego typu i obliczającą element maksymalny, minimalny i medianę (taką wartość, od której połowa elementów tablicy jest mniejsza, a połowa większa). Tak jak poprzednio, typ elementów musi dopuszczać porównywanie. Minimum i maksimum zwracane jest poprzez argumenty przekazane przez referencję, a mediana jako wartość zwracana funkcji. Dodatkowo, również za pomocą szablonów definiujemy funkcje pisztab (do wypisywnia elementów tablicy), inssort (do porządkowania tablicy metodą sortowania przez wstawianie — insertion sort) oraz funkcję test, która wywołuje poprzednie funkcje.


P83: sorttempl.cpp     Szablony funkcji

      1.  #include <iostream>
      2.  #include <cstring>    // memcpy
      3.  using namespace std;
      4.  
      5.  template<typename T>
      6.  void pisztab(ostream&,const T[],int);
      7.  
      8.  template<typename T>
      9.  void inssort(T[],int);
     10.  
     11.  template<typename T>
     12.  double minmaxmed(const T[],int,T&,T&);
     13.  
     14.  template<typename T>
     15.  void test(T[],int);
     16.  
     17.  int main() {
     18.      cout << "\n===tablica int===" << endl;
     19.      int tabi[] = {9,7,2,6,6,2,7,9,2,9,5,2};
     20.      test(tabi,sizeof(tabi)/sizeof(int));
     21.  
     22.      cout << "\n===tablica double===" << endl;
     23.      double tabd[] = {9.5,2.5,6,7.5,9,2,5,2.5};
     24.      test(tabd,sizeof(tabd)/sizeof(double));
     25.  
     26.      cout << "\n===tablica unsigned===" << endl;
     27.      unsigned tabu[] = {23,32,12,76,21,45,20,67};
     28.      test(tabu,sizeof(tabu)/sizeof(unsigned));
     29.  }
     30.  
     31.  template<typename T>
     32.  void test(T tab[],int size) {
     33.      T min, max;
     34.  
     35.      double mediana = minmaxmed(tab,size,min,max);
     36.  
     37.      cout << "min = " << min << ", max = " << max
     38.           << ", mediana = "  <<  mediana   << endl;
     39.  
     40.      cout << "Tablica  oryginalna: ";
     41.      pisztab(cout, tab, size);
     42.  
     43.      inssort(tab, size);
     44.  
     45.      cout << "Tablica posortowana: ";
     46.      pisztab(cout, tab, size);
     47.  }
     48.  
     49.  template<typename T>
     50.  void pisztab(ostream& str, const T t[], int size) {
     51.      str << "[ ";
     52.      for (int i = 0; i < size; ++i) str << t[i] << " ";
     53.      str << "]" << endl;
     54.  }
     55.  
     56.  template<typename T>
     57.  void inssort(T a[], int size) {
     58.      int i, indmin = 0;            // wartownik
     59.      for (i = 1; i < size; ++i)
     60.          if (a[i] < a[indmin]) indmin = i;
     61.      if (indmin != 0) {
     62.          T p = a[0];
     63.          a[0] = a[indmin];
     64.          a[indmin] = p;
     65.      }
     66.  
     67.      for (i = 2; i < size; ++i) {  // sortowanie
     68.          int j = i;
     69.          T v = a[i];
     70.          while (v < a[j-1]) {
     71.              a[j] = a[j-1];
     72.              j--;
     73.          }
     74.          if (i != j ) a[j] = v;
     75.      }
     76.  }
     77.  
     78.  template<typename T>
     79.  double minmaxmed(const T t[], int size, T& min, T& max) {
     80.      T* tab = new T[size];
     81.      memcpy(tab,t,size*sizeof(T));
     82.  
     83.      inssort(tab, size);
     84.  
     85.      min            = tab[0];
     86.      max            = tab[size-1];
     87.      double mediana = size%2 == 0 ?
     88.                         0.5*(tab[size/2] + tab[size/2-1])
     89.                       : tab[size/2];
     90.  
     91.      delete [] tab;
     92.      return mediana;
     93.  }

Funkcja minmaxmed najpierw tworzy kopię przekazanej tablicy (aby nie zmodyfikować oryginału), sortuje tę kopię, znajduje wtedy łatwo szukane elementy, po czym usuwa roboczą tablicę. Z wydruku

    ===tablica int===
    min = 2, max = 9, mediana = 6
    Tablica  oryginalna: [ 9 7 2 6 6 2 7 9 2 9 5 2 ]
    Tablica posortowana: [ 2 2 2 2 5 6 6 7 7 9 9 9 ]

    ===tablica double===
    min = 2, max = 9.5, mediana = 5.5
    Tablica  oryginalna: [ 9.5 2.5 6 7.5 9 2 5 2.5 ]
    Tablica posortowana: [ 2 2.5 2.5 5 6 7.5 9 9.5 ]

    ===tablica unsigned===
    min = 12, max = 76, mediana = 27.5
    Tablica  oryginalna: [ 23 32 12 76 21 45 20 67 ]
    Tablica posortowana: [ 12 20 21 23 32 45 67 76 ]
widzimy, że dzięki szablonom poradziliśmy sobie z tablicami różnych typów. Zauważmy, że ponieważ do kopiowania tablic użyliśmy, choć oczywiście nie musieliśmy, funkcji memcpy, wszystko będzie działać tylko dla typów, dla których kopiowanie obiektów „bit po bicie” daje właściwy rezultat! Zwróćmy uwagę, że wywołania funkcji określonych przez szablony następują tu również wewnątrz innych szablonów: na przykład szablonowa funkcja test wywołuje szablonowe funkcje minmaxmed, inssortpisztab.

W tym programie szablony, tak jak zwykłe funkcje, najpierw zostały zadeklarowane, a dopiero później zdefiniowane. Zarówno w deklaracji, jak i w definicji trzeba oczywiście użyć słowa template.


Zilustrujmy jeszcze jeden „trik” składniowy, pozwalający na „zrzucenie” na kompilator pełnej odpowiedzialności za wydedukowanie typu zwracanego funkcji. Od wersji standardu C++14, możemy typ zwracany funkcji zadeklarować jako decltype(auto) — kompilator wydedukuje wtedy typ zwracany analizując kod ciała funkcji i patrząc na wszystkie instrukcje return —  wszystkie one, oczywiście, muszą zwracać wartości dokładnie tego samego typu. Spójrzmy na prosty przykład


P84: autoret.cpp     Dedukcja typu zwracanego

      1.  #include <iostream>
      2.  #include <typeinfo>
      3.  using namespace std;
      4.  
      5.    // typ zwracany może być wydedukowany, gdy T i U znane
      6.  template<typename T, typename U>
      7.  decltype(auto) mul(T x, U y) {
      8.      return x*y;
      9.  }
     10.  
     11.  int main() {
     12.      auto r1 = mul(2.0,7); // double*int -> double
     13.      std::cout << r1 << " :: " << typeid(r1).name() << "\n";
     14.      auto r2 = mul(2,7L);  //   int*long -> long
     15.      std::cout << r2 << " :: " << typeid(r2).name() << "\n";
     16.  }

Z wydruku

    14 :: d
    14 :: l
widzimy, że

T.R. Werner, 23 lutego 2019; 23:59