Plezier met het uitbreiden van de C-Preprocessor
Zoals ik in eerdere berichten heb genoemd, schrijf ik voor mijn plezier mijn eigen C2Y-compiler / interpreter / REPL.
Een van de eerste dingen die men moet doen bij het schrijven van een C-compiler, is het schrijven van een C-preprocessor (ook wel de CPP genoemd). In tegenstelling tot wat de meeste mensen denken, is de C-preprocessor geen tekst-naar-tekst-vertaler. In plaats daarvan is het een vertaler van broncode naar C-tokens, waarbij de meeste implementaties een tekstoutput aanbieden om inzichtelijk te maken wat er gebeurt. Historisch gezien is het wellicht geïmplementeerd als een tekst-naar-tekst-vertaler, maar de C-standaard specificeert het als een reeks fasen die eindigt met C-tokens. Deze tokens worden vervolgens geconsumeerd door de C-parser totdat er uiteindelijk een C-translatie-eenheid (translation unit) ontstaat.
Anderen hebben waarschijnlijk al geschreven over hoe je een C-preprocessor bouwt, dus daar zal ik me niet op concentreren. In plaats daarvan focus ik me op enkele leuke manieren waarop ik de C-preprocessor heb uitgebreid, ofwel om het gebruiksvriendelijker te maken, of simpelweg omdat ik het een grappig idee vond. Ik zal ze niet allemaal opsommen, maar alleen de bijzonder interessante.
print() / where()
De meest eenvoudige uitbreidingen: ingebouwde debugging-macro's.
print(...) fungeert als een printfunctie voor de preprocessor; het breidt de argumenten uit en schrijft deze naar stderr. Dit is zeer nuttig om te debuggen waar een macro precies tot uitbreidt. Omdat je alleen krijgt wat je specifiek met print() aanroept, is het veel gemakkelijker om te zien wat de preprocessor doet zonder de volledige translatie-eenheid te hoeven dumpen, zoals bij cpp.
$ printf "#define X 1\n__print(X X)" | drcpp
(stdin):2:1: 1 1
Heb je er wel eens moeite mee gehad om uit te zoeken waar in vredesnaam een macro is gedefinieerd die op een vreemde manier uitbreidt? Misschien is een barokke glibc-macro je leven aan het verpesten? __where() is dan je beste vriend. Deze schrijft naar stderr waar de macro is gedefinieerd.
$ printf "#define X 1\n\n\n\n__where(X)" | drcpp
(stdin):1:9: info: X defined
__map()
Iets wat af en toe voorkomt, is de behoefte om een bepaalde transformatie toe te passen op elk argument in een lijst. Misschien wil je een reeks waarden printen die je in een preprocessor-sequentie hebt staan. De standaard functionele manier om dit te implementeren zou zijn door een recursieve functie te schrijven (in de preprocessor een functie-achtige macro), maar de C-standaard blokkeert directe recursie omdat de tokens "blauw worden geverfd" en hun expansie wordt onderdrukt.
Je hebt echter over het algemeen geen willekeurig lange recursie nodig, dus je kunt het faken door de recursie zelf aan te sturen voor een vast aantal iteraties. Als je een afkeer hebt van walgingwekkend preprocessor-misbruik, kijk dan niet naar dit voorbeeld:
Ik heb je gewaarschuwd.
// map.c
#define CAT(a, b) CAT_(a, b)
#define CAT_(a, b) a##b
#define NARG(...) \
NARG_(__VA_ARGS__ __VA_OPT__(,) 8,7,6,5,4,3,2,1,0)
#define NARG_(_1,_2,_3,_4,_5,_6,_7,_8,N,...) N
#define MAP_0(m)
#define MAP_1(m, x) m(x)
#define MAP_2(m, x, ...) m(x) MAP_1(m, __VA_ARGS__)
#define MAP_3(m, x, ...) m(x) MAP_2(m, __VA_ARGS__)
#define MAP_4(m, x, ...) m(x) MAP_3(m, __VA_ARGS__)
#define MAP_5(m, x, ...) m(x) MAP_4(m, __VA_ARGS__)
#define MAP_6(m, x, ...) m(x) MAP_5(m, __VA_ARGS__)
#define MAP_7(m, x, ...) m(x) MAP_6(m, __VA_ARGS__)
#define MAP_8(m, x, ...) m(x) MAP_7(m, __VA_ARGS__)
#define __map(m, ...) \
CAT(MAP_, NARG(__VA_ARGS__))(m __VA_OPT__(,) __VA_ARGS__)
int x = 1;
int y = 2;
int z = 3;
#define PRINTINT(x) printf(#x " = %d\n", x);
int printf(const char*, ...);
int main(){
__map(PRINTINT, 1, 2, 3, 4, x, y, z)
}
Dit breidt uit naar (witruimte toegevoegd voor leesbaarheid):
// map.i
int x = 1;
int y = 2;
int z = 3;
int printf(const char*, ...);
int main(){
printf("1" " = %d\n", 1); // 1 = 1
printf("2" " = %d\n", 2); // 2 = 2
printf("3" " = %d\n", 3); // 3 = 3
printf("4" " = %d\n", 4); // 4 = 4
printf("x" " = %d\n", x); // x = 1
printf("y" " = %d\n", y); // y = 2
printf("z" " = %d\n", z); // z = 3
}
Dit is nuttig, maar overduidelijk vrij smakeloos en over het algemeen behoorlijk traag, omdat je misbruik maakt van de preprocessor-expansie. De preprocessor moet telkens opnieuw elk argument van elke functie-achtige macro scannen en uitbreiden. Daarom heb ik __map(macro, ...) aan mijn C-preprocessor toegevoegd.
Met een ingebouwde __map() wordt het bovenstaande voorbeeld vereenvoudigd tot:
int x = 1;
int y = 2;
int z = 3;
#define PRINTINT(x) printf(#x " = %d\n", x);
int printf(const char*, ...);
int main(){
__map(PRINTINT, 1, 2, 3, 4, x, y, z)
}
Nu hoef je niet meer te klooien met het simuleren van recursie: de preprocessor kan de macro in een lineaire scan op elk argument toepassen. Bovendien is er geen willekeurige limiet aan het aantal items dat je kunt mappen!
VA_COUNT
C23 heeft het werken met variadic macro's handiger en krachtiger gemaakt met VA_OPT(), maar sommige use-cases blijven lastig. Zoals in de vorige sectie gedemonstreerd, kan het nuttig zijn om een macro te "overladen" op basis van de ariteit (het aantal argumenten), maar dit vereist grove, handmatige preprocessor-pumping.
Een andere use-case is het doorgeven van een aantal (count) plus een pointer aan een functie, voor het geval je compiler de grootste fout van C nog steeds niet heeft hersteld, of het doorgeven van een parameter-aantal aan een variadic functie.
// sum.c
#define CAT(a, b) CAT_(a, b)
#define CAT_(a, b) a##b
#define NARG(...) \
NARG_(__VA_ARGS__ __VA_OPT__(,) 8,7,6,5,4,3,2,1,0)
#define NARG_(_1,_2,_3,_4,_5,_6,_7,_8,N,...) N
int sum(size_t count, int* data){
int result = 0;
for(size_t i = 0; i < count; i++){
result += data[i];
}
return result;
}
#define sum(...) sum(NARG(__VA_ARGS__), (int[]){__VA_ARGS__})
int printf(const char*, ...);
int main(){
printf("%d\n", sum(1,2,3)); // 6
printf("%d\n", sum(4,5)); // 9
}
In plaats van al deze onzin te gebruiken om simpelweg het aantal variadic argumenten te krijgen (waar de preprocessor dit eigenlijk al van weet!), heb ik een ingebouwde macro VA_COUNT toegevoegd. Hiermee kun je het aantal variadic argumenten direct opvragen.
// sum-drc.c
int sum(size_t count, int* data){
int result = 0;
for(size_t i = 0; i < count; i++){
result += data[i];
}
return result;
}
#define sum(...) sum(__VA_COUNT__, (int[]){__VA_ARGS__})
int printf(const char*, ...);
int main(){
printf("%d\n", sum(1,2,3)); // 6
printf("%d\n", sum(4,5)); // 9
}
Netter en sneller!
Conclusie
De C-preprocessor is een krachtig hulpmiddel, maar vereist enige onleesbare "duistere magie" om er echt optimaal gebruik van te maken. Dit is echter niet inherent aan wat je ermee wilt bereiken — het is simpelweg een gevolg van de onderontwikkelde mechanismen ervan. Met enkele uitbreidingen kunnen we de CPP krachtiger maken, terwijl we tegelijkertijd de leesbaarheid verbeteren en het gebruik aangenamer maken.
Ik heb nog meer om te vertellen, inclusief enkele vormen van échte duistere magie zoals muteerbare preprocessor-status, maar dat is alles voor dit bericht!
***
Alle code in dit artikel is vrijgegeven in het publieke domein.
Groetjes,