SimpleVM #5: OOP

Category: 

Heute werden wir die String Unterstützung der VM etwas verbessern und außerdem die Objektorientierte Programmierung einführen.

Dieser Artikel ist Teil der Serie: Simple Virtual Machine
#1 Einstieg
#2 Prototyp
#3 Bedingte Sprünge
#4 Funktionen
#5 OOP
#6 Globale Variablen und Java Funktionen
#7 Operator overloading
#8 Details & Loading from File
#9 Bonus: Assemblercode

Strings

Grundsätzlich unterstützen wir Strings bereits, allerdings fehlt uns noch eine Möglichkeit diese direkt aus dem Bytecode heraus auf den Stack zu legen.
Bytes auf den Stack zu legen ist sehr simpel, da wir es einfach als Parameter hinter der Instruktion mitgeben können. Bei Strings ist dies jedoch sehr unschön, da sie beliebig lang sein können. Dementsprechend müssen wir uns hier etwas anderes überlegen. Ein "Standart" Ansatz ist es, jeder Funktion ein Array aus Strings mitzugeben. Mit einem neuen Bytecode, können wir dann einen dieser Strings auf den Stack legen:

private final VMString[] strings;
public VMFunction(byte[] code, VMFunction[] functions, String[] strings, int localCount, int paramCount) {
  // ...
  if(strings == null) {
    this.strings = null;
  } else {
    this.strings = new VMString[strings.length];
    for(int i=0;i<strings.length;i++)
      this.strings[i] = new VMString(strings[i]);
  }
}

Der PUSH_STRING OpCode ist sehr ähnlich zu dem PUSH_FUNCTION Code:

case OpCode.PUSH_STRING:
  obj = strings[code[pc++]];
  stack.push(obj);
  break;

Damit können wir jetzt auch endlich das klassische "Hello World" Programm schreiben:

new VMFunction(new byte[] {
    PUSH_STRING, 0,
    PUSH_STRING, 1,
    ADD,
    PRINT
}, functions, new String[] {"Hello ", "World"}, 0, 0);

Dieses Programm legt die beiden Strings "Hello " und "World" auf den Stack, konkateniert sie und gibt das Ergebnis auf der Konsole aus.

OOP

Objektorientierte Programmierung ist äußerst mächtig, jedoch auch sehr komplex in der Implementierung. Insbesondere wenn man den Klassenorientierten Ansatz verfolgt. Klassen sind bekanntermaßen Vorlagen, aus denen konkrete Instanzen erzeugt werden können. Wichtig ist dort auch die Vererbung und die dynamische Methodenbindung. Diese Dinge sind allesamt sehr aufwendig zu implementieren. Allerdings gibt es auch noch andere Ansätze der OOP. Einer der einfachsten Ansätze, ist der Ansatz von der Scriptsprache Lua.

Table basierte Objektorientierung

Bei diesem Ansatz ist ein Objekt nichts weiter als eine Map, welche den Namen eines Members auf den eigentlichen Wert abbildet. Somit können wir beliebig verschachtelte Objekte erzeugen.

package com.infectedbytes.tutorials.simplevm.types;
import java.util.HashMap;
public class VMTable extends VMObject {
  private final HashMap<String, VMObject> member = new HashMap<String, VMObject>();
  public VMObject get(String name) {
    return member.get(name);
  }
  public void set(String name, VMObject obj) {
    member.put(name, obj);
  }
}

Jetzt müssen wir natürlich noch zwei OpCodes hinzufügen, welche einen Member holen und setzen können. Der GET Befehl benötigt einen Table, sowie den Namen eines Members auf dem Stack. Falls der Table ein Member mit dem Namen besitzt, so wird dieser auf den Stack gelegt, ansonsten wird null auf den Stack gelegt:

Die SET Instruktion funktioniert ähnlich. Sie erwartet ebenfalls den Table und den Member Namen auf dem Stack. Zusätzlich wird natürlich noch das Objekt benötigt, welches als Member eingetragen werden soll:

Nun fügen wir die beiden Instruktionen zu den OpCodes hinzu und erweitern das Switch-Statement der VMFunction:

case OpCode.GET:
  other = stack.pop(); // Name of Member
  obj = stack.pop(); // Table
  stack.push(((VMTable)obj).get(other.toString()));
  break;
