2.3 Makrók, feltételes fordítás és veszélyes helyettesítés

A makró látszólag lehet konstans, függvény vagy rövidített utasítás, de a C nyelvben ezek egyikével sem azonos: az előfeldolgozó, vagyis a preprocessor helyettesítési szabálya. Ez a különbség akkor válik veszélyessé, amikor egy ártalmatlannak tűnő makróhívás megváltoztatja a műveleti sorrendet, vagy többször hajt végre egy mellékhatást.

A Headerek, deklarációk és include-higiénia részben látott headerek makródefiníciókat is közvetíthetnek. Ezek azonban nem követik a függvények és változók blokkszintű hatóköreit. A példákhoz A szükséges C szintaxis tömör áttekintése kifejezés- és függvényfogalmait használjuk.

A nyelvi alap az ISO C17; az eltéréseket külön jelöljük. A teljes példa kijelölt laborprofilja GCC 14.2.0 vagy Clang 19.1.7, Linux x86-64 System V ABI, LP64 és glibc 2.40. A bemutatott eredmények levezetett, várt eredmények, nem dokumentált futtatási megfigyelések.

2.3.1 A makró tokeneket helyettesít

Az object-like macro, azaz objektumszerű makró egy azonosítóhoz helyettesítési listát rendel. A névben szereplő „objektum” itt nem nyelvi memóriaobjektumot jelent: a makrónak nincs címe, típusa vagy tárolója.

Az alábbi C17-részlet két definíciót mutat:

#define RETRY_LIMIT 3
#define WINDOW_SIZE 2 + 3

A RETRY_LIMIT megfelelő előfordulásának helyére a 3 előfeldolgozási token kerül. A WINDOW_SIZE három tokent helyettesít be: 2, +, 3.

Ezért:

/* C17-részlet. */
int capacity = 10 * WINDOW_SIZE;

a kifejtés után lényegében:

int capacity = 10 * 2 + 3;

A fordító ezt 10⋅2+3=2310 \cdot 2 + 3 = 23 értékű kifejezésként értelmezi, nem pedig 10⋅(2+3)=5010 \cdot (2+3)=50 alakban. A javítás:

#define WINDOW_SIZE (2 + 3)

A helyettesítés tokenalapú, nem tetszőleges részszövegek cseréje. A RETRY_LIMIT_EXTRA azonosító része nem cserélődik ki, és a "RETRY_LIMIT" sztringliterál belseje sem.

A function-like macro, vagyis függvényszerű makró paramétereket fogad:

#define SQUARE(x) ((x) * (x))

Definíciókor a makrónév és a nyitó zárójel között nem lehet szóköz. A #define SQUARE (x) már objektumszerű makrót definiálna. Használatkor viszont a SQUARE (3) is kiváltja a függvényszerű makró kifejtését.

A makró argumentuma tokensorozat, nem előre kiszámított érték. Az argumentumokat vessző választja el, kivéve a belső, párosított kerek zárójeleken belüli vesszőket. A kapcsos zárójel önmagában nem védi meg a vesszőt az argumentumelválasztó szereptől.

2.3.2 Zárójelezés és többszörös kiértékelés

Egy kifejezésmakróban rendszerint külön kell zárójelezni minden paraméterelőfordulást és az egész eredményt:

#define BAD_SQUARE(x) x * x
#define SQUARE(x) ((x) * (x))

A BAD_SQUARE(2 + 3) kifejtése:

2 + 3 * 2 + 3

Először a szorzás történik meg, ezért az eredmény 2+6+3=112+6+3=11. A javított változat:

((2 + 3) * (2 + 3))

eredménye 2525.

A zárójelezés azonban nem szünteti meg a többszörös kiértékelést. A SQUARE(n++) hibás, UB-s tanítási példa: kifejtése ((n++) * (n++)), amelyben ugyanazon skalár objektum két módosítása között nincs megfelelő sorrendi kapcsolat. Ehhez nincs garantált eredmény; nem helyes azt mondani, hogy „legfeljebb kétszer növeli”.

