C++ bez cholesterolu

Zastosowania wzorców

Wprowadzenie

Dostarczenie wzorców, a zwłaszcza doprowadzenie ich do aktualnej postaci wprowadziło do C++ niewąskie zamieszanie. Początkowo miało to służyć właśnie wspomnianym meta-typom, czyli czemuś, czym jest taki np. wskaźnik, tablica czy referencja. Koncepcje te były jeszcze później rozszerzane, po czym wzorce doprowadzono do takiej postaci, że właściwie trudno powiedzieć, do czego mogłyby NIE służyć. ;)

Ale zanim co, to należałoby wiedzieć parę rzeczy na temat samych klas, które niekoniecznie muszą być oczywiste. Na przykład, klasa może mieć w środku alias do jakiegoś typu przez typedef. Ten typ następnie może zostać użyty przez inny wzorzec, gdzie zostanie mu to podane jako parametr... Zatem może najpierw spróbuję podać kilka typowych zastosowań wzorców.

Typ z parametrem

To jest właśnie taki odpowiednik wskaźnika. Możemy go sobie nawet tak zadeklarować i to tak, żeby się zachowywał jak normalny wskaźnik:

template<class T>
struct ptr {
private:
  T* in;
public:
  ptr( const T* x ): in( x ) {}
  ptr() {}
  T& operator*() { return *in; }
  T* operator->() { return in; }
};

No i owszem, ptr<int> będzie odpowiadało int*. Oczywiście nie do końca, bo uważni na pewno spostrzegli, że nie da się na takim wskaźniku robić tzw. "arytmetyki". I bardzo dobrze! Tak ma być! ;*) Niektórzy też na pewno spostrzegli, że ptr() niczym nie inicjalizuje pola `in'. To również jest celowe, bo ma się to zachowywać jak regularny wskaźnik.

W tej definicji chyba niczego nie trzeba wyjaśniać. Możemy sobie tutaj utworzyć ptr<int>, również ptr<void>, a także ptr<const double>. Owo `T' zostanie zamienione na podany typ.

Niektórzy bardzo mocno zarzucali wzorcom, że działają jak makrogenerator. Nie jest to prawda. Wzorce nie mają nic wspólnego z makrogeneratorem. Makrogenerator z definicji rozkłada jedynie plik źródłowy na tokeny i wybiera z nich to, co go nie interesuje. Trochę pluje tym, co musi, ale głównie odsyła na wyjście to, co odczytał. Wzorce działają zupełnie inaczej, bo polegają na rozkładzie nie leksykalnym, ale drzewa wyrażeń. Zatem wzorzec musi być zbudowany w sposób poprawny składniowo. Ale... niekoniecznie musi być definicyjnie spójny. Tak dokładnie to nawet w ogóle nie musi.

O co tutaj chodzi? Spróbuj tam np. w powyższym wzorcu dodać jakąś metodę, która zrobi takie wywołanie: in->make();. W main() zadeklaruj sobie zmienną typu ptr<int> (ale niczego poza tym nie rób). Jak możnaby domniemać, jest to definicyjnie błędne, bo typ int nie ma w ogóle żadnych metod, nie mówiąc o jakiejś tam make(). No więc skompiluj i zobacz, czy rzeczywiście.

Skompilowało się, zatem w tym programie wszystko jest w porządku. Więc co z tym make()? Otóż, jak się chyba można domyślić, wystarczy dodać wywołanie tej metody, która wywołuje owo make() i program natychmiast przestanie się kompilować. Czy jest to zatem jakieś selektywne wybieranie tego, co jest poprawne, a co nie?

To akurat nie tak. Otóż, konkretyzacja niejawna (!) struktur odbywa się w specjalny sposób. To znaczy struktura składa się z elementów i faktycznie wybiera się je osobno do konkretyzacji. Istnieją jednak elementy stałe i luźne. Oczywiście pola, jako elementy stałe, są dołączane od razu i np. nie można utworzyć obiektu jakiegoś typu, jeśli jakieś pole będzie typu, który nie został zadeklarowany (tak też można, ale o tym później ;). Ale metody (również statyczne), czyli elementy luźne, są dołączane tylko wtedy, gdy istnieje taki wymóg. Tzn., tylko w przypadku gdy zostaną użyte. Jeśli nie, w ogóle nie są brane pod uwagę podczas konkretyzacji. Dlatego właśnie mogliśmy utworzyć zmienną typu ptr<int>. Efektywnie bowiem typ ptr<int> nie ma w ogóle tej metody, która wywołuje make(). Dlatego właśnie jej ewentualna poprawność definicyjna nic nie znaczy.

Częściowa specjalizacja wzorca struktury

Jak powiedziałem, struktura taka może zawierać również deklarację typu, np.:

template<class T>
struct ptr {
private:
  T* in;
public:
  ptr( const T* x ): in( x ) {}
  ptr() {}

  typedef T type;
  type& operator*() {
    return *in;
  }
  T* operator->() {
    return in;
  }
  type def() { return type(); }
};

