Accueil Nos publications Blog JVM Hardcore – Part 16 – Bytecode – Comparaisons et contrôle – 1/3

JVM Hardcore – Part 16 – Bytecode – Comparaisons et contrôle – 1/3

academic_duke
La possibilité d’avoir des chemins alternatifs ou des boucles sont des éléments indispensables à tout langage de programmation et programme. Au cours de cet article nous étudierons une première série d’instructions permettant de faire des comparaisons.

Le code est disponible sur Github (tag et branche)

Tous les articles déjà publiés de la série portent le tag jvmhardcore.

Représentation de la pile (Rappel)

La JVM étant basée sur le modèle de la pile, il est essentiel de connaître quel est l’impact des instructions. Pour représenter l’état avant/après l’exécution d’une instruction, nous allons reprendre le format utiliser par la JVMS et qui est le suivant :

..., valeur1, valeur2 → ..., résultat, où les valeurs les plus à droite sont au sommet de la pile. valeur1 et valeur2 étant les deux valeurs utilisées pour le calcul et résultat le résultat.

Il est important de noter que dans cette représentation les long et les double sont considérés comme une seule valeur. Par conséquent, lorsque nécessaire nous présenterons les différents cas d’utilisation d’une instruction en utilisant plusieurs formes.

Comparer un nombre de type int à zéro

Les six instructions if<cond> permettent d’effectuer des comparaisons entre une valeur de type int et 0.

État de la pile avant → après exécution : ..., v → ...v représente la valeur comparée à 0, avec 0 toujours à droite dans la comparaison (v op 0, où op est un opérateur relationnel).

En plus d’une opérande, ces instructions prennent en argument un nombre signé de deux octets dont la valeur est comprise dans l’intervalle [-32768 ; 32767], représentant l’offset de l’instruction à exécuter si la comparaison est vraie. Cet offset est égal à l’adresse de l’instruction à exécuter, à laquelle nous soustrayons l’adresse de l’instruction de comparaison.

Pour clarifier la valeur de l’offset voyons ce que génère la classe HexDumper (seulement les instructions d’une méthode sont présentées ici) :

// ...
0082          0  iload_0
0083          1  iflt <5>
0086          4  iconst_1
0087          5  ireturn
0088          6  iconst_0
0089          7  ireturn
// ...

Pour rappel, la colonne de gauche est l’offset (en octets affiché sous forme hexadécimale) de l’élément. Dans le cas présent, l’élément est une instruction dont l’offset indique sa position dans la totalité d’un fichier .class. La colonne de droite représente l’instruction. La valeur encadrée par le symbole en diamant (<>) est l’offset de débranchement de l’instruction iflt si elle retourne vrai. La colonne du milieu a été ajoutée pour indiquer l’offset (en octets affiché sous forme décimale) d’une instruction dans une méthode.

L’instruction iflt <5> signifie que si la comparaison x < 0 (où la valeur de x est à l’index 0 des variables locales) est vraie alors nous débranchons vers l’instruction se trouvant 5 octets plus loin. L’instruction étant à l’offset 1, si nous ajoutons 5 nous devons exécuter l’instruction se trouvant à l’adresse 6 de la méthode si la comparaison retourne vraie. En d’autres termes, l’instruction iconst_0.

Le code ci-dessus peut être décompilé en Java de la manière suivante :

if (x < 0) {
  return 0;
} else {
  return 1; 
}

Voyons à présent l’ensemble des six instructions, qui reprennent les six opérateurs rationnels que nous avons en Java (<, <=, >, >=, ==, !=).

Hex Mnémonique Argument Description
0x99 ifeq offset Va à l’offset si x == 0, où x est de type int
0x9a ifne offset Va à l’offset si x != 0, où x est de type int
0x9b iflt offset Va à l’offset si x < 0, où x est de type int
0x9c ifge offset Va à l’offset si x >= 0, où x est de type int
0x9d ifgt offset Va à l’offset si x > 0, où x est de type int
0x9e ifle offset Va à l’offset si x <= 0, où x est de type int

