Preprocesor w C++

0

Ja hobbystycznie czasem programuję w C na mikrokontrolery i C++/Qt na Windows. W obu przypadkach często korzystam z dyrektywy #define i czasem z #ifdef. U mnie najczęstsze zastosowania są następujące:

  1. Włączanie i wyłączanie fragmentów kodu poprzez zmianę w jednym miejscu (na przykład, jak testuję różne rozwiązania tego samego, bądź jak chce mieć możliwość wydruku pewnych informacji przez cout tylko do testów, podczas, gdy ostatecznie ten wydruk nie jest potrzebny).
  2. Stała, która pojawia się w wielu miejscach w kodzie, również w definicji zmiennej (np. wielkość tablicy).
  3. Bardziej skomplikowane wyrażenie, zamiast pisać w wielu miejscach to robię #define i w nim to wyrażenie, a jak potrzebuję zmienić, to zmieniam w jednym miejscu.

Ja doskonale znam sposób działania #define i używam szczególnie, jak zależy mi na wydajności (kod funkcji jest bardzo często uruchamiany w pętli). Wiem co to jest "inline", ale czytałem, że nie każdy kompilator i nie w każdym przypadku to respektuje. A jak chce się używać tego samego dla zmiennych różnych typów, to albo musi być tyle funkcji, ile jest możliwości, albo kompilator musi dodać zmianę typu zmiennej przy uruchamianiu funkcji, podczas, gdy to nie jest potrzebne. Wyrażenia warunkowe dla wartości stałych? Trochę bez sensu obsługiwać zmienną, która przez cały czas będzie mieć tą samą wartość i na przykład w zależności od niej ma uruchomić lub pominąć jakiś kod. A jak wyrażenie nie jest nigdzie użyte, to nie powstaje martwy kod.

Widzę, że w innych językach nie ma preprocesora, w C# jakiś jest, ale nie ma możliwości zdefiniowania stałych fragmentów kodu (czyli #define z wyrażeniem, które ma być wstawiane w miejscu zdefiniowanego słowa).

Przykładowo, mam takie wyrażenia, które używam w swoich projektach. jest to chyba lepsze rozwiązanie wydajnościowo niż robienie funkcji dla różnych typów liczb, prawda?

#define Diff(X, Y) ((((X) - (Y)) > 0) ? ((X) - (Y)) : ((Y) - (X))) // Różnica bezwzględna
#define Abs(X) (((X) > 0) ? (X) : (0 - (X))) // Wartość bezwzględna
#define Min(X, Y) (((X) > (Y)) ? (Y) : (X)) // Wartość minimalna z dwóch
#define Max(X, Y) (((X) < (Y)) ? (Y) : (X)) // Wartość maksymalna z dwóch
#define Shl(X, Y) (((Y) >= 0) ? ((X) << (Y)) : ((X) >> (0 - (Y)))) // Przesunięcie bitowe w lewo, ale wartość ujemna w ilości bitów powoduje przesunięcie w prawo
#define Shr(X, Y) (((Y) >= 0) ? ((X) >> (Y)) : ((X) << (0 - (Y)))) // Przesunięcie bitowe w prawo, ale wartość ujemna w ilości bitów powoduje przesunięcie w lewo
#define Range(X, A, B) (((X) < (A)) ? (A) : (((X) > (B)) ? (B) : (X))) // Ograniczenie wartości do wskazanego zakresu, jak wykracza poza zakres, to wartość jest zmieniana do wartości granicznej zakresu

Czy może #define ma jakieś wady, które sprawiają, że w innych językach programowania nie ma takiej możliwości?
Czy w "poważnych" (dużych, komercyjnych) projektach w C i C++ używa się #define? Jeżeli nie to dlaczego (chodzi o inny powód niż brak potrzeby w danym projekcie)?

Inny przykład, który wymyśliłem na własne potrzeby, który pozwala wychwycić utworzone i niezniszczone obiekty (wyciek pamięci), ponowne zniszczenie zniszczonego obiektu, pomylenie delete z delete[], pomieszanie new/delete z malloc/free:

