Les opérations bit à bit/Les subtilités du XOR
Dans cette partie, nous allons voir ce qu'il est possible de faire avec l'instruction XOR. Faire un XOR entre deux nombres permet de faire pas mal de choses : émuler certaines instructions, échanger deux variables, fabriquer des listes chainées et j'en passe. Les techniques que nous allons voir se basent sur les propriétés de l'opération XOR. Pour rappel, nous avons vu au premier chapitre que : et . Ces deux propriétés sont assez importantes, surtout la dernière.
La mise à zéro rapide d'une variable ou d'un registre
[modifier | modifier le wikicode]Initialiser une variable à 0 est une opération extrêmement courante. En conséquence, il vaut mieux la rendre la plus rapide possible. Et il existe plusieurs méthodes pour mettre un registre à 0 en assembleur, certaines n'étant compatibles qu'avec certains processeurs. Certains processeurs ont une instruction dédiée pour mettre à 0 un registre : c'est alors la solution idéale dans la (quasi-)totalité des cas. Sur les processeurs qui n'ont pas cette instruction, on peut utiliser une instruction MOV (ou équivalent) pour mettre un 0 dans le registre. Mais il existe une dernière solution : faire un XOR entre le registre et lui-même.
Pour comprendre pourquoi cette solution fonctionne, il faut rappeler que l'on obtient 0 lorsque l'on XOR un bit avec lui-même : . Si cela vaut pour un bit, cela s'applique aussi quand on effectue un XOR bit à bit sur un nombre : chacun de ses bits sera donc XORé avec lui-même, ce qui fait qu'un nombre XOR lui-même donnera toujours zéro.
L'utilisation d'une opération XOR peut utiliser moins de cycles d'horloge et/ou économiser de la mémoire (encodage plus court). Si c'est le cas, elle est utilisée par les compilateurs pour mettre à zéro un registre. C'est notamment ce qui est fait sur les architectures x86, sur lesquelles cette solution est légèrement meilleure que celle utilisant un MOV. Sur ces architectures, il faut utiliser MOV en adressage immédiat, c'est à dire que la constante à mettre dans le registre est stockée dans l'instruction elle-même. Et cette constante prend facilement 8 à 16 bits, ce qui fait qu' l'instruction MOV est assez longue avec ce mode d'adressage. En comparaison, le XOR entre deux registre est très court car il suffit de préciser l'opcode et deux noms de registre eux-mêmes très courts (quelques bits). Sur d'autres processeurs, qui ne supportent pas l'adressage immédiat, la constante est lue depuis la mémoire. En comparaison, un XOR entre deux registres ne va rien charger en RAM et est donc plus rapide.
L'émulation de l'instruction MOV
[modifier | modifier le wikicode]L'instruction XOR permet d'émuler des opérations plus complexes, dont des transferts entre registres/variables. Elle permet notamment d'émuler une instruction MOV sur les processeurs qui n'en disposent pas, comme certains processeurs MIPS. Le MOV est alors remplacé par une opération XOR à trois adresses, qui XOR le registre à copier avec zéro, et place le résultat dans le registre de destination. L'opération est d'autant plus utile que ces processeurs disposent d'un registre en lecture seule, qui contient toujours la valeur 0.
Pour résumer : MOV Ra -> Rb <=> Ra XOR 0 -> Rb
Les listes chainées XOR
[modifier | modifier le wikicode]La propriété est aussi utilisée pour créer des listes doublement chainées plus économes en mémoire que la normale, appelées listes liées par ou exclusif, ou encore listes à chaînage XOR.
Une liste doublement chainée normale stocke deux pointeurs : un vers le nœud suivant et un vers le nœud précédent.
Les listes liées par ou exclusif (XOR linked lists) remplacent les deux pointeurs par un seul entier, qui contient le XOR entre ces deux pointeurs. Ainsi, on gagne un petit peu de mémoire : au lieu de stocker deux adresses, on n'en stocke plus qu'une. La consommation mémoire est identique à celle d'une liste simplement chainée.
- Dans ce qui suit, le pointeur sur le nœud précédent sera noté P (pour Précédent), le pointeur contenu dans le nœud actuellement visité est appelé Ptr et le pointeur vers le nœud suivant sera noté S (pour Suivant). Pour rappel, le pointeur stocké dans le nœud est égal au XOR entre P et S : .
Parcourir une liste liée par ou exclusif
[modifier | modifier le wikicode]Traverser une liste à chaînage XOR ne se fait que dans un sens : soit on part du début pour arriver à la fin, soit on va dans l'autre sens.
Le parcours commençant par le début de la liste vers la fin de celle-ci s'effectue en gardant deux pointeurs. Obtenir le pointeur vers le prochain élément demande de faire un XOR entre le pointeur vers l’élément précédent avec le pointeur stocké dans le nœud. En effet, on a :
- .
Le même raisonnement peut s'adapter dans le cas où l'on parcours la liste dans l'autre sens, si ce n'est qu'il faut effectuer un ou exclusif entre le pointeur de l’élément précédemment visité et le pointeur stocké dans le nœud.
Il existe des listes à chaînage XOR qui permettent le parcours dans les deux sens. De telles listes doivent maintenir un pointeur vers le premier élément et un pointeur vers le dernier. Quand la liste est vide, les deux pointeurs valent NULL / NIL (adresse 0). Quand la liste ne contient qu'un élément, les deux pointeurs pointent le même élément.
Algorithme de parcours des éléments d'une liste du début vers la fin :
prev = 0 pelem = list.first // ... traiter l'élément pointé par pelem ... // Élément suivant p = pelem pelem = prev ^ list.ptr prev = p // si pelem == 0 alors le parcours est terminé
Algorithme de parcours des éléments d'une liste de la fin vers le début :
next = 0 pelem = list.last // ... traiter l'élément pointé par pelem ... // Élément précédent p = pelem pelem = next ^ list.ptr next = p // si pelem == 0 alors le parcours est terminé
Les 2 différences entre les 2 parcours sont le nom de la variable (prev / next) stockant l'ancienne valeur du pointeur pelem, et l'initialisation de ce pointeur (list.first / list.last).
Les avantages et inconvénients des listes chaînées liées par ou exclusif
[modifier | modifier le wikicode]Les listes à chaînage XOR ne sont pas une structure de données sans inconvénients.
Le premier inconvénient est le fait que l'on doive parcourir la liste à chaînage XOR du début à la fin. Impossible de revenir en arrière pendant le parcours de la liste chaînée, à moins d'utiliser une variable supplémentaire. Certes, revenir en arrière de cette manière est assez rare, mais cela peut avoir son utilité.
Ensuite, les insertions et retraits demandent de parcourir toute la liste, sauf dans quelques cas assez rares, contrairement à ce qu'on a avec les listes doublement chainées. Par exemple, il est impossible de retirer un élément de la liste si on ne connaît que son adresse. Il est aussi impossible d'insérer un élément avant/après un élément repère si on ne connaît que l'adresse du dit repère. Les 2 inconvénients sur le parcours en sens inverse et la modification à partir d'un pointeur sont résolus en utilisant deux pointeurs : un sur l'élément et un autre sur le suivant ou le précédent de l'élément pointé.
Enfin, de telles listes marchent assez mal avec les algorithmes de ramasse-miettes (garbage collection). Avec l'usage d'un ramasse-miette, la libération de la mémoire inutilisée est automatique. Les algorithmes de libération de la mémoire inutilisée se basent cependant sur l'usage de pointeurs. Pour être précis, ils analysent les données accessibles via les pointeurs d'un programmes pour déterminer la mémoire utile de la mémoire libre. Si les pointeurs sont cachées, comme dans les listes à chaînage XOR, les algorithmes ne peuvent pas les reconnaître et donnent un résultat sous-optimal. La libération de la mémoire se fait moins bien, ce qui peut poser quelques problèmes. Ironiquement, cela peut augmenter la consommation mémoire alors que le but du chaînage XOR est justement de la réduire. Cependant les langages à ramasse-miette n'autorisent pas les pointeurs, car le langage les gère. Il n'est donc pas possible d'implémenter de telles listes dans ces langages. Par contre, le problème peut se poser si un ramasse-miette est utilisé dans un langage qui n'en utilise pas nativement. En effet, il existe des librairies qui permettent d'ajouter un ramasse-miette dans des programmes codés en C ou en C++, un bon exemple étant le Boehm garbage collector. L'usage d'une liste à chaînage XOR avec de telles librairies peut éventuellement poser quelques problèmes.
Ces inconvénients sont à comparer avec les avantages promis pour cette structure de données. L'avantage principal d'une liste à chaînage XOR est évidemment la consommation de mémoire équivalente à celle d'une liste chaînée simple. Le gain et surtout sensible sur les longues listes chaînées, sur les machines où les pointeurs sont longs, et où chaque élément a une petite taille. Plus les éléments de la liste sont gros, moins l'économie est importante, rapportée au poids total de la liste chaînée. De plus, il existe d'autres solutions pour économiser de la mémoire avec les listes chaînées, comme les listes chaînées déroulées (unrolled linked lists). Tout cela expliquer que le chaînage XOR est actuellement peu, voire pas du tout, utilisé sur les machines modernes. Il peut éventuellement être utile sur quelques machines embarquées très peu puissantes, et encore !