Skip to content

Catégorie : Tutoriels

Quelques articles pour expliquer comment réaliser un projet ou comment configurer un programme.

Le binaire ou Comment compter jusqu’à plus de 1000 avec ses dix doigts

Bonjour à toutes et à tous !

Il y a quelques temps, en parcourant le site Reddit, je suis tombé sur un post du subreddit r/Jokes où quelqu’un racontait la blague suivante :

"Je peux compter sur une main le nombre de fois où je suis allé à Tchernobyl. C'est douze"
« Je peux compter sur une main le nombre de fois où je suis allé à Tchernobyl. C’est douze »

Pour la plupart des gens, c’est une blague tout à fait valable. Je pense que j’aurais ri aussi, ou du moins souri, si je n’avais pas été au courant qu’il est parfaitement possible de compter jusqu’à douze, voire plus, sur une seule main – à condition d’avoir au moins quatre doigts à cette main.

Mais d’abord, comme d’habitude, un petit peu de mise en contexte.

Compter avec les doigts

Les bases

Une manière très naturelle de compter est simplement d’énumérer ce que l’on cherche à compter. Si j’ai cinq pommes devant moi, chaque pomme désignera un seul élément, un seul chiffre, et les cinq pommes formeront le nombre 5. En tout cas, en base 1 ou système unaire.

La base, en mathématiques, désigne le nombre de valeurs que peut prendre un chiffre qui compose un nombre. Notre système numérique utilise généralement la base 10, puisque nos chiffres prennent dix valeurs, allant de 0 à 9. C’est ce que l’on appelle communément le système décimal.

Qu’importe la base utilisée, lorsqu’un chiffre doit être incrémenté au-delà de sa valeur maximale, il revient à sa valeur minimale et l’on incrémente la valeur du chiffre suivant. C’est ainsi qu’en base 10 on passe, par exemple, du nombre 9 au nombre 10. (1)

En base 1, il n’y a qu’une valeur possible. Donc, pour rajouter un élément, on rajoute simplement un chiffre.

Un système pratique mais limité

C’est un système de comptage très rudimentaire, très simple. Les enfants apprennent à compter en utilisant leurs doigts. Seulement, comme vous avez dû vous en rendre compte au bout d’un moment, on se retrouve assez vite limité par le nombre de doigts : dix pour la plupart des gens, moins de dix pour les plus malchanceux et douze pour quelques rares personnes.

Les petits malins diront qu’il suffit d’utiliser ses doigts de pieds et les plus lourds diront qu’on peut ajouter encore autre chose… Mais je vais vous montrer qu’il est possible de repousser – voire carrément exploser – cette limite à deux chiffres en utilisant simplement la base 2, communément appelé le système binaire.

Compter MIEUX avec les doigts

Pour passer de la base 1 à la base 2, c’est très simple : il suffit de rajouter une valeur possible à nos chiffres. Pour un doigt, il suffit de prendre en compte l’état du doigt : baissé pour la valeur 0 et levé pour la valeur 1.

En pratique, cela donne ceci :

Décompte de 0 à 8, en binaire, avec les doigts d'une seule main
Décompte de 0 à 8, en binaire, avec les doigts d’une seule main

Notez que c’est déjà un peu plus optimal puisqu’en base 1 les doigts baissés sont tout simplement inutiles.

Si on se perd dans son comptage, il est possible de reconstituer le nombre en utilisant un peu de calcul mental. Chaque chiffre peut être multiplié par sa base portée à la puissance de sa position – si ce n’est pas clair, je vais expliquer, ne vous inquiétez pas – et, si l’on fait la somme de chaque résultat, on obtient le nombre final. Par exemple, en base 10, pour le nombre 123, nous avons :

1 × 102 + 2 × 101 + 3 × 100

=

1 × 100 + 2 × 10 + 3 × 1

=

100 + 20 + 3

=

123