#define OBJMEMSTR std::cout
#define OBJMEMSIZETYPE size_t
#ifdef OBJMEMDEBUG
 #define NEW(T, C)              ([](T * __ptr) -> T*   { OBJMEMSTR << "NEW_OBJ_" << (void*)__ptr << "_" << #T << std::endl;   return __ptr; })(new C)
 #define NEWARR(T, C)           ([](T * __ptr) -> T*   { OBJMEMSTR << "NEW_ARR_" << (void*)__ptr << "_" << #T << std::endl;   return __ptr; })(new C)
 #define NEWARRINIT(T, C, N, V) ([](T * __ptr, OBJMEMSIZETYPE __N, T __V) -> T*   { OBJMEMSTR << "NEW_ARR_" << (void*)__ptr << "_" << #T << std::endl; for (OBJMEMSIZETYPE __it = 0; __it < __N; __it++) { __ptr[__it] = __V; } return __ptr; })(new C, N, V)
 #define DEL(T, P)              ([](T * __ptr) -> void { OBJMEMSTR << "DEL_OBJ_" << (void*)__ptr << "_" << #T << std::endl;   delete __ptr; })(P)
 #define DELARR(T, P)           ([](T * __ptr) -> void { OBJMEMSTR << "DEL_ARR_" << (void*)__ptr << "_" << #T << std::endl; delete[] __ptr; })(P)

 #define MALLOC(S)              ([](OBJMEMSIZETYPE __S)                     -> void* { void * __ptr = malloc(__S);      OBJMEMSTR << "NEW_MEM_" << __ptr << "_void" << std::endl; return __ptr; })(S)
 #define CALLOC(N, S)           ([](OBJMEMSIZETYPE __N, OBJMEMSIZETYPE __S) -> void* { void * __ptr = calloc(__N, __S); OBJMEMSTR << "NEW_MEM_" << __ptr << "_void" << std::endl; return __ptr; })(N, S)
 #define FREE(P)                ([](void * __ptr)                           -> void  {                                  OBJMEMSTR << "DEL_MEM_" << __ptr << "_void" << std::endl;  free(__ptr); })(P)
 #define REALLOC(P, S)          ([](void * __ptr, OBJMEMSIZETYPE __S)       -> void* { void * __ptr0; OBJMEMSTR << "DEL_MEM_" << __ptr << "_void" << std::endl; __ptr0 = realloc(__ptr, __S); OBJMEMSTR << "NEW_MEM_" << __ptr0 << "_void" << std::endl; return __ptr0; })(P, S)
#else
 #define NEW(T, C)              new C
 #define NEWARR(T, C)           new C
 #define NEWARRINIT(T, C, N, V) ([](T * __ptr, OBJMEMSIZETYPE __N, T __V) -> T*   { for (OBJMEMSIZETYPE __it = 0; __it < __N; __it++) { __ptr[__it] = __V; } return __ptr; })(new C, N, V)
 #define DEL(T, P)              delete P
 #define DELARR(T, P)           delete[] P

 #define MALLOC(S)              malloc(S)
 #define CALLOC(N, S)           calloc(N, S)
 #define FREE(P)                free(P)
 #define REALLOC(P, S)          realloc(P, S)
#endif

Wystarczy zamiast new i delete używać NEW i DELETE, a po przetestowaniu programu skasować #define OBJMEMDEBUG i program skompiluje się dokładnie tak, jakby w ogóle tego powyższego nie było.

2

Wiem co to jest "inline", ale czytałem, że nie każdy kompilator i nie w każdym przypadku to respektuje

Z dwadzieścia lat temu to pewnie tak było - obecnie jednak to kompilatory są lepsze w tego typu optymalizacjach niż ludzie.

Widzę, że w innych językach nie ma preprocesora

