Klassenanalyse

Gesamtübersicht: Klassenanalyse


Werkzeuge

Um genauer zu analysieren was zur Laufzeit in Clojure Funktionen passiert, wird der Code compiliert und der erzeugte Java-Bytecode anschließend mittels eines Disassemblers in Java Code übersetzt. Hier wurde JD-Gui[1] sowie JAD[2] zum Disassemblieren verwendet. Ein Makefile hilft dabei diese Aufgaben zu automatisieren:

COMP_DIR = clj_classes
CLJ_JARS = lib/clojure.jar:lib/clojure-contrib.jar
CLJC     = clojure.lang.Compile
JAVA     = /opt/java/bin/java
DISSASM  = /home/clj/bin/jd-gui
CP       = $(COMP_DIR):src:$(CLJ_JARS)

comp%: clean outdir
        $(JAVA) -Dclojure.compile.path=$(COMP_DIR) -cp $(CP) $(CLJC) $*

reflect:
        $(DISSASM) $(COMP_DIR)/*.class &

outdir:
        mkdir -p $(COMP_DIR)

clean:
        rm -rf $(COMP_DIR)

Beispiel: Einfache Funktionen

Hier werden Grundfunktionen unter die Lupe genommen.

ID-Funktion

Eingabe
(defn id-fct [x] x)
Ausgabe

Das Ergebnis entspricht dem, was man auch direkt in Java geschrieben hätte:

import clojure.lang.AFunction;

public class simple$id_fct__9 extends AFunction
{
  public Object invoke(Object x)
    throws Exception
  {
    return x;
  }
}

Type Hint

Eingabe
(defn type-hint [#^String s]
  (.charAt s 0))
Ausgabe

Da hier angegeben wurde, dass das Argument s ein String sein könnte (Type-Hints sind nur Hinweise), wird in der generierten Klasse s direkt nach String gecastet und dann die charAt Methode aufgerufen. Auffällig ist, dass relativ viel mit Objekten, anstelle primitiver Typen gearbeitet wird. Diese kommen nur dann zum Einsatz, wenn dies explizit angegeben wird.

import clojure.lang.AFunction;

public class simple$type_hint__17 extends AFunction {

    public static final Object const__0 = Integer.valueOf(0);

    public Object invoke(Object s) throws Exception {
        s = null; // das muss ein Fehler des Disassemblers sein
        return Character.valueOf(((String) s)  //Cast nach String
                .charAt(((Number) const__0).intValue()));
    }
}

Destructuring

Die Funktion bekommt ein Argument (einen Vektor), das erste Element wird an das Symbol x gebunden, der Rest an das Symbol r. Der Rückgabewert ist ein Vektor, welcher die beiden Werte enthält.

Eingabe
(defn vec-to-vec [[x & r]]
  [x r])
Ausgabe

Man sieht, dass die Destrukturierung relativ intuitiv erledigt wird. Die Generierung des Ergebnisvektors hingegen benötigt ein Object-Array als Eingabe, was zunächst erzeugt wird. Dies erscheint ineffizient.

import clojure.lang.AFunction;
import clojure.lang.IFn;
import clojure.lang.RT;
import clojure.lang.Var;

public class simple$vec_to_vec__13 extends AFunction {

    public static final Var const__0 = (Var) RT.var("clojure.core", "let");
    public static final Var const__1 = (Var) RT.var("clojure.core", "nth");
    public static final Object const__2 = Integer.valueOf(0);
    public static final Var const__3 = (Var) RT.var("clojure.core", "nthnext");
    public static final Object const__4 = Integer.valueOf(1);

    public Object invoke(Object p__12) throws Exception {
        Object vec__15;
        Object x;
        Object localObject1 = p__12;
        Object localObject2 = RT.nth(localObject1, ((Number) const__2).intValue(), null);
        Object r = ((IFn) const__3.get()).invoke(localObject1, const__4);
        return RT.vector(new Object[]{x, r});
    }
}

Beispiel: Rekursion, innere Funktionen und Sequenzen

Hier werden einige Varianten der Fakultätsfunktion ausprobiert. Scheinbar ist der generierte Code bei rekursiven Funktionen nicht durch JD-Gui interpretierbar. Durch JAD wird jedoch nachvollziehbar dargestellt, was passiert.

n!

Eingabe

Der hier gewählte Weg entspricht ziemlich genau der mathematischen Definition. Es handelt sich nicht um eine Endrekursion, der Stack wird daher bei großen Werten von n überlaufen (linearer Platzbedarf zur Rekursionstiefe).

(defn stack-fac
  "Berechnet n!, primitiv-rekursiv" [n]
  (cond
    (= n 0) 1
    (> n 0) (* n (stack-fac (dec n)))))
Ausgabe

Das Resultat entspricht den Erwartungen, wenn man bedenkt, dass hier beliebig große Zahlen verarbeitet werden. Es werden jedoch viele Vars aus clojure.core geladen, die anschließend nicht verwendet werden.

import clojure.lang.*;

public class fac$stack_fac__3 extends AFunction {
    public static final Var const__0 = (Var) RT.var("clojure.core", "cond");
    public static final Var const__1 = (Var) RT.var("clojure.core", "=");
    public static final Object const__2 = Integer.valueOf(0);
    public static final Object const__3 = Integer.valueOf(1);
    public static final Var const__4 = (Var) RT.var("clojure.core", ">");
    public static final Object const__5 = Integer.valueOf(0);
    public static final Var const__6 = (Var) RT.var("clojure.core", "*");
    public static final Var const__7 = (Var) RT.var("fac", "stack-fac");
    public static final Var const__8 = (Var) RT.var("clojure.core", "dec");

    public Object invoke(Object n)
            throws Exception {
        return Util.equiv(n, const__2) ?
            const__3 :
            Numbers.gt(n, const__5) ?
                Numbers.multiply(n, ((IFn) const__7.get()).invoke(Numbers.dec(n = null))) :
                null;
    }

    public fac$stack_fac__3() {
    }
}

n! mit innerer Funktion

Eingabe

Hier wird ein anderer Weg eingeschlagen, um die Fakultät zu berechnen. Zunächst wurde eine endrekursive Form gewählt, in der jedoch ein weiterer Parameter, ein Akkumulator, mit angegeben werden muss. Damit die Funktion für alle Eingaben korrekt arbeitet, wurde um diese herum eine äußere Funktion geschachtelt, welche eine gültige Initialisierung für den Akkumulator vornimmt.

Weiterhin wurde hier das Schlüsselwort recur eingeführt. Dieses ist nötig, da die JVM keine Tail-Call-Optimization (TCO) beherrscht. Recur springt wieder an den Kopf der Funktion und bindet vorher n und r an die übergebenen Argumente. Dadurch wird ein konstanter Platzbedarf sicher gestellt.

(defn fac
  "Berechnet n! mittels innerer Funktion endrekursiv. TCO" [n]
  (letfn
    [(n! [n r]
       (cond
         (= n 0) r
         (> n 0) (recur (dec n) (* n r))))]
    (n! n 1)))
Ausgabe

Wie zu erwarten war, wird das recur mittels bedingter Sprünge abgebildet. Ohne diese wäre der konstante Platzbedarf in der JVM nicht realisierbar. Clojure bietet für dieses Einsatzgebiet also angemessenere Mittel (ein recur) als Java (viele goto's).

import clojure.lang.*;

public class fac$fac__11 extends AFunction
{

    public Object invoke(Object n)
        throws Exception
    {
        Object n_BANG_ = null;
        class n_BANG___13 extends AFunction
        {

            public Object invoke(Object n, Object r)
                throws Exception
            {
_L5:
                if(!Util.equiv(n, const__2)) goto _L2; else goto _L1
_L1:
                r;
                  goto _L3
_L2:
                if(!Numbers.gt(n, const__4))
                    break; /* Loop/switch isn't completed */
                r = Numbers.multiply(n, r);
                n = Numbers.dec(n);
                if(true) goto _L5; else goto _L4
