JVM Hardcore – Part 13 – Bytecode – Ajouter des fonctionnalités à PJBA

academic_duke
Au cours des articles précédents nous avons vu comment fonctionne une JVM, environ 150 instructions et les éléments nécessaires à la création d’un assembleur de bytecode extrêmement basique. Le résultat de ces connaissances a donné PJBA, un assembleur de bytecode Java permettant de créer des classes possédant des méthodes statiques. La conception de PJBA est parfaitement adaptée à nos besoins et semble suffisamment évolutive pour que l’on puisse rajouter des éléments aux différentes classes du package org.isk.jvmhardcore.structure. Néanmoins, actuellement il est nécessaire d’écrire quelque 30 lignes pour générer une simple classe, possédant une méthode statique et quatre instructions.


Nous avons donc besoin de nouvelles fonctionnalités. Que ce soit pour créer un graphe d’objets de type ClassFile et de ses membres sous-jacents, mais aussi pour pouvoir explorer le contenu d’un fichier .class ou générer un fichier .pjb à partir d’un fichier .class.

Pour ce faire, dans cet article nous allons passer en revue plusieurs éléments de base que nous ferons évoluer jusqu’à la fin de l’arc dédié au bytecode Java.

Les différents éléments seront construits autour des classes suivantes :

  • ClassFileBuilder permettant de créer un graphe d’objets ClassFile rapidement, tout en réduisant les erreurs possibles.
  • Assembler permettant de générer un flux d’octets (de type DataOutput) pouvant résulter en un fichier .class.
  • Disassembler permettant de désassembler un fichier .class et de créer un graphe d’objets ClassFile.
  • PjbDumper permettant de générer un fichier .pjb à partir d’un graphe d’objets ClassFile.
  • HexDumper permettant d’afficher de manière humainement lisible le contenu d’un fichier .class à partir d’un graphe d’objets ClassFile.

Notons que l’expression “un graphe d’objets ClassFile” utilisée plusieurs fois au cours de cet article est un abus de langage signifiant “un graphe d’objets dont le type de l’instance racine du graphe est ClassFile“.

Le code est disponible sur Github (tag et branche).

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

ClassFileBuilder

Commençons par l’élément qui nous sera le plus utile par la suite, un builder permettant de créer un graphe d’objets ClassFile simplement.

En reprenant l’exemple Classes de l’article précédent, il nous suffit de lister les parties variables qui deviendront des paramètres des méthodes de notre builder :

  • le nom complètement qualifié de la classe,
  • le nom et le descripteur de la méthode,
  • et les différentes instructions.

Le modèle suivant répond parfaitement à notre besoin :

final String className = "org/isk/jvmhardcore/pjba/MyFirstClass";

final ClassFile classFile = new ClassFileBuilder(className)
  .newMethod("add", "(II)I")
    .iload_0()
    .iload_1()
    .iadd()
    .ireturn()
.build();

Nous enchaînons des méthodes dans un style fonctionnel, où l’indentation nous permet de reconstituer – visuellement – un code source .pjb. De plus, chaque méthode est nommée de manière à identifier son utilité rapidement, rendant caduque la création d’une javadoc – pour qui connaît la structure d’un fichier .class. A ceci, nous pouvons ajouter diverses contraintes dans l’utilisation des méthodes en créant des builders intermédiaires. Par exemple, la classe ClassFileBuilder peut porter les méthodes newMethod() et build(), et une classe MethodBuilder les différentes instructions. Par conséquent, chaque méthode devra retourner l’instance d’un builder, hormis la méthode build() qui doit retourner l’instance de la classe ClassFile qui vient d’être créée.

Voyons comment nous pouvons implémenter les classes ClassFileBuilder et
MethodBuilder.

La structure de base de la classe ClassFileBuilder n’a rien de remarquable :

public class ClassFileBuilder {
  private ClassFile classFile;

  public ClassFileBuilder(final String fullyQualifiedName) {
    super();
    this.classFile = new ClassFile(fullyQualifiedName);
  }

  public ClassFile build() {
    return this.classFile;
  }
}

Source

Le seul détail à prendre en compte est la méthode newMethod() qui retourne un builder de type MéthodBuilder. Le reste de la méthode n’est qu’un simple copier/coller d’une partie de la classe Classes.

public MethodBuilder newMethod(String methodName, String methodDescriptor) {
  final int methodIndex = this.classFile.addConstantUTF8(methodName);
  final int descriptorIndex = this.classFile.addConstantUTF8(methodDescriptor);
  final Method method = new Method();
  method.setNameIndex(methodIndex);
  method.setDescriptorIndex(descriptorIndex);
  this.classFile.addMethod(method);
  final int parametersCount = method.countParameters(methodDescriptor);

  return new MethodBuilder(this, method, parametersCount);
}

