Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Teoria kompilacji: Preprocessing


2018-12-20, 00:00

Jak nam już wiadomo, kompilacja jest złożonym procesem składającym się z kilku etapów. Pierwszym krokiem tego procesu jest rozwijanie dyrektyw preprocesora. Zapraszam na dzisiejszy wpis, który w całości poświecony zostaje preprocesorowi właśnie.

Krótko o preprocesorze

Preprocesor jest to program wchodzący w skład sterownika kompilacji, przetwarzający kod źródłowy według określonych reguł nazywanych dyrektywami, dając w rezultacie kod źródłowy gotowy do kompilacji. Standard C++ w pełni opisuje poprawną pracę preprocesora. Treść standardu obejmującą ten temat możemy znaleźć tutaj.

Małe wtrącenie…

Na samym początku należy również wspomnieć o tym, że preprocesor jest odrobinę starym mechanizmem, który - ma swoje zalety, ale niestety ma również wady. Korzystać z niego należy rozważnie, i w miarę możliwości stawiać bardziej na rozwiązania, które oferuje bezpośrednio C++.

Czym jest dyrektywa preprocesora?

W kompilatorach dla języków z rodziny C (a więc C oraz C++) każda linijka rozpoczynająca się od znaku # oznacza dyrektywę preprocesora. Oznacza to, że ta składnia nie jest częścią tych języków programowania - kompilator nie będzie wiedział co z taką instrukcją zrobić, w rezultacie czego zgłosi błąd.

Najbardziej popularnymi dyrektywami preprocesora są:

  • #include - załącza pliki nagłówkowe
  • #ifdef oraz #ifndef - sterują procesem kompilacji
  • #define - definiuje stałe oraz makra
  • #pragma - zbiór dodatkowych dyrektyw zależnych od implementacji kompilatora

Załączanie plików nagłówkowych

Jedną z najbardziej podstawowych dyrektyw preprocesora niewątpliwie jest #include. Umożliwia ona załączanie plików nagłówkowych do aktualnej jednostki kompilacji. Pliki nagłówkowe zawierają najczęściej deklaracje funkcji, klas, stałych oraz makr - jest to ogólnie przyjęta konwencja programowania. Nic nie stoi jednak na przeszkodzie, aby znajdowały się tam również definicje, czyli implementacje np. funkcji lub metod. Możemy tam zamieścić dowolną konstrukcję w języku C/C++, ponieważ ostatecznie wszystko i tak rozwijane zostaje do jednego pliku źródłowego (który służy jako tzw. jednostka kompilacji właśnie).

Spłycając nieco, możemy stwierdzić, że instrukcja #include służy prostemu: “kopiuj i wklej w to miejsce treść pliku, o który proszę”.

Mimo, że prawie każdy zna składnię dyrektywy #include, myślę że warto ją tutaj przedstawić:

#include <iostream>
#include "file.hpp"

Jaka jest różnica pomiędzy pierwszą a drugą linijką? Mianowicie, jeżeli szukamy pliku nagłówkowego w systemie operacyjnym lub w ścieżkach przekazanych kompilatorowi, używamy znaków <>. Jeżeli szukamy pliku w katalogu pliku źródłowego (bądź relatywnie do niego), to używamy znaków cudzysłowiu "".

Definiowanie stałych

Drugą bardzo popularną funkcją preprocesora jest możliwość definiowania stałych. Służy do tego dyrektywa #define, którą stosuje się według następującego szablonu:

#define NAZWA_STALEJ wartosc

Zwyczajowo nazwy stałych rozwijanych przez preprocesor tworzymy używając wielkich liter. Nic nie stoi jednak na przeszkodzie, aby nazwy te mogły być pisane z małej litery. Jeżeli chcecie poczytać więcej regułach nazewnictwa, to zachęcam do przeczytania standardu.

Stałe definiowane za pomocą dyrektywy #define są bezkontekstowe, a co za tym idzie - podczas podmiany nie jest sprawdzane typowanie (ponieważ preprocesor prawie nic nie wie o naszym kodzie). Podobnie jak przy dyrektywie #include, ma tutaj miejsce zwykła podmiana tekstowa.