Exemples

Pour utiliser simplement ces instructions dans un fichier .pjb, nous allons remplacer l’offset par un label. Ce label sera utilisé à la fois en argument de l’instruction mais aussi juste avant l’instruction à exécuter en cas de débranchement, en le faisant suivre par deux-points (‘:’) pour ne pas le confondre avec une instruction.

La grammaire EBNF d’un label est la suivante :

label = labelAsArg labelEnd
labelAsArg = {labelCharacter}
labelEnd = ':'
labelCharacter = ?[a-zA-Z]? | ?[0-9]? | '_'

Un label peut contenir des lettres ASCII (majuscules et minuscules), des chiffres et le caractère souligné (‘_’), par exemple label_1, et doit se terminer par le caractère deux-points (‘:’). Le symbole labelAsArg est la forme d’un label utilisable en tant qu’argument d’une instruction. Le concept de label est uniquement d’une aide pour écrire du code PJB, il n’est aucunement lié au bytecode et à la JVM. Les caractères composant un label peuvent être changés en modifiant le code de PJBA.

iload_0
ifeq ko     @ si a == 0 nous allons à l'instruction 
            @   suivant le label "ko:"
iconst_1    @ sinon nous retournons 1
ireturn
ko:
iconst_0
ireturn     @ nous retournons 0

Comparaisons

Ce bout de code peut être traduit en Java de la manière suivante :

if (a != 0) {
  return 1;
} else {
  return 0;
}

Pour que notre builder soit aussi simple à utiliser nous devons aussi introduire la notion de label dans PJBA.

builder.newMethod(Method.MODIFIER_PUBLIC 
                | Method.MODIFIER_STATIC, "test", "(I)I")
  .iload_0()
  .ifeq("ko")
  .iconst_1()
  .ireturn()
  .label("ko")  // Attention, ici le label ne doit
                // pas être suivi des deux-points
  .iconst_0()
  .ireturn()

Comme nous l’avons déjà vu, la JVM interprète le type boolean comme un intfalse == 0 et true == 1. Par conséquent, les instructions que nous avons vues sont utilisables avec des int et des boolean, mais aussi avec des byte, des short et des char.

@ return a == 0; 
@ ou
@ return a == true;
@ où a est à l'index 0 des variables locales
.method public static ifeq(Z)Z
  iload_0
  ifeq ko
  iconst_1
  ireturn
  ko:
  iconst_0
  ireturn
.methodend

Source

@Test
public void ifeq() {
  final boolean b1 = TestIfcond.ifeq(true);
  Assert.assertTrue(b1);

  final boolean b2 = TestIfcond.ifeq(false);
  Assert.assertFalse(b2);
}

Source

Voyons à présent comment convertir en bytecode des expressions utilisant les opérateurs logiques AND et OR (&& et ||), nous laisserons de côté l’opérateur NOT (!) pour l’instant. La JVM étant basée sur le modèle d’une pile, avoir des instructions permettant de représenter les opérateurs AND et OR est inutile. Le mécanisme de débranchement est suffisant pour simuler ces opérateurs.

Nous savons par définition que pour qu’une expression contenant un opérateur AND soit vraie, les termes de droite et de gauche doivent être vrais. Pour l’expression a == 1 && b == 1

Si a != 1 (ou a == 0) nous débranchons vers un label 'ko' sinon nous continuons
Si b != 1 (ou b == 0) nous débranchons vers un label 'ko' sinon nous continuons

En utilisant uniquement les instructions de comparaison nous avons donc :

ifeq ko  @ a != 0
ifeq ko  @ b != 0         

Ceci peut être traduit en PJB de la manière suivante :