La classe MethodBuilder est un copier/coller du reste de la classe Classes :

public class MethodBuilder {

  final private ClassFileBuilder classFileBuilder;
  final private Method method;
  final private int parametersCount;

  private Code code;

  public MethodBuilder(ClassFileBuilder classFileBuilder,
                       Method method, int parametersCount) {
    this.classFileBuilder = classFileBuilder;
    this.method = method;
    this.parametersCount = parametersCount;

    final ClassFile classFile = this.classFileBuilder.getClassFile();
    final int codeAttributeIndex = classFile.addConstantUTF8(Code.ATTRIBUTE_NAME);
    this.code = new Code(codeAttributeIndex);
    this.code.setParameterCount(this.parametersCount);
    this.method.addAttibute(this.code);
  }

  public MethodBuilder iload_0() {
    this.code.addInstruction(Instructions.ILOAD_0);
    return this;
  }

  // ...
}

Source

Mais attention, bien que notre besoin actuel ne corresponde pas forcément à notre besoin futur, il ne faut en aucun cas essayer de l’anticiper. Ceci à plusieurs avantages dont le principal est de créer des tests unitaires nominaux. Et en utilisant une approche de développement piloté par les tests, c’est exactement ce que nous faisons :

  • Nous créons un test, ou tout du moins un morceau de code, répondant à un besoin,
  • et ensuite le code répondant à ce besoin, tout en utilisant une conception évolutive, permettant d’ajouter de nouvelles fonctionnalités simplement.

Tester les classes ClassFileBuilder et MethodBuilder

Pour tester les deux classes que nous venons de créer, il est nécessaire de revenir sur les différentes étapes de construction du projet.

Pour rappel la structure du projet pjba (il en va de même pour le projet bytecode) est la suivante :