Jedynym wyjątkiem jest podmiana wewnątrz literałów zdefiniowanych w kodzie, gdzie ta podmiana nie ma miejsca. Przykładowo:

#include <iostream>
#include <string>

#define MY_VALUE "10"

int main(int argc, char* argv[]) {
    std::string s = "The value is MY_VALUE !";
    std::cout << s << std::endl;
}

Na wyjście programu zostanie zwrócone:

The value is MY_VALUE !

Jak możemy zauważyć, podmiana preprocesora nie jest stosowana wewnątrz literałów, nawet jeśli te są zdefiniowane bezpośrednio w kodzie. Jeżeli chcemy “wyprodukować” jeden literał, wewnątrz którego ma zostać wykonana podmiana, musimy to zrobić w następujący sposób:

#include <iostream>
#include <string>

#define MY_VALUE "10"

int main(int argc, char* argv[]) {
    std::string s = "The value is " MY_VALUE " !";
    std::cout << s << std::endl;
}

Zwróćmy uwagę na to, że stała MY_VALUE zostaje rozwiązana, oddając jako rezultat literał. Oczywiście, po wykonaniu kodu przez preprocesor wyprodukowane zostaną aż trzy literały, ale te już podczas procesu kompilacji zostają połączone w jeden ciąg.

Zanim zaczniemy definiować własne stałe, musimy mieć kilka rzeczy na uwadze:

  • Stałe tworzone przez dyrektywę #define nie są kontrolowane przez żaden mechanizm typowania
  • Stałe tworzone przez dyrektywę #define są zawsze publiczne (w obrębie wszystkich miejsc, które załączają to makro)
  • Stałe tworzone przez dyrektywę #define modyfikują kod bezkontekstowo. Oznacza to, że jeżeli będziemy mieli zdefiniowaną stałą year, a w kodzie również będziemy mieli zmienną o nazwie year - zostanie ona podmieniona przez preprocesor na wartość stałej, co w większości przypadków spowoduje błąd.

Sterowanie procesem kompilacji

Dopełnieniem do stałych definiowanych przez #define są dyrektywy warunkowe, czyli kolejno: #ifdef, #ifndef, #else oraz #endif. Dzięki nim możemy włączać części kodu do procesu kompilacji, bądź ukryć je przed kompilatorem. Są to instrukcje sprawdzające, czy stała została zdefiniowana lub nie. Prosty przykład obrazujący użycie tych dyrektyw:

#include <iostream>

#define MY_VALUE 10

int main(int argc, char* argv[]) {
    #ifdef MY_VALUE
        std::cout << "MY_VALUE is " << MY_VALUE << std::endl;
    #else
        std::cout << "There is no MY_VALUE" << std::endl;
    #endif
}

Ku naszej uciesze na wyjściu pojawi się:

MY_VALUE is 10

Oprócz dyrektyw sprawdzających czy stała została zdefiniowana, istnieją również dyrektywy warunkowe porównujące wartości zdefiniowanych stałych. Mowa o #if oraz #elif. Przykładowe użycie:

#include <iostream>

#define MY_PREV_VALUE 20
#define MY_VALUE 10
#define MAX_VALUE 200

int main(int argc, char* argv[]) {
    #if MY_VALUE*MY_PREV_VALUE > MAX_VALUE
        std::cout << "More than 200" << std::endl;
    #else
        std::cout << "Less or equal 200" << std::endl;
    #endif
}

Operacje przeprowadzane wewnątrz warunków muszą być rozwijalne przez preprocesor, co oznacza, że wszystkie wartości wewnątrz warunku muszą być znane w chwili jego sprawdzenia. Nie muszą to być stałe utworzone za pomocą dyrektywy #define - mogą to być wyrażenia stałe, takie jak np. 2*3+4 zawarte bezpośrednio w warunku.

Stałe predefiniowane

Prawdziwa moc dyrektyw #define oraz tych z rodziny #ifdef drzemie w predefiniowanych stałych określających środowisko, na którym aktualnie kompilowany jest program. Właśnie to daje nam możliwość decydowania, jakie platformy będzie obsługiwał nasz program. Większość współczesnych kompilatorów definiuje stałe określające bieżący system operacyjny oraz architekturę, na której przebiega kompilacja.

