SimpleVM #6: Globals & Java Funktionen

Category: 

In diesem Artikel werden wir die VM um globale Variablen erweitern, wodurch verschiedene Scripte auf gemeinsame Daten zugreifen können. Außerdem werden wir die Möglichkeit schaffen, Java Funktionen aus unserem Bytecode heraus aufzurufen.

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

Globale Variablen

Bisher kann eine Funktion nur auf die eigenen lokalen Variablen zugreifen, sowie bereits bekannte Funktionen aufrufen. Jetzt wollen wir aber auch auf globale Variablen zugreifen, welche von verschiedenen Scripten bearbeitet werden können. Erst einmal müssen wir uns überlegen, wie der globale Speicher aufgebaut sein soll und was er alles speichern können soll.
Im Grunde wollen wir einfach alles darin speichern können. Also von einem Integer bis hin zu Funktionen einfach alles. Dabei sollen diese Variablen durch einen Namen angesprochen werden können. Hier sollte einem auffallen, das die Anforderungen dieselben sind, wie bei den Tables. Dementsprechend ist es ein durchaus sinnvoller Ansatz, einfach einen Table global zugreifbar zu machen. Dieser Ansatz wird beispielsweise auch von der Scriptsprache Lua benutzt. Dort kann man von überall auf den globalen _G Table zugreifen. Alles was nicht als lokal definiert wird, wird automatisch in diesen eingetragen.

Global Table

Für unsere VM bedeutet das nun, dass wir jeder Funktion eine Referenz auf diesen globalen Table mitgeben müssen:

private final VMTable globals;
public VMFunction(VMTable globals, byte[] code, VMFunction[] functions, String[] strings, int localCount, int paramCount) {
  this.globals = globals;
  // ...
}

Doch wie können wir nun auf die Elemente in diesem Table zugreifen? Im Grunde sehr einfach, wir müssen nichts weiter machen, als diesen Table durch einen speziellen Befehl auf den Stack legen. Der Zugriff auf die Elemente wird dann durch die altbekannten GET und SET Befehle erreicht.

case OpCode.PUSH_GLOBALS:
  stack.push(globals);
  break;

Jetzt müssen wir nicht mehr Zwangsweise jede Funktion kennen, die wir aufrufen wollen, sondern können bei Bedarf auch eine Funktion aus dem globalen Table holen:

VMTable globals = new VMTable();
globals.set("max", new VMFunction(globals, new byte[] {
  LOAD, 0,
  LOAD, 1,
  IF_GE, 5,
  LOAD, 1,
  RETURN,
  LOAD, 0,
  RETURN
}, null, null, 2, 2));

VMFunction function = new VMFunction(globals, new byte[] {
  PUSH_GLOBALS,
  PUSH_STRING, 0,
  GET,
  PUSH, 10,
  PUSH, 20,
  CALL, 2,
  PRINT
}, null, new String[] {"max"}, 0, 0);
try {
  function.call(null);
} catch(VMException e) {
  e.printStackTrace();
}

Hier definieren wir erst einmal eine neue Funktion, welche das Maximum von zwei Parametern zurückgibt. Diese Funktion speichern wir dann global unter dem Namen "max" ab. Danach definieren wir eine weitere Funktion, welche sich die globale max-Funktion holt und mit den Werten 10 und 20 aufruft.

Manch einer mag jetzt vielleicht glauben, dass wir das Funktionen Array aus der VMFunction Klasse löschen können, aber dies muss weiter beibehalten werden. Denn globale Variablen können nach belieben überschrieben werden, das Funktionen Array nicht. Außerdem können wir durch das Array auch namenlose Funktionen definieren.

Shortcuts

Grundsätzlich sind die globalen Variablen nun fertig, allerdings kann man auf Wunsch auch noch zwei kleine Shortcuts einbauen, durch welche man gezielt auf globale Variablen zugreifen kann. Bisher muss man immer erst den globalen Table pushen, dann einen String pushen und anschließend GET ausführen. Dies drei Schritte können wir aber auch einfach in einem Spezial Schritt ausführen:

case OpCode.GET_GLOBAL:
  name = strings[code[pc++]].toString(); // get name of member
  obj = globals.get(name); // get global object
  stack.push(obj); // push object
  break;