project
|  +- 01_src (code source)
|  |  +- main
|  |  |  +- assembler (Assembleur)
|  |  |  +- java
|  |  |  +- pjb (Plume Java Bytecode)
|  |  +- test
|  |  |  +- java
|  |  |  +- resources
|  +- 02_build (généré)
|  |  |  +- assembler
|  |  |  |  +- classes (Assembleur compilé)
|  |  |  |  +- reports (Rapport d'assemblage)
|  |  |  +- classes
|  |  |  +- junit-data
|  |  |  +- junit-reports
|  |  |  +- pjb-classes (.pjb assemblés en .class)
|  |  |  +- test-classes
|  +- 03_dist (généré)

Pour construire notre projet nous avons besoin de plusieurs étapes :

  1. Compilation des sources
    • Répertoire à compiler : 01_src/main/java
    • Classpath : Vide
    • Répertoire de sortie : 02_build/classes
  2. Compilation de l’assembleur
    • Répertoire à compiler : 01_src/main/assembler
    • Classpath :
      • 02_build/classes
      • junit
    • Répertoire de sortie : 02_build/assembler/classes
  3. Génération des fichiers .class
    • Répertoire d’exécution : 02_build/assembler/classes
    • Classpath :
      • 02_build/classes
      • junit
    • Répertoire de sortie : 02_build/pjb-classes
  4. Compilation des tests
    • Répertoire à compiler : 01_src/test/java
    • Classpath :
      • 02_build/classes
      • 02_build/pjb-classes
      • junit
    • Répertoire de sortie : 02_build/test-classes
  5. Exécution des tests
    • Répertoire d’exécution : 02_build/test-classes
    • Classpath :
      • 02_build/classes
      • 02_build/pjb-classes
      • junit

Il nous suffit donc de créer une nouvelle classe nommée Classes dans le répertoire 01_src/main/assembler pour générer un second fichier .class nommé MySecondClass.

public class Classes {
  @Test
  public void assemble0() throws Exception {
    final String fullyQualifiedName = "org/isk/jvmhardcore/pjba/MySecondClass";
    final ClassFile classFile = new ClassFileBuilder(fullyQualifiedName)
    .newMethod("add", "(II)I")
      .iload_0()
      .iload_1()
      .iadd()
      .ireturn()
     .newMethod("add2", "(II)I")
      .iload_1()
      .iload_0()
      .iadd()
      .ireturn()
    .build();

    org.isk.jvmhardcore.pjba.AssembleForTest.createFile(classFile);
  }
}

Source

Pour finir, nous pouvons écrire le test suivant :

@Test
public void assemble1() {
  final int sum1 = MySecondClass.add(3, 5);
  Assert.assertEquals(8, sum1);

  final int sum2 = MySecondClass.add2(2, sum1);
  Assert.assertEquals(10, sum2);
}

Source

Disassembler

Pouvoir explorer un fichier .class est souvent utile, que ce soit pour comprendre comment javac compile du code Java en bytecode, générer un fichier .pjb à partir d’un code existant, ou bien pour des actions de débogage. Quel que soit le besoin, la problématique est toujours la même, il est nécessaire de désassembler le fichier .class et de créer un graphe d’objets ClassFile, de cette manière :

// ...
final InputStream is = new FileInputStream(/* file */);
final DataInputStream dataInput = new DataInputStream(is);
final Disassembler disassambler = new Disassembler(dataInput);
final ClassFile classFile = disassambler.disassemble();
// ...

Concrètement un désassembleur effectue les opérations inverses à un assembleur. Au lieu d’utiliser les méthodes d’écritures (writeXxx()) d’un DataOutput, nous utiliserons les méthodes de lecture (readXxx()) d’un DataInput.

Notre désassambleur est constitué d’une seule classe nommée Disassembler. Sa conception est extrêmement simple, et pour l’instant ceci est suffisant. Dans la même idée que le builder créé précédemment il est complètement inutile de chercher une conception plus adaptée/complexe si nous n’en avons pas le besoin. Il n’y que deux questions à se poser :

  • Est-ce que nous répondons au besoin ?
  • Est-ce que le code est facile à maintenir et a déboguer ?

Tout autre question est hors de propos.

A présent, voyons une partie du code :

public class Disassembler {
  final private DataInput dataInput;
  final private ClassFile classFile;

  public Disassembler(DataInput dataInput) {
    super();
    this.dataInput = dataInput;
    this.classFile = new ClassFile();
  }

  public ClassFile disassemble() {
    final long magicNumber = this.readUnsignedInt();
    if (magicNumber != 0xCAFEBABE) {
      throw new RuntimeException("Invalid Class File Format");
    }

    this.readClass();

    this.checkEOF();

    return this.classFile;
  }

  // ...
}

Source

Cette classe ne mérite pas que l’on s’y attarde indéfiniment. Les deux précédents articles devraient être suffisants pour la comprendre. Néanmoins notons quelques éléments remarquables :

  • Chaque structure a sa propre méthode readClass(), readConstants(), readMethods(), etc.
  • Les méthodes de lecture de l’interface DataInput sont appelés par des méthodes chapeau du même nom, pour éviter d’avoir à gérer des exceptions lors de chaque appel.
  • Les méthodes de type readUnsignedXxx() lisent un certain nombre de bits et retournent un nombre non signé. Ceci est utile lorsque l’on souhaite lire un index ou une taille (le nombre de constantes dans le pool de constantes par exemple).

Source

Pour savoir si une classe a été désassemblée correctement, il suffit de l’assembler à nouveau et de tester la classe résultante. Pour ce faire, nous allons donc ajouter des répertoires à notre projet ainsi que des étapes à la construction.

project
|  +- 01_src (code source)
|  |  +- main
|  |  |  +- disassembler
|  |  |  +- ...
|  +- 02_build (généré)
|  |  |  +- class-classes (Classes ré-assemblées)
|  |  |  +- disassembler
|  |  |  |  +- classes (Désassembleur compilé)
|  |  |  |  +- reports (Rapport de désassemblage et de ré-assemblage)
|  |  |  +- ...
  • 2.5 Compilation du désassembleur
    • Répertoire à compiler : 01_src/main/disassembler
    • Classpath :
      • 02_build/classes
      • junit
    • Répertoire de sortie : 02_build/disassembler/classes
  • 3.5 Désassemblage de toutes les classes du répertoire pjb-classes et re-génération des fichiers .class
    • Répertoire d’exécution : 02_build/disassembler/classes
    • Classpath :
      • 02_build/classes
      • 02_build/pjb-classes
      • junit
    • Répertoire de sortie : 02_build/class-classes
  • 5.5 Exécution des tests avec un classpath différent
    • Répertoire d’exécution : 02_build/test-classes
    • Classpath :
      • 02_build/classes
      • 02_build/class-classes (pjb-classes est utilisé dans l’autre test)
      • junit

Le code exécuté à l’étape 3.5 (utilisant la classe Disassembling) est comparable à celui de l’étape 3 (utilisant la classe Assembling).

Assembler, PjbDumper et HexDumper

Les classes Assembler, PjbDumper et HexDumper ont toutes les trois un mécanisme similaire. A partir d’un graphe d’objets ClassFile, elles ont besoin de parcourir le graphe pour formater le contenu, que ce soit un flux d’octets, un fichier .pjb, ou une représentation lisible du contenu d’un fichier .class.

Dans l’article précédent nous avions déjà un assembleur. A l’aide de la méthode toBytecode() nous avons vu une façon de traverser un graphe d’objets. Malheureusement cette solution, nous ne permet pas de séparation entre les données et ce que nous en faisons. Grâce à la méthode toBytecode(), nous pouvons générer un flux d’octets, mais pour générer un fichier .pjb, ou bien afficher le contenu des objets, il faudrait rajouter deux nouvelles méthodes. Pour résoudre ce problème, la solution la plus communément utilisée est le patron de conception visiteur. Mais avant d’arriver au résultat final, nous allons explorer plusieurs pistes nous permettant de comprendre en détail le fonctionnement de ce patron de conception.

Prenons trois classes (ClassFile, Method et Field), comparable à celles utilisés par PJBA, mais simplifiées :

public class ClassFile {
  final private String className;
  final private List<Field> fields;
  final private List<Method> methods;

  public ClassFile(String className) {
    this.className = className;
    this.fields = new ArrayList<Field>();
    this.methods = new ArrayList<Method>();
  }

  public void addField(Field field) {
    this.fields.add(field);
  }

  public void addMethod(Method method) {
    this.methods.add(method);
  }
}

Source

public class Field {
  final private String name;

  public Field(String name) {
    this.name = name;
  }
}

Source

public class Method {
  final String name;

  public Method(String name) {
    this.name = name;
  }
}

Source

Notes :

  • Les getters ne sont pas représentés.
  • Les exemples suivants sont dans un projet dédié nommé visitor.

Nous souhaitons afficher le contenu du graphe de la manière suivante :

Class Name: MyClass
    Fields:
        field1
        field2
    Methods:
        method1
        method2
        method3

Nous pouvons donc créer le test unitaire suivant (où EXPECTED est un chaîne de caractères comparable à celle présentée ci-dessus) :

@Test
public void prettyPrint() {
  final ClassFile classFile = new ClassFile("MyClass");
  classFile.addMethod(new Method("method1"));
  classFile.addMethod(new Method("method2"));
  classFile.addField(new Field("field1"));
  classFile.addMethod(new Method("method3"));
  classFile.addField(new Field("field2"));

  final PrettyPrint prettyPrint = new PrettyPrint(classFile);
  final String classFileAsString = prettyPrint.build();
  Assert.assertEquals(EXPECTED, classFileAsString);
}

Source

Exemple 1

Outre la solution utilisé dans l’article précédent, nous avons la possibilité de créer une classe de type Manager. Comparable à la classe Disassembler, ce qui nous permet d’avoir une séparation entre nos objets et ce que l’on fait des données.

public class PrettyPrint {
  final private ClassFile classFile;

  public PrettyPrint(ClassFile classFile) {
    this.classFile = classFile;
  }

  public String build() {
    final StringBuilder sb = new StringBuilder();
    sb.append("Class Name: ").append(classFile.getClassName()).append("\n");

    // Add fields
    sb.append("  Fields:").append("\n");
    for (Field field : classFile.getFields()) {
      sb.append("    ").append(field.getName()).append("\n");
    }

    // Add methods
    sb.append("  Methods:").append("\n");
    for (Method method : classFile.getMethods()) {
      sb.append("    ").append(method.getName()).append("\n");
    }

    return sb.toString();
  }
}

Source

Néanmoins, avec la méthode build() nous avons introduit un nouveau problème, nous l’obligeons à savoir comment traverser le graphe d’objets, alors qu’elle devrait se contenter d’afficher des chaînes de caractères.

Exemple 2

Le patron de conception visiteur permet à une classe d’indiquer QUI peut la traverser et COMMENT elle doit être traversée (l’ordre de ses champs) et, délègue à ses enfants la tâche d’indiquer aussi comment ils doivent être traversés. Pour ce faire, nous créons une interface Visitable qui sera implémentée par nos trois classes.

public interface Visitable {
  void accept(Visitor visitor);
}

Source

Une classe implémentant l’interface Visitable indique qu’elle accepte d’être traversée par une objet de type Visitor.

Le type Visitor est aussi une interface possédant des méthodes permettant de traiter les données en fonction du type de l’objet visité. Par exemple :

public interface Visitor {
  void visit(ClassFile classFile);
  void visit(Method method);
  void visit(Field field);
}

Source

Adaptons à présent nos trois classes :

public class ClassFile implements Visitable {
  // ...

  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);

    for (Field field : this.fields) {
      field.accept(visitor);
    }

    for (Method method : this.methods) {
      method.accept(visitor);
    }
  }
}