@ a && b
.method public static and(ZZ)Z
  iload_0
  ifeq ko
  iload_1
  ifeq ko
  iconst_1
  ireturn
  ko:
  iconst_0
  ireturn
.methodend

Source

Mais il est aussi possible de changer la deuxième instruction en ifne, en déplaçant le label ko sous cette dernière et en rajoutant un label ok.

.method public static and(ZZ)Z
  iload_0
  ifeq ko
  iload_1
  ifne ok
  ko:
  iconst_0
  ireturn
  ok:
  iconst_1
  ireturn
.methodend

Ces deux cas peuvent être testés de la même manière :

@Test
public void ifeq_and() {
  final boolean b1 = TestIfcond.and(true, true);
  Assert.assertTrue(b1);

  final boolean b2 = TestIfcond.and(false, false);
  Assert.assertFalse(b2);

  final boolean b3 = TestIfcond.and(true, false);
  Assert.assertFalse(b3);

  final boolean b4 = TestIfcond.and(false, true);
  Assert.assertFalse(b4);
}

Source

Le principe est identique pour l’opérateur OR :

ifne ok
ifne ok

@ ou

ifne ok
ifeq ko

Le deuxième cas permet de garder l’ordre des instructions (if ... else ...) :

@ a || b
.method public static or(ZZ)Z
  iload_0
  ifne ok
  iload_1
  ifeq ko
  ok: 
  iconst_1
  ireturn
  ko:
  iconst_0
  ireturn
.methodend

Source

@Test
public void ifeq_or() {
  final boolean b1 = TestIfcond.or(true, true);
  Assert.assertTrue(b1);

  final boolean b2 = TestIfcond.or(false, false);
  Assert.assertFalse(b2);

  final boolean b3 = TestIfcond.or(true, false);
  Assert.assertTrue(b3);

  final boolean b4 = TestIfcond.or(false, true);
  Assert.assertTrue(b4);
}

Source

Pour finir prenons un cas un peu plus complexe en utilisant plusieurs opérateurs logiques.

@ a && (b || c) && (d || e)
.method public static and_or(ZZZZZ)Z
  iload_0   @ a
  ifeq ko
  iload_1   @ b
  ifne and1
  iload_2   @ c
  ifeq ko
  and1:
  iload_3   @ d
  ifne ok
  iload 4   @ e
  ifeq ko
  ok:
  iconst_1
  ireturn   @ true
  ko:
  iconst_0
  ireturn   @ false
.methodend

Source

@Test
public void ifeq_and_or() {
  final boolean b1 = TestIfcond.and_or(true, false, true, false, true);
  Assert.assertTrue(b1);

  final boolean b2 = TestIfcond.and_or(true, true, false, true, false);
  Assert.assertTrue(b2);

  final boolean b3 = TestIfcond.and_or(false, true, false, true, false);
  Assert.assertFalse(b3);

  final boolean b4 = TestIfcond.and_or(true, false, false, true, false);
  Assert.assertFalse(b4);

  final boolean b5 = TestIfcond.and_or(true, true, false, false, false);
  Assert.assertFalse(b5);
}

Source

Suite à ces quelques exemples nous nous rendons compte que convertir manuellement une expression logique en bytecode peut être une tâche compliquée. Nous verrons dans un article suivant comment automatiser cette conversion.

De plus, ce mécanisme explique pourquoi en Java nous avons uniquement trois opérateurs (NOT, AND, OR). Les quatre autres opérateurs (NAND, NOR, XOR et XNOR) utilisés en algèbre booléenne peuvent être représentés en combinant les opérateurs NOT, AND, OR :

a NAND b = !(a && b)
a NOR b = !(a || b)
a XOR b = (a && !b) || (!a && b) = a && !b || !a && b
a XNOR b = !(a && !b || !a && b)

Boucles

L’argument des instructions de comparaison (représentant un offset) pouvant être négatif, nous pouvons les utiliser pour simuler des boucles de type do/while.

