Język C++, chyba jako pierwszy język stosowany komercyjnie, zaoferował możliwość metaprogramowania, które przez programistów C++ uważane jest za wyższy stopień wtajemniczenia1. Podejście to wymaga nieco innego spojrzenia na programowanie…
Artykuł adresujemy do osób które programowały już w C++, i korzystały z szablonów jednak jeszcze nie przeszły na „ciemną stronę mocy” – stronę metaprogramowania.
Język C++ jest językiem o ścisłej kontroli typów. Oznacza to, że kompilator będzie zgłaszał błędy wszędzie tam, gdzie typ zmiennej nie będzie się zgadzał z oczekiwaniami tej czy innej operacji, dlatego mamy do wyboru kilka możliwości stosowania własnych typów:
- Możemy ograniczyć się do absolutnego minimum i nie tworzyć dodatkowych typów, tylko „re-używać” już istniejące. W końcu jeśli coś daje się wyrazić liczbą – to po co komplikować sobie życie.
- Możemy też tworzyć wiele nowych typów po to by reprezentować różne wielkości, różnymi klasami, nawet jeśli w środku siedzi jedna liczba. W końcu kwota i cena – to dwa zupełnie różne pojęcia. Kwota – to ilość określonych jednostek monetarnych, a cena jest taką ilością za jednostkę miary. Możemy więc zrzucić na kompilator sprawdzenie czy przypisujemy wielości nie tylko podobne jeśli chodzi o reprezentację binarną, ale także mające określone zastosowanie. Może nawet poniesie nas fantazja i cena za kilogram będzie innym typem niż cena za sztukę. W końcu ma to sens.
W pierwszym przypadku – tracimy dodatkową kontrolę logiki prowadzonych działań, które można zrzucić na kompilator. W drugim – jesteśmy zmuszani do pisania wielu różnych wariantów funkcji – na przykład mnożenia. Wydaje się to szczególnie pozbawione sensu, bo samo ciało funkcji wygląda dokładnie tak samo, różniąc się jedynie typami parametrów.
Aby nie pisać niepotrzebnego kodu stosując metodę kopiego-pasty, stworzono specjalną konstrukcję pozwalającą na napisania kawałka kodu w taki sposób jakby typy były zmiennymi. Kompilator stara się podstawić typy parametrów z którymi ma do czynienia do tak zdefiniowanego wzorca funkcji lub klasy generując odpowiedni kod. Jednak stałe typy do których po prostu dopasowujemy kompilowany kod – wydały się programistom za proste, i zauważono, że pisząc wzorce można używać nie tylko typów przekazanych przez miejsce użycia, ale także obliczone przez metafunkcje.
Metafunkcja – to taki parametryzowany typ (template), którego jeden z typów zdefiniowanych wewnętrznie – jest interpretowany jako wartość. Konkretne „wyliczenie” wartości następuje poprzez „przeciążenie” funkcji – czyli skonkretyzowania odpowiadającego jej wzorca – w nadanie odpowiedniej wartości. Co ważne – funkcje możemy składać a także, przy wyznaczaniu wartości – możemy używać rekurencji. A wszystko to by obliczyć… typ jakiegoś elementu w naszym programie.
Brzmi to bardzo zawile, ale pozwala na użycie kompilatora, do dopasowania konkretnej realizacji algorytmu – poprzez polimorfizm, ale już na etapie kompilacji, a nie w czasie wykonywania programu. To że wybór odpowiedniej realizacji kodu jest wykonywany na poziomie kompilatora – jest bardzo istotne, bo na etapie tłumaczenia programu na język maszynowy, można wiele operacji zoptymalizować, wiedząc jakie są użyte wcześniej i później. Jeśli wyboru dokonujemy w czasie wykonania, to nie tylko tracimy czas na odwołanie się do tablicy metod wirtualnych, ale także godzimy się na wykonywanie operacji które nie są potrzebne, ale kompilator nie mógł tego przewidzieć.
Ale c++ jest językiem obiektowym, więc funkcje – to trochę za mało. Skoro metafunkcja – jest realizowana poprzez zbiór wzorców klas, to czy nie można by go trochę rozszerzyć – i stworzyć metaobiektu – czyli obiektu parametryzowanego typem, który by miał wiele funkcji ale także będzie mógł posiadać pola? Oczywiście wartości tych pól będą typami, ale przydałoby się przy okazji dziedziczenie i wszystko to co pamiętamy z programowania obiektowego.
Takie metaobiekty oczywiście istnieją. Najczęściej stosowanym przypadkiem ich użycia – są zbiory wzorców klas opisujących właściwości innych klas – czyli traits-y.
traits to wzorce zawierający wszystkie potrzebne informacje o typie który jest ich parametrem. Odpowiednio przygotowane wzorce pozwalają na znalezienie odpowiedzi na pytanie czy typ jest typem numerycznym, czy ma domyślny konstruktor, Jaka jest wartość jego domyślna, czy może być użyty jako… Informacje te wydają się niepotrzebne jeśli programujemy w sposób obiektowy nie używając wzorców klas, ale jeśli chcemy przygotować nieco bardziej zaawansowane template-y – to szybko okaże się, że nie każdą klasą możemy nasz wzorzec sparametryzować. Dla programistów piszących samotnie niewielkie programy – nie jest to wielki problem, nawet jeśli kompilator poinformuje nas o problemy w dość zagmatwany sposób2. Jeśli jednak piszemy bibliotekę której ktoś poza nami będzie używał i nie chcemy by się denerwował i napisał ją jeszcze raz po swojemu – musimy się zabezpieczyć przed błędnym użyciem na przykład stosując statyczne asercje – a tu już potrzebujemy szczegółowych informacji o typie. Informacji których mogą dostarczyć właśnie takie metaobiekty.
Używanie szablonów klas, w świecie programistów C++ uważane jest za wyższy stopień wtajemniczenia, dlatego każdy kto używa tego języka prędzej czy później próbuje samodzielnie napisać coś w rodzaju biblioteki opartej o wzorce. Ma to duże walory dydaktyczne, jednak nie szedłbym zbyt daleko w tym kierunku. Po kilku wprawkach warto zacząć używać tego co jest dostępne jako biblioteki szablonów i dobrze nauczyć się ich używać, zanim zbyt daleko damy się ponieść ambicji. Bo może się okazać że nie tylko wyważaliśmy otwarte drzwi, ale że były to drzwi do lasu.