Source

Nous utilisons la méthode visitor.visit(this) sur l’objet courant et déléguons à chaque enfant la tâche d’indiquer au visiteur comment il doit la lire, en utilisant la méthode accept(visitor).

public class Field implements Visitable {
  // ...

  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);
  }
}

Source

public class Method implements Visitable {
  // ...

  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);
  }
}

Source

Pour finir créons une nouvelle classe PrettyPrint qui implémente l’interface Visitor :

public class PrettyPrint implements Visitor {
  final private StringBuilder sb = new StringBuilder();

  final private ClassFile classFile;

  public PrettyPrint(ClassFile classFile) {
    this.classFile = classFile;
  }

  @Override
  public void visit(ClassFile classFile) {
    this.sb.append("Class Name: ").append(classFile.getClassName()).append("\n");
  }

  @Override
  public void visit(Method method) {
    this.sb.append("  ").append(method.getName()).append("\n");
  }

  @Override
  public void visit(Field field) {
    this.sb.append("  ").append(field.getName()).append("\n");
  }

  public String build() {
    this.classFile.accept(this);
    return sb.toString();
  }
}

Source

L’appel à la méthode build() permet d’initialiser la traversée du graphe d’objets, grâce à l’invocation de la méthode this.classFile.accept(this).

On constate que le test unitaire précédent est en erreur, puisque nous n’avons pas la possibilité indiquer d’en-tête pour les champs et les méthodes.

