Języki C C++ są ze sobą prawie w pełni kompatybilne (istnieją pewne odstępstwa, ale to temat na jeden z przyszłych artykułów). Oznacza to, że język C można traktować jako swoisty podzbiór języka C++ (kompatybilność ta jest jednym ze źródeł popularności języka C++, ponieważ wielu programistów języka C mogła dużo łatwiej migrować do języka C++, niż do jakiegokolwiek innego języka). Przejawem tego jest m.in. obecność makr (ang. macros) w języku C++. Stanowią one dosyć prymitywny mechanizm, realizowany przez preprocesor (jest on uruchamiany zanim do właściwej pracy przystąpi kompilator), polegający na prostym zastępowaniu tekstukodzie źródłowym. Prostota tego rozwiązania niesie ze sobą pewne niebezpieczeństwa i niedogodności. Makra można najczęściej zastąpić innymi, bezpieczniejszymi w użyciu, elementami języka C++. Eliminacja użycia makr jest bardziej uzasadniona wraz z pojawieniem się nowszych standardów języka C++11/14, bowiem umożliwiają one wykonywanie operacji w czasie kompilacji (bez makr). Można więc zaryzykować stwierdzenie, że makra są obecnie rozwiązaniem (prawie) przestarzałym (ang. obsolete), przy czym nie prędko znikną one z języka C++ ze względu na nadal istniejące zastosowania oraz wspomnianą kompatybilnośćjęzykiem C.

Makra Do Zastąpienia

Jako przykład posłużą nam 3 makra reprezentujące odpowiednio:

  • stałą
  • blok kodu z przekazaną funkcją
  • operację podnoszenia liczby do kwadratu
#include <iostream>

#define PI 3.1415926

#define EXECUTE(F)                                \
        do {                                      \
           auto f = F;                            \
           std::cout << "EXECUTE: " << f << "\n"; \
        } while(0)
        
#define SQUARE(X) ((X) * (X))        
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   std::cout << "PI: " << PI << "\n";
   EXECUTE(add(7, 42));
   std::cout << "SQUARE(7): " << SQUARE(7) << "\n";
}

Uruchom w edytorze

Po uruchomieniu powyższego kodu otrzymujemy spodziewany output:

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                              
SQUARE(7): 49

W dalszej części artykułu będziemy modyfikować ten kod tak, aby zupełnie pozbyć się makr.

Stała PI

PI reprezentuje stałą w naszym programie. Symbol PI nie jest jednak reprezentowany przez żadną nazwaną stałą, a więc nie ma on typu (typ wynika jedynie z literału który zostaje wstawiony zamiast symbolu PI podczas jego rozwijania przez preprocesor). W efekcie finalny plik binarny nie będzie zawierał symbolu PI (można to sprawdzić za pomocą szeregu narzędzi takich jak nm, obbjdump czy readelf). Podstawowy problem, dotyczący w sumie wszystkich makr, pojawia podczas błędu kompilacji wynikającego z nieprawidłowego kodu ukrytego pod makrem. W efekcie nie zobaczymy w logach z kompilacji symbolu reprezentującego makro, ponieważ zostało ono rozwinięte jeszcze przed etapem kompilacji. Korzystając z możliwości jakie daje nam standard C++11, zastąpimy to makro stałą w czasie kompilacji za pomocą słowa kluczowego constexpr (dodatkowo posłużyłem się słowem kluczowym auto tak aby kompilator samodzielnie wydedukował jaki jest typ literału):

#include <iostream>

constexpr auto PI = 3.1415926;

#define EXECUTE(F)                                \
        do {                                      \
           auto f = F;                            \
           std::cout << "EXECUTE: " << f << "\n"; \
        } while(0)
        
#define SQUARE(X) ((X) * (X))        
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   std::cout << "PI: " << PI << "\n";
   EXECUTE(add(7, 42));
   std::cout << "SQUARE(7): " << SQUARE(7) << "\n";
}

Uruchom w edytorze

Kod ten daje ten sam output co poprzednio, ale zyskujemy prawdziwy symbol PI, który ma swój typ wynikający z typu przypisywanego literału (w tym przypadku double):

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                              
SQUARE(7): 49

Blok Kodu EXECUTE

Pisząc większe makra, należy zadbać o to, żeby kod w nim zawarty nie wchodził w niechciane interakcje z kodem otaczającym. Dlatego stosuje się standardową sztuczkę z pętlą do-while wykonywaną tylko raz. Obrazuje to makro EXECUTE, gdzie dodatkowo przekazujemy parametr, który zgodnie z intencją programisty, ma być wywołaniem funkcji. Pomijając nieco sztuczną konstrukcję z pętlą do-while, głównym problemem jest tutaj brak typu dla parametru F. Inaczej mówiąc jako argument można przekazać dowolny ciąg tekstowy, natomiast ewentualny błąd pojawi się dopiero w miejscu jego rozwinięcia w kodzie źródłowym. Oznacza to, że do makra można przekazać cokolwiek, chociaż w naszym przypadku pierwotną intencją programisty było wywołanie funkcji:

#include <iostream>

constexpr auto PI = 3.1415926;

#define EXECUTE(F)                                \
        do {                                      \
           auto f = F;                            \
           std::cout << "EXECUTE: " << f << "\n"; \
        } while(0)
        
#define SQUARE(X) ((X) * (X))        
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   int i{};    
   std::cout << "PI: " << PI << "\n";
   EXECUTE(++i);
   std::cout << "SQUARE(7): " << SQUARE(7) << "\n";
}

Uruchom w edytorze

Powyższy kod skompiluje się i uruchomi, ale tylko ze względu na poprawność składniową:

PI: 3.14159
EXECUTE: 1                                                                                                                                                                                  
SQUARE(7): 49

Potwierdza to tylko fakt, iż makra nie zapewniają żadnej kontroli typów. Innym wariantem takiego makra jest jego wariadyczna wersja (ang. variadic macro), pozwalająca przekazać dowolną ilość parametrów:

#include <iostream>

constexpr auto PI = 3.1415926;

#define EXECUTE(F, ...)                           \
        do {                                      \
           auto f = F(__VA_ARGS__);               \
           std::cout << "EXECUTE: " << f << "\n"; \
        } while(0)
        
#define SQUARE(X) ((X) * (X))        
        
float sum(float a, float b)
{
   return a + b;
}

int main()
{
   std::cout << "PI: " << PI << "\n";
   EXECUTE(sum, 7, 42);
   std::cout << "SQUARE(7): " << SQUARE(7) << "\n";
}

Uruchom w edytorze

Po uruchomieniu otrzymujemy spodziewany output:

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                                 
SQUARE(7): 49

Sytuacja uległa nieco poprawie, ponieważ teraz użycie symbolu F wygląda jak wywołanie funkcji. Oznacza to, że kod z preinkrementacją nie skompiluje się:

#include <iostream>

constexpr auto PI = 3.1415926;

#define EXECUTE(F, ...)                           \
        do {                                      \
           auto f = F(__VA_ARGS__);               \
           std::cout << "EXECUTE: " << f << "\n"; \
        } while(0)
        
#define SQUARE(X) ((X) * (X))        
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   int i{};    
   std::cout << "PI: " << PI << "\n";
   EXECUTE(i++);
   std::cout << "SQUARE(7): " << SQUARE(7) << "\n";
}
main.cpp: In function ‘int main()’:
main.cpp:7:34: error: expression cannot be used as a function
            auto f = F(__VA_ARGS__);               \
                                  ^
main.cpp:22:4: note: in expansion of macro ‘EXECUTE’
    EXECUTE(i++);
    ^~~~~~~

Nadal należy jednak pamiętać, iż makro nie zapewnia kontroli typu i wymusza użycie pętli do-while. Aby wyeliminować użycie tego makra należy zastosować wariadyczny szablon funkcji (ang. varidaic template). Funkcję execute oznaczymy jako inline, aby jej zachowywanie było zbliżone do wstawiania bloku kodu przez makro:

#include <iostream>

constexpr auto PI = 3.1415926;

template <typename F, typename ... Args>
inline void execute(F e, Args&& ... args)
{
   auto f = e(std::forward<Args>(args)...);
   std::cout << "EXECUTE: " << f << "\n";
}
        
#define SQUARE(X) ((X) * (X))        
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   std::cout << "PI: " << PI << "\n";
   execute(add, 7, 42);
   std::cout << "SQUARE(7): " << SQUARE(7) << "\n";
}

Uruchom w edytorze

Po uruchomieniu powyższego kodu otrzymamy oczekiwany output:

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                                 
SQUARE(7): 49

Makro SQUARE

SQUARE jest klasycznym makrem wykonującym jakąś operację (w tym przypadku podnoszenie liczby do kwadratu). Występujące nawiasy stosunkowo dobrze chronią je przed interakcją z otaczającym kodem. Zobaczmy jednak jak zachowa się makro jeśli jako argument przekażemy mu preinkrementacje zmiennej i typu int:

#include <iostream>

constexpr auto PI = 3.1415926;

template <typename F, typename ... Args>
inline void execute(F e, Args&& ... args)
{
   auto f = e(std::forward<Args>(args)...);
   std::cout << "EXECUTE: " << f << "\n";
}
        