Niektóre inne języki posiadają systemy makr (np. Rust), który pełni zbliżoną funkcję.

Przykładowo, mam takie wyrażenia, które używam w swoich projektach. jest to chyba lepsze rozwiązanie wydajnościowo niż robienie funkcji dla różnych typów liczb, prawda?

"chyba"? W sensie, że klepnąłeś sobie kilkadziesiąt makr i ani jednego nie przetestowałeś pod kątem wydajności, tylko w ciemno założyłeś, że Twoja wersja jest lepsza?

Pierwsze wydanie GCC pojawiło się w 1987 roku - od tego czasu twórcy mieli naprawdę ogrom czasu na wprowadzenie wszelkiego rodzaju optymalizacji; GCC (kontynuując przykład) potrafi wektoryzować kod, automatycznie inline'ować funkcje (wcale nie musisz mu w tym pomagać!), zmieniać kolejność instrukcji w celu optymalizacji kodu na konkretną architekturę i tak dalej, i tak dalej.

Twoje makra mające na celu "inline'owanie" wypadają przy tym wszystkim blado i prawdopodobnie nawet te dwadzieścia lat temu byłyby zbędne - to są absolutne podstawy i punkty wejścia do kolejnych optymalizacji we wszystkich kompilatorach.

Rzuć sobie okiem, jakie cuda potrafi robić LLVM (np. kompilując wysokopoziomowy, idiomatyczny kod Rusta) to dopiero zrobisz wielkie oczy ;-)

Inny przykład, który wymyśliłem na własne potrzeby, który pozwala wychwycić [...] pomylenie delete z delete[] [...]

Przecież w dalszym ciągu mogę zrobić NEWARR(...), po którym odpalę FREE(...) - w czym tu pomogą Twoje makra?

0

Przykładowo, mam takie wyrażenia, które używam w swoich projektach. jest to chyba lepsze rozwiązanie wydajnościowo niż robienie funkcji dla różnych typów liczb, prawda?

"chyba"? W sensie, że klepnąłeś sobie kilkadziesiąt makr i ani jednego nie przetestowałeś pod kątem wydajności, tylko w ciemno założyłeś, że Twoja wersja jest lepsza?

Pierwsza wersja GCC pojawiła się w 1987 roku - od tego czasu twórcy mieli naprawdę ogrom czasu na wprowadzenie wszelkiego rodzaju optymalizacji; GCC (kontynuując przykład) potrafi wektoryzować kod, automatycznie inline'ować funkcje (wcale nie musisz mu w tym pomagać!), zmieniać kolejność instrukcji w celu optymalizacji kodu na konkretną architekturę i tak dalej, i tak dalej.

Twoje makra mające na celu optymalizację wypadają przy tym wszystkim blado i prawdopodobnie nawet te dwadzieścia lat temu byłyby zbędne - to są absolutne podstawy we wszystkich kompilatorach.

Przykład pierwszy z brzegu, może i nie trafiony, ale chciałem zilustrować sens używania makr. Równie dobrze może być bardziej skomplikowane wyrażenie potrzebne w danym konkretnym projekcie nie występujące w bibliotece standardowej.

Inny przykład, który wymyśliłem na własne potrzeby, który pozwala wychwycić [...] pomylenie delete z delete[] [...]

Przecież w dalszym ciągu mogę zrobić NEWARR(...), po którym odpalę FREE(...) - w czym tu pomogą Twoje makra?

Na standardowym wyjściu lub w innym strumieniu wypisze się taki tekst:
NEW_ARR_int_1234
DEL_MEM_1234
Po tym tekście będziesz wiedział, dlaczego Twój program nie zadziałał tak, jak oczekiwałeś (w miejscu 1234 będzie adres obiektu). Nawet, jak zadziałał, to przy dalszym rozwoju prędzej czy później będzie jakiś problem i normalnie to byś pół dnia szukał, dlaczego jest problem.