En partant du code Java suivant :

public void static ifeq_neg() {
  int counter = -10;
  int sum = 0;

  do {
    counter++;
    sum++;
  } while (counter < 0);

  return sum;
}

Nous pouvons le transformer très simplement en PJB :

.method public static ifeq_neg()I
  bipush -10
  istore_0      @ counter 
  iconst_0
  istore_1      @ sum
  before:
  iinc 0 1
  iinc 1 1
  iload_0
  iflt before
  iload_1
  ireturn
.methodend

Source

Le test nous permet de constater que le code s’étendant des lignes 7 à 10 a été exécuté dix fois.

@Test
public void ifeq_neg() {
  final int i = TestIfcond.ifeq_neg();
  Assert.assertEquals(10, i);
}

Source

Comparer des nombres de type int

Les six instructions suivantes permettent de comparer deux nombres de type int. Si la comparaison retourne vraie, nous débranchons à l’offset indiqué en argument de l’instruction.

État de la pile avant → après exécution : ..., v1, v2 → ..., où v1 et v2 sont de type int.

Telles les instructions if<cond>, les instructions if_icmp<cond> prennent en argument un nombre signé de deux octets dont la valeur est comprise dans l’intervalle [-32768 ; 32767], représentant l’offset de l’instruction à exécuter si la comparaison est vraie.

Hex Mnémonique Argument Description
0x9f if_icmpeq offset Va à l’offset si v1 == v2
0xa0 if_icmpne offset Va à l’offset si v1 != v2
0xa1 if_icmplt offset Va à l’offset si v1 < v2
0xa2 if_icmpge offset Va à l’offset si v1 >= v2
0xa3 if_icmpgt offset Va à l’offset si v1 > v2
0xa4 if_icmple offset Va à l’offset si v1 <= v2

L’utilisation de ces instructions dans un fichier .pjb ou en Java est identique à celles que nous avons vu précédemment.

En partant du code Java suivant :

public static boolean if_icmpge(int i1, int i2) {
    return i1 >= i2;
}

Nous pouvons simplement le transformer en PJB :

.method public static if_icmpge(II)Z
  iload_0
  iload_1
  if_icmplt ko
  iconst_1
  ireturn
  ko:
  iconst_0
  ireturn
.methodend

Source

Mais aussi en utilisant PJBA :

methodBuilder.iload_0()
             .iload_1()
             .if_icmpge("ok")
             .iconst_0()
             .ireturn()
             .label("ok")
             .iconst_1()
             .ireturn();

Source

Le test unitaire associé vérifie les trois cas possibles :

@Test
public void if_icmpge() {
  final boolean b1 = TestIfICompCond.if_icmpge(5, 5);
  Assert.assertTrue(b1);

  final boolean b2 = TestIfICompCond.if_icmpge(10, 5);
  Assert.assertTrue(b2);

  final boolean b3 = TestIfICompCond.if_icmpge(5, 10);
  Assert.assertFalse(b3);
}

Source

Le fonctionnement est aussi identique si nous souhaitons utiliser des opérateurs logiques, qui comme nous l’avons vu sont traduits par divers mécanismes de débranchement.

@ (a >= b || c == d) && a != d
.method public static or_and(IIII)Z
  iload_0
  iload_1
  if_icmpge and
  iload_2
  iload_3
  if_icmpne ko
  and:
  iload_0
  iload_3
  if_icmpeq ko
  iconst_1
  ireturn
  ko:
  iconst_0
  ireturn
.methodend

Source

Le test, bien que non exhaustif, permet de vérifier que le code PJB est correct :