Class Name: MyClass
  field1
  field2
  method1
  method2
  method3

Exemple 3

Nous pouvons donc modifier nos objets en créant autant de types qu’il y a de champs dans nos classes. Bien qu’en théorie nous pouvons avoir des méthodes de type visit(String s) ou visit(List l), en pratique la première méthode ne nous permet pas de savoir de quelle classe vient la chaîne de caractères et la seconde nous oblige à tester le type des éléments contenus dans la liste.

L’interface Visitor doit donc évoluer en conséquence :

public interface Visitor {
  void visit(ClassFile classFile);
  void visit(ClassName className);
  void visit(MethodList methods);
  void visit(Method method);
  void visit(FieldList fields);
  void visit(Field field);
}

Source

Tout comme notre structure de base qui doit intégrer les nouveaux types ClassName, MethodList et FieldList :

public class ClassFile implements Visitable {
  final private ClassName className;
  final private FieldList fields;
  final private MethodList methods;

  // ...

  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);
    visitor.visit(this.className);
    this.fields.accept(visitor);
    this.methods.accept(visitor);
  }
}

Source

La seule chose remarquable de la classe ClassName est qu’elle n’implémente pas l’interface Visitable, puisque dans la méthode accept() de la classe ClassFile nous appelons la méthode visit() acceptant comme paramètre un objet de type ClassName : visitor.visit(this.className).

public class ClassName {
  final private String name;

  public ClassName(String name) {
    super();
    this.name = name;
  }
}

Source

Les autres classes sont tout aussi simple :

public class FieldList implements Visitable {
  final private List<Field> fields;

  // ...

  public void accept(Visitor visitor) {
    visitor.visit(this);

    for (Field field : this.fields) {
      field.accept(visitor);
    }
  }
}

Source

public class Field implements Visitable {
  final private String name;

  // ...

  @Override
  public void accept(Visitor visitor) {
    visitor.visit(this);
  }
}

Source

Les classes Method et MethodList sont comparables à Field et FieldList.

A présent, nous pouvons rajouter les en-têtes dans notre classe PrettyPrint et notre test unitaire se termine en succès :

public class PrettyPrint implements Visitor {
  // Initialisation

  // visit(ClassFile classFile)

  @Override
  public void visit(ClassName className) {
    this.sb.append(className.getName()).append("\n");
  }

  @Override
  public void visit(MethodList methods) {
    this.sb.append("  Methods:").append("\n");
  }

  // visit(Method method)

  @Override
  public void visit(FieldList fields) {
    this.sb.append("  Fields:").append("\n");
  }

  // visit(Field field)

  // build()
}

Source