Nem minden többszörös kiértékelés UB. A következő önálló program definiált, de meglepő viselkedést szemléltet.

Fájl: macro_demo.c; minimum C17, a fent megadott laborprofil.

#include <stdio.h>

#define BAD_SQUARE(x) x * x
#define SQUARE(x) ((x) * (x))
#define MIN_EXPR(a, b) ((a) < (b) ? (a) : (b))

static inline int min_int(int a, int b)
{
    return a < b ? a : b;
}

int main(void)
{
    int n = 3;
    int macro_result = MIN_EXPR(n++, 10);
    int after_macro = n;

    n = 3;
    int function_result = min_int(n++, 10);

    if (printf("%d %d\n",
               BAD_SQUARE(2 + 3), SQUARE(2 + 3)) < 0) {
        return 1;
    }

    if (printf("%d %d; %d %d\n",
               macro_result, after_macro,
               function_result, n) < 0) {
        return 1;
    }

    return 0;
}

Külön laborban használható fordítási parancsok:

gcc -std=c17 -Wall -Wextra -Wpedantic -O0 macro_demo.c -o macro_demo-gcc
clang -std=c17 -Wall -Wextra -Wpedantic -O0 macro_demo.c -o macro_demo-clang

Bizonyításvázlat a makrós és függvényes változat eltérésére. A makró helyettesítése:

((n++) < (10) ? (n++) : (10))
  1. Az első n++ értéke 3, mellékhatása után n értéke 4.
  2. A feltétel igaz. A feltételes operátor első operandusának kiértékelése megelőzi a kiválasztott ágét.
  3. A második n++ értéke 4, majd n értéke 5.

Így a makrós eredmény 4, a végső n pedig 5. A függvényhívás egyetlen n++ argumentumot értékel ki: a függvény 3 és 10 értéket kap, ezért az eredmény 3, a végső n pedig 4.

Sikeres kimeneti írás mellett a várt sorok:

11 25
4 5; 3 4

A static inline függvény valódi, típusellenőrzött függvény. Az inline nem garantálja a gépi kód beillesztését, és nem ez biztosítja az egyszeri argumentumkiértékelést: az a függvényhívás tulajdonsága. Részletes kapcsolási szabályait a static inline, _Generic és headeres eszköztár tárgyalja.

A függvény sem tesz minden hívást biztonságossá: a min_int(n++, n++) C17-ben továbbra is UB. A külön argumentumok kiértékeléséhez a nyelv nem biztosít itt szükséges sorrendet; ezt a sequencing szabályai részletezik.

2.3.3 Argumentumkifejtés, sztringesítés és tokenösszeillesztés

Közönséges helyettesítéskor az argumentumokban található makrók előbb kifejtődnek. A helyettesítés eredményét az előfeldolgozó újra megvizsgálja további makrók szempontjából. Két fontos kivétel a # és a ## közvetlen operandusaként szereplő paraméter.

A # stringification, azaz sztringesítés: az argumentum tokenjeiből sztringliterált készít. A ## token pasting, azaz tokenösszeillesztés: két tokenből egyetlen új előfeldolgozási tokent alkot.

/* C17-részlet. */
#define SLOT 7

#define STR_RAW(x) #x
#define STR(x) STR_RAW(x)

#define CAT_RAW(a, b) a ## b
#define CAT(a, b) CAT_RAW(a, b)

A különbségek:

Használat Kifejtés Indok
STR_RAW(SLOT) "SLOT" A # mellett nincs előzetes argumentumkifejtés.
STR(SLOT) "7" A külső szint előbb kifejti a SLOT makrót.
CAT_RAW(metric_, SLOT) metric_SLOT A ## mellett nincs előzetes kifejtés.
CAT(metric_, SLOT) metric_7 A külső szint a második argumentumot 7-re fejti ki.