I tutaj można określić, że ptr<int>::type będzie oznaczać int. Co prawda taka definicja nie jest specjalnie użyteczna, bo tak samo zamiast type mogliśmy napisać T. Należy jednak tutaj zauważyć kilka paskudnych rzeczy. Po pierwsze, taki typ już zaczyna mieć swoje wymagania (tak się rozpuścił!). Stanowi mianowicie, że podany typ musi mieć konstruktor domyślny i móc dostarczyć do siebie referencję. Z większością typów nie ma problemu, ale jest np. problem z typem void. Jednak na to akurat da się coś poradzić:

template <>
struct ptr<void> {
private:
  T* in;
public:
  ptr( const T* x ): in( x ) {}
  ptr() {}
  typedef int type;
  type& operator*() { return *in; }
  T* operator->() { return in; }
  type def() { return type(); }
};

Taka właśnie deklaracja, jak powyżej, nazywa się jawną konkretyzacją. Jak widać, kwestia tego, że jest to typ void, nie stanowi dla nas problemu. W przypadku tego typu, operator * zwróci referencję to int. Co prawda trzeba by jeszcze poprawić definicję tej metody, bo w takiej postaci jak tu próbuje i tak zwrócić void (ale z tym sobie już chyba poradzisz ;).

Pozostał nam jednak jeszcze jeden drobny problem. Konstruktorów domyślnych nie mają również typy referencyjne. Ale spróbujemy i na to coś niecoś zaradzić:

template<class T>
struct ptr<T&> {
private:
  T* in;
public:
  ptr( const T* x ): in( x ) {}
  ptr() {}

  typedef T type;
  type& operator*() { return *in; }
  T* operator->() { return in; }
  type def() { return type(); }
};

I takim sprytnym sposobem obeszliśmy kwestię referencji ;). To samo zresztą można robić ze wskaźnikiem. To powyżej nazywa się częściową specjalizacją i jest jedną z nowszych właściwości C++ (czytaj: nie wszystkie kompilatory to posiadają, nie posiada np. Visual C++ aż do wersji 7.0). Polega to - jak widać - na tym, że tworzy się nowy wzorzec na bazie innego. Ponieważ, jak wiemy, zarówno wskaźnik, referencja jak i tablica są takimi meta-typami, więc nie ma również problemu, żeby np. zrobić to z innymi meta-typami, np. standardowymi wzorcami z STL-a. Zróbmy sobie np. wskaźnik do iteratora wektora:

template<class T>
struct ptr<typename vector<T>::iterator> {
  typedef typename vector<T>::iterator type;
private:
  it* in;
public:
  ptr( const it* x ): in( x ) {}
  ptr() {}
  type& operator*() { return *in; }
  it* operator->() { return in; }
  type def() { return type(); }
};

Nie różni się ona owszem od tej "ogólnej" postaci, ale chodziło mi o zaprezentowanie, jak to wygląda. Zwracam od razu uwagę na słowo typename, o którym już wspominałem. Słowo to wskazuje, że znajdujący się za nim identyfikator (pełny oczywiście, włącznie z różnymi zasięgami i specjalizacjami wzorców) jest identyfikatorem typu, a nie obiektu.

Ale tak, jak w poprzednim przykładzie, jest tu jedna paskudna rzecz. Jak widać, musieliśmy skopiować dokładnie w szczegółach całą deklarację klasy, żeby tylko sobie raz jeden palnąć inny typedef. Chyba nie po to mamy wzorce, żeby się nam rozrastały copy-paste'owane kody i na pewno nie po to, żeby ten problem rozwiązywać preprocesorem. No to spróbujmy... wzorcami ;).

template <class T>
struct ptr_traits {
  typedef T type;
};

struct ptr_traits<void> {
  typedef int type;
};

template <class T>
struct ptr {
  T* in;
  typedef typename ptr_traits<T>::type type;
  ptr(){}
  ptr( T* x ): in( x ) {}
  type& operator*() { return *(type*)in; }
  T* operator->() { return in; }

  type def() { return type(); }
};

Jak widać, posłużyliśmy się zewnętrzną nieco deklaracją ptr_traits. Deklaracja ta przenosi tamten typ, co mamy, z wyjątkiem przypadku, gdy typem jest void. Jest to zresztą dość uniwersalna technika, stosowana również przez same kompilatory (i nazwa "traits" wcale nie jest przypadkowa). Jest to takoż technika bardzo podobna do przeciążania. No bo to jest przeciążanie...

Przeciążanie przez wzorce

W C++ mamy dwie możliwości przeciążania funkcji. Pierwsza z nich (starsza zresztą) opiera się na możliwości stworzenia funkcji o jednej nazwie, lecz innym zestawie argumentów. Przyjmijmy na razie dla uproszczenia, że w przeciążaniu "ogólnym" skupimy się wyłącznie na takim przeciążaniu, gdzie we wszystkich przeciążonych funkcjach ilość argumentów jest taka sama.