Cette solution à tout de même le désavantage de devoir créer de multiple objets qui n’ont que pour seul intérêt d’aider au formatage. Si l’on reprend l’ensemble des objets utilisés dans PJBA, il est indéniable que cette solution n’est pas envisageable. De plus, la conception actuelle a toujours un problème, notre visiteur est obligé d’appeler les méthodes getName() de chaque objet. Or nous souhaitons réduire au maximum les erreurs de lecture de chaque objet, en ayant des méthodes n’acceptant que des primitifs ou des objets de type String.

Exemple 4

Nous allons corriger ce problème en nommant précisément les méthodes du visiteur. Nous pourrons donc supprimer les classes superflues introduites précédemment tout en créant une classe de type liste générique.

public interface Visitor {
  void visitClass();
  void visitClassName(String name);

  void visitMethodListSize(int size);
  void visitMethodName(String name);

  void visitFieldListSize(int size);
  void visitFieldName(String name);
}

Source

La liste que nous allons créer doit implémenter Visitable et n’accepter que des objets de type Visitable pour que nous puissions appeler la méthode accept() sur chaque objets contenu dans la liste.

public class CustomList<E extends Visitable>
    extends ArrayList<E>
    implements Visitable {

  @Override
  public void accept(Visitor visitor) {
    for (E e : this) {
      e.accept(visitor);
    }
  }
}

Source

Nous repartons à présent de nos trois classes initiales, dont chacune définit une méthode accept() décrivant en détail comment elle doit être traversée.

public class ClassFile implements Visitable {
  final private String className;
  final private CustomList<Field> fields;
  final private CustomList<Method> methods;

  public ClassFile(String className) {
    this.className = className;
    this.fields = new CustomList<Field>();
    this.methods = new CustomList<Method>();
  }

  @Override
  public void accept(Visitor visitor) {
    visitor.visitClass();
    visitor.visitClassName(this.className);

    visitor.visitFieldListSize(this.fields.size());
    this.fields.accept(visitor);

    visitor.visitMethodListSize(this.methods.size());
    this.methods.accept(visitor);
  }
}

Source

public class Field implements Visitable {
  final private String name;

  public Field(String name) {
    this.name = name;
  }

  @Override
  public void accept(Visitor visitor) {
    visitor.visitFieldName(this.name);
  }
}

Source

public class Method implements Visitable {
  final private String name;

  public Method(String name) {
    this.name = name;
  }

  @Override
  public void accept(Visitor visitor) {
    visitor.visitMethodName(this.name);
  }
}

Source

La classe PrettyPrint quant à elle ne fait que traiter des primitifs et des String comme nous le souhaitions :

public class PrettyPrint implements Visitor {
  final private StringBuilder sb = new StringBuilder();

  final private ClassFile classFile;

  public PrettyPrint(ClassFile classFile) {
    this.classFile = classFile;
  }

  @Override
  public void visitClass() {
    this.sb.append("Class Name: ");
  }

  @Override
  public void visitClassName(String name) {
    this.sb.append(name).append("\n");
  }

  @Override
  public void visitMethodListSize(int size) {
    this.sb.append("  Methods:").append("\n");
  }

  @Override
  public void visitMethodName(String name) {
    this.sb.append("    ").append(name).append("\n");
  }

  @Override
  public void visitFieldListSize(int size) {
    this.sb.append("  Fields:").append("\n");
  }

  @Override
  public void visitFieldName(String name) {
    this.sb.append("    ").append(name).append("\n");
  }

  public String build() {
    this.classFile.accept(this);
    return this.sb.toString();
  }
}

Source

Avec tous ces exemples, nous avons vu que le patron de conception visiteur est simple à utiliser et qu’il nous permet une séparation des tâches. En revanche, il rend le code un peu plus difficile à tester. Les erreurs les plus communes proviennent d’une mauvaise délégation au niveau des objets ou du visiteur lors du traitement des données. De fait, une classe de type PrettyPrint est tout à fait adaptée pour des opérations de débogage.

Assembler

Dans PJBA, passer de la solution précédente utilisant la solution BytecodeEnabled/toBytecode() au patron de conception visiteur est une action triviale. Par conséquent, nous ne détaillerons pas toutes les modifications (qui sont visibles directement dans le code du projet).

L’interface Visitable est comparable à celle que nous avons vu précédemment et qui n’a pas changé au cours de nos différents exemples.

L’interface Visitor, quant à elle reprend tous les champs de nos objets en ne prenant en paramètre que des primitifs ou des String :

public interface Visitor {
  // -----------------------------------------------------------------
  // ClassFile
  // -----------------------------------------------------------------
  void visitMagicNumber(int magicNumber);
  void visitVersion(int version);
  void visitConstantPoolSize(int size);
  void visitClassAccessFlags(int accessFlags);
  void visitThisClass(int thisClass);
  void visitSuperClass(int superClass);
  void visitInterfacesSize(int length);
  void visitFieldsSize(int size);
  void visitMethodsSize(int size);
  void visitClassAttributeSize(int size);