A sztringesítés nem a forrás karakterhű másolata: például a belső whitespace-sorozatokat egyetlen szóközzé alakítja. Az összeillesztésnek érvényes előfeldolgozási tokent kell eredményeznie; nem alkalmas tetszőleges szövegek összeragasztására.

A kétlépcsős minta hasznos generált azonosítókhoz és diagnosztikai sztringekhez. Közönséges számításhoz azonban többnyire szükségtelen bonyolítás.

2.3.4 X-macro: egyetlen taglista több helyen

Az X-macro technika egy adatleírást egyetlen helyen, egy listamakróban rögzít, és ebből több kódrészletet generál: a struct deklarációját, a tagok számát, a név szerinti kiírást vagy egy szerializálót. A listamakró paramétere maga is egy makró (szokásosan X), amelyet minden felhasználási hely másképp definiál. Így egy új tag felvétele egyetlen sor, és a struct, a számláló és a kiírás nem csúszhat el egymástól.

/* xmacro.c — C17: egyetlen taglistából struct, tagszám és kiírás */
#include <stddef.h>
#include <stdio.h>

/* A taglista egyetlen helyen: X(típus, név, printf-formátum). */
#define CONFIG_FIELDS(X)              \
    X(int,          port,    "%d")    \
    X(unsigned,     workers, "%u")    \
    X(const char *, bind,    "%s")

/* 1. A struct a listából épül. */
#define DECLARE_FIELD(type, name, fmt) type name;
struct config {
    CONFIG_FIELDS(DECLARE_FIELD)
};
#undef DECLARE_FIELD

/* 2. A tagszám a listából számolódik: minden tag egy "+ 1". */
#define COUNT_FIELD(type, name, fmt) + 1
enum { CONFIG_FIELD_COUNT = 0 CONFIG_FIELDS(COUNT_FIELD) };
#undef COUNT_FIELD