@Test
public void if_cmp_or_and() {
  // (a >= b || c == d) && a != d
  // a >= b && a != d
  final boolean b1 = TestIfICompCond.or_and(5, 4, 9, 10);
  Assert.assertTrue(b1);

  // c == d && a != d
  final boolean b2 = TestIfICompCond.or_and(1, 4, 10, 10);
  Assert.assertTrue(b2);

  // a >= b mais a == d
  final boolean b3 = TestIfICompCond.or_and(10, 4, 10, 10);
  Assert.assertFalse(b3);

  // a < b && c != d
  final boolean b4 = TestIfICompCond.or_and(1, 4, 9, 10);
  Assert.assertFalse(b4);
}

Source

Une fois encore, ces instructions peuvent être utilisées pour simuler des boucles do/while.

public int if_icmplt_neg() {
  int counter = 0;

  do {
    counter++;
  } while (counter < 10);

  return counter;
}

La transformation en PJB n’étant en rien compliquée :

.method public static if_icmplt_neg()I
  iconst_0
  istore_0
  before:
  iinc 0 1
  iload_0
  bipush 10
  if_icmplt before
  iload_0
  ireturn
.methodend

Source

Le test étant identique à celui que nous avons vu précédemment :

@Test
public void if_icmplt_neg() {
  final int i = TestIfICompCond.if_icmplt_neg();
  Assert.assertEquals(10, i);
}

Source

Comparer des nombres de type long, float et double

Contrairement à toutes les instructions que nous venons de voir, les cinq instructions suivantes permettent de comparer deux valeurs de même type en empilant le résultat de la comparaison au sommet de la pile du cadre (frame) en cours d’exécution.