#define SQUARE(X) ((X) * (X))        
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   int i{7};    
   std::cout << "PI: " << PI << "\n";
   execute(add, 7, 42);
   std::cout << "SQUARE(8): " << SQUARE(++i) << "\n";
   std::cout << "I: " << i << "\n";
}

Uruchom w edytorze

Otrzymany wynik nie jest jednak zgodny z oczekiwaniami, bowiem zamiast wartości 64 otrzymujemy 81, a zmienna i po opuszczeniu makra ma wartość 9 zamiast spodziewanego 8:

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                                 
SQUARE(8): 81
I: 9

Należy pamiętać, że makro zostanie rozwinięte do następującej postaci :

((++i) * (++i))

Nie należy zatem spodziewać się prawidłowego rezultatu, bowiem jest to przykład niezdefiniowanego zachowania (ang. undefined behaviour). Z pomocą ponownie przyjdzie szablon funkcji wariadycznej. Funkcję tą oznaczymy jako constexpr (niejawnie będzie również oznaczona jako inline), aby operacja podnoszenia liczby do kwadratu, podobnie jak dla makra, wykonana została w czasie kompilacji (dodatkowe nawiasy wokół parametru x zostawiłem jedynie aby zachować analogiczny zapis jak w makrze):

#include <iostream>

constexpr auto PI = 3.1415926;

template <typename F, typename ... Args>
inline void execute(F e, Args&& ... args)
{
   auto f = e(std::forward<Args>(args)...);
   std::cout << "EXECUTE: " << f << "\n";
}

template <typename T>        
constexpr auto square(T const& x) -> decltype(x * x)
{
   return x * x;
}
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   std::cout << "PI: " << PI << "\n";
   execute(add, 7, 42);
   std::cout << "SQUARE(7): " << square(7) << "\n";
}

Uruchom w edytorze

Po uruchomieniu otrzymamy spodziewany output:

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                                 
SQUARE(7): 49

Prawidłowo również wykona się kod z preinkrementowaną zmienną:

#include <iostream>

constexpr auto PI = 3.1415926;

template <typename F, typename ... Args>
inline void execute(F e, Args&& ... args)
{
   auto f = e(std::forward<Args>(args)...);
   std::cout << "EXECUTE: " << f << "\n";
}

template <typename T>        
constexpr auto square(T const& x) -> decltype(x * x)
{
   return x * x;
}
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   int i{7};    
   std::cout << "PI: " << PI << "\n";
   execute(add, 7, 42);
   std::cout << "SQUARE(8): " << square(++i) << "\n";
}

Uruchom w edytorze

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                                 
SQUARE(8): 64

Warto w tym miejscu zauważyć, iż pojawiające się szablony funkcji, podobnie jak makra, pozwalają przyjąć różne typy argumentów. Podstawowa różnica polega jednak na tym, że parametry szablonów funkcji posiadają swój typ (zależny od dedukcji typów przeprowadzonej przez kompilator).

Ostatnie Poprawki

Standard C++14 wprowadził nową własność: szablon zmiennej (ang. variable template). Dzięki temu można jawnie podać typ naszej stałej PI. Ponadto nie musimy podawać następującej instrukcji: ‚‚-> decltype((x) * (x))” przy definicji funkcji, a jedynie auto (ewentualnie decltype(auto)) jako typ zwracany. Oto ostateczna wersja omawianego programu:

#include <iostream>

template<class T>
constexpr T PI = T(3.1415926);

template <typename F, typename ... Args>
inline void execute(F e, Args&& ... args)
{
   auto f = e(std::forward<Args>(args)...);
   std::cout << "EXECUTE: " << f << "\n";
}

template <typename T>        
constexpr auto square(T const& x)
{
    return x * x;
}
        
float add(float a, float b)
{
   return a + b;
}

int main()
{
   std::cout << "PI: " << PI<double> << "\n";
   execute(add, 7, 42);
   std::cout << "SQUARE(7): " << square(7) << "\n";
}

Uruchom w edytorze

PI: 3.14159                                                                                                                                                                                 
EXECUTE: 49                                                                                                                                                                                 
SQUARE(7): 49

Podsumowanie:

  • Symbol stałej reprezentowanej przez makro nie posiada typu i nie jest obecny w pliku binarnym (po kompilacji)
  • Stałe czasu kompilacji (słowo kluczowe constexpr) posiadają swój typ
  • Argumenty makr nie podlegają kontroli typu
  • Preprocesor realizuje jedynie proste wklejanie argumentów makra (może być przyczyną błędów)
  • Szablon funkcji pozwala przyjmować różne typy argumentów podlegających kontroli typów
  • Słowo kluczowe inline (dla funkcji), odpowiada operacji wklejania kodu realizowanego przez makro
  • Słowo kluczowe constexpr (dla funkcji) odpowiada wykonaniu kodu w czasie kompilacji realizowanego przez makro