/* 3. A kiírás is a listából generálódik: a nevet a # operátor adja. */
#define PRINT_FIELD(type, name, fmt) \
    if (printf("%s=" fmt "\n", #name, c->name) < 0) return -1;
static int print_config(const struct config *c)
{
    CONFIG_FIELDS(PRINT_FIELD)
    return 0;
}
#undef PRINT_FIELD

_Static_assert(CONFIG_FIELD_COUNT == 3, "a taglista három elemű");

int main(void)
{
    const struct config c = { 8080, 4u, "127.0.0.1" };
    printf("fields=%d\n", CONFIG_FIELD_COUNT);
    return print_config(&c) == 0 ? 0 : 1;
}

A CONFIG_FIELDS(DECLARE_FIELD) előfeldolgozás után int port; unsigned workers; const char * bind; lesz, a tagszám pedig a 0 + 1 + 1 + 1 konstans kifejezés. A kiírásban a #name sztringesítés (lásd fent) adja a tag nevét. Minden felhasználás után #undef-eljük a segédmakrót, hogy a következő felhasználás saját definíciót adhasson neki.

A technika korlátai: a lista és a típus szinkronja csak addig garantált, amíg minden tag ténylegesen a listából jön létre; a hibaüzenetek a makrókifejtésre mutatnak, nem a listára; a debugger és sok eszköz a kifejtett kódot látja. A C++26 statikus reflexiója (C++26 nyelvi kitekintés) éppen ezt a kézi listát váltja ki: ott a tagok listája magából a típusból kérdezhető le.

Buildjegyzőkönyv (lab-gcc profil, gcc -std=c17 -Wall -Wextra -Wpedantic -O2). A program figyelmeztetés nélkül fordult, kimenete: fields=3, port=8080, workers=4, bind=127.0.0.1. A gcc -E -P kimenete a fenti struct-deklarációt és CONFIG_FIELD_COUNT = 0 + 1 + 1 + 1-et mutatta.

2.3.5 Variadikus makrók és szabványverziók

ISO C99-től, így C17-ben is használható variadikus makró:

/* C17-részlet; <stdio.h> szükséges. */
#define LOG(...) fprintf(stderr, __VA_ARGS__)

Például a LOG("ready\n") és a LOG("attempt=%d\n", attempt) egyaránt használható. A __VA_ARGS__ a változó argumentumrész tokenjeit helyettesíti be.

Rögzített paraméter után más a helyzet:

#define TRACE(fmt, ...) fprintf(stderr, fmt, __VA_ARGS__)

C17-ben a TRACE("ready\n") elhagyott variadikus argumentummal nem szabványos használat. A TRACE("ready\n", ) pedig a helyettesítés után fölösleges vesszőt hagy a függvényhívásban.

ISO C23-ban a __VA_OPT__ erre szabványos megoldást ad:

/* Kizárólag C23-részlet. */
#define TRACE(fmt, ...) \
    fprintf(stderr, fmt __VA_OPT__(,) __VA_ARGS__)

A vessző csak nem üres variadikus tokenlista esetén jelenik meg. A GNU-féle , ##__VA_ARGS__ vesszőelnyelő megoldás fordítóbővítés, nem C17-es helyettesítője ennek.

A C17-es fallback a teljes argumentumlistát fogadó LOG(...), vagy két külön nevű makró. A C23-as alak használata előtt a tényleges fordítótámogatást külön ellenőrizni kell; ezt a Preprocessor-labor és feature detection folytatja.

2.3.6 Több utasítás egyetlen szintaktikai egységként

Két csupasz utasítást helyettesítő makró megtörheti az if–else szerkezetet. A szokásos burkolás:

/* C17-részlet. */
#define ADD_AND_COUNT(total, count, value) \
    do {                                 \
        (total) += (value);               \
        ++(count);                       \
    } while (0)

Használata:

/* C17-részlet. */
if (accepted)
    ADD_AND_COUNT(total, count, value);
else
    ++rejected;

A do törzse egyszer végrehajtódik, majd a nulla feltétel lezárja a ciklust. Az egész szerkezet egyetlen utasítás; a pontosvesszőt a hívó írja.

A példa szerződése szerint total és count módosítható egész változók, és egyik aritmetikai művelet sem lép túl a típus megengedett tartományán. A burkolás csak szintaktikai védelmet ad: nem biztosít típusbiztonságot, nem javítja a paraméterek ismételt használatát, és a benne szereplő break vagy continue célpontját is befolyásolja.

A makróba bevezetett lokális név ráadásul ütközhet a hívó nevével. A projektprefix csökkenti ezt a kockázatot, de nem hoz létre valódi névhigiéniát.

2.3.7 Feltételes fordítás és konfigurációs szerződés

A #if fordítás előtt választ tokenszakaszokat; nem futásidejű if. A #ifdef NAME csak a makró létezését vizsgálja. Ezért egy nullára definiált kapcsolóra is igaz.

Egy C17-es konfigurációs részlet:

#include <stdio.h>

#ifndef APP_TRACE
#define APP_TRACE 0
#endif

#if (APP_TRACE != 0) && (APP_TRACE != 1)
#error "APP_TRACE must be 0 or 1"
#endif

#if APP_TRACE
#define APP_LOG(...) fprintf(stderr, __VA_ARGS__)
#else
#define APP_LOG(...) ((void)0)
#endif

A konfiguráció szerződése: az APP_TRACE numerikus 0 vagy 1. A GCC/Clang -DAPP_TRACE=1 kapcsolója fordítóeszköz-funkció, nem C nyelvi szintaxis.

A defined(APP_TRACE) az előfeldolgozási feltételben a definíció létezését vizsgálja; a #ifdef APP_TRACE ennek egyszerűbb alakja. A #error diagnosztikát követel meg, ezért alkalmas tiltott konfiguráció jelzésére; a buildben ezt hibának kell kezelni.

A #if makrókifejtése után megmaradó közönséges azonosítókat az előfeldolgozó nullának tekinti. Egy elgépelés így észrevétlenül kikapcsolhat egy ágat. A GCC/Clang -Wundef figyelmeztetése ebben segíthet. C-változó, enum-konstans vagy sizeof nem használható úgy, mintha a #if a C fordító teljes kifejezésértelmezője volna.

Fontos hibaminta:

/* C17-részlet: konfigurációfüggő mellékhatás! */
APP_LOG("processed=%d\n", ++processed);

Kikapcsolt naplózásnál a növelés is eltűnik. A program szükséges állapotváltozását külön utasításba kell tenni. Mindkét konfigurációt fordítani és ellenőrizni kell: az inaktív ág sikeres kihagyása nem bizonyítja annak helyességét.

2.3.8 Feladatok

2.3.8.1 Kifejtés és precedencia

Fejtsd ki a #define DOUBLE(x) x + x makróval a 3 * DOUBLE(2 + 1) kifejezést! Mi az értéke? Javítsd a zárójelezést, majd indokold meg, miért veszélyes továbbra is a DOUBLE(n++).

2.3.8.2 Függvényes alternatíva

Írj static inline int twice_int(int x) függvényt a [−1000,1000][-1000,1000] bemeneti tartományra. Milyen eredményt és végső n értéket vársz, ha kezdetben n = 4, és a hívás twice_int(n++)?

2.3.8.3 Konfigurációs diagnózis

Miért jelez hibát az alábbi, szándékosan hibás C17-részlet? Javítsd úgy, hogy csak az APP_TRACE egyes értéke legyen tiltott!

#define APP_TRACE 0

#ifdef APP_TRACE
#error "Trace mode is forbidden in this build"
#endif

2.3.9 Megoldások

2.3.9.1 Kifejtés és precedencia

A kifejtés 3 * 2 + 1 + 2 + 1, értéke 1010. A javítás:

#define DOUBLE(x) ((x) + (x))

Ezzel az eredmény 3⋅(3+3)=183 \cdot (3+3)=18. Az ellenőrzési feltétel a tokenkifejtésből kapott műveleti sorrend. A DOUBLE(n++) továbbra is két, egymáshoz képest nem megfelelően rendezett módosítást eredményez, tehát UB.

2.3.9.2 Függvényes alternatíva

/* C17-részlet; a macro_demo.c main függvénye elé illeszthető. */
static inline int twice_int(int x)
{
    return x + x;
}

A megadott tartományban az eredmény [−2000,2000][-2000,2000], amelyet minden szabványos C17-es int képes ábrázolni. Az argumentum egyszer értékelődik ki: az eredmény 8, a végső n értéke 5. Ez a két érték az ellenőrzési feltétel.

2.3.9.3 Konfigurációs diagnózis

Az APP_TRACE definiálva van, ezért a #ifdef ága aktív, függetlenül a nulla értéktől. A javítás:

#if APP_TRACE
#error "Trace mode is forbidden in this build"
#endif

Ellenőrzési feltétel: 0 mellett ez a direktíva ne okozzon diagnosztikát, 1 mellett okozzon. Külső buildkonfigurációhoz a fix definíció helyett a korábbi #ifndef-es alapértékminta szükséges.

2.3.10 Összefoglaló

A makró tokenhelyettesítés, nem függvényhívás. A zárójelezés védi a precedenciát, de nem akadályozza meg a mellékhatások ismétlését. Számításokhoz általában típusellenőrzött függvényt válassz; makrót ott használj, ahol valóban előfeldolgozás szükséges. A #, ##, variadikus argumentumok és konfigurációs kapcsolók esetén a kifejtett tokensorozatot vizsgáld, ne csupán a hívás látszólagos jelentését.