Oui, ça a l’air ridicule de dire « 123 = 123 » mais je vous rappelle que c’est un système que l’on utilise couramment, au point que pour lire un nombre dans une base autre que 10, on ne peut s’empêcher de la convertir en base 10. Ainsi, pour un nombre en base 2, par exemple 101010, machinalement nous allons passer par une conversion en base 10 pour arriver à l’interpréter correctement. Et pour en revenir à la reconstitution d’un nombre binaire, ici nous avons :

1 × 25 + 1 × 23 + 1 × 21

=

1 × 32 + 1 × 8 + 1 × 2

=

32 + 8 + 2

=

42

Notez que, pour plus de clarté, j’ai ignoré les chiffres à valeur nulle, puisqu’une multiplication par 0 fait toujours 0.

Avec ce système, il est ainsi possible de compter jusqu’à 31 sur une main et 1023 sur deux mains ! Pour une main, on a multiplié notre résolution par 6 et pour deux mains par 100 ! Avouez que c’est quand même plus efficace ainsi !

Conclusion

Voilà, j’espère que cette astuce vous sera utile. Personnellement, j’utilise ce système tous les jours, c’est beaucoup plus pratique même si cela demande un peu d’entraînement pour avoir le réflexe d’utiliser ce système et pour arriver à transposer mentalement un nombre binaire en nombre décimal. C’était aussi l’occasion pour moi d’expliquer le principe du binaire de manière ludique.

N’hésitez pas à partager cette astuce si elle vous a plu.

Sur ce, je vous laisse et je vous dis à bientôt.

Nicolas SAN AGUSTIN

(1) Je fais ici une parenthèse pour rappeler une notion élémentaire : il ne faut pas confondre chiffre et nombre. Un nombre est composé d’un ou plusieurs chiffres. 1 est un chiffre et accessoirement un nombre, 10 est un nombre mais pas un chiffre – c’est un nombre à deux chiffres. Cette confusion est probablement dû à cause de l’usage abusif du terme « chiffre » dans les milieux financiers et commerciaux.

Faire tout et (surtout) n’importe quoi avec le préprocesseur

Bonjour à toutes et à tous !

Je souhaiterais aborder une fonctionnalité du langage C – et du C++ entre autres – que je vois souvent mal comprise et/ou mal utilisée : le préprocesseur. Pour ce faire, je vais détailler les différents mots-clés spécifiques au préprocesseur, en donnant quelques exemples d’utilisation et quelques pièges à éviter.

Mais avant toute chose…

Qu’est-ce que le préprocesseur ?

On désigne généralement par préprocesseur les instructions qui sont exécutées avant la compilation proprement dite. Pour rappel, un compilateur procède en plusieurs étapes pour compiler un programme à partir d’un code source, notamment en procédant à plusieurs analyses pour retirer les commentaires, isoler les mots-clés, vérifier la syntaxe et la sémantique des instructions, puis à la transformation en un code intermédiaire avant de lier le tout en un exécutable ou une bibliothèque.

Contrairement aux instructions du programme en C, les directives de préprocesseur sont exécutées pendant la compilation – et UNIQUEMENT pendant la compilation. Ces directives ne sont pas nombreuses mais permettent beaucoup de choses.

Définir des macros

Lorsqu’on définit une macro en C/C++, on cherche généralement à remplacer un bout de code par un mot-clé plus compréhensible.

Par exemple, la macro suivante permet de définir une macro nommée NB_PATTES_ARAIGNEE pour la remplacer lors de la compilation par la valeur 8.

#define NB_PATTES_ARAIGNEE    8
...
int i = NB_PATTES_ARAIGNEE;

Il y a de nombreux avantages à procéder ainsi :

  • Plutôt que d’utiliser une valeur brute dans un code sans indication claire de ce à quoi elle correspond, nous avons un mot-clé qui explicite la signification de cette valeur, sans devoir rajouter un commentaire à chaque endroit où cette valeur est utilisée ou, pire, laisser cette valeur sans explication au risque de ne pas pouvoir connaître sa signification lors de la relecture ;
  • Si la valeur de cette macro est susceptible de changer, il suffit de la modifier là où elle est définie. On évite ainsi d’avoir à modifier la valeur partout où elle est utilisée.

