II) Compilation du code
-
Le code complet du compilateur se trouve : Code du compilateur de byte code
Pour le compiler vous aurez besoin de notre batterie d'utilitaires : Divers utilitaires
Le compilateur est fait pour compiler des fichiers aux formats décrit par la première partie : I) Le byte code.
Le code est diviser en deux packages :
- jhelp.compiler.compil : contient ce qu'il faut pour compiler un fichier. C'est le compilateur lui même (Voir : II.1) La compilation)
- jhelp.compiler.instance : contient un utilitaire pour travailler par unité de compilation, compiler en parallèle, inclure des classes et des jar extérieur et aussi intégrer le code à la volée. (Voir : II.2) Utilisation de jhelp.compiler.instance.ClassManager)
II.1) La compilation
Pour simplifier le travail, nous avons utilisé les classe du package com.sun.org.apache.bcel.internal.generic (Inclut à la JRE).
Il contient tout ce qu'il faut pour représenter programmatiquement le byte code et le compilé.
Bien entendu il ne fait pas tout, il faut "nourrir" chacune des parties de manière approprié pour obtenir un résultat valide.
C'est ce que fait ce code, il se "contente" d'analyser un flux, remplir les classes de com.sun.org.apache.bcel.internal.generic et d'appeler la méthode qui construit tout.
Nous avons ajouter une inspecteur de pile. Son rôle est de vérifier si la pile de la méthode est correctement utilisée pour éviter des problèmes à l'exécution, car la compilation pourrait se faire, mais les erreurs ne seront pas clair, mal placés, de quoi pas mal s'arracher les cheveux.
De plus de notre point de vu il vaut mieux savoir à l'avance que notre code ne fonctionnera pas correctement. Notre inspecteur de pile n'est pas encore parfait et laisse encore passé certains problèmes de pile, mais permet d'en éviter pas mal.
Le point d'entré de la compilation est jhelp.compiler.compil.Compiler.
La méthode public com.sun.org.apache.bcel.internal.generic.ClassGen compile(java.io.InputStream) throws jhelp.compiler.compil.CompilerException prend en paramètre un flux sur le fichier à compiler. (Voir I) Le byte code pour connaître le format attendu)
L'objet com.sun.org.apache.bcel.internal.generic.ClassGen est une description de la classe compilé.
Pour récupéré le byte code il faut faire :
classGen.getJavaClass().dump(outputStream);
Remarque : la méthode dump peut levé une java.io.IOException. Et le paramètre est un objet étendant java.io.OutputStream.
Pour revenir à la méthode compile l'exception qu'elle peut levé donne, quand c'est possible, la ligne dans le code qui à provoqué l'exception.
Il peut être intéressant de vérifier (Avec un instanceof) que cette exception est une jhelp.compiler.compil.StackInspectorException. Car si c'en est une, une fois "casté", elle donne des information sur l'état de la pile au moment du non respect de la pile. Ainsi que le chemin parcouru pour arriver à la ligne fautive avec l'état de la pile pour chaque étape.
En effet le chemin de parcouru n'est pas forcément linéaire avec les instructions de saut. Notre inspecteur de pile essaie plusieurs combinaisons et s'arrête si une ne respecte pas la pile et renvoie comment il a obtenu l'erreur.
II.1.a) Rôle des classes de jhelp.compiler.compil
Compiler :
- Point d'entrée/sortie de la compilation
- Analyse le flux
- Compte les lignes
- Jette les lignes vides et les commentaires
- Découpe les lignes en une instruction et ces paramètres
- Fait quelques vérifications simples
- Délègue le reste du travail aux autres classes
CompilerContext, son rôle principale : Partager des informations de compilation entre les classes. Il enregistre divers informations ainsi que l'état de la compilation. Il stocke notamment :
- Le nom complet de classe en cours
- L'instance de com.sun.org.apache.bcel.internal.generic.ClassGen qu'on est entrain de remplir et qui sera renvoyé par Compiler
- La liste des imports
- Le nom complet de la classe parente
- La liste des interfaces implémentées
- Le com.sun.org.apache.bcel.internal.generic.ConstantPoolGen, qui va associé des références aux diverses constantes utilisées par la classe. Cette table d'association sert, au moment de la compilation, de créer la table des constantes d'une classe. Pour plus de détails voir : II.3) Détails sur les classes d'un point de vu du byte code
- Les champs de la classes
- Les références aux champs extérieurs (Contenu dans une autre classe)
- La liste des exceptions que la méthode en cours d'analyse peut levée
- La liste des variables locales de la méthode en cours d'analyse
- La liste des instructions de saut dont il faudra résoudre le point de saut à la fin de l'analyse de la méthode courante
- La liste des "Switch" dont il faudra résoudre les points de saut à la fin de l'analyse de la méthode courante
- Le dernier label à qui attaché une vraie instruction de code pour pouvoir le résoudre
- La liste des labels résolus
FieldInformation : Contient les informations sur un champs de la classe ou extérieur à celle-ci.
MethodDescription : Décrit la méthode en cours d'analyse. Son rôle :
- Détenir le nom et le type de retour de la méthode
- Contenir la liste de paramètres déclarés pour la méthode.
- Contenir le code de la méthode
Tout ce que la méthode à accumulées pendant la phase d'analyse, serra transféré à CompilerContext pendant la dernière partie qui donne toutes les informations nécessaires sur la méthode (nom, paramètres, code, ...) à com.sun.org.apache.bcel.internal.generic.ClassGen
Pendant ce transfert elle calcul la "table des lignes". C'est une table d'association, qui associe un endroit où se trouve une instruction byte code à un numéro de ligne du code original. Cela sert ensuite à l'exécution pour dire à quelle ligne à été levée une exception.
CodeLine : Décrit une instruction de code pour une méthode. Elle stocke l'instruction et ses paramètres. Au moment de la compilation, après quelques vérifications, elle se convertit en la classe de com.sun.org.apache.bcel.internal.generic correspondante.
CompilerConstants : Contient les mots clefs pour la partie déclaration du format.
OpcodeConstants : Contient le nom des instructions byte code, ainsi que leur description (En commentaire).
StackInspector : Une fois qu'une méthode est finie d'être compilé, les labels et instructions de saut résolu. Juste avant de l'enregistrée, cette classe va regarder si la pile d'exécution de la méthode sera respectée lors de son exécution.
II.1.b) Étapes de la compilation
Ces informations peuvent vous aider à comprendre la logique que suit le code lors de la compilation.
- Enlève les lignes vides et commentaires
- Formate les lignes intéressantes et les transforme en : instruction suivit de paramètres
- Si l'instruction est class ⇒ récupérer le nom de la classe
- Si l'instruction est import ⇒ ajouter à la liste des imports
- Si l'instruction est extends, implements, field ou filed_reference enrichir la Constant Pool et mémorisé les références
- Si l'instruction est method, parameter, return, throws, enrichir la description de la méthode courante
- Si l'instruction est { indique le début du code de la méthode courante
- Si l'instruction est aucune ci dessus, ni }, c'est une instruction de code que l'on ajoute au code de la méthode en cours
- Si l'instruction est } on compile le code de la méthode en cours :
- Initialise la liste des variables locales
- Puisque c'est une méthode non statique on ajoute this comme première variable locale. Note : le byte code impose que this soit TOUJOURS la première variable locale d'une méthode non statique
- On ajoute à la liste des variables locales les paramètres de la méthodes dans l'ordre des déclarations. Notez que l'ordre est important.
- Maintenant on sauvegarde la taille de la liste des variables locales pour plus tard. Cette information servira à savoir où commence les variables locales déclarées avec VAR
- Compilation du code (En même temps on créé la "table des lignes") :
- Si l'instruction est VAR, on ajoute la variable à la liste des variables locale, on ne génère pas d'instruction byte code
- Si l'instruction est LABEL on se souvient du nom du label pour pouvoir l'associé à la prochaine vraie instruction byte code (C'est à dire ni VAR, ni LABEL). On ne génère pas d'instruction byte code
- Si l'instruction est une instruction de saut on génère une instruction de saut avec une destination inconnu pour le moment, puis on la mémorise pour la résoudre plus tard
- Si l'instruction est un "switch" on match avec des destinations inconnus (Pour chaque cas et la destination par défaut). On la sauvegarde pour la résoudre plus tard
- Les autres instructions sont directement généré correctement et n'auront pas besoin d'être résolu plus tard
- Résolution des instructions de saut et des "switch"
- Création, maintenant que c'est possible, du com.sun.org.apache.bcel.internal.generic.MethodGen
- Ajout des exceptions levés
- Ajout des variables locales déclarés avec VAR. Note : c'est ici que sert d'avoir sauvegardé là où elles commencent
- Ajout de la "table des lignes" précédemment créée
- Vérification que la pile pour la méthode est respectée
- Ajout de la méthode désormais prête
- Si on est à la fin du fichier, on renvoie l'instance de com.sun.org.apache.bcel.internal.generic.ClassGen remplit prête à l'emploi
II.2) Utilisation de jhelp.compiler.instance.ClassManager
jhelp.compiler.instance.ClassManager travail par unité de compilation. Une unité de compilation est un ensemble de sources à compiler.
Chaque compilation d'une source est effectué en parallèle des autres.
Les méthodes compileASMs vous demande un écouteur pour vous informer si une compilation échoue, et quand les compilations du groupe sont terminé.
Cette méthode n'attend pas que les compilations lancées en parallèle soit terminées pour sortir. Elle fournit par conter un identifiant de compilation.
Cet identifiant vous servira à identifier le groupe de compilation dans les retours de ClassManagerListener.
Pour utiliser une code compilé vous avez deux façons à votre disposition.
La plus pratique est que votre code implémente une interface du projet. Il suffit alors de voir la classe comme l'interface et d'appeler les méthodes de l'interface.
Un petit exemple pour être plus clair. Admettons que l'on est l'interface :
package jhelp.example.operations;
public interface Operation
{
public int execute(int first, int second);
}
Et que l'on est le ficher "add.asm" :
class jhelp.asm.ADD
import jhelp.example.operations.Operation
implements Operation
method execute
parameter int first
parameter int second
return int
{
ILOAD first
ILOAD second
IADD
IRETURN
}
Un exemple d'utilisation :
//...
//... classManagerListener : instance of ClassManagerListener to signal compilation status
//... inputStreamASM : Stream on "add.asm"
//...
// Create the class manager that will do compilation
final ClassManager classManager = new ClassManager();
// Launch the compilation of several class in parallel
classManager.compileASMs(classManagerListener, inputStreamASM);
// ...
Et dans la classe qui implémente ClassManagerListener :
Au passage vous remarquerez que le code de "add.asm" est compilé et intégré à l'application à la volé.
Pour être précis la classe n'est pas connue du ClassLoader courant mais du ClassLoader embarqué par ClassManager.
C'est pour cela qu'il faut passer par ClassManager pour avoir une instance ou faire de la réflexion.
Une autre solution est de passer par la réflexion. Les méthodes de ClassManager suivantes vous le permettront :
- obtainClass : Obtient l'objet Class décrivant une classe. A partir de lui toutes les opérations classique de réflexion sont possibles. Les autres méthodes de ce point sont là pour faciliter le travail
- newInstance : Crée une instance d'une classe en utilisant le constructeur avec le moins d'argument possible. Donc utilisera le constructeur par défaut, si disponible. Sinon la méthode va donnée des valeurs par défaut aux arguments du constructeur.
- newInstance2 : Crée une instance en contrôlant le constructeur que l'on désire atteindre et les paramètres qu'on lui donne
- listOfMethod : Donne la liste de toutes les méthodes disponibles directes d'une classe
- invoke : Invoque une méthode non statique d'un objet
- invokeStatic : Invoque une méthode statique d'une classe
A savoir, ClassManager, en plus des classes que vous lui donnez à compiler, connaît toutes les classes que connaît le ClassLoader de votre projet.
C'est à dire toute la JRE, les classes de l'application courante ainsi que toutes celles du "class path". C'est pour cela que l'exemple de tout à l'heure fonctionnait.
Il est donc tout a fait possible d'utiliser ClassManager avec d'autres classes que celles que l'on compile à la volée, même si c'est pas son but premier.
On peut, en plus des classes citées au-dessus ajouter d'autres classes connues au ClassManager. L'intérêt est de pouvoir utiliser de nouvelle classes sans avoir a recompiler le projet ni a arrêté l'application.
La méthode addClassFile ajoute un fichier .class au ClassManager. Le fichier doit bien sûr être valide, mais en plus son chemin doit finir par son package et le nom du fichier doit être le nom de la classe .class (Même nom que le compilateur JAVAC donne)).
Par exemple si on a le fichier "Test.class" qui représente la classe jhelp.example.test.Test, le chemin où se trouve le fichier devra se e finir par :.../jhelp/example/test/Test.class
La méthode addJarFile permet quand à elle d'ajouter tout un jar. A part que le jar soit valide elle n'exige rien d'autre.
Les deux dernières méthodes répondent à une problématique particulière que nous avons rencontré pour notre éditeur de byte code.
La problématique est la suivante : une fois qu'un ClassLoader à résolu une classe on ne peut pas lui faire oublié.
Il faut connaître comment sont chargé les classes dans un ClassLoader pour comprendre ce que veut dire résolu. En résumé une classe est résolu quand on demande sa description en mémoire pour la première fois.
En détail, quand en Java on fait un new, la JVM va demandé au ClassLoader une description de la classe à instanciée. Si celle-ci est déjà résolu il va donner directement la description.
Sinon, le ClassLoader va résoudre la classe en regardant les classes dont elle à besoin pour s'exécute, il va alors se lancé dans la résolution de toutes les classes référencés qu'il ne connaît pas encore avant de finir la résolution de la classe de départ.
Donc une classe peut être résolu soit directement ou indirectement.
Pour revenir à notre problématique on a ajouté la possibilité aux utilisateurs de l'éditeur non seulement la possibilité de compiler, mais aussi de pouvoir tester le code compiler.
Pour tester le code compiler il nous fallait une instance de la classe et donc celle-ci sera résolu. Mais vola admettons que l'utilisateur veuille modifier son code, il n'aurait pas pu le compiler à nouveau. Ce qui est plutôt gênant.
Pour contourner ce problème et permettre de changer une classe même résolue nous avons ajouter les deux méthodes suivantes :
- isResolved : Permet de savoir si on classe est déjà résolu ou non, pour savoir si il est absolument nécessaire d'appeler la méthode suivante pour compiler à nouveau cette classe
- newClassLoader : Permet de faire comme si le ClassLoader n'avait encore rien résolu. Cette méthode passe par une astuce, ne l'appelez pas trop souvent, surtout si la liste des classes à "oublier" est grand et ou qu'elles ont beaucoup de lignes de code. En effet elles ne sont pas vraiment oublié d'un point de vu mémoire.
II.3) Détails sur les classes d'un point de vu du byte code
D'un point de vu byte code, une classe contient un Constant Pool.
Ce Constant Pool va contenir toutes les constantes utilisées par la classes, les autres parties qui utilisent ces constantes se contentent de stockées un référence qui pointe à une valeur dans le Constant Pool.
C'est la JVM lors de l'exécution qui va prendre la référence et demandé au Constant Pool a quoi elle correspond.
Il y a plusieurs sorte des constantes stockées :
- Des nombres que les instructions qui pousse des constantes optimisé ne peuvent pas directement poussé. C'est à dire toutes les valeurs non poussés par les ?CONST, BIPUSH ou SIPUSH. Voir I.6.g) Instructions pour pousser des constantes
- De chaînes de caractères qui sont de plusieurs natures :
- Celles qui sont utilisés par les méthodes de la classe. Quand vos faites un truc comme System.out.println("Hello world !");. Et bien "Hello world !" se trouvera dans la Constant Pool
- Les noms des classes, dont celle en cours, référencés que ce soit par héritage, implémentation ou simple utilisation
- Le nom des types : Ljava/langString;, [I, ....
- Les noms des champs, que se soit ceux de la classes ou ceux appelé
- Les noms des méthodes, que se soit celles de la classes ou celles appelées
- Les signatures des méthodes, que se soit celles de la classes ou celles appelées
- Les noms des paramètres des méthodes
- Les noms des sections utilisées pour générées le byte code (Section de code, section de table de lignes, ...)
- Des références aux classes. Ce sont des couples de références. Une référence vers le nom, l'autre vers le type
- Des références aux champs. Ce sont des couples de références. Une référence vers le nom du champs, l'autre vers une référence de classe
- Des références aux signatures de méthodes. Ce sont des couples de référence. Une référence vers le nom de la méthode, l'autre vers la signature
- Des références sur les méthodes. Ce sont des couples de référence. Une vers une référence de signature de méthode, l'autre vers une référence de classe
Bien sûr tout ce qui est stocké dans la Constant Pool ne l'est qu'une fois.
Vous remarquerez que certaines constantes font références à d'autres. Ce qui vous donne une idée du travail effectué par la JVM à chaque fois qu'elle doit les résoudre.
A noté, il se peut que nous en ayons oublié.
En plus du Constant Pool, la classe contient des sections que nous ne détailleront pas ici.
La classes contient également les méthodes, nous allons un peu détaillé la section d'une méthode.
La section d'une méthode contient divers blocs d'informations, dont :
- Le bloc qui contient le code
- La table des lignes (Optionnel)
- La portée des variables locales
- La table des exceptions