Si être insupportable au travail vous séduit, apprenez à donner bien du mal à vos collègues grâce aux bitwise operators, ou opérateurs bit à bit!
Méconnus du public, ces opérateurs sont pourtant utiles dans le monde réel. Qui sont les malades qui utilisent ces opérateurs magiques? Démystifions-les ensemble!
Table of Contents
Qu’est-ce qu’un opérateur bit à bit
Afin de maximiser l’agacement de nos collègues, il est de bon ton de comprendre le fonctionnement d’un opérateur bit à bit. Alors déjà, c’est quoi un bit?
En informatique et précisément en binaire, chaque nombre est représenté par une série de 0 et de 1, appelés bits. Voici quelques exemples de nombres écrits en binaire.
Base 10 | Base 2 (binaire) |
1 | 0001 |
2 | 0010 |
5 | 0101 |
13 | 1101 |
Les opérateurs bit à bit s’appliquent sur chacun de ces bits, un à un. Prenons par exemple l’opérateur NOT (~), il s’agit d’inverser chacun des bits d’un nombre! Ainsi:
Opération | Base 2 (binaire) |
13 | 1101 |
~13 | 0010 |
Le NOT aura inversé chacun des bits du nombre 13, ce qui nous donne un nouveau nombre binaire: 0010.
Les opérateurs bit à bit pour se venger
NOT (~)
Grande star du monde des opérateurs bit à bit, et souvent le plus connu, il inverse les bits d’un nombre. Une utilisation répugnante de cet opérateur est la suivante. Pouvez-vous deviner ce que cette fonction fait?
function damnYouInParticular(n: number): number {
return ~~(n + 0.5);
}
Afin de comprendre ce qui vient de se produire, reprenons l’exemple de l’introduction.
Opération | Base 2 (binaire) |
13 | 1101 |
~13 | 0010 |
De la même manière que l’opérateur ! permet d’inverser une valeur (par exemple, le booléen true devient false), s’il est utilisé deux fois, il retrouve sa valeur originelle. Ainsi:
const aBooleanValue = true;
!aBooleanValue // false
!!aBooleanValue // true
En mathématiques, cela s’appelle une involution. Ainsi, on dit que l’opérateur ! est involutif. De la même manière, l’opérateur NOT (~) est lui-même involutif! Ainsi, en l’appliquant deux fois, on retrouve le nombre originel.
Seulement, la manière dont ces opérateurs sont gérés fait qu’en cas de traitement d’un nombre à virgule, ce nombre sera arrondi inférieur. Ainsi:
Ainsi:
Opération | Base 2 (binaire) |
13 | 1101 |
~13 | 0010 |
~13.4 | 0010 |
~13.8 | 0010 |
Tous ces résultats sont similaires. Et si on applique l’opérateur 2 fois:
Opération | Base 2 (binaire) |
13 | 1101 |
~~13 | 1101 |
~~13.4 | 1101 |
~~13.8 | 1101 |
On revient au nombre de base, à savoir 13. On vient d’arrondir notre nombre à l’inférieur!
function roundDown(n: number): number {
return ~~n;
}
En partant de ce principe, il nous suffit d’ajouter 0.5 au nombre de base, pour faire un arrondi au plus proche. Si l’on part de 13,8, en ajoutant 0.5, on arrive à 14,3. L’opération sera désormais ~~14,3. Soit le nombre supérieur.
function roundToNearest(n: number): number {
return ~~(n + 0.5);
}
AND (&)
Cet opérateur, semblable au &&, va comparer les bits un par un de deux nombres, et s’assurer qu’à un même emplacement, les deux bits sont à 1. Voici un exemple de son utilisation, avant d’expliquer plus en détail son fonctionnement. Une idée de ce qu’elle fait?
function whatTheHeck(n: number): boolean {
return (n & 1) == 1;
}
Voici un exemple d’utilisation de l’opérateur AND (&). Ici, faisons 12 & 6 (à savoir, 1100 & 0110)
Opération | Base 2 (binaire) |
12 | 1100 |
& 6 | 0110 |
= | 0100 |
L’opération a comparé un à un les bits des deux nombres. Rappelons qu’avoir les deux bits à 1 donnera 1, sinon 0.
- 1 & 0 donne 0
- 1 & 1 donne 1
- 0 & 1 donne 0
- 0 & 0 donne 0
On obtient alors le résultat: 12 & 6 = 4 (en binaire, 0100).
Comprenons désormais l’utilité du & 1 dans notre fonction: le nombre 1, en binaire, s’écrit simplement… 1. Précédé d’un certain nombre de zéros. Ainsi, l’opération n & 1 nous permettra de savoir si le dernier bit du nombre n est 1. Si ce bit est à 1, alors ce nombre n est… impair!
Nombre | Base 2 (binaire) | Résultat de l’opération & 1 |
1 | 0001 | 1 |
2 | 0010 | 0 |
3 | 0011 | 1 |
4 | 0100 | 0 |
5 | 0101 | 1 |
Et voilà alors le fonctionnement de cette fonction: si le résultat de l’opération n & 1 est 1, le nombre n est impair. Sinon, si le résultat est 0, ce nombre est pair.
// Ce code fonctionne mais renvoie un nombre plutôt qu'un booléen
function truthyIsOdd(n: number): number {
return n & 1;
}
// Ce code fonctionne et force le renvoi d'un booléen.
function typeSafeIsOdd(n: number): boolean {
return (n & 1) === 1;
}
Notez qu’à cause de la priorité des opérateurs (operators precedence), il est nécessaire de faire l’opération n & 1 entre parenthèses.
OR (|)
Cet opérateur, semblable au ||, va comparer les bits de deux nombres un par un, et s’assurer qu’à un même emplacement, au moins un des deux bits est à 1. Voici un exemple de son utilisation, avant d’expliquer plus en détail son fonctionnement. Saurez-vous dire ce que l’exemple fait?
function ohMyGod(str: string): string {
return String.fromCharCode(character.charCodeAt(0) | " ".charCodeAt(0));
}
function iGiveUp(str: string): string {
return str.split("").map(ohMyGod).join("");
}
Voici un exemple d’utilisation de l’opérateur OR (|). Ici, faisons 12 | 6 (à savoir, 1100 | 0110)
Opération | Base 2 (binaire) |
12 | 1100 |
| 6 | 0110 |
= | 1110 |
L’opération a comparé un à un les bits des deux nombres. Rappelons qu’avoir au moins un des deux bits à 1 donnera 1, sinon 0.
- 1 | 0 donne 1
- 1 | 1 donne 1
- 0 | 1 donne 1
- 0 | 0 donne 0
On obtient alors le résultat: 12 | 6 = 14 (en binaire, 1110).
Mais alors, quel rapport avec nos fonctions? Pour comprendre, laissez-moi les renommer…
function charToLowercase(str: string): string {
return String.fromCharCode(character.charCodeAt(0) | " ".charCodeAt(0));
}
function stringToLowercase(str: string): string {
return str.split("").map(charToLowercase).join("");
}
Il s’agissait de fonctions permettant de transformer un caractère, ainsi qu’une chaîne de caractère, en leur version minuscule!
Comment ça fonctionne? Décomposons.
- String.fromCharCode transforme un code ascii en caractère
- charCodeAt(0) nous donne le code ascii du premier caractère d’une chaîne de caractère
Ainsi, concentrons-nous sur l’opération suivante:
character.charCodeAt(0) | " ".charCodeAt(0));
Prenons le caractère « A », cette « A ».charCodeAt(0) nous renverra 65. Pour le caractère ESPACE, la fonction » « .charCodeAt(0) nous renverra 32. Ainsi:
Opération | Code ascii | Base 2 (binaire) |
« A ».charCodeAt(0) | 65 | 0100 0001 |
» « .charCodeAt(0) | 32 | 0010 0000 |
65 | 32 | 97 | 0110 0001 |
La résultante de 65 | 32 est 97. Il se trouve que c’est le code ascii de… La lettre « a » minuscule! Et voyez:
Caractère | Caractère (ascii) | Caractère (binaire) |
» » (espace) | 32 | 0010 0001 |
« A » | 65 | 0100 0001 |
« a » | 97 | 0110 0001 |
En fait, en ASCII, chacune des lettres minuscule est située 32 codes plus loin que sa majuscule associée. L’opération | 32 nous permet de rattraper ces 32 rangs (en ajoutant un 1 à la 6ème position de bit), afin d’atteindre la minuscule. Ce nouveau code obtenu, ici 97, est ensuite transformé en caractère par la fonction String.fromCharCode, pour l’afficher en tant que texte.
Portons-nous alors sur la seconde fonction:
function stringToLowercase(str: string): string {
return str.split("").map(charToLowercase).join("");
}
Cette fonction fait simplement trois choses, une par une:
- split transforme la chaîne de caractères en tableau,
- map applique la fonction charToLowercase sur chacun des caractères du tableau,
- join transforme le tableau en string.
Tout ceci additionné nous donne une fonction transformant chacun des caractères en leur version minuscule.
function charToUppercase(char: string): string {
return String.fromCharCode(char.charCodeAt(0) & "_".charCodeAt(0));
}
Le caractère 95 (0101 1111), à savoir l’underscore, nous permet de passer le 6ème bit à 1 dans tous les cas, ce qui fait passer un caractère de sa minuscule à sa version majuscule.
Un exercice d’entraînement pourrait être d’écrire une fonction qui vérifie si un caractère est en majuscule ou en minuscule. En êtes-vous capable?
XOR (^)
Cet opérateur va comparer les bits de deux nombres un par un, et s’assurer qu’à un même emplacement, exactement un seul des deux bits est à 1. Voici un exemple de son utilisation, avant d’expliquer plus en détail son fonctionnement. Avec vos nouvelles connaissances, pensez-vous pouvoir deviner ce qui se trame là-dessous?
function myGodWhatsThis(char: string, key: string): string {
return String.fromCharCode(char.charCodeAt(0) ^ key);
}
function andThisToo(str: string, key: string): string {
return str.split("").map((char) => myGodWhatsThis(char, key)).join("");
}
Comme précédemment, la seconde fonction applique la première fonction sur chaque caractère d’une chaîne de caractères. Bon. Que fait alors la première fonction? Voyons le fonctionnement du XOR (^). Ici, 12 ^ 6.
Opération | Base 2 (binaire) |
12 | 1100 |
^ 6 | 0110 |
= | 1010 |
L’opération a comparé un à un les bits des deux nombres. Rappelons qu’avoir exactement un des deux bits à 1 donnera 1, sinon 0.
- 1 | 0 donne 1
- 1 | 1 donne 0
- 0 | 1 donne 1
- 0 | 0 donne 0
On obtient alors le résultat: 12 ^ 6 = 10 (en binaire, 1010).
L’opérateur XOR est involutif lorsqu’une des deux valeurs est fixe. C’est à dire qu’en l’appliquant deux fois de suite, on retrouve le nombre de départ. Ainsi, n ^ k ^ k = n. Par exemple, 13 ^ 5 ^ 5 = 13.
En partant de ce principe, on peut créer un algorithme d’encryptage et de décryptage involutif! En renommant les fonctions, on comprends mieux:
function encryptDecryptChar(char: string, key: string): string {
return String.fromCharCode(char.charCodeAt(0) ^ key);
}
function encryptDecrypt(str: string, key: string): string {
return str.split("").map((char) => encryptDecryptChar(char, key)).join("");
}
Et voici ce que ça pourrait donner:
encryptDecrypt("Jacques DANIEL", 8); // Biky}m{(LIFAMD
encryptDecrypt("Biky}m{(LIFAMD", 8); // Jacques DANIEL
encryptDecrypt(encryptDecrypt("Jacques DANIEL", 15), 15); // Jacques DANIEL
Et boom, vous venez de créer une fonction qui crypte et décrypte les messages secrets secrets que vous voulez transférer. Tentez de l’utiliser pour hasher vos mots de passe en base de données, et voyez comment le département sécurité réagit!
Bit shift (<<, >>)
Ces opérateurs vont décaler les bits vers la gauche ou la droite, en rajoutant des zéros pour combler le vide laissé par le décalage effectué. Avant de mieux comprendre le fonctionnement de ces opérateurs, voici deux fonctions intéressantes. Peut-être saurez vous deviner ce qu’elles font?
function thisOneLooksFunny(n: number) {
return n << 1 >> 1 === n;
}
function howAboutThisOne(n: number) {
return n >> 1 << 1 === n;
}
Avant de vous dévoiler ce qu’elles font, comprenons le fonctionnement de ces opérateurs, avec quelques exemples. Prenons pour exemple le nombre 12, qui s’écrit 0000 1100 en binaire. Décalons les bits vers la gauche, d’une place. On rajoute un zéro à droite et on retire un zéro à gauche.
Base 10 | Base 2 (binaire) | |
Opération | 12 << 1 | 0000 1100 << 1 |
Résultat | 24 | 0001 1000 |
Un autre exemple, cette fois en décalant de plusieurs bits. On rajoute 3 zéros à droite et on retire 3 zéros à gauche.
Base 10 | Base 2 (binaire) | |
Opération | 12 << 3 | 0000 1100 << 3 |
Résultat | 96 | 0110 0000 |
Et enfin, dans l’autre sens. On rajoute 1 zéro à gauche et on retire 1 zéro à droite.
Base 10 | Base 2 (binaire) | |
Opération | 12 >> 1 | 0000 1100 >> 2 |
Résultat | 6 | 0000 0110 |
Ceci en tête, peut-être aurez-vous compris quelque chose: aller vers la gauche multiplie le nombre par 2, et aller vers la droite le divise par 2. Bien sûr, si le nombre est à virgule, il sera arrondi inférieur avant de faire l’opération.
C’est suffisant pour comprendre la première des deux fonctions! L’opération n << 1 >> 1 va multiplier un nombre par deux, puis le diviser par deux. Si le résultat de ces opérations est égal au nombre de départ, alors ce nombre est… un entier!
function isInteger(n: number) {
return n << 1 >> 1 === n;
}
Opération | << 1 (multiplication par 2) | >> 1 (division par 2) | Résultat |
14 << 1 >> 1 | 28 | 14 | 14 === 14 est vrai, 14 est un entier |
15 << 1 >> 1 | 30 | 15 | 15 === 15 est vrai, 15 est un entier |
16.8 << 1 >> 1 | 32 (16.8 a été arrondi inférieur) | 16 | 16.8 === 16 est faux, 16.8 n’est pas un entier |
Mais alors que fait la seconde fonction ? La même chose dans l’autre sens? Pas tout à fait. Démontrons.
Base 10 | Base 2 (binaire) | |
Opération | 13 >> 1 | 0000 1101 >> 1 |
Résultat | 6 | 0000 0110 |
Lorsque l’on divise un nombre impair par 2, son bit le plus à droite disparaît. Le résultat de la division par 2 est donc arrondi inférieur! Lorsque l’on multiplie ce résultat par 2, on ne retrouve pas le nombre de départ, s’il était impair. Ainsi, si le nombre de départ et le résultat de l’opération sont différents, cela signifie que le nombre est impair!
function isEven(n: number) {
return n >> 1 << 1 === n;
}
Démontrons:
Opération | >> 1 (division par 2) | << 1 (multiplication par 2) | Résultat |
13 >> 1 << 1 | 6 (6.5 a été arrondi inférieur) | 12 | 13 === 12 est faux, 13 est impair. |
14 >> 1 << 1 | 7 | 14 | 14 === 14 est vrai, 14 est pair. |
15 >> 1 << 1 | 7 (7.5 a été arrondi inférieur) | 14 | 15 === 14 est faux, 15 est impair. |
Ces deux méthodes, bien que similaires, n’ont pas tout à fait le même résultat! Enfin, les voici dévoilées. Avez-vous correctement deviné leur fonctionnement?
function isInteger(n: number) {
return n << 1 >> 1 === n;
}
function isEven(n: number) {
return n >> 1 << 1 === n;
}
Les opérateurs bit à bit dans le monde réel
Ce qui m’a inspiré à écrire cet article est un cas que j’ai rencontré au boulot. Je faisais une revue de code d’un composant ReactJS. Il s’agissait d’ouvrir un sous menu, sous certaines conditions que voici:
- Nous avons deux variables: isSmall, et isUpdated. Ce sont des booléens.
- Si isSmall est true et isUpdated est false, on doit fermer le menu.
- Si isSmall est false et isUpdated est true, on doit fermer le menu.
- Dans les autres cas, on doit ouvrir le menu.
Voici le code originel:
if(isSmall && !isUpdated) {
menu.close();
} else if(!isSmall && isUpdated) {
menu.close();
} else {
menu.open();
}
Fort de mes acquis, et souhaitant montrer que j’en sais des choses dans ma petite tête, j’ai réfléchis à une manière de rendre ce code plus court, et surtout, plus stylé, parce que c’est bien l’important dans un code: le style (et certainement pas la lisibilité).
Finalement, en dressant un petit tableau des conditions, on se retrouve avec les informations suivantes:
isSmall | isUpdated | Action |
1 | 1 | Ouvrir |
1 | 0 | Fermer |
0 | 1 | Fermer |
0 | 0 | Ouvrir |
Peut-être ceci vous est familier: il s’agit simplement du tableau… Du XOR!
isSmall | isUpdated | Résultat |
1 | 1 | 0 |
1 | 0 | 1 |
0 | 1 | 1 |
0 | 0 | 0 |
J’ai alors proposé le refactor suivant:
menu.setOpen(!(isSmall ^ isUpdated))
// Ou bien
menu.setClose(isSmall ^ isUpdated);
Fier de ma trouvaille, j’ai partagé ce code les versions avant-après avec des amis histoire de crâner un peu. « Regardez comme je suis fortiche », avais-je l’air de dire. Ils ont trouvé cette version trop compliquée et préféraient la première, avant mes modifications. Les arguments évoqués étaient les suivants:
- Le XOR (^) est trop peu connu;
- Le XOR (^) est trop difficile à imaginer rapidement en lisant du code.
Ces arguments s’entendent. Une solution pourrait être la suivante:
const isEitherSmallOrUpdated = isSmall ^ isUpdated;
menu.setOpen(!isEitherSmallOrUpdated);
Ceci rend la logique parfaitement claire, mais le débat reste ouvert: l’opérateur XOR (^) est inconnu du grand public. Et c’est ainsi que je me suis dis: c’est ce qu’on va voir, je vais écrire dessus, et le monde entier connaîtra la puissance des opérateurs bit à bit.
Enfin bref, le linter a refusé le nouveau code. On est revenu à la première version.