Il existe une autre façon d’utiliser les macros : les macros fonctions. Le principe reste le même, sauf que cette fois la macro peut recevoir des arguments.

#define ma_macro_fonction(x)    x + 2
...
int i = NB_PATTES_ARAIGNEE;
int n = ma_macro_fonction(i);

Ce code définit une macro fonction qui prend un argument quelconque appelé x puis ajoute 2 à sa valeur. Au moment de la compilation, le code sera remplacé par ceci :

int i = 8;
int n = i + 2;

En effet, le principe d’une macro est qu’elle sera remplacée telle quelle par sa valeur partout dans le code. Une macro fonction représente un gain par rapport à une fonction classique. Là où cette dernière nécessite un espace dédié dans la pile pour y stocker ses arguments et s’exécuter, la macro fonction se contente de placer uniquement les instructions dans le code comme si on les y avait placées nous-mêmes.

Cependant, il s’agit d’une arme à double tranchant. Comme je l’ai dit précédemment, le préprocesseur remplace toute occurrence d’une macro par sa valeur telle quelle.

Je vous invite à jeter un œil au code suivant (il est correct, vous pouvez vérifier), à essayer de deviner les valeurs de i et n lors de leur affichage, puis à les comparer en les vérifiant à l’exécution.

#define ma_macro_fonction(x) i
#define ma_macro_vide(x)

int main() {
  int i = 1;
  int n = 2;

  ma_macro_function(n)++;
  printf("i = %d ; n = %d\n", i, n);

  ma_macro_function(n++);
  printf("i = %d ; n = %d\n", i, n);

  m_macro_vide(n++);
  printf("i = %d ; n = %d\n", i, n);
}

Bizarre, n’est-ce pas ? On peut remarquer plusieurs choses :

  • La première macro fonction n’a pour valeur que le caractère i ;
  • Les deux macros n’utilisent jamais leur argument ;
  • La première macro équivaut à i, il est donc tout à fait possible d’utiliser cette macro fonction – en lui passant n’importe quel argument puisqu’il sera de toute façon ignoré – comme substitut d’une variable i ;
  • Il est possible de définir une macro sans valeur. Ce qui peut avoir son utilité, notamment dans le cas où une fonction n’est pas implémentée mais est dispensable, auquel cas on déclare une macro fonction vide pour retirer simplement les appels à ces fonctions sans avoir à les rechercher.

Bien entendu, le code précédent n’a aucune utilité en soi, c’est juste pour illustrer la trop grande liberté que peut offrir la définition des macros. À la limite, cela pourrait servir pour obscurcir son code (c’est-à-dire le rendre difficilement lisible pour d’autres personnes), soit par esprit de sabotage, soit pour participer à une compétition comme l’IOCCC.

En tout cas, le langage C dispose, depuis C99, des fonctions inline qui permettent en gros d’effectuer la même chose que les macros fonctions, avec la sûreté des fonctions classiques. Dans la mesure du possible, privilégiez ces fonctions-là plutôt que les macros fonctions.

Une autre possibilité est d’utiliser quelques astuces pour protéger l’utilisation des macros. Pour une macro avec une valeur simplement numérique, il est généralement recommandé de définir sa valeur entre parenthèses pour forcer la priorité des opérateurs. En effet, dans l’exemple suivant :

#define MACHIN     2
#define TRUC       6
#define MACHINTRUC MACHIN + TRUC
...
int i = 2 * MACHINTRUC;

i sera égal à 2 * 2 + 6 = 10 au lieu de 2 * (2 + 6) = 16. Il vaut mieux donc placer, au moins, la valeur de MACHINTRUC entre parenthèses comme ceci :

#define MACHINTRUC (MACHIN + TRUC)

On ne peut cependant pas utiliser des parenthèses pour protéger des instructions. Pour cela, il existe une autre astuce, consistant à placer une suite d’instructions dans une structure do { … } while(0) qui aura pour effet d’exécuter une seule fois la série d’instructions avant de sortir de la boucle.

Enfin, pour dé-définir une macro, il existe la directive #undef :

#undef ma_macro