  // -----------------------------------------------------------------
  // Constant
  // -----------------------------------------------------------------
  void visitConstantTag(int tag);
  void visitConstantUTF8(java.lang.String value);
  void visitConstantInteger(int integer);
  void visitConstantFloat(float floatValue);
  void visitConstantLong(long longValue);
  void visitConstantDouble(double doubleValue);
  void visitConstantClass(int nameIndex);
  void visitConstantString(int utf8Index);

  // -----------------------------------------------------------------
  // Method
  // -----------------------------------------------------------------
  void visitMethodAccessFlags(int accessFlags);
  void visitMethodNameIndex(int nameIndex);
  void visitMethodDescriptorIndex(int descriptorIndex);
  void visitMethodAttributesSize(int size);

  // -----------------------------------------------------------------
  // Attribute
  // -----------------------------------------------------------------
  void visitAttributeNameIndex(int nameIndex);
  void visitAttributeLength(int length);

  // -----------------------------------------------------------------
  // Code
  // -----------------------------------------------------------------
  void visitCodeMaxStack(int maxStack);
  void visitCodeMaxLocals(int maxLocals);
  void visitCodeLength(int codeLength);
  void visitCodeExceptionsSize(int size);
  void visitCodeAttributesSize(int size);

  // -----------------------------------------------------------------
  // Instruction
  // -----------------------------------------------------------------
  void visitOpcode(int opcode);
}

Source

A présent, il nous suffit de remplacer l’interface BytecodeEnabled par Visitable, et de modifier la méthode toBytecode() en accept(). Les appels aux méthodes (writeX()) de l’instance de type DataOutput sont remplacés par des appels aux méthodes visitX() et les délégations passent de toByteCode() à accept().

Il ne nous reste plus qu’à créer une classe Assembler implémentant l’interface Visitor, ce qui n’a rien de compliqué. Notons tout de même que nous avons une fois encore englobé les méthodes writeXxx() de l’objet de type DataOutput, dans des méthodes de même nom, pour éviter d’avoir à gérer des exceptions lors de chaque appel.

PjbDumper

Pouvoir désassembler un fichier .class pour générer un fichier .pjb peut s’avérer utile, notamment pour comprendre de manière simple comment du code Java est compilé en bytecode.

Grâce au patron de conception visiteur créer la classe PjbDumper est extrêmement simple (les méthodes vides ne sont pas représentées) :

public class PjbDumper implements Visitor {

  final private ClassFile classFile;
  final private StringBuilder pjb;
  private int methodCount;

  public PjbDumper(ClassFile classFile) {
    super();
    this.classFile = classFile;
    this.pjb = new StringBuilder();
  }

  public String dump() {
    this.classFile.accept(this);

    this.pjb.append("  .methodend\n");
    this.pjb.append(".classend");
    return this.pjb.toString();
  }

  @Override
  public void visitMagicNumber(int magicNumber) {
    pjb.append(".class ");
  }

  @Override
  public void visitThisClass(int thisClass) {
    final Constant.Class constantClass =
        (Constant.Class) this.classFile.getConstant(thisClass);
    final Constant.UTF8 constantUtf8 =
        (Constant.UTF8) this.classFile.getConstant(constantClass.nameIndex);
    this.pjb.append(constantUtf8.value).append("\n");
  }

  @Override
  public void visitMethodAccessFlags(int accessFlags) {
    if (this.methodCount > 0) {
      this.pjb.append("  .methodend\n\n");
    }

    this.pjb.append("  .method ");

    this.methodCount++;
  }

  @Override
  public void visitMethodNameIndex(int nameIndex) {
    final Constant.UTF8 constantUtf8 =
        (Constant.UTF8) this.classFile.getConstant(nameIndex);
    this.pjb.append(constantUtf8.value);
  }

  @Override
  public void visitMethodDescriptorIndex(int descriptorIndex) {
    final Constant.UTF8 constantUtf8 =
        (Constant.UTF8) this.classFile.getConstant(descriptorIndex);
    this.pjb.append(constantUtf8.value).append("\n");
  }

  @Override
  public void visitOpcode(int opcode) {
    final String mnemonic = Instructions.getMnemonic(opcode);
    this.pjb.append("    ").append(mnemonic).append("\n");
  }
}

Source

Dans ce code, deux éléments sont notables :