Poniżej znajduje się lista stałych definiujących kompilator, na którym kompilowany jest kod:

  • _MSC_VER - Microsoft Visual Studio
  • __GNUC__ - GCC
  • __clang__ - Clang
  • __MINGW32__ - MinGW w systemie 32-bitowym oraz MinGW 32bit w systemie 64-bitowym
  • __MINGW64__ - MinGW 64bit

Niestety, stałe określające system operacyjny oraz architekturę nie są na ogół ustandaryzowane. Istnieje jednak kilka takich, które dobrym zwyczajem, często są definiowane przez popularne kompilatory:

  • __APPLE__ - stała informująca, że kompilujemy w ekosystemie Apple
  • __APPLE__ && __MACH__ - para stałych informująca, że kompilujemy pod MacOSX
  • __APPLE__ && TARGET_OS_IPHONE - para stałych informująca, że kompilujemy pod IPhone
  • _WIN32 - stała informująca, że kompilujemy pod systemem Windows wersja 32bit
  • _WIN64 - stała informująca, że kompilujemy pod systemem Windows wersja 64bit
  • __linux__ - stała pojawiająca się na systemach z rodziny Linux
  • __ANDROID__ - stała która mówi, że kompilujemy pod androidem

Dzięki takiemu zestawowi flag możemy skomponować takie cudo:

#ifdef _WIN32
   // System Windows
   #ifdef _WIN64
      // Windows 64bit
   #else
      // Windows 32bit
   #endif
#elif __APPLE__
    #include "TargetConditionals.h"
    #if TARGET_IPHONE_SIMULATOR
         // Symulator iOS
    #elif TARGET_OS_IPHONE
        // Produkcyjny iOS
    #elif TARGET_OS_MAC
        // MacOS
    #else
    #   error "Unknown Apple platform"
    #endif
#elif __linux__
    // Systemy z rodziny Linux
#elif __unix__
    // Pozostałe Unix-y
#endif

Powyższy listing zapożyczyłem ze StackOverflow ;) Rozszerzoną listę stałych określających system operacyjny możecie znaleźć tutaj, a listę stałych definiujących bieżącą architekturę mamy tutaj.

Ostrzeganie programisty

Kiedy tworzymy bibliotekę, która będzie używana przez nieznaną nam osobę, możemy chcieć poinformować tą osobę o tym, że podczas kompilacji coś poszło nie tak. Na przykład, nasza biblioteka wymaga istnienia innej biblioteki w systemie, a my zamiast mało czytelnych błędów linkera chcemy poinstruować programistę, co powinien zrobić aby było dobrze. Do tego celu posłużą nam następujące stałe:

  • #warning - zwykłe ostrzeżenie, które można wyciszać flagą -W#warnings. Kompilacja nie zostaje zatrzymana w tym miejscu.
  • #error - informacja o błędzie. Tutaj następuje zatrzymanie procesu kompilacji.
  • __DATE__ - stała rozwijająca się do daty uruchomienia preprocesora
  • __TIME__ - stała rozwijająca się do czasu uruchomienia preprocesora
  • __FILE__ - stała, która rozwija się do ścieżki aktualnie procesowanego pliku
  • __LINE__ - stała, która rozwija się do “aktualnej linii” w kodzie

Przykładowe wykorzystanie powyższych stałych:

#include <iostream>

#ifdef __APPLE__
    #include "TargetConditionals.h"
    #if TARGET_IPHONE_SIMULATOR
        #warning "Pamiętaj, to tylko symulator!"
    #elif TARGET_OS_MAC
        #error "Nie kompilujemy pod Makami"
    #endif
#endif

int main(int argc, char* argv[]) {
    std::cout << "Przekompilowano dnia " << __DATE__ << " " << __TIME__;
}

Definiowanie makr

Pod dyrektywą #define kryje się również druga funkcjonalność: możliwość definiowania makr. W prostych słowach możemy stwierdzić, że makra działają identycznie jak stałe, przy czym możemy je parametryzować. Przykładowo, mamy taki kod:

#include <string>