Très pratique pour délimiter la portée d’une macro ou redéfinir une macro déjà définie comme ceci :

#ifdef MACRO_EXISTANTE
#undef MACRO_EXISTANTE
#endif
#define MACRO_EXISTANTE

Les directives #ifdef et #endif servent à tester des conditions, ce que nous allons voir tout de suite.

Vérifier des conditions

Le préprocesseur permet de tester des conditions grâce aux directives #if, #ifdef, #ifndef, #elif, #else et #endif.

Chaque test de condition ouvert par une directive #if, #ifdef ou #ifndef doit se terminer par la directive #endif. #if permet de tester la valeur d’une macro, tandis que #ifdef et #ifndef testent respectivement la définition ou la non définition d’une macro.

#else délimite le comportement du préprocesseur dans le cas où le test de condition initié par #if, #ifdef ou #ifndef échoue. #elif permet la même chose en initiant un autre test de condition (l’équivalent du « sinon si »).

De base, ces directives permettent d’activer une partie du code source si certaines conditions sont réunies. C’est pratique, par exemple, pour assurer la portabilité d’un programme en activant une portion de code qui est propre à une plateforme ou un compilateur spécifique.

#ifdef _WIN32
// Cette portion de code ne s'exécutera que sur un système Windows
#elif defined __unix__ // defined permet la même chose que #ifdef
// Cette portion de code ne s'exécutera que sur un système Unix (Linux, BSD, etc).
#endif

Une variante de cette technique existe pour le cas où du code C est intégré dans du code en C++. Pour éviter que le compilateur C++ traite du code en C comme étant du C++, il convient de délimiter la portion de code en C comme ceci :

#ifdef __cplusplus
extern "C" {
#endif

// Portion de code en C

#ifdef __cplusplus
}
#endif

Ainsi, tout ce qui sera compris dans le bloc

extern "C" { ... }

sera considéré par le compilateur C++ comme étant du C et le compilera donc comme du C.

Un autre usage très courant du test de condition est pour éviter l’inclusion multiple de fichiers, que nous allons voir dans la partie suivante.

Inclure des fichiers

En C/C++, il est d’usage de séparer le code source en plusieurs fichiers : les fichiers sources avec l’extension .c et les fichiers d’entête avec l’extension .h (1). Pour un module dédié à une tâche particulière, par exemple gérer la configuration d’un programme, on séparera ce module en un fichier source config.c qui contiendra la définition des fonctions dédiées à la configuration et un fichier config.h qui contiendra les déclarations de ces fonctions (je dis « fonctions », mais c’est également valable pour des variables, des structures, etc).

Pour qu’un fichier puisse prendre connaissance du contenu d’un fichier .h, il existe une directive de préprocesseur appelée #include. Cette directive indique au préprocesseur qu’il doit rechercher un certain fichier dans les répertoires qu’il connaît et inclure le contenu de ce fichier à l’emplacement de la directive #include. À vrai dire, le préprocesseur ne vérifie pas le nom du fichier qu’il doit inclure – à part pour indiquer par une erreur que ce fichier n’existe pas – il est donc théoriquement possible d’inclure n’importe quel fichier texte – par opposition à un fichier binaire – pour peu que le contenu de ce fichier soit cohérent avec la syntaxe du C/C++. Il est même possible d’inclure un fichier avec l’extension .c.

Cependant, il est possible qu’à la compilation nous ayons des erreurs dues à des inclusions multiples. C’est-à-dire, si le fichier main.c inclue deux fichiers .h (fichier1.h et fichier2.h) mais que chacun de ces deux fichiers inclue un fichier en commun (fichier_commun.h), les fonctions, variables, etc de ce dernier fichier seront déclarées/définies plusieurs fois et le compilateur remontera à une erreur.

Pour éviter cela, la technique généralement utilisée est de définir dans chaque fichier .h une macro propre à ce fichier – généralement à partir du nom du fichier – en prenant soin de vérifier au préalable que cette macro n’a pas déjà été définie.