_L4:
                null;
_L3:
                return;
            }

            public static final Var const__0 = (Var)RT.var("clojure.core", "cond");
            public static final Var const__1 = (Var)RT.var("clojure.core", "=");
            public static final Object const__2 = Integer.valueOf(0);
            public static final Var const__3 = (Var)RT.var("clojure.core", ">");
            public static final Object const__4 = Integer.valueOf(0);
            public static final Var const__5 = (Var)RT.var("clojure.core", "dec");
            public static final Var const__6 = (Var)RT.var("clojure.core", "*");

            public n_BANG___13()
            {
            }
        }

        n_BANG_ = new n_BANG___13();
        n_BANG___13 _tmp = (n_BANG___13)n_BANG_;
        n = null;
        n_BANG_ = null;
        return ((IFn)n_BANG_).invoke(n, const__2);
    }

    public static final Var const__0 = (Var)RT.var("clojure.core", "letfn");
    public static final Var const__1 = (Var)RT.var("clojure.core", "fn");
    public static final Object const__2 = Integer.valueOf(1);

    public fac$fac__11()
    {
    }
}

n! mit Clojure-Sequenz

Eingabe

Ein noch eleganterer Weg zur Berechnung der Fakultät ergibt sich durch Nutzung einer Sequenz. range erzeugt eine Lazy-Sequenz von 1 bis n. Diese wird dann von der Funktion reduce mittels * zusammengefaltet ((((1*2)*3)*...)*n).

(defn n!
  "Computes n! using Clj-LazySeq." [n]
  (reduce * (range 1 (inc n))))
Ausgabe
import clojure.lang.AFunction;
import clojure.lang.IFn;
import clojure.lang.Numbers;
import clojure.lang.RT;
import clojure.lang.Var;

public class fac$n_BANG___16 extends AFunction {

    public static final Var const__0 = (Var) RT.var("clojure.core", "reduce");
    public static final Var const__1 = (Var) RT.var("clojure.core", "*");
    public static final Var const__2 = (Var) RT.var("clojure.core", "range");
    public static final Object const__3 = Integer.valueOf(1);
    public static final Var const__4 = (Var) RT.var("clojure.core", "inc");

    public Object invoke(Object n) throws Exception {
        n = null;
        return ((IFn) const__0.get()).invoke(const__1.get(),
                ((IFn) const__2.get()).invoke(const__3, Numbers.inc(n)));
    }
}

Abschluss

Es lässt sich feststellen, dass der Clojure Compiler insgesamt ordentlichen Bytecode generiert, wobei teilweise etwas zuviel getan wird. Es werden beispielsweise Vars zu Symbolen ermittelt, welche dann nicht verwendet werden. Dies geschieht an den Stellen, wo Funktionen verwendet werden, die nicht durch Clojure-Code, sondern durch Java-Code realisiert worden sind. Bei der Instanziierung einer Clojure Funktion werden damit evtl. einige unnötige Suchoperationen auf einer Tabelle durchgeführt. Da Funktionen keinen inneren Zustand aufweisen und sie als Var selbst in der Tabelle stehen, wird dies aber nur einmalig beim Laden notwendig.

Weitaus teurer erscheinen aber die Zwischenobjekte, welche generiert werden, um Clojure-Sequenzen zu erzeugen. Dies geschieht bei jeder Funktionsauswertung.