case OpCode.SET_GLOBAL:
  name = strings[code[pc++]].toString(); // get name of member
  obj = stack.pop(); // get object
  globals.set(name, obj);
  break;

Der obige Code, welcher die max Funktion aufruft, vereinfacht sich somit zu:

GET_GLOBAL, 0,
PUSH, 10,
PUSH, 20,
CALL, 2,
PRINT

Java Funktionen

Unsere bisherige VM ist ja schön und gut, aber bisher kann unser Bytecode nur anderen Bytecode aufrufen. Es wäre aber oft schön, wenn wir auch auf Java Funktionen zugreifen könnten.
Dies lässt sich glücklicherweise relativ leicht erreichen. Wir müssen nichts weiter tun, als von VMObject erben und die call-Methode überschreiben. Anschließend können wir ein Objekt dieser Klasse als globale Funktion eintragen. Als kleines Beispiel definieren wir uns eine print-Funktion:

globals.set("print", new VMObject() {
  @Override
  public VMObject call(VMObject[] params) throws VMException {
    if(params!=null){
      for(VMObject obj : params)
        System.out.printf("%s ", obj.toString());
    }
    System.out.println();
    return null;
  }
});

Diese Methode können wir nun ganz einfach Aufrufen:

GET_GLOBAL 0
PUSH_STRING 1
CALL 1

Aufräumen

Nun mag dem einen oder anderen aufgefallen sein, das die PRINT und READ Befehle nun recht unnötig geworden sind. Grundsätzlich kann man sie natürlich drin lassen, jedoch stören sie schon etwas das Design der VM, da sich sowohl Ein-, als auch Ausgabe eigentlich wie gewöhnliche Funktionen verhalten sollten.
Daher löschen wir diese beiden OpCodes und ersetzen sie durch globale Java Funktionen.

final Scanner scanner = new Scanner(System.in);
globals.set("readLine", new VMObject() {
  @Override
  public VMObject call(VMObject[] params) throws VMException {
    return new VMString(scanner.nextLine());
  }
});
globals.set("readInt", new VMObject() {
  @Override
  public VMObject call(VMObject[] params) throws VMException {
    return new VMInt(scanner.nextInt());
  }
});
globals.set("readFloat", new VMObject() {
  @Override
  public VMObject call(VMObject[] params) throws VMException {
    return new VMFloat(scanner.nextFloat());
  }
});

Damit ist die VM schon um einiges übersichtlicher geworden. Allerdings können wir noch eine Sache verbessern. Anstatt unsere Java Funktion direkt von VMObject erben zu lassen, könnten wir auch eine Zwischenklasse einbauen:

package com.infectedbytes.tutorials.simplevm.types;
import com.infectedbytes.tutorials.simplevm.VMException;
public abstract class JavaFunction extends VMObject {
  @Override
  public abstract VMObject call(VMObject[] params) throws VMException;
}

Im Grunde brauchen wir diese Klasse nicht unbedingt, jedoch ist es aus Benutzersicht intuitiver die eigenen Funktionen von JavaFunction erben zu lassen, anstatt von VMObject. Außerdem sorgen wir so dafür, dass die call-Methode auf jedenfall überschrieben werden muss.

Standart Funktionen

Jetzt wo wir globale Variablen haben und auch Java Funktionen vom Bytecode aus verwenden können, bietet es sich an einige Standartfunktionen zu definieren. Denn oft gebrauchte Funktionen, wie die print-Funktion, sollte man nicht jedes Mal neu definieren müssen. Daher erstellen wir ein neues package namens "lib" und erstellen darin ein paar neue Klassen, welche benutzt werden können um Funktionen in einen global Table einzutragen.
Die erste Standart "Bibliothek" ist für mathematische Funktionen:

package com.infectedbytes.tutorials.simplevm.lib;
import com.infectedbytes.tutorials.simplevm.VMException;
import com.infectedbytes.tutorials.simplevm.types.JavaFunction;
import com.infectedbytes.tutorials.simplevm.types.VMFloat;
import com.infectedbytes.tutorials.simplevm.types.VMInt;
import com.infectedbytes.tutorials.simplevm.types.VMNumber;
import com.infectedbytes.tutorials.simplevm.types.VMObject;
import com.infectedbytes.tutorials.simplevm.types.VMTable;

