JVM Hardcore – Part 16 – Bytecode – Comparaisons et contrôle – 1/3
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 → ...
où 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 int
où false == 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
@Test
public void ifeq() {
final boolean b1 = TestIfcond.ifeq(true);
Assert.assertTrue(b1);
final boolean b2 = TestIfcond.ifeq(false);
Assert.assertFalse(b2);
}
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
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);
}
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
@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);
}
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
@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);
}
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
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);
}
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
Mais aussi en utilisant PJBA :
methodBuilder.iload_0()
.iload_1()
.if_icmpge("ok")
.iconst_0()
.ireturn()
.label("ok")
.iconst_1()
.ireturn();
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);
}
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
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);
}
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
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);
}
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
Ou à l’aide de PJBA :
methodBuilder.lload_0()
.lload_2()
.lcmp()
.ireturn();
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);
}
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
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);
}
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
etb
sont du même type (long
,float
oudouble
)- 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
oudcmpg
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
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);
}
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
@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);
}
What’s next ?
Après avoir passé en revue 17 instructions, dans l’article suivant nous en verrons 8 nouvelles.