XIII. Assembleur de bytecode▲
Voilà quelques chapitres que notre périple dans les tréfonds de la JVM a commencé. Nous avons abordé de nombreux sujets, plus ou moins liés à la JVM. Dans cet article nous allons recréer la bibliothèque PJBA - que nous utilisions jusqu'à présent - nous permettant de générer des fichiers .class à partir d'un fichier texte. Dans l'article suivant, il sera question d'étudier une solution permettant de traverser un arbre syntaxique simplement, et dans celui d'après nous enrichirons la bibliothèque PJBA. Nous pourrons ensuite reprendre notre étude - que nous avions mise de côté - des instructions et des éléments constituant un fichier .class.
Dans la suite de cet article, et sauf mention contraire, le terme variables locales correspondra au tableau des variables locales d'un cadre (frame).
Le code est disponible sur Github (tag et branche).
XIII-A. Tableaux▲
Dans l'article précédent, nous avons créé un graphe d'objets, tel qu'il est décrit dans la JVMS. Tous les éléments devant être présents dans un fichier .class ont été indiqués. Néanmoins, l'utilisation de tableaux est loin d'être souple ; en les gardant, nous risquons d'avoir plus de problèmes que de solutions. Par conséquent, nous allons supprimer tous les attributs count et length, et remplacer les tableaux par des java.util.LinkedList, ou plus précisément par une classe héritant de LinkedList. Vous en comprendrez la raison avant la fin de l'article.
public
class
PjbaLinkedList<
E>
extends
LinkedList<
E>
{
}
À noter que nous ne toucherons pas au champ int
[] interfaces de la classe ClassFile, et le champ byte
[] code de la classe Code sera modifié un peu plus loin.
XIII-B. Attribute▲
Dans l'article précédent, nous avons vu que le type Attribute était présent dans la classe ClassFile, Method et Code. Or, tous les attributs ne peuvent pas être utilisés partout. De fait, nous allons créer trois interfaces qui auront pour seul objectif de contraindre le contenu des listes.
public
interface
ClassAttribute {
}
public
interface
MethodAttribute {
}
public
interface
CodeAttribute {
}
Le seul attribut que nous avons vu pour l'instant est Code, qui ne peut être présent que dans une méthode. Modifions la classe en conséquence :
public
class
Code extends
Attribute implements
MethodAttribute {
}
XIII-C. Enrichissement des classes▲
Actuellement, toutes nos classes ont des champs privés et utilisent toutes le constructeur par défaut (un constructeur sans paramètre). À présent, il nous faut pouvoir fixer la valeur de ces champs simplement, sans pour autant transformer nos classes en builder. Cette tâche fera partie du prochain article. Nous allons donc passer en revue tous les champs un par un. Cela peut paraître rébarbatif et répétitif, mais c'est une tâche indispensable pour bien comprendre comment former un fichier .class. Pour une explication détaillée : Format d'un fichier classClassFile.
XIII-C-1. ClassFile▲
Commençons par tous les champs ayant des valeurs constantes.
final
private
int
magicNumber =
0xcafebabe
;
final
private
int
version =
0x30
; // 48.0
final
private
int
accessFlags =
0x0001
|
0x0020
; // public super
Note : Nous lèverons les limitations de la version et des modificateurs d'une classe très prochainement.
XIII-C-1-a. contantPool▲
Pour ajouter des constantes dans le pool de constantes, nous allons ajouter une méthode par constante qui prendra les mêmes paramètres que le constructeur de la constante et qui retournera l'index de la constante dans le pool de constantes.
Par exemple pour la constante ConstantUTF8 :
public
int
getCurrentCPIndex
(
) {
return
this
.constantPool.size
(
) -
1
;
}
public
int
addConstantUTF8
(
final
String value) {
final
Constant.UTF8 constant =
new
Constant.UTF8
(
value);
this
.constantPool.add
(
constant);
return
this
.getCurrentCPIndex
(
);
}
Néanmoins, arrêtons-nous quelques secondes. Si l'on écrit et exécute le test unitaire ci-dessous, nous constatons qu'il est correct :
@Test
public
void
stringEquality
(
) {
final
String s1 =
"Bonjour"
;
final
String s2 =
"Bonjour"
;
Assert.assertEquals
(
s1, s2);
Assert.assertTrue
(
s1 ==
s2);
}
Les deux assignations peuvent être traduites en PJB de la manière suivante :
ldc "Bonjour"
astore_0
ldc "Bonjour"
astore_1
Dans un fichier .class, les instructions ldc pointent vers l'index d'une constante de type ConstantString, elle-même pointant vers une constante de type ConstantUTF8. Dans le cas présent, les deux instructions pointeront vers la même constante.
Mais new
String
(
"Bonjour"
) !=
new
String
(
"Bonjour"
). Et ceci est aussi valable pour les types numériques :
5
==
5
et
Integer i1 =
5
;
Integer i2 =
5
;
i1 ==
i2;
mais
Integer i1 =
5_000
;
Integer i2 =
5_000
;
i1 !=
i2;
et
new
Integer
(
5
) !=
new
Integer
(
5
)
Nous le savions, Java aime faire les choses simplement et sans surprise…
Pour les types primitifs, nous comparons des valeurs, par conséquent elles sont égales. Jusque-là, rien de très surprenant. En revanche pour les objets les choses sont bien différentes.
Commençons par les objets de type String. Toutes les littérales de type String - dont la chaîne de caractères est identique - ont toujours la même référence quel que soit l'endroit de leur déclaration.
Les littérales numériques sont quant à elles autoboxées. C'est-à-dire que l'assignation Integer i =
5
est compilée en Integer i =
Integer.valueOf
(
5
). Or, comme indiqué dans la javadoc, la méthode valueOf() a un cache initialisé au chargement de la classe, contenant des objets de type Integer pour toutes les valeurs comprises entre -128 et 127 (oublions la fin de la phrase « et peut mettre en cache d'autres valeurs hors de cet intervalle »). Ceci explique pourquoi dans le premier cas (pour la valeur 5) deux primitifs autoboxés en Integer sont égaux et dans le second (pour la valeur 5000) ne le sont pas.
Pour le type Long, bien que le système de mise en cache ne soit pas requis, il est bien présent.
En revanche, il n'y a aucun mécanisme similaire pour les types Float et Double.
Néanmoins, il est déconseillé de se reposer sur l'autoboxing/unboxing pour effectuer une comparaison, il est préférable de faire des comparaisons uniquement sur des types primitifs.
Note : les tests présentés ci-dessus peuvent être retrouvés dans la classe suivante : EqualityTest
Nous en venons à la conclusion que dans le pool de constantes d'un fichier .class, chaque constante peut être unique, sans que cela ne pose de problèmes. Il nous faut donc adapter les méthodes addConstantXYZ() en conséquence.
Prenons quelques exemples :
private
int
addConstant
(
final
ConstantPoolEntry constant) {
final
int
index =
this
.constantPool.indexOf
(
constant);
if
(
index ==
-
1
) {
this
.constantPool.add
(
constant);
return
this
.getCurrentCPIndex
(
);
}
else
{
return
index;
}
}
public
int
addConstantUTF8
(
final
String value) {
final
Constant.UTF8 constant =
new
Constant.UTF8
(
value);
return
this
.addConstant
(
constant);
}
public
int
addConstantString
(
final
int
utf8Index) {
final
Constant.String constant =
new
Constant.String
(
utf8Index);
return
this
.addConstant
(
constant);
}
public
int
addConstantLong
(
final
long
value) {
final
Constant.Long constant =
new
Constant.Long
(
value);
final
int
index =
this
.addConstant
(
constant);
this
.constantPool.add
(
null
);
return
index;
}
public
int
addConstantDouble
(
final
double
value) {
final
Constant.Double constant =
new
Constant.Double
(
value);
final
int
index =
this
.addConstant
(
constant);
this
.constantPool.add
(
null
);
return
index;
}
Comme mentionné dans l'article précédent, les constantes ConstantLong et ConstantDouble prennent deux index dans le pool de constantes (n et n+1). Dans le fichier .class généré, l'index n+1 n'est en réalité pas utilisé. Imaginons que nous ajoutons une constante ConstantInt à l'index 11, puis une constante ConstantLong à l'index 12, la prochaine constante sera à l'index 14.
De plus, nous devons surcharger dans toutes les classes de constantes, les méthode hashCode() et equals().
XIII-C-1-b. thisClass et superClass▲
Les champs thisClass et superClass ont pour valeur l'index d'une classe de type ConstantClass dans le pool de constantes. Dans cette version de PJBA, toute classe aura pour parent java.lang.Object. Nous allons donc pouvoir valoriser le champ dans le constructeur. Quant au champ thisClass, plusieurs solutions s'offrent à nous. Ajouter un paramètre de type String au constructeur prenant le nom complètement qualifié de la classe ou ajouter un setter, ce qui nécessitera en amont de créer une constante ConstantUTF8, puis une constante ConstantClass et de passer au setter l'index de cette dernière. Bien évidemment, nous pouvons mettre en place les deux solutions, néanmoins la première semble convenir à la majorité des situations.
public
ClassFile
(
final
String fullyQualifiedName) {
//...
// This
final
int
classNameIndex =
this
.addConstantUTF8
(
fullyQualifiedName);
this
.thisClass =
this
.addConstantClass
(
classNameIndex);
// Parent - Codé en dur pour l'instant
final
String superName =
"java/lang/Object"
;
final
int
superNameIndex =
this
.addConstantUTF8
(
superName);
this
.superClass =
this
.addConstantClass
(
superNameIndex);
//...
}
Avant de continuer, ajoutons deux champs qui nous seront utiles lors de la génération de nos fichiers .class, le répertoire dans lequel devra être généré le fichier (le package en terme Java) et le nom de la classe. Nous avons ces deux informations dans le nom complètement qualifié d'une classe (le paramètre du constructeur). Il nous faut donc le découper en deux parties, sachant qu'une classe n'est pas obligatoirement dans un package.
private
String className;
private
String directories;
private
void
parseName
(
final
String fullyQualifiedName) {
int
index =
fullyQualifiedName.lastIndexOf
(
Ascii.SLASH);
if
(
index >=
0
) {
this
.directories =
fullyQualifiedName.substring
(
0
, ++
index);
this
.className =
fullyQualifiedName.substring
(
index);
}
else
{
this
.className =
fullyQualifiedName;
}
}
La méthode parseName() sera appelée au début du constructeur.
À noter que nous n'effectuons absolument aucune vérification quant à la validité des données. De fait, la génération se fera sans aucune erreur, en revanche si des données sont incorrectes, quelle qu'en soit la raison, les erreurs seront remontées par la JVM.
XIII-C-1-c. Tableau et liste▲
Le tableau et les listes sont aussi initialisés dans le constructeur :
public
ClassFile
(
final
String fullyQualifiedName) {
//...
this
.interfaces =
new
int
[0
];
this
.fields =
new
PjbaLinkedList<>(
);
this
.methods =
new
PjbaLinkedList<>(
);
this
.attributes =
new
PjbaLinkedList<>(
);
//...
}
Pour l'instant, nous nous intéresserons uniquement à la liste d'objets de type Method, qui seront ajoutés très simplement à l'aide d'une méthode addMethod() :
public
void
addMethod
(
final
Method method) {
this
.methods.add
(
method);
}
XIII-C-2. Method▲
Tout comme la classe ClassFile, la classe Method définit les modificateurs d'une méthode qui sont, pour l'instant, définis sous la forme d'une constante :
final
private
int
accessFlags =
0x0001
|
0x0008
; // public static
Les champs nameIndex et descriptorIndex ont pour valeur l'index de deux constantes ConstantUTF8 :
public
void
setNameIndex
(
int
utf8NameIndex) {
this
.nameIndex =
utf8NameIndex;
}
public
void
setDescriptorIndex
(
int
utf8DescriptorIndex) {
this
.descriptorIndex =
utf8DescriptorIndex;
}
Terminons par la liste attributes d'objets de type MethodAttribute :
public
Method
(
) {
super
(
);
this
.attributes =
new
PjbaLinkedList<>(
);
}
public
void
addAttibute
(
final
MethodAttribute attribute) {
this
.attributes.add
(
attribute);
}
XIII-C-3. Code▲
Comme mentionné dans la partie précédente (Format d'un fichier classClassFile), les instructions sont définies dans un tableau de byte de la classe Code. Or, définir des instructions ayant des arguments en utilisant des bytes n'est pas simple et surtout, ce n'est pas une conception orientée objets. Nous allons donc créer une classe Instruction :
public
class
Instruction {
final
private
int
opcode;
public
Instruction
(
final
int
opcode) {
super
(
);
this
.opcode =
opcode;
}
}
puis remplacer le tableau de byte de la classe Code par une liste d'Instruction.
Pour pouvoir tester notre assembleur, définissons quelques instructions :
public
class
Instructions {
final
public
static
int
ILOAD_0_OPCODE =
0x1a
;
final
public
static
int
ILOAD_1_OPCODE =
0x1b
;
final
public
static
int
IADD_OPCODE =
0x60
;
final
public
static
int
IRETURN_OPCODE =
0xac
;
}
Comme nous l'avons vu dans l'article précédent, la classe Code a trois champs. Nous allons à présent détailler comment leur valeur est fixée.
private
int
maxStack;
private
int
maxLocals;
private
int
codeLength;
Chaque instruction a une taille, 1 octet auquel s'ajoute la taille de ses arguments si elle en a. De plus, nous pouvons déduire l'impact de l'exécution d'une instruction sur les variables locales et la pile d'opérandes.
Par exemple :
Instruction |
Variables locales |
Pile d'opérandes |
Description |
---|---|---|---|
iload 6 |
7 |
+1 |
Prend la valeur (de catégorie 1) à l'index 6 des variables locales et l'empile (+1) |
lload_2 |
3 |
+2 |
Prend la valeur (de catégorie 2) à l'index 2 des variables locales et l'empile (+2) |
fstore_2 |
3 |
-1 |
Dépile une valeur de catégorie 1 (-1) et l'ajoute dans les variables locales à l'index 2 |
dstore 3 |
5 |
-2 |
Dépile une valeur de catégorie 2 (-2) et l'ajoute dans les variables locales à l'index 3 |
ldc |
0 |
+1 |
Empile une référence (catégorie 1, +1) |
iadd |
0 |
-1 |
Dépile deux valeurs de catégorie 1 (-2), les additionne et empile le résultat (+1) |
ladd |
0 |
-2 |
Dépile deux valeurs de catégorie 2 (-4), les additionne et empile le résultat (+2) |
La colonne Variables locales correspond à la taille minimum que doivent avoir les variables suite à l'exécution de l'instruction. Par exemple dans l'instruction dstore 3, le 3 indique que le tableau doit avoir une taille au moins égale à 4 et le ‘d‘ que l'élément prend aussi la case à l'index suivant. D'où 4 + 1 = 5. La colonne Pile d'opérandes correspond à la taille des valeurs empilées et dépilées suite à l'exécution de l'instruction.
Les instructions load et store sont un peu particulières. Prenons un cas simple :
public
int
locals
(
int
i1, int
i2) {
int
t1 =
i1 +
2
;
int
t2 =
i2 +
3
;
int
t3 =
t1 *
t2;
int
t4 =
t2 /
t1;
return
t4 -
t3;
}
Le code ci-dessus peut être écrit en bytecode, sans aucune transformation, de la manière suivante :
// int t1 = i1 + 2;
iload_1 // i1
iconst_2 // 2
iadd // i1 + 2
istore_0 // t1
// int t2 = i2 + 3;
iload_2 // i2
iconst_3 // 3
iadd // i2 + 3
istore_1 // t2
// int t3 = t1 * t2;
iload_0 // t1
iload_1 // t2
imul // t1 * t2
istore_2 // t3
// int t4 = t2 / t1;
iload_1 // t1
iload_0 // t2
idiv // t1 / t2
istore_3 // t4
// return t4 - t3;
iload_3 // t4
iload_4 // t3
isub // t4 - t3
ireturn // return
Nous avons donc sept variables :
- this puisque nous sommes dans le cas d'une méthode d'instance ;
- les deux paramètres de la méthode ;
- et quatre variables temporaires
et nous utilisons seulement les quatre premiers index des variables locales. Néanmoins, une autre solution - bien plus simple - qui est utilisée par javac est de considérer qu'une variable a une place fixe dans les variables locales et de laisser le compilateur JIT effectuer les optimisations.
Ceci se traduit par :
- la modification de la classe Instruction et de la gestion des instructions ;
- l'ajout de méthodes dans la classe Code.
Voyons le détail :
public
class
Instruction {
final
private
int
opcode;
final
private
int
stack;
final
private
int
locals;
final
private
int
length;
public
Instruction
(
int
opcode, int
stack, int
locals, int
length) {
this
.opcode =
opcode;
this
.stack =
stack;
this
.locals =
locals;
this
.length =
length;
}
}
public
class
NoArgInstruction extends
Instruction {
public
NoArgInstruction
(
int
opcode, int
stack, int
locals) {
super
(
opcode, stack, locals, 1
);
}
}
public
class
Instructions {
final
public
static
int
ILOAD_0_OPCODE =
0x1a
;
final
public
static
int
ILOAD_1_OPCODE =
0x1b
;
final
public
static
int
IADD_OPCODE =
0x60
;
final
public
static
int
IRETURN_OPCODE =
0xac
;
final
public
static
Instruction ILOAD_0 =
new
NoArgInstruction
(
ILOAD_0_OPCODE, 1
, 1
);
final
public
static
Instruction ILOAD_1 =
new
NoArgInstruction
(
ILOAD_1_OPCODE, 1
, 2
);
final
public
static
Instruction IADD =
new
NoArgInstruction
(
IADD_OPCODE, -
1
, 0
);
final
public
static
Instruction IRETURN =
new
NoArgInstruction
(
IRETURN_OPCODE, 0
, 0
);
public
static
Instruction iload_0
(
) {
return
ILOAD_0;
}
public
static
Instruction iload_1
(
) {
return
ILOAD_1;
}
public
static
Instruction iadd
(
) {
return
IADD;
}
public
static
Instruction ireturn
(
) {
return
IRETURN;
}
}
La classe Instructions doit être considérée comme une méthode utilitaire, nous permettant de rajouter simplement des instructions. De plus, le fait d'utiliser des méthodes en plus des constantes a pour but d'homogénéiser l'instanciation d'une instruction lorsque nous utiliserons des instructions ayant des arguments. Les ILOAD_0, IADD, etc. étant publiques, rien ne nous empêche de les utiliser.
La base de la classe Code est la suivante :
public
class
Code extends
Attribute implements
MethodAttribute {
public
final
static
String ATTRIBUTE_NAME =
"Code"
;
private
int
maxStack;
private
int
maxLocals;
private
int
codeLength;
private
PjbaLinkedList<
Instruction>
instructions;
private
PjbaLinkedList<
Exception>
exceptions;
private
PjbaLinkedList<
CodeAttribute>
attributes;
public
Code
(
final
int
attributeNameIndex) {
super
(
attributeNameIndex);
this
.instructions =
new
PjbaLinkedList<>(
);
this
.exceptions =
new
PjbaLinkedList<>(
);
this
.attributes =
new
PjbaLinkedList<>(
);
}
}
À chaque fois que l'on ajoute une instruction, nous mettons à jour les champs maxStack, maxLocals et length et nous ajoutons l'instruction à la liste d'instructions.
public
void
addInstruction
(
final
Instruction instruction) {
this
.addStack
(
instruction.getStack
(
));
this
.addLocals
(
instruction.getLocals
(
));
this
.addLength
(
instruction.getLength
(
));
this
.instructions.add
(
instruction);
}
Le champ length est extrêmement simple à valoriser puisque nous prenons la taille de chaque instruction et nous les additionnons. En revanche, nous verrons que dans certains cas, il est difficile - mais pas impossible - de connaître la taille d'une instruction.
private
void
addLength
(
int
length) {
this
.codeLength +=
length;
}
Pour calculer la taille maximum de la pile d'opérandes, nous avons rajouté un champ (currentStack) nous permettant de garder la taille courante de la pile suite à l'ajout d'instructions. Nous pouvons très bien avoir une suite d'instructions qui vont augmenter la taille de la pile, suivie par des instructions la faisant diminuer. De fait, nous modifions la variable maxStack uniquement si la variable currentStack est supérieure à la valeur actuelle de maxStack.
private
int
currentStack;
private
void
addStack
(
int
stack) {
this
.currentStack +=
stack;
if
(
this
.maxStack <
this
.currentStack) {
this
.maxStack =
this
.currentStack;
}
}
Comme nous l'avons vu, pour fixer la valeur du champ maxLocals, nous nous intéressons uniquement à la valeur la plus élevée, qui correspond soit à l'index + 1 puisque le tableau est indexé à partir de 0 pour les types de catégorie 1, soit à l'index + 2 pour les types de catégorie 2.
private
void
addLocals
(
int
locals) {
if
(
this
.maxLocals <
locals) {
this
.maxLocals =
locals;
}
}
Pour terminer, nous devons ajouter une méthode nous permettant d'indiquer le nombre de cases que prennent les paramètres dans le tableau des variables locales. Hormis les types long et double prenant deux cases, tous les autres en prennent une. La méthode setParameterCount() est indispensable puisqu'une méthode peut avoir plusieurs paramètres, mais aucune variable locale (au sens Java).
public
void
setParameterCount
(
int
parameterCount) {
this
.addLocals
(
parameterCount);
}
XIII-C-4. Créer une classe▲
Nous avons ajouté des méthodes pour valoriser tous les champs de nos classes, voyons à présent comment les utiliser, en reprenant notre exemple habituel d'une méthode statique additionnant deux entiers et retournant un entier.
Puisque nous connaissons maintenant parfaitement comment est constitué un fichier .class, le code suivant est très simple à comprendre.
// ConstantPoolEntries
final
String className =
"org/isk/jvmhardcore/pjba/MyFirstClass"
;
final
String methodName =
"add"
;
final
String methodDescriptor =
"(II)I"
;
// ClassFile
final
ClassFile classFile =
new
ClassFile
(
className);
// Method
final
int
methodIndex =
classFile.addConstantUTF8
(
methodName);
final
int
descriptorIndex =
classFile.addConstantUTF8
(
methodDescriptor);
final
Method method =
new
Method
(
);
method.setNameIndex
(
methodIndex);
method.setDescriptorIndex
(
descriptorIndex);
classFile.addMethod
(
method);
// Code
final
int
codeAttributeIndex =
classFile.addConstantUTF8
(
Code.ATTRIBUTE_NAME);
final
Code code =
new
Code
(
codeAttributeIndex);
final
int
parameterCount =
method.countParameters
(
methodDescriptor);
code.setParameterCount
(
parameterCount);
// Instructions
code.addInstruction
(
Instructions.iload_0
(
));
code.addInstruction
(
Instructions.iload_1
(
));
code.addInstruction
(
Instructions.iadd
(
));
code.addInstruction
(
Instructions.ireturn
(
));
method.addAttibute
(
code);
Pour tester ce code, nous allons utiliser la même technique que pour le projet bytecode, en utilisant un test unitaire pour générer nos fichiers .class.
La Classe à assembler, l'Assembleur et le test unitaire de la classe.
XIII-C-4-a. Compter le nombre de paramètres d'un descripteur▲
Dans le code ci-dessus, une méthode demande une explication, puisque nous ne l'avions pas vue jusqu'à présent. La méthode countParameters() compte le nombre de paramètres d'une méthode en analysant son descripteur. Pour un être humain, cette méthode n'est pas forcément utile, puisque nous pouvons indiquer directement le nombre de paramètres de la méthode que nous sommes en train de créer. Néanmoins, il n'en va pas de même pour l'analyseur syntaxique que nous créerons prochainement.
Pour rappel, un descripteur peut avoir la forme suivante :
(
[[[Ljava.lang.Object;[[LObject;)V
ce qui donnerait en Java :
void
nomDeLaMethode
(
java.lang.Object[][][], Object[][])
En repartant d'une situation plus simple, les types primitifs sont assez simples à analyser. Les types long et double prennent deux cases dans les variables locales, les autres une seule et void zéro.
Pour les classes, nous savons qu'elles commencent par la lettre « L » et se terminent par un point-virgule « ; ».
En revanche, les tableaux sont représentés par un crochet ouvrant « [ » suivi du type du tableau, et pour les tableaux à plusieurs dimensions les crochets se suivent mais comptent toujours pour un seul paramètre. Le cas extrême est celui illustré ci-dessus, un tableau à plusieurs dimensions d'objets.
La tâche étant légèrement complexe, le code de la méthode l'est aussi. Sachant que pour l'occasion, l'optimisation du code a été mise en avant.
L'idée est de parcourir la chaîne en analysant chaque caractère, d'incrémenter le nombre de paramètres si le caractère est une lettre représentant un type ou s'il s'agit du premier crochet rencontré et d'ignorer tous les autres caractères.
En ignorant le cas des tableaux, le code de la méthode est le suivant (Le détail de chaque étape est en commentaire) :
private
static
final
String ENCODING =
"UTF-8"
;
public
int
countParameters
(
final
String methodDescriptor) {
try
{
// Nous transformons la chaîne de caractères en tableau de bytes
final
byte
[] descriptor =
methodDescriptor.getBytes
(
ENCODING);
// Le premier caractère est une parenthèse ouvrante
int
index =
1
;
// Représente le nombre de paramètres du descripteur
int
locals =
0
;
// Permet de savoir si le compte a changé entre les différentes itérations
int
previousLocals =
locals;
for
(
;;) {
byte
character =
descriptor[index++
];
switch
(
character) {
case
Ascii.UPPERCASE_B: // byte
case
Ascii.UPPERCASE_C: // char
case
Ascii.UPPERCASE_F: // float
case
Ascii.UPPERCASE_I: // int
case
Ascii.UPPERCASE_S: // short
case
Ascii.UPPERCASE_Z: // boolean
locals++
;
break
;
case
Ascii.UPPERCASE_J: // long
case
Ascii.UPPERCASE_D: // double
locals +=
2
;
break
;
// Nous ignorons tous les caractères jusqu'au point-virgule
case
Ascii.UPPERCASE_L: // reference
locals++
;
while
(
index <
descriptor.length
&&
(
character =
descriptor[index++
]) !=
Ascii.SEMICOLON);
break
;
}
// Nous avons rencontré un crochet fermant ou un V
// Il n'y a plus rien à compter
if
(
locals ==
previousLocals) {
break
;
}
previousLocals =
locals;
}
return
locals;
}
catch
(
UnsupportedEncodingException e) {
throw
new
RuntimeException
(
"Should never happen"
, e);
}
}
En introduisant les tableaux, le code ci-dessus a besoin d'être modifié. Si nous rencontrons un crochet, nous ajoutons 1 à la variable locals, puis nous devons ignorer les crochets suivants ainsi qu'un type qui peut être représenté par un ou plusieurs caractères. Pour ce faire, nous devons utiliser une méthode récursive qui contiendra le switch. Le problème est que nous mettons à jour deux variables, index et locals. Utiliser un objet est généralement la solution la plus simple. Néanmoins, dans un tel cas, utiliser un objet mutable rend le code difficile à lire et à maintenir. Nous allons donc choisir une autre solution, la méthode de packing/unpacking (rassembler plusieurs valeurs dans une même variable) que nous avons déjà vue dans la partie Manipulation de la pileManipulation de la pile. Nous savons que les variables locales ne peuvent pas excéder 65 535 éléments (soit un short non signé) et qu'un tableau en Java ne peut avoir plus de 2 147 483 647 éléments (soit un int), or à moins d'être complètement dérangé, et dans ce cas il est conseillé d'aller consulter, le descripteur d'une méthode ne fera pas plus de deux milliards de caractères. De fait, un long conviendra parfaitement. Les 32 premiers bits seront utilisés pour l'index et les 32 derniers pour le nombre de paramètres.
La méthode précédente est à présent découpée en deux méthodes :
public
int
countParameters
(
final
String methodDescriptor) {
try
{
final
byte
[] descriptor =
methodDescriptor.getBytes
(
ENCODING);
int
index =
1
; // Step over the first element: Left parenthesis
int
locals =
0
;
int
previousLocals =
locals;
for
(
;;) {
final
long
pack =
countOneParameter
(
descriptor, index, locals);
locals =
(
int
) (
pack &
0xffff
);
index =
(
int
) (
pack >>>
32
);
// We encountered a right bracket or a void
if
(
locals ==
previousLocals) {
break
;
}
previousLocals =
locals;
}
return
locals;
}
catch
(
UnsupportedEncodingException e) {
throw
new
RuntimeException
(
"Should never happen"
, e);
}
}
private
long
countOneParameter
(
byte
[] descriptor, int
index, int
locals) {
byte
character =
descriptor[index++
];
switch
(
character) {
case
Ascii.UPPERCASE_B: // byte
case
Ascii.UPPERCASE_C: // char
case
Ascii.UPPERCASE_F: // float
case
Ascii.UPPERCASE_I: // int
case
Ascii.UPPERCASE_S: // short
case
Ascii.UPPERCASE_Z: // boolean
locals++
;
break
;
case
Ascii.UPPERCASE_J: // long
case
Ascii.UPPERCASE_D: // double
locals +=
2
;
break
;
case
Ascii.LEFT_BRACKET: // array
locals++
;
final
long
pack =
this
.countOneParameter
(
descriptor, index, 0
)
index =
(
int
) (
pack >>>
32
);
break
;
case
Ascii.UPPERCASE_L: // reference
locals++
;
while
(
index <
descriptor.length &&
(
character =
descriptor[index++
]) !=
Ascii.SEMICOLON)
;
break
;
}
// Locals maximum value = 65,535
// Arrays maximum size in Java = 2,147,483,647
return
(((
long
) index) <<
32
) |
locals;
}
Si l'on fait l'impasse sur le packing/unpacking qui n'a rien de particulier en soi, le seul changement est l'ajout d'un case dans le switch :
case
Ascii.LEFT_BRACKET: // Array
locals++
;
final
long
pack =
this
.countOneParameter
(
descriptor, index, 0
);
index =
(
int
) (
pack >>>
32
);
break
;
Nous prenons bien en compte le premier crochet, puis appelons la méthode countOneParameter() récursivement en ignorant le paramètre locals. Nous avons les cas suivants :
- si l'élément suivant est un type primitif, l'index est incrémenté de 1 ou 2 ;
- s'il s'agit d'un objet, l'index est incrémenté d'autant de caractères le constituant ;
- s'il s'agit à nouveau d'un ou plusieurs crochets ouvrants, nous aurons des appels récursifs successifs.
XIII-C-5. Génération d'un fichier .class▲
Pour générer un fichier .class, nous avons à notre disposition la classe java.io.DataOutputStream que nous pouvons utiliser de la manière suivante :
final
ClassFile classFile =
new
ClassFile
(
);
// ...Création de la classe...
final
String directoryStr =
classFile.getDirectories
(
);
// Création des répertoires contenant le fichier .class, s'ils n'existent pas.
final
File directory =
new
File
(
directoryStr);
if
(!
directory.exists
(
)) {
directory.mkdirs
(
);
}
// Création d'un fichier .class
final
String filename =
directoryStr +
classFile.getClassName
(
) +
".class"
;
final
FileOutputStream file =
new
FileOutputStream
(
filename);
// Génération du contenu du fichier
final
DataOutput bytecode =
new
DataOutputStream
(
file);
classFile.toBytecode
(
bytecode);
file.close
(
);
En définitive, la seule chose qu'il nous manque est la méthode toBytecode() de la classe ClassFile. Pour éviter d'avoir une méthode extrêmement longue, nous allons déléguer à chaque élément sa construction en bytecode. Pour ce faire, nous ajouterons dans toutes les classes une méthode toBytecode(). Et pour être sûr que toutes les classes nécessitant la méthode toBytecode() l'implémentent, la meilleure solution est de créer une interface BytecodeEnabled.
public
interface
BytecodeEnabled {
void
toBytecode
(
final
DataOutput dataOutput) throws
IOException;
}
Prenons par exemple la classe ClassFile (toutes les autres classes présentes dans le package org.isk.jvmhardcore.jpba.struture sont basées sur le même modèle) :
public
class
ClassFile implements
BytecodeEnabled {
@Override
public
void
toBytecode
(
DataOutput dataOutput) throws
IOException {
dataOutput.writeInt
(
this
.magicNumber);
dataOutput.writeInt
(
this
.version);
dataOutput.writeShort
(
this
.constantPool.size
(
));
this
.constantPool.toBytecode
(
dataOutput);
dataOutput.writeShort
(
this
.accessFlags);
dataOutput.writeShort
(
this
.thisClass);
dataOutput.writeShort
(
this
.superClass);
dataOutput.writeShort
(
this
.interfaces.length);
// short interfaces[interfacesCount];
dataOutput.writeShort
(
this
.fields.size
(
));
this
.fields.toBytecode
(
dataOutput);
dataOutput.writeShort
(
this
.methods.size
(
));
this
.methods.toBytecode
(
dataOutput);
dataOutput.writeShort
(
this
.attributes.size
(
));
this
.attributes.toBytecode
(
dataOutput);
}
}
XIII-D. What's next?▲
Ce fut long, mais nous avons pu voir notre assembleur en détail. Nous sommes à présent capables de faire les modifications permettant de lever toutes les limitations que nous avons fixées. Avec une telle conception, nous constaterons que l'ajout des instructions à notre assembleur est trivial.
Dans l'article suivant, nous allons voir comment traverser un graphe d'objets simplement, tout comme nous l'avons fait avec la méthode toBytecode(), mais en offrant de la souplesse et c'est bien là tout l'enjeu d'un code réutilisable.