ft_printf
// Une réécriture pédagogique de printf(3) — la machine à formater du système.
Recoder printf, c'est affronter trois monstres d'un coup : les
fonctions variadiques du C, un parseur à machine à états sur une
grammaire ambiguë, et un dispatcher qui route vers une poignée de handlers
spécialisés. Ce guide dissèque l'implémentation fichier par fichier, du va_start
jusqu'au buffer batché.
Le sujet exige une gestion correcte des conversions sSpdDioOuUxXcC, du
modificateur %%, et des flags #0-+ et espace. La
gestion de la largeur, de la précision et des length modifiers (hh h l ll j z) est
bonus mais nécessaire pour un 100%. Aucune fonction de la libc hors
write/malloc/free ne peut être appelée.
Le projet
printf est l'une des fonctions les plus chargées de la libc : elle doit
interpréter une chaîne de format contenant du texte littéral mélangé à des
spécifications de conversion introduites par %, récupérer les
arguments correspondants depuis une pile variadique, les formater selon des règles
précises (flags, largeur, précision, base numérique), et tout écrire sur la sortie
standard — tout en renvoyant le nombre de caractères effectivement écrits.
Que doit faire ft_printf ?
- Être déclarée
int ft_printf(const char *format, ...); - Gérer les conversions
c s p d i u x X %au minimum - Gérer les combinaisons de flags
#,0,-,+, espace - Gérer la largeur minimale du champ et la précision
- Bonus : length modifiers,
o,O,D,U,C,S - Renvoyer le nombre total de caractères écrits (ou
-1en cas d'erreur)
Le piège conceptuel
Contrairement à la plupart des projets 42, ici le défi n'est pas algorithmique mais
structurel. Un printf naïf (un seul fichier monolithique,
des if en cascade) devient vite illisible. Le sujet suggère explicitement
l'usage d'un tableau de pointeurs sur fonctions pour dispatcher vers les handlers.
C'est l'occasion d'apprendre un pattern d'architecture réutilisable partout.
Avant d'écrire une seule ligne, lisez man 3 printf en entier, puis
testez les cas tordus avec le vrai printf. Votre seule source de vérité,
c'est le comportement observé du vrai. Le man est parfois ambigu ; le vrai ne l'est jamais.
Anatomie d'une spécification de conversion
Tout commence après un %. La grammaire est :
% [flags] [width] [.precision] [length] conversion │ │ │ │ │ │ │ │ │ └─ sSpdDioOuUxXcC % │ │ │ └─ hh h l ll j z │ │ └─ . puis chiffres ou * │ └─ chiffres ou * (largeur min du champ) └─ # 0 - + espace (dans n'importe quel ordre)
Chaque tranche est optionnelle sauf la conversion finale. Le parser doit les
reconnaître dans cet ordre strict, sinon le comportement est indéfini. La subtilité :
% peut être suivi de n'importe quoi, et tout ce qui n'est pas reconnu
comme une spec valide doit être imprimé tel quel (le % + le caractère inconnu).
Les fonctions variadiques
Une fonction variadique accepte un nombre variable d'arguments.
printf en est l'exemple canonique : printf("%d %s", 42, "x")
ou printf("%d %d %d %d", 1,2,3,4) — le compilateur n'impose aucune borne
supérieure. Le C fournit quatre macros dans <stdarg.h> pour parcourir
ces arguments : va_start, va_arg, va_end,
va_copy.
Comment ça marche : la pile d'appel
Sur x86-64 (System V ABI), les premiers arguments entiers/pointeurs passent par des
registres (rdi, rsi, rdx, rcx, r8, r9), les suivants vont sur la
pile. Mais peu importe l'ABI exact : du point de vue du programmeur C, on peut
imaginer tous les arguments empilés de droite à gauche. La va_list est
essentiellement un curseur opaque qui avance dans cette zone.
format (le dernier
paramètre nommé). Chaque va_arg(ap, T) lit T octets à la position courante puis avance.
Le type T est crucial : il détermine combien d'octets lire.
Les quatre macros
va_list ap pour commencer à lire
après le paramètre nommé last. À appeler une fois avant
tout va_arg.ap (sur certaines
plateformes c'est un no-op, sur d'autres il faut nettoyer). À appeler avant de retourner.va_list. Indispensable
pour parcourir deux fois les mêmes args (par ex. une passe de mesure, une passe d'écriture).Le C ne stocke aucune information de type ni de nombre d'arguments au runtime.
C'est la chaîne de format qui dicte quoi lire. Si elle dit %s mais
que l'argument est un int, vous lisez une adresse absurde et crash garanti.
C'est pour ça que les compilateurs modernes émettent -Wformat : le
contrôle se fait à la compilation, jamais à l'exécution.
Signature et squelette minimal
int ft_printf(const char *format, ...) { va_list ap; t_buf buf; int ret; buf.len = 0; buf.total = 0; va_start(ap, format); /* 1. initialiser le curseur après 'format' */ process_format(format, &ap, &buf); /* 2. parser + dispatcher + buffer */ va_end(ap); /* 3. nettoyer */ buf_flush(&buf); /* 4. écrire le reste du buffer */ return (buf.total); }
Passer une va_list entre fonctions
Une subtilité essentielle : on ne passe jamais une va_list
par valeur si on veut que l'appelé puisse consommer des arguments. On passe un
pointeur sur la va_list. Sinon, selon l'ABI, la modification
n'est pas visible côté appelant (la va_list peut être un tableau sur
certaines plateformes).
/* BIEN : pointeur sur va_list, l'avance est visible partout */ void conv_int(t_conv *c, va_list *ap, t_buf *buf) { long long n; n = va_arg(*ap, int); /* consomme un int, visible par l'appelant */ ... } /* MAL : copie locale, l'appelant ne voit pas l'avance sur certaines plateformes */ void conv_int_broken(t_conv *c, va_list ap, t_buf *buf) { long long n = va_arg(ap, int); /* l'avance reste locale */ ... }
Le type passé à va_arg subit les promotions par défaut :
char/short → int,
float → double. On lit donc toujours un
int pour %c, jamais un char.
C'est pourquoi le code cast : (char)va_arg(*ap, int).
Architecture
L'implémentation se découpe en quatre couches distinctes, chacune dans son fichier. Cette séparation n'est pas cosmétique : elle rend chaque brique testable et remplaçable indépendamment.
| Fichier | Rôle | Fonctions clés |
|---|---|---|
ft_printf.c | Entry point + dispatcher + boucle | ft_printf, dispatch, process_format |
parse.c | Machine à états sur la spec | parse_conv, parse_flags... |
conv_int.c | Conversions d/u (entiers) | conv_int, conv_uint |
conv_hex.c | Conversions o/x/X | conv_oct, conv_hex |
conv_text.c | Conversions c/s/p/% | conv_char, conv_str... |
buffer.c | Batching des writes | buf_char, buf_flush... |
utils.c | Outils + padding | ft_strlen, ull_to_base, apply_padding |
Flux général d'exécution
À chaque caractère de la chaîne de format, le flux est déterministe :
c != '%' : on court-circuite, on écrit directement dans le buffer. Sinon on entre
dans le parseur, puis le dispatcher choisit le handler, qui formatte et pousse dans le buffer.
Le write réel n'a lieu qu'à la fin (ou quand le buffer déborde).
La boucle principale
static void process_format(const char *fmt, va_list *ap, t_buf *buf) { t_conv conv; while (*fmt) { if (*fmt != '%') { buf_char(buf, *fmt); /* texte littéral → buffer direct */ fmt++; continue; } fmt++; /* skip '%' */ conv.flags = 0; conv.width = 0; conv.precision = -1; /* -1 = non spécifiée */ conv.length = LEN_NONE; conv.conv = 0; fmt = parse_conv(fmt, &conv, ap); /* remplit conv */ if (conv.conv) dispatch(&conv, ap, buf); /* route vers le handler */ else if (*fmt) /* spec invalide → imprime % + char */ { buf_char(buf, '%'); buf_char(buf, *fmt); fmt++; } else buf_char(buf, '%'); /* '%' final solitaire */ } }
Notez la valeur sentinelle -1 pour precision :
elle permet de distinguer « précision non demandée » de « précision demandée mais valant 0 »
(%.0d). Cette distinction est cruciale pour %d avec la valeur 0.
Le dispatcher
Le sujet suggère un tableau de pointeurs sur fonctions. Deux approches coexistent :
un switch lisible, ou un tableau indexé par le caractère de conversion.
Les deux sont valables ; le tableau est plus « idiomatique 42 » et évite une chaîne de
if/else.
int dispatch(t_conv *c, va_list *ap, t_buf *buf) { char conv = c->conv; if (conv == 'd' || conv == 'i' || conv == 'D') conv_int(c, ap, buf); else if (conv == 'u' || conv == 'U') conv_uint(c, ap, buf); else if (conv == 'o' || conv == 'O') conv_oct(c, ap, buf); else if (conv == 'x' || conv == 'X') conv_hex(c, ap, buf); else if (conv == 'c' || conv == 'C') conv_char(c, ap, buf); else if (conv == 's' || conv == 'S') conv_str(c, ap, buf); else if (conv == 'p') conv_ptr(c, ap, buf); else if (conv == '%') conv_percent(c, ap, buf); return (0); }
Version alternative avec tableau — plus extensible, plus « 42 » :
typedef void (*t_handler)(t_conv*, va_list*, t_buf*); /* Table indexée par code ASCII ; NULL pour les chars sans handler. */ static t_handler g_table[128] = { ['d'] = conv_int, ['i'] = conv_int, ['D'] = conv_int, ['u'] = conv_uint, ['U'] = conv_uint, ['o'] = conv_oct, ['O'] = conv_oct, ['x'] = conv_hex, ['X'] = conv_hex, ['c'] = conv_char, ['C'] = conv_char, ['s'] = conv_str, ['S'] = conv_str, ['p'] = conv_ptr, ['%'] = conv_percent, }; int dispatch(t_conv *c, va_list *ap, t_buf *buf) { unsigned char conv = (unsigned char)c->conv; if (conv < 128 && g_table[conv]) g_table[conv](c, ap, buf); return (0); }
Avec un if/else en cascade, le temps de dispatch croît linéairement
avec le nombre de cas. Avec un tableau indexé, c'est un accès direct O(1). Pour 8 handlers
la différence est négligeable, mais le pattern est réutilisable (moteur de commandes,
opcodes de VM, événements clavier…). Sur un projet 42, un correcteur qui voit un tableau
de pointeurs sur fonctions sait immédiatement que vous avez compris l'enjeu d'architecture.
Les structures de données
Deux structures portent tout le projet :
typedef struct s_conv { int flags; /* bitmask FLAG_* */ int width; /* largeur min */ int precision; /* -1 si absent */ t_len length; /* hh h l ll j z */ char conv; /* 'd','x',... */ } t_conv;
typedef struct s_buf { char data[BUFF_SIZE]; /* 4096 */ int len; /* rempli */ int total; /* return value */ } t_buf;
Les flags sont stockés dans un bitmask plutôt que dans 5 booléens séparés.
Avantages : passage par copie trivial, combinaisons testables en une expression
(c->flags & (FLAG_ZERO | FLAG_MINUS)), et économie de mémoire. C'est le
pattern standard pour tout ensemble d'options binaires en C.
#define FLAG_HASH (1 << 0) /* # */ #define FLAG_ZERO (1 << 1) /* 0 */ #define FLAG_MINUS (1 << 2) /* - */ #define FLAG_PLUS (1 << 3) /* + */ #define FLAG_SPACE (1 << 4) /* ' '*/ typedef enum e_len { LEN_NONE = 0, LEN_HH, LEN_H, LEN_L, LEN_LL, LEN_J, LEN_Z } t_len;
Le parsing
Le parser est une machine à états linéaire : il consomme la chaîne de format
de gauche à droite, dans l'ordre strict flags → width → precision → length → conversion.
Chaque étape avance un pointeur const char ** ; si l'étape ne reconnaît
rien, elle ne fait simplement rien et passe à la suivante.
const char *parse_conv(const char *fmt, t_conv *c, va_list *ap) { parse_flags(&fmt, c); parse_width(&fmt, c, ap); parse_precision(&fmt, c, ap); parse_length(&fmt, c); if (*fmt) { c->conv = *fmt; fmt++; } return (fmt); }
Étape 1 — Les flags
Les flags # 0 - + espace peuvent apparaître dans n'importe quel ordre et
être répétés. On boucle tant qu'on en reconnaît un, et on positionne le bit correspondant
dans le bitmask.
static void parse_flags(const char **fmt, t_conv *c) { while (**fmt == '#' || **fmt == '0' || **fmt == '-' || **fmt == '+' || **fmt == ' ') { if (**fmt == '#') c->flags |= FLAG_HASH; else if (**fmt == '0') c->flags |= FLAG_ZERO; else if (**fmt == '-') c->flags |= FLAG_MINUS; else if (**fmt == '+') c->flags |= FLAG_PLUS; else if (**fmt == ' ') c->flags |= FLAG_SPACE; (*fmt)++; } }
%-0d et %0-d sont équivalents :
les deux flags sont posés, mais à l'affichage c'est - qui gagne
(le 0 est ignoré en présence de -). Le parser
ne fait pas ce choix — il stocke tout. C'est le handler d'affichage qui applique la règle
de priorité. Séparation parsing / affichage = code plus clair.
Étape 2 — La largeur
La largeur minimale du champ est soit un nombre décimal, soit * qui
signifie « lire la largeur depuis la va_list ». La particularité : si
la largeur lue via * est négative, le comportement est équivalent à un
flag - suivi de la valeur absolue.
static void parse_width(const char **fmt, t_conv *c, va_list *ap) { if (**fmt == '*') { c->width = va_arg(*ap, int); if (c->width < 0) /* width négative = flag '-' implicite */ { c->flags |= FLAG_MINUS; c->width = -c->width; } (*fmt)++; } else if (**fmt >= '0' && **fmt <= '9') c->width = ft_atoi(fmt); /* avance fmt */ }
Étape 3 — La précision
Introduite par un point .. Sans chiffre derrière, elle vaut 0
(%.d ≡ %.0d). Avec *, elle
vient de la va_list ; si elle est négative, elle est ignorée (comme si
elle n'avait pas été spécifiée).
static void parse_precision(const char **fmt, t_conv *c, va_list *ap) { if (**fmt == '.') { (*fmt)++; c->precision = 0; /* "." seul = précision 0 */ if (**fmt == '*') { c->precision = va_arg(*ap, int); if (c->precision < 0) c->precision = -1; /* précision négative = ignorée */ (*fmt)++; } else c->precision = ft_atoi(fmt); } }
La précision a deux sémantiques selon la conversion :
- Pour les nombres (
diouxX) : nombre minimum de chiffres. Ajoute des zéros non significatifs. - Pour les chaînes (
s) : nombre maximum de caractères affichés. Tronque.
Étape 4 — Les length modifiers
hh h l ll j z indiquent la taille attendue de l'argument. Ils déterminent
comment on doit le lire dans la va_list et éventuellement le caster.
Attention : hh et ll sont à deux caractères, il faut
les tester avant h et l seuls.
static void parse_length(const char **fmt, t_conv *c) { if ((*fmt)[0] == 'h' && (*fmt)[1] == 'h') { c->length = LEN_HH; *fmt += 2; } else if ((*fmt)[0] == 'h') { c->length = LEN_H; (*fmt)++; } else if ((*fmt)[0] == 'l' && (*fmt)[1] == 'l') { c->length = LEN_LL; *fmt += 2; } else if ((*fmt)[0] == 'l') { c->length = LEN_L; (*fmt)++; } else if ((*fmt)[0] == 'j') { c->length = LEN_J; (*fmt)++; } else if ((*fmt)[0] == 'z') { c->length = LEN_Z; (*fmt)++; } }
| Modifier | Type attendu | Effet sur la lecture |
|---|---|---|
hh | signed/unsigned char | Cast depuis int → (signed char) |
h | signed/unsigned short | Cast depuis int → (short) |
l | long / unsigned long | va_arg(ap, long) |
ll | long long / unsigned long long | va_arg(ap, long long) |
j | intmax_t / uintmax_t | Lu comme long long (suffisant en 64-bit) |
z | size_t / ssize_t | Lu comme long long |
Une fois l'argument lu et promu en long long / unsigned long long,
tout le reste du traitement (conversion en base, padding) est identique.
Les length modifiers ne changent que la première ligne de chaque handler. C'est ce qui
permet de factoriser ull_to_base au lieu d'écrire 6 variantes.
Les conversions
Chaque handler a la même signature void conv_x(t_conv *c, va_list *ap, t_buf *buf)
et suit le même plan :
- Lire l'argument selon le length modifier (toujours promu vers le type large).
- Convertir en chaîne de chiffres via
ull_to_base(ouft_strlenpour les strings). - Appliquer la précision (zérosLeading pour les nombres, troncature pour les strings).
- Désactiver le flag
0si une précision est donnée (règle du man). - Calculer signe + préfixe (
+,-,0x,0…). - Appliquer le padding gauche/droite/zero via
apply_padding.
Entier signé — %d %i
Le cas d'école. On lit un int (ou long/long long
selon le modifier), on extrait le signe, on convertit la valeur absolue en base 10, puis
on gère précision et padding. Le piège INT_MIN est géré en travaillant
en unsigned long long après avoir extrait le signe : -n sur
INT_MIN déborderait en int signé, mais pas en ull.
void conv_int(t_conv *c, va_list *ap, t_buf *buf) { long long n; char num[32]; int num_len; char sign; /* 1. LIRE — promotion selon length modifier */ if (c->length == LEN_HH) n = (signed char)va_arg(*ap, int); else if (c->length == LEN_H) n = (short)va_arg(*ap, int); else if (c->length == LEN_L) n = va_arg(*ap, long); else if (c->length == LEN_LL) n = va_arg(*ap, long long); else n = va_arg(*ap, int); /* 2. SIGNE — mémorisé à part, n devient valeur absolue (en ull) */ sign = 0; if (n < 0) { sign = '-'; n = -n; } else if (c->flags & FLAG_PLUS) sign = '+'; else if (c->flags & FLAG_SPACE) sign = ' '; /* 3. CONVERTIR en base 10 (digits écrits à l'envers) puis reverse */ ull_to_base((unsigned long long)n, 10, 0, num, &num_len); reverse(num, num_len); /* helper — voir utils */ /* 4. PRÉCISION — précision 0 + valeur 0 → affiche rien */ if (c->precision == 0 && n == 0) num_len = 0; else if (c->precision > num_len) num_len = prepend_zeros(num, num_len, c->precision); /* 5. DÉSACTIVER '0' si précision donnée (règle du man) */ if (c->precision != -1) c->flags &= ~FLAG_ZERO; /* 6. PADDING selon flags '-' / '0' / rien */ apply_padding(c, buf, num, num_len, sign, "", 0); }
INT_MIN = -2147483648. Sur un int signé,
-INT_MIN est un débordement (UB) car +2147483648 n'est pas
représentable. La solution : caster en long long avant la négation, ou —
comme ici — travailler en unsigned long long dès qu'on a extrait le signe.
ull_to_base reçoit une valeur non signée, donc plus aucun souci.
Non signé — %u et octal %o
%u est la version non signée de %d : pas de signe, mais
même logique de précision et de padding. %o convertit en base 8 ; avec le
flag #, un 0 est ajouté en préfixe (pour garantir que le
résultat commence par un 0, convention octale).
void conv_oct(t_conv *c, va_list *ap, t_buf *buf) { unsigned long long n; char num[32]; char *prefix = ""; int prefix_len = 0; n = read_unsigned(c, ap); /* selon length */ ull_to_base(n, 8, 0, num, &num_len); reverse(num, num_len); if (c->flags & FLAG_HASH) /* '#' → préfixe "0" */ { prefix = "0"; prefix_len = 1; } /* Note : pour octal, '#' interagit avec la précision : si precision <= 1 et n==0, le '0' du préfixe compte dans la précision. */ if (c->precision == 0 && n == 0 && !(c->flags & FLAG_HASH)) num_len = 0; /* ... précision (prepend zeros), disable '0' flag, apply_padding ... */ apply_padding(c, buf, num, num_len, 0, prefix, prefix_len); }
Hexadécimal — %x %X
%x affiche en hexa minuscule, %X en majuscule. Le flag
# ajoute le préfixe 0x ou 0X — mais
seulement si la valeur est non nulle. %#x de 0 affiche
0, pas 0x0.
void conv_hex(t_conv *c, va_list *ap, t_buf *buf) { unsigned long long n; char num[32]; int upper = (c->conv == 'X'); char *prefix = ""; int prefix_len = 0; n = read_unsigned(c, ap); ull_to_base(n, 16, upper, num, &num_len); /* digits ABCDEF si upper */ reverse(num, num_len); /* '#' ajoute 0x/0X UNIQUEMENT si n != 0 */ if (c->flags & FLAG_HASH && n != 0) { prefix = upper ? "0X" : "0x"; prefix_len = 2; } if (c->precision == 0 && n == 0) num_len = 0; /* ... précision, disable '0', apply_padding ... */ apply_padding(c, buf, num, num_len, 0, prefix, prefix_len); }
Chaîne — %s
Cas particulier : la précision devient un maximum de caractères affichés, pas un
minimum. Et si l'argument est NULL, le vrai printf affiche
la chaîne littérale (null) (comportement gnu, pas POSIX strict, mais c'est
ce que testent les corrections 42).
void conv_str(t_conv *c, va_list *ap, t_buf *buf) { char *s; int len; s = va_arg(*ap, char *); if (s == NULL) /* gère NULL → "(null)" */ s = "(null)"; len = ft_strlen(s); if (c->precision != -1 && c->precision < len) len = c->precision; /* précision = max chars (troncature) */ if (c->flags & FLAG_MINUS) /* aligné à gauche */ { buf_str(buf, s, len); buf_pad(buf, ' ', c->width - len > 0 ? c->width - len : 0); } else /* aligné à droite (défaut) */ { buf_pad(buf, ' ', c->width - len > 0 ? c->width - len : 0); buf_str(buf, s, len); } }
Avec une précision < 6, printf("%.3s", (char*)NULL) affiche
(nu (troncature de (null)). Testez ce comportement avec le vrai
printf avant de le reproduire — certaines versions affichent une chaîne vide.
Caractère — %c et littéral %%
%c lit un int (rappel : promotion par défaut) et l'affiche
comme un char. Seuls les flags - et la largeur sont
pertinents. %% est un cas dégénéré : il affiche un % littéral
et ne consomme aucun argument de la va_list.
void conv_char(t_conv *c, va_list *ap, t_buf *buf) { char ch = (char)va_arg(*ap, int); if (c->flags & FLAG_MINUS) { buf_char(buf, ch); buf_pad(buf, ' ', c->width - 1 > 0 ? c->width - 1 : 0); } else { buf_pad(buf, ' ', c->width - 1 > 0 ? c->width - 1 : 0); buf_char(buf, ch); } }
void conv_percent(t_conv *c, va_list *ap, t_buf *buf) { (void)ap; /* ne consomme RIEN */ if (c->flags & FLAG_MINUS) { buf_char(buf, '%'); buf_pad(buf, ' ', c->width - 1 > 0 ? c->width - 1 : 0); } else if (c->flags & FLAG_ZERO) { /* '%050%' → "0000%" */ buf_pad(buf, '0', c->width - 1 > 0 ? c->width - 1 : 0); buf_char(buf, '%'); } else { buf_pad(buf, ' ', c->width - 1 > 0 ? c->width - 1 : 0); buf_char(buf, '%'); } }
Pointeur — %p
Un pointeur, c'est une adresse — un nombre non signé qu'on affiche en hexa avec un
préfixe 0x systématique (contrairement à
%#x où le préfixe dépend de la valeur). Sur 64-bit, un pointeur tient dans
un unsigned long long.
void conv_ptr(t_conv *c, va_list *ap, t_buf *buf) { unsigned long long addr; char num[32]; int num_len; addr = (unsigned long long)va_arg(*ap, void *); ull_to_base(addr, 16, 0, num, &num_len); reverse(num, num_len); /* "0x" TOUJOURS, même pour NULL → "0x0" */ apply_padding(c, buf, num, num_len, 0, "0x", 2); }
Sur certaines plateformes rares, void* n'a pas la même taille que
unsigned long long. Pour être strictement correct, il faudrait utiliser
uintptr_t de <stdint.h>. Mais ce header n'est pas
autorisé en C 42 norme stricte ; le cast via unsigned long long est
l'approche pragmatique universellement acceptée en correction.
Le helper apply_padding
Toute la logique d'alignement (gauche / droite / zero-padding) est factorisée dans une seule fonction réutilisée par tous les handlers numériques. Elle prend le contenu déjà formaté, le signe éventuel, et le préfixe éventuel, puis écrit dans le bon ordre.
static void apply_padding(t_conv *c, t_buf *buf, char *content, int content_len, char sign, char *prefix, int prefix_len) { int total_len = content_len + (sign ? 1 : 0) + prefix_len; int pad = c->width - total_len; char pad_char = (c->flags & FLAG_ZERO && !(c->flags & FLAG_MINUS) && c->precision == -1) ? '0' : ' '; if (c->flags & FLAG_MINUS) /* '-' : contenu puis espaces */ { if (sign) buf_char(buf, sign); buf_str(buf, prefix, prefix_len); buf_str(buf, content, content_len); buf_pad(buf, ' ', pad > 0 ? pad : 0); } else if (pad_char == '0') /* '0' : signe+prefix+zéros+chiffres */ { if (sign) buf_char(buf, sign); buf_str(buf, prefix, prefix_len); buf_pad(buf, '0', pad > 0 ? pad : 0); buf_str(buf, content, content_len); } else /* défaut : espaces puis contenu */ { buf_pad(buf, ' ', pad > 0 ? pad : 0); if (sign) buf_char(buf, sign); buf_str(buf, prefix, prefix_len); buf_str(buf, content, content_len); } }
Quand on pad avec des 0, l'ordre est :
signe → préfixe (0x) → zéros → chiffres. C'est crucial :
%+08.0x de 255 donne +00000ff, pas 0000+ff.
Le signe et le préfixe restent collés aux chiffres, jamais noyés dans le padding.
Les flags
Cinq flags, chacun avec sa sémantique fine et ses interactions. Voici la référence
complète, avec exemples sur la valeur 42 (ou -42 pour
les flags de signe).
| Flag | Nom | Effet | Exemple | Sortie |
|---|---|---|---|---|
# | hash | Préfixe 0x/0X pour hex (si n≠0), 0 pour octal | %#x 255 | 0xff |
0 | zero | Pad avec des 0 au lieu d'espaces (ignoré si - ou précision) | %05d 42 | 00042 |
- | minus | Aligne à gauche (pad à droite). Prioritaire sur 0 | %-5d| 42 | 42 | |
+ | plus | Affiche toujours le signe (+ ou -). Prioritaire sur espace | %+d 42 | +42 |
| space | Préfixe un espace aux positifs (alignement des négatifs) | % d 42 | 42 |
Le flag # — préfixe de base
Ajoute un préfixe qui indique explicitement la base : 0x/0X
pour l'hexa, 0 pour l'octal. Comportement subtil pour l'octal :
%#o de 0 affiche 0 (un seul zéro, pas
00) car le préfixe est déjà le chiffre zéro. Pour l'hexa,
%#x de 0 affiche 0 sans
0x.
Le flag 0 — zero-padding
Remplace le padding espace par du padding zéro, mais uniquement à gauche (n'a
pas de sens avec -). Et surtout, il est ignoré si une précision
est spécifiée pour les conversions numériques. C'est une règle du man qui surprend
beaucoup de monde.
/* '0' est invalide si : */ /* 1. '-' est présent (le '-' aligne à gauche) */ /* 2. une précision est donnée pour un nombre */ /* (la précision gère déjà les zéros significatifs) */ if (c->precision != -1) c->flags &= ~FLAG_ZERO; /* disable '0' */ /* Le '-' est géré directement dans apply_padding : le test */ /* (c->flags & FLAG_ZERO && !(c->flags & FLAG_MINUS)) */ /* garantit que '-' gagne toujours. */
printf("%08.3d", 42) → 042 (5 espaces + 042).
Le 0 est ignoré à cause de la précision. Sans la règle, on aurait eu
00000042. Toujours comparer avec le vrai printf.
Le flag - — alignement gauche
Par défaut, tout est aligné à droite dans la largeur. - inverse : le
contenu d'abord, puis le padding d'espaces à droite. Toujours gagne contre
0 : %-05d de 42 donne 42 (espaces), pas
42000.
Les flags + et espace — signe explicite
+ force l'affichage d'un + devant les positifs.
espace met un espace à la place. + est prioritaire.
Utile pour aligner visuellement des nombres positifs et négatifs dans un tableau. N'a
d'effet que sur les conversions signées (d i), pas sur u o x.
| Spec | Valeur | Sortie | Commentaire |
|---|---|---|---|
%d | 42 | 42 | défaut |
%+d | 42 | +42 | signe + explicite |
% d | 42 | 42 | espace pour positif |
%d | -42 | -42 | négatif : toujours un - |
%+d | -42 | -42 | + n'affecte pas les négatifs |
%+05d | 42 | +0042 | signe collé aux zéros |
% 5d | 42 | 42 | padding normal |
Précision & largeur
C'est la source numéro un des bugs. La largeur (width) et la précision (precision) sont deux concepts orthogonaux mais qui interagissent.
Si le contenu est plus court, on pad (espaces ou
0).Si le contenu est plus long, on ne tronque jamais.
Sémantique identique pour toutes les conversions.
• Nombres : nombre minimum de chiffres → ajoute des zéros à gauche.
• String : nombre maximum de caractères → tronque.
Par défaut
-1 (non spécifiée).
L'interaction, en image
Soit printf("%8.3d", 42). Décomposons :
042 (3 digits) 042La précision agit en premier (sur les chiffres seuls), puis la largeur
englobe le tout. C'est pourquoi 0 est désactivé par la précision :
il y aurait conflit entre deux sources de zéros.
Cas tordus à maîtriser
| Spec | Valeur | Sortie | Explication |
|---|---|---|---|
%.0d | 0 | (vide) | précision 0 + valeur 0 → rien |
%.0d | 42 | 42 | précision 0 n'affecte que le zéro |
%5.0d | 0 | (5 espaces) | rien + largeur 5 → 5 espaces |
%-5.0d| | 0 | (5 espaces)| | aligné gauche |
%05.0d | 0 | (5 espaces) | 0 désactivé par précision |
%.3s | "hello" | hel | troncature à 3 chars |
%.10s | "hi" | hi | pas d'extension pour les strings |
%10.3s | "hello" | hel | troncature puis largeur |
Précision = contenu. Largeur = contenant. La précision modifie ce qui est imprimé (nombre de digits, nombre de chars), la largeur ne fait qu'ajouter du vide autour. Cette image mentale résout 90% des cas tordus.
Largeur et précision dynamiques — *
Le * permet de lire la largeur ou la précision depuis la
va_list au runtime. L'ordre de consommation est strictement
gauche-droite dans la spec : d'abord la largeur, puis la précision.
/* %*.*d : arg1=largeur, arg2=précision, arg3=valeur */ ft_printf("%*.*d", 8, 3, 42); /* ^ ^ ^ */ /* | | └─ valeur à afficher */ /* | └───── précision (3) */ /* └───────── largeur (8) */ /* → " 042" (5 espaces + "042") */
Largeur négative via * → équivaut à - + valeur absolue.
Précision négative via * → ignorée (comme si pas de précision).
Le buffer
Appeler write(1, ...) pour chaque caractère serait suicidaire en
termes de performance. write est un appel système :
chaque appel provoque un context switch user→kernel, copie les données en espace
noyau, puis vers le driver terminal. Sur un printf qui écrit 10000
caractères, c'est 10000 syscalls au lieu d'un seul.
Pourquoi un buffer ?
write coûte ~1-10 µs en contexte user/kernel. Sur 10k caractères, on passe de 10 ms à 10 µs.write par batch → la sortie n'est pas entrelacée avec d'autres processus écrivant sur le même terminal.buf.total compte les caractères logiques, indépendamment des write physiques.La structure et les opérations
#define BUFF_SIZE 4096 /* taille d'une page mémoire typique */ typedef struct s_buf { char data[BUFF_SIZE]; /* stockage local */ int len; /* octets actuellement stockés */ int total; /* compteur logique (return value) */ } t_buf;
/* Flush : vide le buffer sur stdout en un seul write */ void buf_flush(t_buf *buf) { if (buf->len > 0) { write(1, buf->data, buf->len); buf->len = 0; } } /* Ajoute un caractère ; flush automatique si plein */ void buf_char(t_buf *buf, char c) { if (buf->len >= BUFF_SIZE) /* débordement → on vide d'abord */ buf_flush(buf); buf->data[buf->len] = c; buf->len++; buf->total++; /* compte logique toujours incrémenté */ } /* Ajoute une chaîne de longueur connue */ void buf_str(t_buf *buf, const char *s, int len) { int i = 0; while (i < len) buf_char(buf, s[i++]); /* gère le flush auto */ } /* Ajoute n fois le caractère c (pour le padding) */ void buf_pad(t_buf *buf, char c, int n) { int i = 0; while (i < n) buf_char(buf, c), i++; }
ft_printf, un dernier buf_flush garantit que tout
est écrit. Le return value (buf.total) reflète le nombre logique de caractères,
pas le nombre de write.
buf_str et buf_pad appellent buf_char
en boucle. C'est correct mais légèrement sous-optimal : on vérifie
len >= BUFF_SIZE à chaque caractère. Une version rapide copierait par
memcpy des blocs entiers quand c'est possible. Pour ft_printf c'est
négligeable, mais c'est l'optimisation naturelle si on voulait pousser plus loin.
Le vrai printf renvoie le nombre de caractères qui auraient été
écrits, même si write échoue partiellement. En suivant
buf.total plutôt que la valeur de retour de write,
on reproduit ce comportement. Mais attention : si write échoue
(erreur disque, pipe fermé), le vrai printf renvoie -1.
Pour un 100%, il faudrait propager l'erreur. Beaucoup de projets 42 l'omettent et passent
quand même — mais c'est un point à connaître.
Taille du buffer : pourquoi 4096 ?
4096 est la taille d'une page mémoire standard sur x86/ARM. C'est
aussi la taille de buffer typique du kernel pour les écritures sur pipe/terminal. En
dessous (256, 1024), on augmente le nombre de syscalls sans gain. Au-dessus (64k), on
gaspille de la stack (la t_buf est locale à ft_printf)
sans gagner en débit. 4096 est le sweet spot.
La t_buf fait 4096 + 8 octets. Si on la déclare
locale à ft_printf, elle vit sur la stack. C'est OK pour
4 Ko, mais pour des buffers plus gros il faudrait malloc. La stack
Linux par défaut est 8 Mo, donc on a de la marge — mais un projet qui declare un buffer
de 1 Mo en local fera planter certaines fonctions récursives.
Pièges & edge cases
Ces subtilités font la différence entre un ft_printf qui passe la moulinette et un qui plante. Chaque cas ci-dessous a été rencontré sur au moins un des 9 testeurs de la communauté.
1. Passer va_list en pointeur, pas en valeur
Sur certaines architectures (notamment x86-64 Linux), va_list
est un type tableau. Quand vous passez un tableau par valeur à une
fonction, C dégrade silencieusement en pointeur — donc la fonction reçoit un pointeur
sur le premier élément, et va_arg modifie l'original. Mais sur d'autres
ABI, va_list est un struct copié par valeur, et l'avance
n'est pas visible par l'appelant. La solution portable : toujours
passer &ap (pointeur sur va_list) et déréférencer avec
*ap dans va_arg.
/* CORRECT : pointeur sur va_list */ void conv_int(t_conv *c, va_list *ap, t_buf *buf) { int n = va_arg(*ap, int); /* ... traite n ... */ } /* CASSÉ : copie locale — l'appelant ne voit pas l'avance sur certains ABI */ void conv_int_broken(t_conv *c, va_list ap, t_buf *buf) { int n = va_arg(ap, int); }
2. Caster selon le length modifier
Quand vous appelez va_arg(*ap, TYPE), le type doit correspondre au
length modifier de la spec. Sinon vous lisez la mauvaise quantité de mémoire.
Attention : les types plus petits que int (char, short) sont promus
en int via les default argument promotions — vous
devez donc toujours faire va_arg(ap, int) puis caster le résultat.
| Spec | va_arg type | Cast après va_arg |
|---|---|---|
%d %i | int | — |
%hd | int | (short) |
%hhd | int | (signed char) |
%ld | long | — |
%lld | long long | — |
%jd | long long | — |
%zd | long long | — |
%u | unsigned int | — |
%lu | unsigned long | — |
%llu | unsigned long long | — |
3. Le flag 0 est invalidé par la précision
glibc dit : si une précision est spécifiée pour une conversion entière
(%d, %i, %u, %o,
%x, %X), le flag 0 est
ignoré. La précision contrôle déjà le nombre de digits, donc le
zero-padding n'a pas de sens. Cette règle ne s'applique qu'aux entiers.
/* %08.3d avec 42 → " 042" (espaces, pas zeros !) */ if (c->precision != -1) c->flags &= ~FLAG_ZERO; /* désactive le zero-padding */
4. %p avec pointeur NULL → (nil)
glibc affiche (nil) pour un pointeur NULL, pas 0x0.
macOS affiche 0x0 — c'est une différence de plateforme.
void conv_ptr(t_conv *c, va_list *ap, t_buf *buf) { unsigned long long addr; addr = (unsigned long long)va_arg(*ap, void *); if (addr == 0) { apply_padding(c, buf, "(nil)", 5, 0, "", 0); return ; } /* ... conversion hex avec préfixe "0x" ... */ }
5. %s avec NULL + précision
glibc affiche (null) pour une string NULL. Mais si la précision est
inférieure à 6 (longueur de "(null)"), rien n'est affiché.
if (s == NULL) { if (c->precision != -1 && c->precision < 6) len = 0; else { s = "(null)"; len = 6; } }
6. INT_MIN et le débordement de signe
INT_MIN = -2147483648. L'expression -INT_MIN
déborde car INT_MAX = 2147483647. Pour éviter ça, on
convertit en unsigned long long avant de négationner. Notre implémentation
utilise long long pour n, ce qui suffit pour int
et long, mais pour LLONG_MIN il faut caster en unsigned
avant la négation.
long long n = va_arg(*ap, int); char sign = 0; if (n < 0) { sign = '-'; n = -n; /* OK car n est long long, pas int */ } ull_to_base((unsigned long long)n, 10, 0, num, &num_len);
7. La valeur de retour
ft_printf doit retourner le nombre exact de caractères
écrits. En cas d'erreur (format NULL), retourner -1. Le comptage se fait
dans buf.total, incrémenté à chaque buf_char.
int ft_printf(const char *format, ...) { va_list ap; t_buf buf; if (!format) return (-1); buf.len = 0; buf.total = 0; /* compteur global */ va_start(ap, format); process_format(format, &ap, &buf); va_end(ap); buf_flush(&buf); return (buf.total); /* nombre de chars écrits */ } /* Chaque buf_char incrémente buf.total */ void buf_char(t_buf *buf, char c) { buf->data[buf->len++] = c; buf->total++; /* <-- comptage */ }
8. Pas de crash — gérer les entrées invalides
Le sujet dit : "In no way can your program quit in an unexpected manner."
Votre ft_printf doit gérer gracieusement : ft_printf(NULL) → -1,
ft_printf("%s", NULL) → (null),
ft_printf("%") → % sans crash, conversions inconnues
affichées littéralement.
9. Respecter la Norme 42
| Règle | Impact sur ft_printf |
|---|---|
Max 5 fonctions par .c | Splitter les conversions en conv_int.c, conv_hex.c, conv_text.c |
| Max 25 lignes par fonction | Factoriser apply_padding, ull_to_base en helpers |
| Max 80 caractères par ligne | Signatures multi-lignes |
Pas de for, do, switch | Utiliser while et if/else if |
| Pas de déclaration au milieu d'un bloc | Toutes les variables en haut de la fonction |
Fichier author | xlogin\n à la racine |
10. Pas de fuites mémoire
Le sujet dit "Your project cannot leaks". Notre implémentation n'utilise
aucun malloc — le buffer est sur la pile
(char data[4096]), les numéros dans un tableau local
(char num[512]). Zéro fuite possible, zéro appel à free.
11. La partie bonus (optionnelle)
Le bonus n'est évalué que si le mandatory est 100% parfait. Le sujet
liste : %e %E (scientifique), %f %F
(flottant), %g %G, %a %A,
%n (compteur), flags $ L ',
conversions custom %b %r %k, couleurs.
12. Stratégie de test : comparer avec le vrai printf
Pour chaque cas de test, comparer la sortie et la valeur de retour
de ft_printf avec le vrai printf. Capturer stdout via pipe,
comparer avec strcmp. Les testeurs de la communauté (Tripouille,
paulo-santana, Sfabi28, etc.) utilisent cette technique avec des centaines de cas.
13. Taille du buffer numérique
Un test comme %.200d produit 200 digits. Si votre buffer fait 32 ou 64
bytes, vous aurez un stack overflow. Utilisez char num[512] minimum.
- va_list en pointeur
&ap - Caster selon le length modifier (promotion en int)
- Flag
0invalidé par précision (entiers seulement) %pNULL →(nil)(Linux/glibc)%sNULL + précision < 6 → vide- INT_MIN : caster en unsigned avant négation
- Valeur de retour = nombre exact de chars écrits
- Pas de crash : gérer NULL, format invalide
- Norme 42 : max 5 fonctions, 25 lignes, 80 chars
- Pas de fuites : éviter malloc entièrement
- Bonus seulement si mandatory = 100%
- Tester en comparant avec le vrai printf
- Buffer numérique ≥ 512 bytes
Compilation & tests
Le Makefile
Comme tout projet 42, on produit une bibliothèque statique
libftprintf.a. Les cibles standard all / clean / fclean / re
sont obligatoires.
NAME = libftprintf.a
CC = gcc
CFLAGS = -Wall -Wextra -Werror
SRC_DIR = src
OBJ_DIR = obj
INC_DIR = includes
SRCS = $(SRC_DIR)/ft_printf.c \
$(SRC_DIR)/buffer.c \
$(SRC_DIR)/utils.c \
$(SRC_DIR)/parse.c \
$(SRC_DIR)/conv_int.c \
$(SRC_DIR)/conv_hex.c \
$(SRC_DIR)/conv_text.c
OBJS = $(SRCS:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
all: $(NAME)
$(NAME): $(OBJS)
ar rcs $(NAME) $(OBJS)
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) -I$(INC_DIR) -c $< -o $@
$(OBJ_DIR):
mkdir -p $(OBJ_DIR)
clean:
rm -rf $(OBJ_DIR)
fclean: clean
rm -f $(NAME)
re: fclean all
.PHONY: all clean fclean re
Compiler et lier
# 1. Construire la bibliothèque $ make # 2. Compiler un programme de test qui l'utilise $ gcc -Wall -Wextra -Iincludes -o test test.c -L. -lftprintf # 3. Lancer $ ./test
Stratégie de test
La seule vérité, c'est le vrai printf. La méthode : rediriger
stdout vers un pipe, capturer la sortie des deux fonctions, et
strcmp + comparer les valeurs de retour. Le script
tests/run_tests.sh fait exactement ça.
#define TEST(name, fmt, ...) \ do { \ char ft_buf[1024], std_buf[1024]; \ int ft_ret, std_ret; \ /* rediriger stdout vers un pipe, capturer ft_printf */ \ int saved = dup(1); \ int pipefd[2]; pipe(pipefd); \ dup2(pipefd[1], 1); \ ft_ret = ft_printf(fmt, ##__VA_ARGS__); \ fflush(NULL); \ close(pipefd[1]); \ dup2(saved, 1); \ int ft_len = read(pipefd[0], ft_buf, sizeof ft_buf - 1); \ ft_buf[ft_len] = '\0'; \ /* idem avec le vrai printf */ \ ... \ if (strcmp(ft_buf, std_buf) == 0 && ft_ret == std_ret) \ pass_count++; \ else \ printf("FAIL: %s\n ft: \"%s\" (%d)\n std: \"%s\" (%d)\n", \ name, ft_buf, ft_ret, std_buf, std_ret); \ } while(0)
Catalogue de cas à tester
%d 42 · %d -42 · %d 0 ·
%u 4294967295 · %o 8 · %x 255 ·
%X 255 · %c 'A' · %s "hi" ·
%%
%#o 8 · %#x 255 · %#x 0 ·
%05d 42 · %-5d| 42 · %+d 42 ·
%+d -42 · % d 42
%5d 42 · %*d 5,42 · %.3d 42 ·
%.0d 0 · %.0d 42 · %8.3d 42 ·
%08.3d 42 · %-8.3d| 42
%s NULL · %d INT_MIN · %d INT_MAX ·
%x -1 · %u -1 · %lld LLONG_MAX ·
%hhd 255 · format vide · % final solitaire
Erreurs courantes
| Symptôme | Cause | Fix |
|---|---|---|
Crash sur %s | Argument NULL non géré | Afficher (null) |
Affichage étrange sur INT_MIN | -n sur int signé | Travailler en ull |
%08.3d affiche 0000042 | Flag 0 non désactivé par précision | if (prec != -1) flags &= ~FLAG_ZERO |
| Comportement erratique | va_list passée par valeur | Passer va_list* |
| Sortie tronquée à 4096 | Flush final oublié | buf_flush avant return |
%c affiche n'importe quoi | va_arg(ap, char) | (char)va_arg(ap, int) |
%#x de 0 affiche 0x0 | Préfixe ajouté sans test | if (n != 0) |
1. Implémentez %s %c %d %x sans flags ni largeur. Faites passer les tests
de base. 2. Ajoutez la largeur. 3. Ajoutez la précision (testez %.0d 0 dès
maintenant). 4. Ajoutez les flags un par un, dans l'ordre -, 0,
+, espace, #. 5. Bonus : length modifiers. Testez après chaque
étape, jamais tout à la fin.
Si votre ft_printf passe le script de test fourni et le
moulinette-style ft_printf de la correction 42, vous avez gagné. La
moulinette teste des centaines de cas générés aléatoirement — il n'y a pas de
raccourci, il faut que chaque règle du man soit implémentée à la lettre. Bon courage,
androïde.