#ifndef FICHIER_COMMUN_H
#define FICHIER_COMMUN_H
// Tout ce qui est compris ici ne sera activé que si la macro FICHIER_COMMUN_H vient d'être définie
#endif /* FICHIER_COMMUN_H */

Ainsi, quand le préprocesseur inclura ce fichier, cette portion de code ne sera inclue que la première fois.

Générer des messages d’erreur

Il est possible de générer des messages d’erreur avec le préprocesseur en utilisant la directive #error.

Posons le cas d’un test de condition visant à vérifier la portabilité d’un programme. Si ce test de condition échoue, on est possiblement dans une situation où le programme risque de ne pas pouvoir être compilé ou de ne pas fonctionner comme prévu, voire ne pas fonctionner du tout. Face à ce genre de situation, il est avisé de signaler à l’utilisateur que ce programme ne peut pas être compilé et d’interrompre la compilation car il est inutile d’aller plus loin.

Par exemple, si nous avons un programme utilisant des fonctionnalités propres à la norme C99, il faudrait d’abord vérifier que le compilateur est configuré pour compiler selon cette norme. Une telle vérification se présente comme ceci :

#if __STDC_VERSION__ 199901L
#error Ce programme n'est pas supporté par les normes du C antérieures à C99
#endif

Notez que le message n’a pas besoin de guillemets pour être affiché. Tout ce qui figure sur une ligne commençant par #error est considéré comme un message d’erreur à afficher. Le seul moyen d’écrire un message sur plusieurs lignes est de terminer une ligne par un antislash (\) pour indiquer que la ligne courante se poursuit sur la ligne suivante.

Si vous préférez plutôt avertir l’utilisateur d’un risque sans interrompre la compilation, vous pouvez utiliser la directive #warning pour générer un avertissement plutôt qu’une erreur.

#warning Ce programme est susceptible de ne pas fonctionner correctement sur cette plateforme

Conclusion

Le préprocesseur C est un outil puissant à utiliser et largement sous-estimé, le plus souvent car il est mal compris. Le revers de la médaille est qu’il est très facile de faire n’importe quoi avec – c’est également valable pour le C/C++ en général. Il est important de maîtriser le préprocesseur pour l’utiliser de manière utile et sûre.

Certains maîtrisent le préprocesseur au point qu’ils parviennent à réaliser un interpréteur brainfuck écrit uniquement avec des directives du préprocesseur C. Je déconseille de l’utiliser car, comme le dit son créateur :

The preprocessor brainfuck interpreter is very very inefficient and will use around 16 GIGABYTES of memory and 15 to 20 minutes of processing time while running hello.bf.

Traduction : L’interpréteur brainfuck via le préprocesseur est très très inefficace et prendra environ 16 GIGAOCTETS de mémoire et 15 à 20 minutes de temps de calcul pour exécuter hello.bf (NdT: un simple « Hello World! »).

C’est particulièrement ridicule, mais il faut admettre que c’est une bonne performance – sans mauvais jeu de mots.

Je n’ai ici abordé que les directives les plus basiques du préprocesseur. Avec ça, vous avez déjà de quoi faire du code efficace et sécurisé, à la condition que vous l’utilisiez avec prudence. Je vous invite à vous renseigner davantage sur les différentes directives du préprocesseur C – et C++ – pour en apprendre toutes les subtilités.

La directive #pragma mériterait une attention particulière. Je ne l’ai pas abordée ici car je ne la maîtrise pas encore assez pour en parler dans un article de blog et ses possibilités sont bien trop vastes et spécifiques pour être intégrées dans un article qui essaye d’aborder le préprocesseur de manière générale.

Sur ce, je vous laisse et vous dis à bientôt.

Nicolas SAN AGUSTIN

(1) Cette façon de faire n’est pas systématique. L’essentiel du code d’un module figure dans un fichier .c et le fichier .h n’a d’utilité que si l’on souhaite que des fonctions, des variables, des structures, etc de ce module soit accessibles facilement depuis le reste du code sans avoir à les redéclarer dans chaque fichier où elles seront utilisées. Ne soyez donc pas étonnés de voir du code source ne comportant pas de fichier .h pour chacun de ses modules.