A jak zobaczysz jeden z poniższych tekstów, to znaczy, że tablicę utworzyłeś i zniszczyłeś prawidłowo.
NEW_ARR_int_1234
DEL_ARR_int_1234
lub
NEW_MEM_1234
DEL_MEM_1234
A jak Twój program nadal nie działa, to przyczyna jest inna niż nieprawidłowość przy tworzeniu i niszczeniu tablicy.
Dodatkowo, jak przy DEL będzie inny adres niż przy NEW, to znaczy, że coś pomieszałeś.

A jak zdebugujesz i usuniesz wszystkie błędy, to robisz jedną zmianę (usunięcie "#define OBJMEMDEBUG") i kod po przejściu przez preprocesor staje się identyczny, jak przy standardowym tworzeniu i niszczeniu obiektów, bez dodatkowych czynności. A jak rozwiniesz swój program, to znowu dodasz "#define OBJMEMDEBUG" i będziesz mógł sprawdzić, czy prawidłowo tworzysz i usuwasz obiekty. Prościej się chyba nie da, a przynajmniej w sposób działający niezależnie od kompilatora i systemu operacyjnego.

0

@andrzejlisek co do wydajności to pytanie jak to testowałeś. Wiadomo, że wywołanie funkcji jest związane z pewnym narzutem. Jednak jest to dosłownie kilka instrukcji na poziomie CPU. Pytanie czy nie jest tak, że w większości przypadków, że będzie to zaledwie 1% czasu całego wywołania funkcji? Wiadomo większość funkcji jest bardziej skomplikowana niż te parę instrukcji. Jeśli tak, to nie tędy droga. Generalnie należałoby zapuścić prolifer i sprawdzić co zjada największą ilość czasu i dopiero te miejsca zacząć optymalizować. Zgodnie z powiedzonkiem The premature optimization is the root of all evil...

Jeśli chodzi o to dlaczego NIE używać makr. Chodzi chociażby o czytelność kodu. Weź spróbuj zdebugować taki kod najeżony wywołaniami takich definicji. Ja dziękuję, postoję.

0
Mr.YaHooo napisał(a):

@andrzejlisek co do wydajności to pytanie jak to testowałeś. Wiadomo, że wywołanie funkcji jest związane z pewnym narzutem. Jednak jest to dosłownie kilka instrukcji na poziomie CPU. Pytanie czy nie jest tak, że w większości przypadków, że będzie to zaledwie 1% czasu całego wywołania funkcji? Wiadomo większość funkcji jest bardziej skomplikowana niż te parę instrukcji. Jeśli tak, to nie tędy droga. Generalnie należałoby zapuścić prolifer i sprawdzić co zjada największą ilość czasu i dopiero te miejsca zacząć optymalizować. Zgodnie z powiedzonkiem The premature optimization is the root of all evil...

Nie robiłem dokładnych testów wydajnościowych, ale przyjąłem założenie, że mam trzy kody robiące dokładnie to samo:
Kod A:

int func(int A, int B)
{
    return (A != B) ? ((A > B) ? 1 : -1) : 0;
}
int main()
{
    int L = 100;
    int T1[100];
    int T2[100];
    int T3[100];
    int T4[100];
    int K1 = 0;
    int K2 = 0;

    // W tym miejscu powinien być kod wypełnienia tablic jakimiś danymi

    for (int I = 0; I < L; I++)
    {
        K1 += func(T1[I], T2[I]);
        K2 += func(T3[I], T4[I]);
    }
    cout << K1 << " " << K2 << endl;
    return 0;
}

Kod B:

int main()
{
    int L = 100;
    int T1[100];
    int T2[100];
    int T3[100];
    int T4[100];
    int K1 = 0;
    int K2 = 0;

    // W tym miejscu powinien być kod wypełnienia tablic jakimiś danymi

    for (int I = 0; I < L; I++)
    {
        K1 += (T1[I] != T2[I]) ? ((T1[I] > T2[I]) ? 1 : -1) : 0;
        K2 += (T3[I] != T4[I]) ? ((T3[I] > T4[I]) ? 1 : -1) : 0;
    }
    cout << K1 << " " << K2 << endl;
    return 0;
}