Wzorce dają nam jeszcze inną możliwość przeciążania funkcji, obarczoną również innymi regułami. Jest to zresztą przeciążanie dokładnie takie, jakie występuje w językach funkcjonalnych. Przypomnijmy sobie przeciążanie funkcji potega:

float potega( float a, unsigned x ) {
  if ( x == 0 )
    return 1;
  float r = a;
  for ( int i = 2; i <= x; i++ )
    r *= a;
  return r;
}

float potega( float a, float x ) {
  return exp( x*log( a ) );
}

Przeciążając przez wzorce, można by to zrobić tak:

template <class Exponent>
float potega( float a, Exponent x );

template <>
float potega<unsigned>( float a, unsigned x ) {
  if ( x == 0 )
    return 1;
  float r = a;
  for ( int i = 2; i <= x; i++ )
    r *= a;
  return r;
}

template <>
float potega<float>( float a, float x ) {
  return exp( x*log( a ) );
}

Różni się to oczywiście zasadniczo od ogólnego przeciążania funkcji, mianowicie:

  1. Zakładamy, że dla konkretnego argumentu, który jest podany przez wzorzec, istnieje zawsze prototyp funkcji. Jeśli ktoś wywoła np. potega(12.0,'h'), to kompilator mu to przyjmie, z tym tylko że przypluje się linker, bo nie znajdzie takiej instancji (nie podaliśmy pełnego wzorca, a jedynie prototyp!).
  2. Nie istnieją przy takim podejściu żadne domyślne konwersje. W postaci ogólnej przeciążania mieliśmy rozróżnienie pomiędzy float a unsigned. Gdybyśmy w tamtej wersji podali char, zostałoby przesunięte do unsigned, a gdyby double - do float. Tu niestety nie. Tu możemy podać tylko i wyłącznie DOKŁADNIE unsigned (nawet int nie wchodzi w rachubę!) albo float. Podanie jakiegokolwiek innego typu zażąda nagłówka dla potega() z takim właśnie typem (i nie zostanie on znaleziony).

Jest oczywiście jeszcze najważniejsza różnica, mianowicie koncepcyjna. W przypadku przeciążania ogólnego, nie mieliśmy nigdy do czynienia z czymś takim, jak postać ogólna, czy jakaś najważniejsza czy coś w tym sensie - każda wersja przeciążonej funkcji jest po prostu osobną funkcją. Tutaj mamy do czynienia właśnie z taką hierarchią. Istnieje tutaj zawsze wersja "ogólnopostaciowa" (lub, jeśli ktoś lubi greckie określenia, holomorficzna ;), która stanowi podstawę do generowania każdej innej postaci. No i opcjonalnie można dostarczyć jawne specjalizacje dla konkretnych parametrów. W tym przykładzie zresztą postąpiłem trochę chamsko, bo wersja holomorficzna składa się jedynie z nagłówka, zatem kompilator z tej wersji nie ma szans niczego wygenerować. No, poza nagłówkiem, powodującym nieplucie się kompilatora, ale powodującym plucie się linkera (co jest w tym wypadku niestety o tyle paskudne, że błąd ten jest wykrywany trochę poniewczasie, poza tym niektóre linkery nie mają zwyczaju przedstawiać nieznalezionych symboli "po ludzku", tylko we własnej postaci wewnętrznej).

Wzajemne wspomaganie się wzorców

Brzmi to może śmiesznie, ale niestety taka jest rzeczywistość używania wzorców w C++. Chodzi mi konkretnie o wspomaganie się wzorców funkcji i wzorców struktur. Przypomnę może charakterystyki wzorców funkcji:

Natomiast wzorce struktur charakteryzują się takimi właściwościami:

Jak zatem widać, wzorce funkcji mają jedno konkretne przeznaczenie: mają stanowić mały pomost pomiędzy wywołaniem funkcji, a samym wzorcem. Posiadają jednak paskudne ograniczenia - nie mogą mieć częściowych specjalizaji, parametrów domyślnych, ani nie można nie użyć parametrów (czasem się to przydaje, jeśli chcemy np. zróżnicować specjalizacje tylko za pomocą wartości parametru), co bardzo ogranicza ich elastyczność. Dlatego też zazwyczaj wzorce funkcji robi się tylko po to, żeby mieć nagłówek funkcjopodobny. Natomiast całą implementację wykonuje się już na wzorcach struktur (a brak częściowych specjalizacji wzorców funkcji obchodzi się przez wzorce struktury z metodą statyczną).

Z drugiej jednak strony, możliwość domniemywania parametrów na podstawie argumentów podanych do wywołania funkcji (z wzorca) jest ich bardzo mocną stroną. Dlatego są często wykorzystywane jako "funkcje pomocnicze", mające zwrócić obiekt tymczasowy typu utworzonego z wzorca struktury. Przykładów nie trzeba daleko szukać, mnóstwo takich rzeczy jest w STL-u, jak choćby `inserter', czy `ptr_fun'.