Quiz sur le langage de programmation C
(stefansf.de)- Les règles du langage C peuvent faire basculer même un code en apparence simple — comme la comparaison de pointeurs, l’aliasing, les pointeurs nuls ou les valeurs non initialisées — dans un comportement indéfini
- Les constantes entières,
sizeof, les constantes caractère et l’arithmétique suruint8_tpeuvent produire des résultats différents selon la plateforme, la notation et l’emplacement des affectations intermédiaires, en raison du choix des types et des promotions entières - Dans les déclarations de fonctions,
foo()etfoo(void), l’absence de prototype, les promotions par défaut des arguments et les fonctions sans valeur de retour diffèrent en légalité ou en comportement entre C et C++ - Un tableau n’est pas un pointeur ; les paramètres de tableau sont ajustés en pointeurs, et
a,&a,&a[0], même s’ils ont la même adresse, ont des types différents et ne sont donc pas interchangeables - La priorité des opérateurs et l’ordre d’évaluation sont distincts ; y compris pour la structure du corps d’un
switchet la durée de vie des objets temporaires, c’est la formulation de la norme qui détermine le résultat réel de l’exécution
Comportement indéfini et règles sur les pointeurs
-
Comparaison de pointeurs et règle de strict aliasing
- Même si deux pointeurs de même type
petqpointent vers la même adresse, si ils proviennent d’objets différents et ne font pas partie du même objet aggregate ou union, la comparaisonp == qpeut constituer un comportement indéfini - L’idée qu’un pointeur est plus abstrait qu’une simple adresse numérique est développée dans cet article
- Accéder à un objet
intvia une lvalueshortconstitue un comportement indéfini selon la règle de strict aliasing - Un pointeur vers
unsigned charconstitue une exception et peut aliaser n’importe quel objet ; il est donc légal d’accéder à un objetintvia une lvalueunsigned char - Il est garanti que
unsigned charn’a ni bits de bourrage ni trap representation, et depuis C11 il est aussi garanti quesigned charn’a pas de bits de bourrage - L’analyse d’aliasing fondée sur les types est traitée dans cet article
- Même si deux pointeurs de même type
-
Pointeur nul et représentation des pointeurs
- La représentation binaire d’un pointeur nul n’est pas nécessairement composée de bits tous à 0
- La norme C définit une null pointer constant, mais ne définit ni la représentation d’un pointeur nul à l’exécution, ni celle d’un pointeur ordinaire
- La Symbolics Lisp Machine 3600 utilise un tuple de la forme
<array-object, index>au lieu de pointeurs numériques, et la représentation du pointeur nul est<nil, 0> - D’autres exemples figurent dans la FAQ clc 5.17
- La constante
0devient selon le contexte soit un entier, soit un pointeur nul, et(void *)0est évalué comme un pointeur nul - Le fait qu’une expression
es’évalue à0ne garantit pas que(void *)edevienne un pointeur nul - Ce n’est que lorsqu’une null pointer constant est convertie vers un type pointeur qu’elle est garantie égale au pointeur nul
- L’arithmétique sur un pointeur nul est un comportement indéfini ; même si
eest un pointeur nul,e + 0n’est donc pas garanti être un pointeur nul
-
Valeurs non initialisées
- Lorsqu’on lit un objet à durée de stockage automatique non initialisé, si cet objet peut avoir la classe de stockage
registeret que son adresse n’a jamais été prise, cela constitue un comportement indéfini selon C11 § 6.3.2.1 ¶ 2 - Cette règle est liée à l’architecture Intel Itanium évoquée dans DR338
- Les registres entiers généraux d’Itanium comportent 64 bits plus un trap bit, ce dernier correspondant à
NaT(not-a-thing), qui indique si le registre a été initialisé - Si l’on prend l’adresse de la variable, cette condition disparaît, mais la valeur reste indéterminée et peut être soit une trap representation, soit une valeur non spécifiée
- Lire une trap representation constitue un comportement indéfini selon C11 § 6.2.6.1 ¶ 5
- S’il s’agit d’une valeur non spécifiée, le résultat de
x != xpeut êtretruecommefalse, et siint xest non spécifié, rien ne garantit quexvaille 0 aprèsx *= 0 - Les notions de valeur indéterminée et de valeur non spécifiée sont discutées dans DR260, DR451, N1793, N1818, N2012, N2013, N2221
- Lorsqu’on lit un objet à durée de stockage automatique non initialisé, si cet objet peut avoir la classe de stockage
-
unsigned charetmemcpy- Le type
unsigned charn’a pas de trap representation selon C11 § 6.2.6.1 ¶ 3, donc sa valeur initiale est non spécifiée - Une réponse sur StackOverflow d’un membre du comité C affirme qu’après un appel à la fonction de bibliothèque standard
memcpy, la valeur dexdevrait devenir spécifiée ; selon cette interprétation,x != xdevient doncfalse - Le fondement textuel de cette lecture dans la norme C n’est pas clair, et la réponse du comité dans DR451 entre en conflit avec cette interprétation en affirmant que l’utilisation d’une valeur indéterminée avec une fonction de bibliothèque constitue un comportement indéfini
- La question reste ouverte ; voir aussi Uninitialized Reads pour des discussions supplémentaires
- Le type
Constantes entières, promotions, sizeof
-
Notation et types des constantes entières
- Une constante entière décimale sans suffixe est toujours choisie dans la liste des types signed, alors qu’une constante octale ou hexadécimale peut être de type signed ou unsigned
- Conformément à C17 § 6.4.4.1, le type d’une constante entière est déterminé comme le premier type de la liste capable de représenter cette valeur
- Sans suffixe, les constantes décimales suivent l’ordre
int,long int,long long int, tandis que les constantes octales et hexadécimales suivent l’ordreint,unsigned int,long int,unsigned long int,long long int,unsigned long long int - Les constantes comprises entre
INT_MAX+1etUINT_MAXpeuvent avoir un type différent selon qu’elles sont écrites en décimal ou en hexadécimal, ce qui peut créer des différences dans du code sensible à l’ABI, comme les appels à des fonctions variadiques - Dans l’ABI Arm 32 bits,
intetlongsont passés sur un seul registre de 32 bits, tandis quelong longest passé sur deux registres pour un total de 64 bits - Sur une plateforme où
intfait 32 bits,-1 < 0x8000vauttrue, alors que sur une plateforme oùintfait 16 bits, l’expression vautfalse, ce qui peut poser un problème de portabilité - La différence de type des constantes peut aussi modifier le résultat dans des expressions impliquant generic selection, la surcharge de fonctions en C++, ou des expressions comme
sizeof(0x80000000) == sizeof(2147483648)
-
sizeof(int) > -1- L’opérateur
sizeofrenvoie un entier unsigned de typesize_t - Selon les usual arithmetic conversions de C11 § 6.3.1.8, si un opérande signed a un rang inférieur à celui d’un opérande unsigned, il est converti vers le type unsigned de même rang
- Un entier signed correspondant à
-1, converti en unsigned, devient l’entier unsigned maximal de ce rang - Par conséquent,
sizeof(int) > -1est toujours évalué àfalse
- L’opérateur
-
Type des constantes caractère
- En C, une constante caractère est de type
intselon C11 § 6.4.4.4 ¶ 10 - Par conséquent, rien ne garantit que
sizeof(char) == sizeof('x')soit toujourstrue, seule l’égalitésizeof(int) == sizeof('x')est garantie - Une integer character constant peut être composée d’une ou plusieurs séquences de caractères multioctets, donc
'abc'est aussi valide, et sa représentation est définie par l’implémentation - La valeur d’une integer character constant contenant un seul caractère est égale à la représentation entière d’un objet de type
charreprésentant ce même caractère unique
- En C, une constante caractère est de type
-
Arithmétique sur
uint8_tet division- Même si
a,betcsont initialisés avant lecture, les valeurs dexetzpeuvent différer à cause des promotions entières et de l’emplacement de l’affectation intermédiaire - La valeur de chaque variable est promue à la taille de
int, puis l’addition et la division sont effectuées, et chaque résultat d’affectation est tronqué pour être stocké dans le type de la variable concernée - Par exemple, si
a=255,b=1,c=2, alorsxvaut((255 + 1) / 2) % 256 = 128 - La variable intermédiaire
ydevient(255 + 1) % 256 = 0, puiszdevient(0 / 2) % 256 = 0, donc128 != 0 - Le dépassement de capacité d’un entier unsigned est un comportement défini
- Comme l’opération modulo est distributive par rapport à l’addition, si l’on remplace la division par une addition,
xetzsont toujours égaux - Si l’on remplace aussi la première affectation par
uint8_t x = ((uint8_t)(a + b)) / c;, alorsxetzsont également toujours égaux
- Même si
-
Variables
constet variable length array- Même si des variables
netmqualifiéesconstsont utilisées comme tailles de tableau, elles ne constituent pas des integer constant expressions en C - Dans C11 § 6.6 ¶ 6, une integer constant expression se limite à une integer constant, une enumeration constant, une character constant, un
sizeof,_Alignofou cast dont le résultat est un entier constant, avec comme opérande immédiat une floating constant, etc. - Si l’expression de taille d’un tableau n’est pas une integer constant expression, alors il s’agit d’une variable length array selon C11 § 6.7.6.2 ¶ 4
- Une variable length array n’est pas autorisée en portée fichier, donc l’unité de compilation contenant le tableau global
xne compile pas - En portée bloc, les variable length arrays sont autorisées, donc l’unité de compilation contenant le tableau local
ypeut être compilée - Les variable length arrays sont une fonctionnalité conditionnelle que les implémentations ne sont pas obligées de prendre en charge, donc, sur un compilateur qui ne les supporte pas, même l’exemple en portée bloc peut ne pas compiler
- En C++, les deux unités de compilation sont compilées, et comme le C++ n’a pas de notion de variable length array,
yest compilé comme un tableau ordinaire de 42 éléments
- Même si des variables
Déclarations de fonctions, valeurs de retour et linkage
-
foo()etfoo(void)- Une déclaration de fonction de la forme
foo()déclare une fonction dont on ne connaît ni le nombre ni le type des arguments, tandis quefoo(void)déclare une fonction nulle-aire sans argument - Cette différence est abordée dans cet article sur les déclarations, définitions et prototypes de fonctions
- Une déclaration sans liste d’arguments n’introduit que le nom de la fonction et ne définit ni le nombre ni le type des arguments ; elle peut donc être légale si elle se combine ensuite avec la définition de la fonction
- Si une fonction est appelée sans prototype, les promotions d’arguments par défaut s’appliquent et
floatest promu endouble - Si le type de fonction après promotion n’est pas compatible avec le type de la définition réelle de la fonction, la combinaison de la déclaration et de la définition n’est pas valide
- Un appel de fonction sans déclaration peut être compilé en C, car les fonctions implicites y étaient autorisées, mais provoque une erreur de compilation en C++
- Si l’on appelle
bar(42)sans déclaration, les promotions des arguments entiers s’appliquent et42est représenté comme unint; sibarn’est pas compatible avecT (*)(int)pour un type de retourTquelconque, cela entraîne un comportement indéfini
- Une déclaration de fonction de la forme
-
Fonction à valeur de retour qui ne renvoie pas de valeur
- Même si une fonction de type de retour
intne renvoie pas de valeur, cela peut être légal en C tant que la valeur de retour de l’appel n’est pas utilisée - En K&R C, le type
voidn’existait pas et, si le type était omis, le type par défautintétait supposé ; les fonctions ne renvoyant pas de valeur et la règle duintimplicite sont donc liées historiquement - La règle du
intimplicite a été supprimée en C99 ; voir la discussion connexe dans N661 et la justification de C99 - C17 § 6.9.1 ¶ 12 précise que si l’exécution atteint le
}final d’une fonction et que l’appelant utilise la valeur de l’appel de fonction, il s’agit d’un comportement indéfini - En C++98 § 6.6.3 ¶ 2, le simple fait d’atteindre la fin d’une fonction censée renvoyer une valeur équivaut à un
returnsans valeur, ce qui constitue un comportement indéfini dans une telle fonction - Comme les compilateurs C++ ne peuvent généralement pas prouver dans quelle branche
abort_program()termine l’exécution, ils ne peuvent souvent émettre qu’un diagnostic plutôt qu’une erreur
- Même si une fonction de type de retour
-
Linkage et
extern- Si l’on redéclare le même identifiant avec
externdans une portée où une déclaration précédente est visible, le linkage de la déclaration ultérieure est le même que celui de la déclaration précédente - C17 § 6.2.2 ¶ 4 dispose que si la déclaration précédente spécifiait un linkage interne ou externe, la déclaration
externultérieure a le même linkage - Si aucune déclaration précédente n’est visible, ou si la déclaration précédente n’avait pas de linkage, l’identifiant déclaré avec
externa un linkage externe - Une combinaison de déclarations dans l’ordre inverse peut entraîner un comportement indéfini, et GCC comme Clang le détectent
- Si l’on redéclare le même identifiant avec
Qualificateurs et types incomplets
-
constsur les paramètres de fonction- Si, dans une déclaration de fonction, le paramètre
xest qualifié parconst, mais pas dans la définition, et que le corps de la fonction écrit dansx, cela reste légal - Selon C11 § 6.7.6.3 ¶ 15, lorsqu’on détermine la compatibilité des types de paramètres de fonction et le type composite, chaque paramètre déclaré avec un type qualifié est traité comme sa version non qualifiée
- Le même sujet est aussi abordé dans DR040
- Si, dans une déclaration de fonction, le paramètre
-
constsur le type de retour d’une fonction- Si seul le type de retour dans la définition de la fonction est qualifié par
const, mais pas dans la déclaration, il est difficile de considérer la réponse comme simplement juste ou fausse - Le consensus général est que les qualificateurs des rvalues devraient être ignorés, mais le texte des standards jusqu’à C11 ne le traitait pas explicitement
- En C17, il devient clair que les qualificateurs de rvalue doivent être ignorés dans les cast, les conversions de lvalue et les déclarateurs de fonction
- C17 § 6.7.6.3 ¶ 5 précise que le type renvoyé par une fonction est la version non qualifiée de
T, et cette formulation a été ajoutée en C17 - Même si la qualification
constdu type de retour diffère, l’affectation de types de fonctions peut rester légale - Voir aussi DR423 et DR481
- Si seul le type de retour dans la définition de la fonction est qualifié par
-
Structure incomplète et variable globale
- Même si
struct fooest un type incomplet au moment de la déclaration d’une variable globale et que sa taille n’est donc pas connue, cela peut être autorisé dans certains cas si le type est complété plus tard dans la même translation unit - Une logique similaire s’applique aux variables globales ou aux tableaux de type incomplet
- Ce point est aussi abordé dans DR016
- Même si
-
Objet externe de type
void- Une déclaration de variable de type
voidavec linkage interne n’est pas légale, mais une déclaration de variable de typevoidavec linkage externe est syntaxiquement légale et n’est explicitement interdite nulle part dans la norme C11 - Selon C11 § 6.2.5 ¶ 19, le type
voidest un type objet incomplet qui ne peut pas être complété, constitué d’un ensemble vide de valeurs - C11 § 6.3.2.1 ¶ 1 définit une lvalue comme une expression de type objet autre que
void, donc le nom d’un objetfoode typevoidn’est pas une lvalue valide - D’après C11, il est difficile d’imaginer une opération significative et conforme sur un objet externe de type
void - DR012 indique que, si l’on change le type en
const void, il devient légal de prendre l’adresse de l’objetfoo, ce qui ressemble davantage à un oubli qu’à une fonctionnalité intentionnelle
- Une déclaration de variable de type
-
Conversion pointeur-vers-
const- Lorsque
Test un type objet dérivé, l’affectation àcpest légale, mais il n’existe pas de réponse courte à la question de savoir si l’affectation àcppl’est aussi - Ce sujet est traité dans cet article sur la conversion implicite de pointeur vers const
- Lorsque
Tableaux, littéraux de chaîne et ajustement des pointeurs
-
Un tableau n’est pas un pointeur
- L’initialisation d’un tableau et l’initialisation d’un pointeur ne sont pas équivalentes
- La première forme initialise un tableau modifiable de durée de stockage automatique ou statique
- La seconde forme initialise un pointeur vers un tableau de durée de stockage statique, et ce tableau n’est pas nécessairement modifiable
- Un tableau n’est pas un pointeur ; voir cet article connexe pour plus de détails
-
a,&a,&a[0]- Avec
int a[42];,a,&aet&a[0]s’évaluent tous à l’adresse du premier élément du tableau - Toutefois, comme les types des trois expressions sont différents, elles ne sont pas interchangeables
- Voir cet article connexe pour plus de détails
- Avec
-
Paramètres tableau et tableaux locaux
- Si le type d’un paramètre de fonction est « tableau de
T», il est ajusté en « pointeur versT» - Même si le paramètre
xressemble àint[42], il est en réalité traité comme unint * - Si la variable locale
yest unint[42], alorssizeof(y)vaut42 * sizeof(int) - Comme, en général, la taille d’un pointeur d’objet n’est pas égale à la taille de 42
int,sizeof(x) == sizeof(y)vaut généralementfalse - Voir cet article connexe pour plus de détails
- Si le type d’un paramètre de fonction est « tableau de
Opérateurs, ordre d’évaluation et flux de contrôle
-
x+++y- En C, il est impossible de définir de nouveaux opérateurs comme en C++, donc il n’existe pas de nouvel opérateur tel que
+++ x+++yest interprété comme une combinaison d’opérateurs existants et équivaut à(x++) + y--*--pn’est pas non plus un nouvel opérateur, mais une combinaison d’opérateurs existants--*--péquivaut à--(*(--p))et, dans l’exemple, s’évalue à-1avec pour effet de bord d’assigner-1àx[0]
- En C, il est impossible de définir de nouveaux opérateurs comme en C++, donc il n’existe pas de nouvel opérateur tel que
-
Ordre d’évaluation des opérandes arithmétiques
- La priorité des opérateurs est bien définie, mais l’ordre d’évaluation des opérandes arithmétiques ne l’est pas
(x=1) + (x=2)est un comportement indéfini, car l’ordre des deux affectations n’est pas défini et la valeur finale dex,1ou2, n’est donc pas déterminée- Avec l’option
-std=c11 -O2, GCC 8.2.1 évalue l’expression d’exemple à4, tandis que Clang 7.0.0 l’évalue à3
-
Ordre d’évaluation des opérateurs logiques
- Avec les opérateurs logiques
&&et||, l’ordre d’évaluation des opérandes est lui aussi bien défini - Dans les termes du standard C, il existe un sequence point entre l’évaluation du premier opérande et celle du second
- Dans l’exemple,
x=1est d’abord évalué et devienttrue, puisx=2est évalué et devient lui aussitrue, donc l’expression entière vauttrue
- Avec les opérateurs logiques
-
Structure libre du corps de
switch- Le corps d’une instruction
switchpeut être n’importe quel statement, donc une structure mêlant boucle etifpeut aussi être valide - Même dans la branche
trued’une instructionifdont l’expression de contrôle vaut toujoursfalse, si un labelcases’y trouve alors cette instruction devient active, etprintf("1");n’est pas du code mort - En sautant vers
case 2, il est possible que la clause-1 et l’expression de contrôle de la boucle ne soient pas exécutées ; la variableidoit donc être initialisée à l’avance - Même s’il n’y a pas de
breakdanscase 1et qu’un fall through se produit, sicase 1se trouve dans la branchetrueduifetcase 2dans la branchefalse, alorscase 2peut être ignoré et l’exécution se poursuivre àcase 3 - Après les trois appels
foo(0); foo(1); foo(2);, la sortie console devient02313223 - Un exemple réel célèbre mêlant boucle et switch est le Duff's device
- Le corps d’une instruction
Durée de vie des objets temporaires et différences entre versions du standard C
- Un fragment de code donné peut relever d’un comportement indéfini en C11, sans que ce soit forcément le cas en C99
- En C11, la durée de vie de certains objets est raccourcie : un objet renvoyé par un appel de fonction ne reste vivant que pendant l’évaluation de l’opérande de droite
- En C99, le même objet reste vivant jusqu’à la fin du bloc englobant
- Référencer un objet dont la durée de vie est terminée constitue un comportement indéfini selon C11 § 6.2.4 ¶ 2
- En C99 également, la durée de vie d’un objet à durée de stockage automatique est liée au bloc englobant le plus proche ; le référencer en dehors de ce bloc constitue donc un comportement indéfini
- C11 § 6.2.4 ¶ 8 précise qu’une expression non-lvalue de type structure ou union qui contient un membre tableau fait référence à un objet ayant une durée de stockage automatique et une durée de vie temporaire
- La durée de vie de cet objet temporaire commence lors de l’évaluation de l’expression et se termine à la fin de l’évaluation de la full expression ou du full declarator qui l’englobe
- Toute tentative de modifier un objet ayant une durée de vie temporaire constitue un comportement indéfini
- L’exemple concerné est tiré de N1285, où l’on trouve aussi des discussions complémentaires
1 commentaires
Avis sur Lobste.rs
La question 4 n’est pas valide en C23, même si elle l’était avant
La question 10 n’est ni juste ni fausse, donc c’est un peu agaçant pour un QCM
La question 15 est techniquement incorrecte, surtout par rapport à la question 13, et la question 20 relève de l’« indéterminé », donc là encore aucune réponse n’est correcte
La question 30 est ambiguë selon la lecture qu’on en fait
Malgré tout, j’en ai eu 27 bonnes sur 31, et le fait d’être développeur de compilateurs aide un peu
Après avoir résolu environ quatre questions, il ne me restait plus rien de cette impression que le C est assez simple pour servir à un side project
clang, en utilisant-std=<language-standard>-pedantic -Wall -Wextraet en corrigeant réellement chaque avertissement, tout en évitant autant que possible les casts de pointeurs et la manipulation de pointeurs, on devrait pouvoir éviter les gros piègesLes avertissements de GCC/
clangsont plutôt bons aujourd’hui, et pour <language-standard> on peut utiliser c89, c99, c11 ou c23En utilisant un compilateur comme tcc, qui ne fait pas d’optimisations bizarres, on a moins de chances d’avoir des surprises étranges
J’ai simplement choisi selon le critère « quel est le comportement le plus absurde ici ? » et j’ai eu 21 bonnes sur 32
La plupart de mes erreurs venaient du fait que je n’avais pas réfléchi assez profondément au degré de cette absurdité
Je n’ai touché au C qu’un peu il y a plus de 15 ans, et voir ce genre de quiz ne me donne pas envie de m’y remettre
En C23, la réponse à la question 4 n’est pas valide
Fait intéressant, alors que je n’avais pas utilisé le C depuis un moment, j’ai quand même eu 27 bonnes sur 32
C’est précisément pour ce genre de choses que je me suis appuyé sur des analyseurs statiques et des linters
Dès la question 1, ça me mettait déjà mal à l’aise
On ne tenait pas compte d’où ces pointeurs pouvaient venir, et il faut des conditions très particulières pour que le cas évoqué tienne
Dans la plupart des cas, le simple fait d’essayer de créer le pointeur relève déjà du comportement indéfini, mais on peut malgré tout considérer que c’est acceptable
La question 3 était vraiment surprenante, encore un autre piège du C
Le fait que les littéraux entiers en C aient un type fixé d’avance est franchement agaçant
Les règles de promotion des entiers rattrapent un peu la situation, mais elles sont aussi une source d’erreurs
Les langages modernes devraient, pour la plupart voire tous, interdire les conversions numériques implicites, inférer le type des littéraux depuis le contexte quand c’est possible, et exiger un cast explicite quand ce ne l’est pas
Après la question 6, j’ai cessé de faire confiance au test et j’ai abandonné
Au début, c’était parce que la réponse à la question 5 semblait conçue pour faire rater la question 6, mais en y revenant, il semble que la question 6 elle-même soit erronée
L’explication dit que l’appel de fonction relève du comportement indéfini, mais la question demandait si la définition de fonction était légale, et elle l’était probablement
Et ça ne semble pas être un cas si rare que ça
Le problème sur
switch()était vraiment excellentC’était subtil, mais très amusant à résoudre mentalement