Kod C:

#define func(A, B) ((A) != (B)) ? (((A) > (B)) ? 1 : -1) : 0
int main()
{
    int L = 100;
    int T1[100];
    int T2[100];
    int T3[100];
    int T4[100];
    int K1 = 0;
    int K2 = 0;

    // W tym miejscu powinien być kod wypełnienia tablic jakimiś danymi

    for (int I = 0; I < L; I++)
    {
        K1 += func(T1[I], T2[I]);
        K2 += func(T3[I], T4[I]);
    }
    cout << K1 << " " << K2 << endl;
    return 0;
}

Intuicja podpowiada, że kod B jest wydajniejszy od kodu A, ponieważ nie ma wywołania i powrotu z funkcji, za to kod A wygeneruje mniejszy plik wykonywalny od kodu B, bo w B powtarza się to samo wyrażenie. Wiadomo, że optymalizacja ogólna nie istnieje, bo albo jest optymalizacja wydajności kosztem wielkości, albo optymalizacja wielkości kosztem wydajności. Natomiast kod C przypomina kod A, jednak po przetworzeniu makr przed właściwą kompilacją stanie się identyczny z kodem B. Czy moje rozumowanie jest prawidłowe?

Jeśli chodzi o to dlaczego NIE używać makr. Chodzi chociażby o czytelność kodu. Weź spróbuj zdebugować taki kod najeżony wywołaniami takich definicji. Ja dziękuję, postoję.

Z tym się zgadzam, jeżeli się za bardzo przesadzi z makrami.

0
Patryk27 napisał(a):

Wiem co to jest "inline", ale czytałem, że nie każdy kompilator i nie w każdym przypadku to respektuje

Z dwadzieścia lat temu to pewnie tak było - obecnie jednak to kompilatory są lepsze w tego typu optymalizacjach niż ludzie.

Widzę, że w innych językach nie ma preprocesora

Niektóre inne języki posiadają systemy makr (np. Rust), który pełni zbliżoną funkcję.

Makro-generacja w C była spadkiem po makro-assemblerach
Język (chyba już słusznie zapomniany) TCL jest jakby jednym wielkim makrogeneratorem, tam nawet if() { był swoistym makrem (dla mnie jest koszmarkiem, kiedyś miałem API aplikacji w TCLu pod opieką)
Java odrzuciła przerosty i miejsca niebezpieczne C++, w tym makra.
Delphi i C# (które podejmowało/korygowało szczęśliwe/nieszczęśliwe pomysły Java) mają warunkową kompilację bloku kodu i tyle. Myślę że to jest właściwe podsumowanie refleksji nad makrami. Resztę historia (ewolucja) wyeliminowała

zagadka, jak się wykona

int c = MAX(*s++, ' ')

Myślę że nie ma sensu dla ambitnych "funkcyjnych" makr, ze względów na inline, potęgę współczesnych kompilatorów itd...

1

Intuicja podpowiada, że kod B jest wydajniejszy od kodu A, ponieważ nie ma wywołania i powrotu z funkcji,

To może nie sugeruj się intuicją, tylko wykonaj benchmark? ;-p

Ewentualnie wklej swój kod do Compiler Explorer i na własne oczy zobacz, że już przy -O1 żadnego wywołania funkcji tam w rzeczywistości nie otrzymasz, ponieważ - jak już wspomniałem - mamy XXI wiek, kompilatory nie są głupie.

0
andrzejlisek napisał(a):

Intuicja podpowiada, że kod B jest wydajniejszy od kodu A, ponieważ nie ma wywołania i powrotu z funkcji, za to kod A wygeneruje mniejszy plik wykonywalny od kodu B, bo w B powtarza się to samo wyrażenie. Wiadomo, że optymalizacja ogólna nie istnieje, bo albo jest optymalizacja wydajności kosztem wielkości, albo optymalizacja wielkości kosztem wydajności. Natomiast kod C przypomina kod A, jednak po przetworzeniu makr przed właściwą kompilacją stanie się identyczny z kodem B. Czy moje rozumowanie jest prawidłowe?

Wiesz, że w podejściu naukowym nie kierujemy się intuicją wysuwając twierdzenia? :) Intuicja może służyć jako pierwsze podejście do postawienia jakiejś tezy, jednak wymaga ona później dowodzenia. Gdybyś się tak mocno interesował optymalizacją, poczytałbyś co oznacza owe wywołanie funkcji na poziomie asemblera. Generalnie to będzie parę instrukcji (przygotowanie ramki stosu, odłożenie argumentów) oraz skok pod odpowiedni adres. To, czy jest o co kruszyć kopię zależy od tego co robi funkcja. Jeśli sama funkcja jest długa, skomplikowana, to te parę instrukcji jest poniżej 1% czasu wykonania całości. Moim zdaniem nie ma co wtedy bawić się makra itp. Natomiast jeśli funkcja jest krótka (jak w przypadku Twojego MIN) można się pokusić o coś takiego. Jednak moim zdaniem o wiele bardziej eleganckie jest zastosowanie funkcji inline niż makra. Jedynym wyjątkiem jaki mógłbym dopuścić było by jakieś oprogramowanie embedded z bardzo ograniczoną, powiedzmy do parę KB ilością pamięci. Wtedy każdy zaoszczędzony bajt jest na wagę złota. Dziś wielkości cache procesorów oraz dysków twardych są tak duże, ze nie opłaca się wywalać funkcji ze względu na rozmiar wynikowego kodu.