Nous avons rajouté une méthode getConstant() à la classe ClassFile qui nous permet de récupérer une constante à un index donné dans le pool de constantes sans nous donner la possibilité de corrompre la liste :

public ConstantPoolEntry getConstant(int index) {
  return this.constantPool.get(index);
}

Source

La deuxième modification est l’ajout d’une méthode statique Instructions.getMnemonic(opcode) qui comme son nom l’indique permet de récupérer une mnémonique à partir d’un opcode.

Nous verrons dans l’article suivant comment avoir des tests solides pour cette classe, mais pour l’instant contentons-nous d’un test unitaire assez simple :

public class PjbDumperTest {
  @Test
  public void dump0() {
    final String className = "org/isk/jvmhardcore/pjba/MyFirstClass";
    final ClassFile classFile = new ClassFileBuilder(className)
      .newMethod("add", "(II)I")
        .iload_0()
        .iload_1()
        .iadd()
        .ireturn()
    .build();

    final PjbDumper dumper = new PjbDumper(classFile);
    final String dump = dumper.dump();
    Assert.assertEquals(EXPECTED, dump);
  }

  final private static String EXPECTED =
      ".class org/isk/jvmhardcore/pjba/MyFirstClass\n"
    + "  .method add(II)I\n"
    + "    iload_0\n"
    + "    iload_1\n"
    + "    iadd\n"
    + "    ireturn\n"
    + "  .methodend\n"
    + ".classend";
}

Source

HexDumper

Pour terminer ce long article, voyons une classe indispensable à la compréhension du format d’un fichier .class et au débogage. Pouvoir visualiser le contenu d’un fichier .class facilite grandement le développement, notamment lors de la création d’un compilateur.

Nous allons donc créer une classe HexDumper qui nous permettra, par exemple en réutilisant notre exemple habituel (une méthode statique add() additionnant deux entiers) la génération de la chaîne de caractères suivante :

00000000	0xcafebabe
00000004	Minor version: 0
00000006	Major version: 48
00000008	Constant Pool: 8
0000000a	  #1 UTF8   org/isk/jvmhardcore/pjba/MyFirstClass
00000032	  #2 Class   #1
00000035	  #3 UTF8   java/lang/Object
00000048	  #4 Class   #3
0000004b	  #5 UTF8   add
00000051	  #6 UTF8   (II)I
00000059	  #7 UTF8   Code
00000060	Access Flags: public super
00000062	This: #2
00000064	Super: #4
00000066	Interfaces: 0
00000068	Fields: 0
0000006a	Methods: 1
0000006c	+ Access Flags: public static
0000006e	  Name index: #5
00000070	  Descriptor index: #6
00000072	  Method Attributes: 1
00000074	  + Attribute name index: #7
00000076	    Length: 16
00000078	    Max stack: 2
0000007a	    Max locals: 2
00000080	      iload_0
00000081	      iload_1
00000082	      iadd
00000083	      ireturn
00000084	    Exceptions: 0
00000086	    Code Attributes: 0
00000088	Class Attributes: 0

La colonne de gauche représente l’offset (en hexadécimal) dans le flux d’octets de l’élément qui suit. Par exemple, les modificateurs de la classes commencent en position 60 (au 96ème octet).

Bien évidemment, un tel affichage nécessite une parfaite connaissance du format d’un fichier .class pour être exploité correctement.

La classe HexDumper étant plutôt longue et sans réelle nouveauté, elle peut être consultée pour une étude approfondie sur github.

En revanche nous pouvons noter la création d’une classe StringValues possédant les méthodes statiques suivantes, qui sont utilisées par la classe HexDumper et prochainement pas la classe PjbDumper :

  • public static String constantTagName(int tag)
  • public static String getClassModifiers(int accessFlags)
  • public static String getMethodModifiers(int accessFlags)

Concernant les tests unitaires, malheureusement la classe HexDumper est une classe typiquement intestable. Seulement son utilisation sur du code réel permettra de savoir s’il y a des bogues.

Avant de conclure, notons que les objets du graphe ClassFile ont légèrement été modifiés pour offrir plus de souplesse. Ceci se traduit par l’ajout de constructeurs vides et, de setters et de getters pour la plupart des champs. La liste complète des changements est disponible en faisant un diff sur la branche courante et la branche précédente :

git diff part12 part13

What’s next ?

Dans l’article suivant, nous allons ajouter à PJBA toutes les instructions que nous avons vues jusqu’à présent et créer un analyseur syntaxique nous permettant de transformer un fichier .pjb en un fichier .class.

Nombre de vue : 22

AJOUTER UN COMMENTAIRE