int main(int argc, char* argv[]) {
    std::string username = std::string(argv[1]);
    username.erase(
        std::remove(username.begin(), username.end(), '@'), username.end()
    );

    std::string name = std::string(argv[2]);
    name.erase(
        std::remove(name.begin(), name.end(), '&'), name.end()
    );
}

Widzimy dwukrotne użycie metody erase(...) wraz z parametrami. Jest to rozwiązanie mało ekspresywne, które na pierwszy rzut oka mało nam mówi o czynności, która zostaje podjęta. Możemy utworzyć makro, które sprawi, że kod będzie bardziej ekspresywny:

#include <string>

#define REMOVE_OCURRS(s, c) s.erase(std::remove(s.begin(), s.end(), c), s.end());

int main(int argc, char* argv[]) {
    std::string username = std::string(argv[1]);
    REMOVE_OCURRS(username, '@');

    std::string name = std::string(argv[2]);
    REMOVE_OCURRS(name, '&');
}

Pamiętać należy jednak, że nie mamy tutaj żadnej kontroli typu! Dla preprocesora nie ma znaczenia, czy wartości są liczbą czy ciągiem znakowym. On tu tylko podmienia kod. Więcej informacji na temat konstrukcji definiowania makr znajdziecie w standardzie.

Zawsze stosuj nawiasy!

Istnieje pewna pułapka, w którą łatwo jest wpaść podczas tworzenia makr. Prześledźmy sobie poniższy kod:

#include <iostream>

#define SUM 20+20
#define DIV(a, b, c) c=a/b

int main(int argc, char* argv[]) {
    int c;
    DIV(400, SUM, c);
    std::cout << c << std::endl;
}

Po uruchomieniu tego programu pojawi nam się wynik w postaci 40, pomimo, że spodziewaliśmy się 10. Dlaczego tak się stało? Odpowiedzią na to pytanie będzie kod, który zostaje wygenerowany po zakończeniu pracy preprocesora:

int main(int argc, char* argv[]) {
    int c;
    c=400/20+20;
    std::cout << c << std::endl;
}

Pssst! Operator / ma większy priorytet niż operator +! :)
Aby zaradzić tego typu błędom, należy stosować nawiasy wokół parametrów makra:

#include <iostream>

#define SUM (20+20)
#define DIV(a, b, c) (c)=(a)/(b)

int main(int argc, char* argv[]) {
    int c;
    DIV(400, SUM, c);
    std::cout << c << std::endl;
}

Nawiasy mają wystarczająco wysoki priorytet, aby zapobiec tego typu błędom. Teraz możemy spać spokojnie, bo wynik naszego programu jest zgodny z naszymi oczekiwaniami (10).

Operatory “#” i “##”

Dyrektywa #define posiada zestaw dwóch ciekawych operatorów. Pierwszym z nich jest #, który oznacza, że wartość która za nim stoi, ma zostać zwrócona jako literał const char*. Możemy dzieki temu np. w bardzo prosty sposób zamieniać różnego rodzaju wartości na ciągi znakowe:

#include <iostream>
#include <string>

#define MK_STR(x) #x

int main(int argc, char* argv[]) {
    std::string s = std::string(MK_STR(10));
    std::cout << s << std::endl;
}

Drugim bardzo przydatnym operatorem jest operator ##, który służy łączeniu dwóch wartości w jeden ciąg znakowy:

#include <iostream>

#define VAR(num) var_ ## num

int main(int argc, char* argv[]) {
    int var_1 = 10;
    int var_2 = 20;

    std::cout << VAR(1) << std::endl;
}

Niestety, ale nie przychodzi mi na myśl żaden przykład z codziennej praktyki, który mógłbym tutaj przedstawić. Może Wam kiedyś było potrzebne użycie któregokolwiek z tych operatorów?


Uwaga. Pamiętajmy jednak, że obydwa te operatory, tak jak i wszystkie dyrektywy preprocesora rozwijane są na etapie kompilacji. Nie liczmy w takim razie na to, że po podstawieniu zmiennej typu int pod którykolwiek z tych operatorów zwrócona zostanie wartość, którą przetrzymuje ta zmienna.

Dyrektywy z rodziny pragma

Oprócz dyrektyw omówionych powyżej standard dopuszcza dyrektywy z rodziny #pragma (link). Są to dyrektywy zależne od kompilatora oraz jego implementacji.