0
Mr.YaHooo napisał(a):

Jednak moim zdaniem o wiele bardziej eleganckie jest zastosowanie funkcji inline niż makra. Jedynym wyjątkiem jaki mógłbym dopuścić było by jakieś oprogramowanie embedded z bardzo ograniczoną, powiedzmy do parę KB ilością pamięci. Wtedy każdy zaoszczędzony bajt jest na wagę złota. Dziś wielkości cache procesorów oraz dysków twardych są tak duże, ze nie opłaca się wywalać funkcji ze względu na rozmiar wynikowego kodu.

No to powiem tak: Atmega 8, kod miałem w konwencji C, w AS v4, potem go "plusplusowałem" w AS 7- wbrew intuicji mikroprocesorowych guru *) - kod okazał się mniejszy w sensie bajtów. Oczywiście nie z każdą kompilacją analizowałem powstały kod maszynowy, ale takie pozytywne "kwiatki" jak wykrycie że pole statyczne znikąd nie jest używane itd ... a sekwencje wywołania znacznie krótsze.

Generalnie specyficzne środowisko programistów embedded trzyma się swoich "intuicji" np o makrach, bardziej niż realnych faktów.

*) że przez C++ kury jajek nie niosą, a krowy dają kwaśne mleko

0

Z tematy wyciągam wniosek taki, że te bardziej znane kompilatory, takie jak GCC lub Visual Studio są tak dopracowane, że są w stanie same przerobić program tak, żeby był bardziej optymalny (jednak nie wiemy, czy jest to optymalizacja wydajności czy wielkości, bo na ogół jedno optymalizuje się kosztem drugiego). W PC to w większości przypadków nie ma różnicy, czy jakiś kod wykonuje się o kilka milisekund dłużej lub krócej, bądź zajmuje więcej czy mniej miejsca.

Co do embedded, to ja jestem przekonany, że wielkość kodu, wydajność i użycie RAM jest ważne, nie wiem, czy kompilatory takie jak AVR Studio czy SDCC też mają takie optymalizacje, pewnie jakieś mają, ale chyba nie tak zaawansowane, jak GCC.

1 użytkowników online, w tym zalogowanych: 0, gości: 1