État de la pile avant → après exécution : ..., v1, v2 → ..., v3, où v1 et v2 sont de même type (long, float ou double en fonction de l’instruction et, v3 est de type int.

  • Si v1 est supérieure à v2, la valeur 1 est empilée ;
  • Si v1 est égale à v2, la valeur 0 est empilée ;
  • Si v1 est inférieure à v2, la valeur -1 est empilée.
Hex Mnémonique Argument Description
0x94 lcmp Compare deux valeurs de type long
0x95 fcmpl Compare deux valeurs de type float
0x96 fcmpg Compare deux valeurs de type float
0x97 dcmpl Compare deux valeurs de type double
0x98 dcmpg Compare deux valeurs de type double

Pour commencer voyons en action le fonctionnement basique de ces instructions en comparant deux nombres de type long.

@ a compareTo b
.method public static lcmp(JJ)I
  lload_0
  lload_2
  lcmp
  ireturn
.methodend

Source

Ou à l’aide de PJBA :

methodBuilder.lload_0()
             .lload_2()
             .lcmp()
             .ireturn();

Source

Le test nous permet de comprendre dans quelles situations la comparaison empile -1, 0 ou 1.

@Test
public void lcmp() {
  final int i1 = TestTypeCmp.lcmp(10, 1);
  Assert.assertEquals(1, i1);

  final int i2 = TestTypeCmp.lcmp(10, 10);
  Assert.assertEquals(0, i2);

  final int i3 = TestTypeCmp.lcmp(1, 10);
  Assert.assertEquals(-1, i3);
}

Source

Si nous souhaitons convertir la code Java suivant :

public static boolean compareLong(long l1, long l2) {
  return a < b;
}

Il est nécessaire d’ajouter une instruction comparant le résultat de l’instruction précédente à zéro. En d’autres termes, et dans le cas présent, l’instruction lcmp doit être suivie de l’instruction ifle :

@ a > b
.method public static lcmp(JJ)Z
  lload_0
  lload_2
  lcmp
  ifle ko
  iconst_1
  ireturn
  ko:
  iconst_0
  ireturn
.methodend

Source

Le test unitaire nous permet de valider le code PJB.

@Test
public void lcmp_ifle() {
  final boolean b1 = TestTypeCmp.lcmp_ifle(10, 5);
  Assert.assertTrue(b1);

  final boolean b2 = TestTypeCmp.lcmp_ifle(5, 5);
  Assert.assertFalse(b2);

  final boolean b3 = TestTypeCmp.lcmp_ifle(5, 10);
  Assert.assertFalse(b3);
}

Source

Les six types de comparaison impliquant des valeurs de type long, float et double peuvent être traduit en PJB de la manière suivante :

Comparaison Instructions suivi du label
ko ok
a < b ifge iflt
a <= b ifgt ifle
a == b ifne ifeq
a != b ifeq ifne
a > b ifle ifgt
a >= b iflt ifge

Notes :

  • a et b sont du même type (long, float ou double)
  • chacun des cas présentés dans le tableau ci-dessus est précédé par l’une des instructions suivantes lcmp, fcmpl, fcmpg, dcmpl ou dcmpg

Les instructions fcmpl et fcmpg ou dcmpl et dcmpg, permettent de comparer pour les deux premières des valeurs de type float et pour les deux dernières des valeurs de type double. La seule différence entre les instructions dont les mnémoniques se terminent par un l et un g est leur gestion des valeurs NaN (Not a Number). Un NaN ne pouvant pas être ordonné, nous indiquons grâce à ces instructions quel doit être le résultat de la comparaison si un NaN est rencontré. Pour rappel, NaN est un nombre particulier qui est retourne lorsque le résultat d’une opération flottante est indeterminé (0 divisé par 0 retourne NaN). Ce nombre n’est égale à rien, pas même à lui-même et doit être testé par la méthode Double.isNaN(); (dans le cas d’un double).

  • en utilisant les mnémoniques se terminant par l, si une des valeurs (ou les deux) de la comparaison est un NaN, la valeur empilée est -1 ;
  • en utilisant les mnémoniques se terminant pas g, si une des valeurs (ou les deux) de la comparaison est un NaN, la valeur empilée est 1.

Voyons ces deux cas, en commençant par les mnémoniques se terminant par un l.

.method public static dcmpl(DD)I
  dload_0
  dload_2
  dcmpl
  ireturn
.methodend

Source

Dans cet exemple nous avons utilisé l’instruction dcmpl, mais le comportement est identique avec l’instruction fcmpl.

@Test
public void dcmpl() {
  final int i1 = TestTypeCmp.dcmpl(5.5, 10.1);
  Assert.assertEquals(-1, i1);

  final int i2 = TestTypeCmp.dcmpl(5.5, 5.5);
  Assert.assertEquals(0, i2);

  final int i3 = TestTypeCmp.dcmpl(10.1, 5.5);
  Assert.assertEquals(1, i3);

  // retourne -1 si au moins une des valeurs est NaN
  final int i4 = TestTypeCmp.dcmpl(Double.NaN, 5.5);
  Assert.assertEquals(-1, i4);

  final int i5 = TestTypeCmp.dcmpl(5.5, Double.NaN);
  Assert.assertEquals(-1, i5);
}

Source

Reprenons l’exemple précédent en changeant l’instruction dcmpl en dcmpg.

.method public static dcmpg(DD)I
  dload_0
  dload_2
  dcmpg
  ireturn
.methodend

Source

@Test
public void dcmpg() {
  final int i1 = TestTypeCmp.dcmpg(5.5, 10.1);
  Assert.assertEquals(-1, i1);

  final int i2 = TestTypeCmp.dcmpg(5.5, 5.5);
  Assert.assertEquals(0, i2);

  final int i3 = TestTypeCmp.dcmpg(10.1, 5.5);
  Assert.assertEquals(1, i3);

  // retourne 1 si au moins une des valeurs est NaN
  final int i4 = TestTypeCmp.dcmpg(Double.NaN, 5.5);
  Assert.assertEquals(1, i4);

  final int i5 = AllInstructionsWithDummiesInCP.dcmpg(5.5, Double.NaN);
  Assert.assertEquals(1, i5);
}

Source

What’s next ?

Après avoir passé en revue 17 instructions, dans l’article suivant nous en verrons 8 nouvelles.