public class MathLib extends VMTable {
  public static void install(VMTable globals) {
    globals.set("math", new MathLib());
  }
  private MathLib() {
    set("abs", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params.length != 1) throw new VMException();
        VMNumber obj = (VMNumber)params[0];
        if (obj instanceof VMInt) return new VMInt(Math.abs(obj.asInt()));
        if (obj instanceof VMFloat) return new VMFloat(Math.abs(obj.asFloat()));
        throw new VMException();
      }
    });
    set("floor", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params.length != 1) throw new VMException();
        VMNumber obj = (VMNumber)params[0];
        return new VMInt((int)Math.floor(obj.asFloat()));
      }
    });
    set("ceil", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params.length != 1) throw new VMException();
        VMNumber obj = (VMNumber)params[0];
        return new VMInt((int)Math.ceil(obj.asFloat()));
      }
    });
    set("log", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params.length != 1) throw new VMException();
        VMNumber obj = (VMNumber)params[0];
        return new VMFloat((float)Math.log(obj.asFloat()));
      }
    });
    set("log10", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params.length != 1) throw new VMException();
        VMNumber obj = (VMNumber)params[0];
        return new VMFloat((float)Math.log10(obj.asFloat()));
      }
    });
    set("sqrt", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params.length != 1) throw new VMException();
        VMNumber obj = (VMNumber)params[0];
        return new VMFloat((float)Math.sqrt(obj.asFloat()));
      }
    });
  }
}

Um diese nun benutzen zu können, müssen wir nur die install Methode aufrufen und ihr unseren global Table übergeben:

VMTable globals = new VMTable();
MathLib.install(globals);

Um die math-Methoden verwenden zu können, müssen wir uns erst den math Table holen und anschließend seine Methoden aufrufen:

VMFunction function = new VMFunction(globals, new byte[] {
  GET_GLOBAL, 2, // get print function
  GET_GLOBAL, 0, // get math table
  PUSH_STRING, 1, // push "sqrt"
  GET, // get sqrt function from math table
  PUSH, 2,
  CALL, 1, // call sqrt function
  CALL, 1 // call print function
}, null, new String[] {"math", "sqrt", "print"}, 0, 0);

Die print-Methode und die verschiedenen read-Methoden sollten wir nun auch in eine extra Klasse auslagern:

package com.infectedbytes.tutorials.simplevm.lib;
import java.util.Scanner;
import com.infectedbytes.tutorials.simplevm.VMException;
import com.infectedbytes.tutorials.simplevm.types.JavaFunction;
import com.infectedbytes.tutorials.simplevm.types.VMFloat;
import com.infectedbytes.tutorials.simplevm.types.VMInt;
import com.infectedbytes.tutorials.simplevm.types.VMObject;
import com.infectedbytes.tutorials.simplevm.types.VMString;
import com.infectedbytes.tutorials.simplevm.types.VMTable;

public class BasicLib extends VMTable {
  private BasicLib() {}
  @SuppressWarnings("resource")
  public static void install(VMTable globals) {
    globals.set("print", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        if (params != null) {
          for (VMObject o : params)
            System.out.printf("%s ", o.toString());
        }
        System.out.println();
        return null;
      }
    });

    final Scanner scanner = new Scanner(System.in);
    globals.set("readLine", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        return new VMString(scanner.nextLine());
      }
    });
    globals.set("readInt", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        return new VMInt(scanner.nextInt());
      }
    });
    globals.set("readFloat", new JavaFunction() {
      @Override
      public VMObject call(VMObject[] params) throws VMException {
        return new VMFloat(scanner.nextFloat());
      }
    });
  }
}

Natürlich können wir nun nach belieben weitere Standartbibliotheken hinzufügen. Denkbar wäre beispielsweise eine String Bibliothek, welche Methoden wie substring und len bereitstellt.

Abschluss

Nach diesem Artikel sind wir nun in der Lage, globale Variablen zu benutzen, sowie Java Funktionen von unserem Bytecode aus zu verwenden. Außerdem haben wir eine Art "Standartbibliothek" hinzugefügt.
Damit ist unsere kleine VM in der Lage selbst komplexe Programme zu erzeugen.

Im nächsten Teil setzen wir unsere Objekte auf Steroide und erweitern sie um die Möglichkeit Operatoren zu überladen: #7 Operator overloading

Code zu diesem Teil: 06_globals.zip