Najpopularniejszą dyrektywą z rodziny #pragma jest niewątpliwie #pragma once. Jest to zastępnik “Strażnika załączeń” - zapewnia nas, że plik nagłówkowy w którym ta dyrektywa jest zawarta, zostanie załączony tylko raz w obrębie jednostki kompilacji.

Obrazujac, możemy stwierdzić że:

#pragma once

class A {

};

zastępuje nam:

#ifndef MY_FILE
#define MY_FILE

class A {

};

#endif

Ciekawostka: standard wymusza, aby niewspierane przez kompilator dyrektywy z rodziny #pragma są pomijane. Nie musimy w takim razie obawiać się błędów spowodowanych przez korzystanie z pragm implementowanych przez różne kompilatory.

Wgląd do przetworzonego kodu

Jeżeli podejrzewamy błąd związany z użytymi przez nas dyrektywami preprocesora, możemy w prosty sposób podejrzeć, jaki kod został wygenerowany w etapie preprocessingu. Aby to zrobić, należy do polecenia kompilującego dorzucić parametr -E (GCC i Clang). Gwarantuje nam to, że proces kompilacji zatrzyma się po zakończeniu przetwarzania kodu przez preprocesor.

gcc main.cpp -E -o main-processed.cpp

Kiedy do polecenia kompilującego podamy parametr -o, to przetworzony kod zostanie zapisany do pliku, którego ścieżkę przekażemy za nim. W sytuacji, kiedy tego parametru nie podamy, przetworzony kod zostanie zwrócony na standardowe wyjście.

Dla kompilatorów z rodziny MSVC potrzebujemy przekazać:

  • /E - jeżeli chcemy, aby preprocesor zwrócił przetworzony kod na standardowe wyjście
  • /P - jeżeli chcemy, aby preprocesor zapisał przetworzony kod w podanym przez nas pliku

Wady preprocesora

Preprocesor daje nam wiele możliwości, z których część opisałem powyżej. Niestety - nie jest to system idealny i ma swoje wady, o których należy wspomnieć.

1. Preprocesor nie zna kontekstu aplikacji

Preprocesor nie wie, czy wartość zostanie dobrze podmieniona. Przykładowo: nie połączy on dobrze ciągu znakowego i liczby, a może spowodować tylko błąd kompilacji. Jest tak dlatego, ponieważ preprocesor działa tuż przed procesem kompilacji. Zaleca się, aby najpierw korzystać z funkcji, które znajdują się bezpośrednio w gamie języka C++, a dopiero kiedy to zawiedzie, użyć makr preprocesora. Mowi o tym również sam twórca C++ w dokumencie Core Guidelines tutaj i tutaj.

2. Błędy spowodowane przez preprocesor są trudniejsze do analizy

Wynik pracy preprocesora nie jest widoczny dla nas gołym okiem w kodzie. Musimy analizować kod wypluty przez preprocesor, który jest znacznie dłuższy i bardziej skomplikowany (zawiera m.in. wszystkie zainclude-owane pliki nagłówkowe).

3. Dyrektywa #define jest słabo wspierana przez popularne IDE

Dzisiejsze IDE również nie stawiają na preprocesor, a co za tym idzie - nie koloryzują wewnątrz kodu tak, jak trzeba. Nie analizują one zbyt wiele tego, co tam się znajduje, ponieważ ciężko jest im dobrać odpowiedni kontekst.

Podsumowanie

Dzisiejszy wpis w całości poświęcony został pierwszemu etapowi procesu kompilacji - preprocessingowi. Dowiedzieliśmy się, czym preprocesor jest oraz kiedy jest on uruchamiany. Dodatkowo nauczyliśmy się, w jaki sposób można zatrzymać proces kompilacji na procesie preprocessingu. Omówiliśmy, czym są dyrektywy #include oraz #define, oraz jak za pomocą #define możemy definiować stałe oraz makra. Omówione przez nas zostały również stałe predefiniowane, które można znaleźć wewnątrz popularnych kompilatorów. Na sam koniec dowiedzieliśmy się, w jaki sposób możemy zatrzymać proces kompilacji na preprocessingu.



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.

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