case OpCode.SET:
  other = stack.pop(); // Object
  String name = stack.pop().toString();
  obj = stack.pop(); // Table
  ((VMTable)obj).set(name, other);
  break;

Jetzt fehlt allerdings noch eines: Wo bekommen wir den Table her? Ganz einfach, wir fügen einen OpCode hinzu, welcher einen neuen (leeren) Table auf den Stack legt. Diesen Table können wir dann nach beliebigen erweitern und benutzen:

case OpCode.NEW:
  stack.push(new VMTable());
  break;

Ein einfaches Objekt mit x und y Koordinaten können wir somit so erzeugen:

new VMFunction(new byte[] {
  NEW,
  STORE, 0,

  LOAD, 0,
  PUSH_STRING, 0,
  PUSH, 10,
  SET,

  LOAD, 0,
  PUSH_STRING, 1,
  PUSH, 20,
  SET,

  LOAD, 0,
  PUSH_STRING, 0,
  GET,
  PRINT
}, functions, new String[] {"x", "y"}, 1, 0);

Allerdings haben wir noch ein Problem, wie können wir von einer Objektmethode auf deren Elemente zugreifen? Denn eine Funktion kennt ja bisher nur ein paar Strings und andere Funktionen. Irgendwie muss die Funktion Zugriff auf den Table bekommen.

Instanzmethoden

Die Lösung ist relativ simpel. Anstatt den umgebenen Table umständlich bei der Konstruktion zu übergeben, geben wir ihn einfach als ersten Parameter mit. Dieser Ansatz wird z.B. auch von Lua verwendet und im Grunde sogar von Java selbst. Alle Objektmethoden erhalten implizit vom Compiler das this Objekt als ersten Parameter mit.
Dementsprechend brauchen wir die VM gar nicht ändern, da Aufrufe von Objektmethoden implizit unterstützt werden, wir müssen nur darauf achten, dass wir den Table auch als ersten Parameter übergeben.

functions[0] = new VMFunction(new byte[] {
  NEW,
  STORE, 0,

  LOAD, 0, // load table
  PUSH_STRING, 0,
  PUSH, 10,
  SET,

  LOAD, 0, // load table
  PUSH_STRING, 1,
  PUSH, 20,
  SET,

  LOAD, 0, // load table
  PUSH_STRING, 2,
  PUSH_FUNCTION, 1,
  SET,

  LOAD, 0, // load table
  PUSH_STRING, 2,
  GET,
  LOAD, 0, // load table
  CALL, 1
}, functions, new String[] {"x", "y", "print"}, 1, 0);

functions[1] = new VMFunction(new byte[] {
  PUSH_STRING, 0,
  PUSH_STRING, 2,
  ADD,
  LOAD, 0, // load table
  PUSH_STRING, 0,
  GET,
  ADD,
  PRINT
}, functions, new String[] {"x", "y", "="}, 1, 1);

Manche Leute mag es vielleicht etwas stören, dass man bei einem Aufruf einer Instanzmethode zweimal den Table laden muss. Einmal um die Methode aus dem Table zu holen und einmal um den Table als ersten Parameter mitzugeben. Um das etwas angenehmer zu machen, kann man es wie Lua machen und einen kleinen Shortcut einbauen. Der Befehl SELF benötigt einen Table auf dem Stack und besitzt einen Parameter, welcher als Index in das String Array benutzt wird. Der Befehl holt dann den Member mit dem im String angegebenen Namen und legt den Table wieder auf den Stack, damit dieser direkt als erster Parameter der Funktion genutzt werden kann:

case OpCode.SELF:
  other = strings[code[pc++]]; // get name of member
  obj = stack.pop(); // get table
  stack.push(((VMTable)obj).get(other.toString())); // push member
  stack.push(obj); // push table
  break;


Dadurch können wir den Funktionsaufruf von oben etwas vereinfachen:

LOAD, 0, // load table
SELF, 2,
CALL, 1

Grundsätzlich haben wir damit die Grundlage für Objektorientierte Programmierung geschaffen. Das OOP Konzept im Bytecode anzuwenden ist natürlich etwas aufwendig, aber dafür werden wir später auch einen Compiler bauen, welcher das ganze stark vereinfachen wird.
Damit sind wir auch am Ende dieses Artikels angelangt. Im nächsten Artikel geht es dann um globale Variablen und Zugriff auf Java Funktionen: #6 Globals & Java Functions

Code zu diesem Teil: 05_oop.zip