Die noch junge Programmiersprache Julia wartet mit vielen praktischen Funktionen auf. Teil 2 unseres Workshops beschreibt unter anderem den Einsatz von Makros und Modulen.
Der erste Teil unserer Julia-Einführung aus Ausgabe 06/2020 [1] stellt die Grundlagen dieser auf wissenschaftliches Rechnen spezialisierten Sprache vor. Der vorliegende Teil taucht tiefer in die zum Teil anspruchsvolle Materie ein. Die Mühe lohnt sich, denn die Sprache steht in ernsthafter Konkurrenz zu Matlab, Mathematica und sogar C++. Julia [2] punktet mit einer sehr aktiven Community, einem stetig wachsenden Ökosystem und kombiniert die Flexibilität von Skriptsprachen wie Python oder Ruby mit rasanter Ausführungsgeschwindigkeit.
Dieser Beitrag setzt die Kenntnis des ersten Teils und die darin beschriebene Installation von Julia voraus. Aktuell steht Version 1.4.1 bereit. Welche Rolle Objektorientierung in Julia spielt, zeigt der Kasten “Objektorientierung light”. In welchen Pfaden die Programmiersprache ihre Daten auf Ihrem System speichert, entnehmen Sie der Tabelle “Speicherorte”.
Objektorientierung light
Auch wenn hier von Objekten und Konstruktoren die Rede ist: Julia ist keine objektorientierte Sprache im Stil von Python, Java oder C++. Das zeigt sich schon am sparsamen Vererbungsmechanismus sowie an der Unveränderlichkeit benutzerdefinierter Typen. Im RGB-Beispiel quittiert Julia eine Zuweisung wie grey.r = 42 mit der Fehlermeldung: immutable struct of type RGB cannot be changed. Möchten Sie mit veränderlichen Typen arbeiten, dann setzen Sie das Schlüsselwort mutable vor die Typdefinition.
Zudem speichern Objektattribute zwar auch Funktionen, doch diese haben mit den Objektmethoden im Stil von Python oder Java wenig gemein. Um eine hohe Ausführungsgeschwindigkeit bei dynamischer Typisierung zu erreichen, setzt Julia voll und ganz auf die in Teil 1 dieser Serie vorgestellte Mehrfachverteilung (Multiple Dispatch): Methoden sind auf mehr oder weniger spezifische Argumenttypen hin definiert. Julia reicht Funktionsaufrufe abhängig von diesen Typen an die passende Methode weiter. Der LLVM-Compiler sorgt dann für eine optimierte und maschinennahe Ausführung des Funktionsaufrufs.
|
Standardbibliothek |
|
|
Metadaten verfügbarer Packages |
|
|
Geladene Packages |
|
|
Vorkompilierte Packages |
|
|
REPL-History |
|
Eigene Typen definieren
Julia erlaubt es, mit dem Schlüsselwort struct maßgeschneiderte Datentypen zu entwerfen. Listing 1 beschreibt einen Typ zum Speichern von Farbwerten in der RGB-Codierung: Drei Werte zwischen 0 und 255 repräsentieren den Rot-, Grün- und Blauanteil.
Listing 1
# file rgb.jl import Base.* struct RGB r::UInt8 g::UInt8 b::UInt8 end RGB(grey) = RGB(grey, grey, grey) *(c::RGB, x) = RGB(round(c.r*x), round(c.g*x), round(c.b*x))
Die Zeilen 5 bis 7 deklarieren die entsprechenden Attribute r, g und b vom Typ UInt8 – also vorzeichenlose 8-Bit-Ganzzahlen, die exakt den geforderten Wertebereich von 0 bis 255 abbilden. Die Typisierung ist optional, im gegebenen Fall aber sinnvoll.
Zusammen mit dem Datentyp erzeugt Julia automatisch einen Default-Konstruktor, der je Attribut ein Argument erwartet. Zeile 10 definiert einen weiteren Konstruktor mit nur einem Argument, der einen Grauwert liefert – alle drei Attribute erhalten denselben Wert. Das Beispiel zeigt: Konstruktoren stellen für Julia letztlich ganz gewöhnliche Methoden dar.
Die Zeile 11 definiert die attributweise Multiplikation eines RGB-Objekts mit dem Argument x. Da dieses auch aus einem nicht ganzzahligen Wert bestehen könnte, erfordert das eine Rundung mittels round(). Andernfalls würde der aufgerufene Default-Konstruktor sich mit einer Fehlermeldung über mangelnde Präzision beschweren.
Zur Erinnerung: Der Begriff Funktion steht in Julia für den Bezeichner einer Funktion. Bei Methoden handelt es sich um konkrete Implementierungen einer Funktion auf bestimmte Argumenttypen. Wenn Sie im interaktiven Modus methods(*) eingeben, erhalten Sie eine Liste von 358 zugeordneten Methoden. Um eine weitere für Multiplikation zu definieren, gilt es, diese aus dem Modul Base zu importieren (Listing 1, Zeile 2). Über Module erfahren Sie im Folgenden mehr. Wenn Sie methods(*) nach dem Einbinden des RGB-Typs ausführen, zählt die Liste 359 Methoden und führt auch die RGB-Multiplikation auf.
Listing 2 zeigt die Anwendung des Typs RGB im interaktiven Modus. Zunächst müssen Sie die entsprechende Datei per include() ausführen. Die Zeilen 2 und 4 erzeugen RGB-Objekte, die ein reines Rot und einen dunklen Grauwert repräsentieren. Julia zeigt vorzeichenlose Ganzzahlen der Farbwerte in der Hexadezimal-Schreibweise an. Zeile 6 wendet die für RGB-Objekte spezifizierte Multiplikation auf den Grauwert an. Beachten Sie: Das ursprüngliche Objekt bleibt unverändert; die Multiplikation liefert ein neues RGB-Objekt mit veränderten Werten.
Listing 2
> include("rgb.jl"); > red = RGB(255, 0, 0) RGB(0xff, 0x00, 0x00) > grey = RGB(64) RGB(0x40, 0x40, 0x40) > grey * 3 RGB(0xc0, 0xc0, 0xc0) > grey_img = fill(grey, 3, 3) 3×3 Array{RGB,2}: [...] > grey_img .* 3 3×3 Array{RGB,2}: [...]
In Zeile 8 füllt die Funktion fill() ein zweidimensionales Array aus 3 mal 3 Feldern mit Grauwerten. Zeile 11 zeigt, dass die RGB-Multiplikation auch für Arrays beliebiger Dimensionalität funktioniert: Sie müssen lediglich den Punkt-Operator vor den Aufruf der Multiplikation setzen.
Die Entwickler legen Wert darauf, dass Julia mit benutzerdefinierten Datentypen genauso flott rechnet wie mit elementaren. Das haben wir für den Typ RGB erfolgreich getestet: Es spricht nichts dagegen, ein 4K-Bild als zweidimensionales Array von RGB-Objekten zu repräsentieren. Dergleichen würde man in Python oder Java kaum versuchen wollen.
Typhierarchien
Julia bietet einen minimalistischen Vererbungsmechanismus, den Listing 3 skizziert. Zeile 4 definiert den abstrakten Datentyp Audio. Ein solcher abstrakter Datentyp kann keine Attribute besitzen, darf aber nur von abstrakten Typen erben. Es ist also nicht möglich, gemeinsame Eigenschaften verschiedener Typen über Vererbung zu organisieren.
Listing 3
# file inherit.jl import Base.show abstract type Audio end struct Song <: Audio name::String end struct Audiobook <: Audio name::String end play(s::Song) = "lala" play(s::Audiobook) = "blabla" show(io::IO, a::Audio) = print(io, a.name)
Die Typen Song und Audiobook erben von Audio. Die Zeilen 15 und 16 definieren verschiedene Methoden play() für Argumente vom Typ Song und Audiobook.
Die Methode show() kommt immer dann zum Zug, wenn die String-Darstellung eines Objektes gefragt ist, zum Beispiel bei einem Aufruf von println(). Zeile 17 definiert show() auf Argumente des Typs Audio für eine maßgeschneiderte String-Ausgabe. Ähnlich wie bei der Multiplikation erfordert das den Import von Base.show().
Das Beispiel zeigt, dass die Vererbung für Julia lediglich dazu dient, unterschiedliche Typen bei Bedarf gemeinsam zu behandeln: play() ist auf die konkreten Typen hin implementiert, show() auf den abstrakten Typ Audio, der Song und Audiobook zusammenfasst.
Listing 4 demonstriert wiederum die Anwendung im interaktiven Modus. Sie sehen, dass bereits die Ausgabe nach dem Konstruktoraufruf die spezifische show-Methode von Audio aufruft. methods(play) informiert, dass es zwei play-Methoden gibt und zeigt die entsprechenden Dateipfade an.
Listing 4
> include("inherit.jl"); > a = Audiobook("Plato: Menon") Plato: Menon > s = Song("ABBA: S.O.S") ABBA: S.O.S > methods(play) # 2 methods for generic function "play": [1] play(s::Audiobook) in Main at ... [2] play(s::Song) in Main at ... > play(a) "blabla" > play(s) "lala" > [a, s] 2-element Array{Audio,1}: Plato: Menon ABBA: S.O.S
Fasst man Songs und Audiobooks mit [a, s] in einem Array zusammen, erkennt Julia den spezifischsten gemeinsamen Supertyp dieser Objekte, nämlich Audio. Bei Objekten, die sich nicht in dieser Form auf einen Nenner bringen lassen, wäre der Typ des Arrays Any.
Möchten Sie mehr über Typen in Julia erfahren, werden Sie in der offiziellen Dokumentation fündig [3]. Zu den für fortgeschrittene Anwender wichtigen, hier ausgelassenen Themen zählen parametrische Typen, primitive Typen und Unions.
Module und Namensräume
Wie jede halbwegs moderne Programmiersprache modularisiert auch Julia Programme und Namensräume über das Schlüsselwort module. Listing 5 definiert das Modul Adder. Es enthält die beiden Methoden add_sugar() und add_salt().
Listing 5
# file adder.jl module Adder export add_sugar add_sugar(x) = string(x)*" + sugar" add_salt(x) = string(x)*" + salt" end
Diese wandeln das übergebene Argument in einen String um und fügen "+ sugar" beziehungsweise "+ salt" hinzu. Die String-Verknüpfung läuft per Asterisk (*). Die Bedeutung der export-Anweisung verdeutlicht Listing 6.
Listing 6
> include("adder.jl") Main.Adder > Adder.add_sugar("tea") "tea + sugar" > Adder.add_sugar(7) "7 + sugar" > Adder.add_salt("fries") "fries + salt" > using .Adder > add_sugar("coffee") "coffee + sugar" > add_salt("soup") ERROR: UndefVarError: add_salt not defined
Wie gehabt fügen wir die Datei adder.jl per include() ein, was es erlaubt, über den Namen des Moduls auf dessen Funktionen zuzugreifen: Adder.add_sugar(). Knapper schreibt es sich, wenn Sie das Modul per using .Adder einbinden, äquivalent mit dem Ausdruck using Main.Adder.
Jetzt klärt sich die Bedeutung des Schlüsselwortes export in der Moduldatei: Nur die exportierte Funktion add_sugar() ist sichtbar, der Aufruf von add_salt() liefert eine Fehlermeldung.
Namensräume anzeigen
Mit varinfo() bringt Julia im interaktiven Modus eine höchst nützliche Funktion mit. Sie zeigt die im Namensraum eines Moduls bekannten Bezeichner an (Listing 7). Ohne Argument gibt varinfo() den Namensraum von Main an. Das erste Beispiel zeigt die Ausgabe in einer frischen Julia-Sitzung. Nach dem Einbinden des Adder-Moduls per include() erscheint auch dieses in der Liste. Der Aufruf varinfo(Adder) liefert die exportierten Bezeichner. Wenn Sie ein Ihnen unbekanntes Modul unter die Lupe nehmen wollen, gibt varinfo() eine erste Orientierung.
Listing 7
> varinfo() name size summary ---------------- ----------- ------- Base Module Core Module InteractiveUtils 130.423 KiB Module Main Module > include("adder.jl"); > varinfo() name size summary ---------------- ----------- ------- Adder 2.767 KiB Module Base Module [...] > varinfo(Adder) name size summary --------- --------- ---------------------------- Adder 2.337 KiB Module add_sugar 0 bytes typeof(Main.Adder.add_sugar)
Die Standardbibliothek
Julias Standardbibliothek umfasst rund 30 Module, die Sie im Bedarfsfall per using laden. Listing 8 zeigt die Anwendung des Moduls Dates aus der Standardbibliothek. Probieren Sie auch den Aufruf varinfo(Dates) aus, dessen Listing wir aus Platzgründen weglassen.
Listing 8
> using Dates > t1 = now() 2020-04-28T12:07:19.993 > t2 = now() 2020-04-28T12:07:25.01 > typeof(t1) DateTime > delta = t2 - t1 5017 milliseconds > typeof(delta) Millisecond
Mit dem Paketmanager-Modul Pkg fügen Sie der Julia-Installation weitere Module hinzu. Es arbeitet ähnlich wie Apt unter Linux oder Pip unter Python. Eine Übersicht und Dokumentationen aller auf diese Weise verfügbaren Erweiterungen finden Sie online [4]. Hier wird schon auf den ersten Blick die naturwissenschaftliche Ausrichtung der Julia-Community deutlich.
Listing 9 demonstriert, wie Sie das Primzahl-Modul Primes installieren und anwenden. Das erstmalige Einbinden des Moduls per using erfordert Geduld, da Julia es nach dem Herunterladen vorkompiliert. Danach gibt primes(21) die 21. Primzahl aus, factor() leistet eine Primfaktorzerlegung und liefert ein Objekt vom Typ Factorization zurück. Möchten Sie die Primfaktoren stattdessen in einem Array speichern, schreiben Sie factor(Array, 1729).
Listing 9
> using Pkg > Pkg.add("Primes") Updating registry at `~/.julia/registries/General` [...] > using Primes [ Info: Precompiling Primes ...] > prime(21) 73 > factors = factor(1729) 7 * 13 * 19 > typeof(factors) Primes.Factorization{Int32} > factor(Array, 1729) 3-element Array{Int32,1}: 7 13 19
Grafische Ausgaben mit PyPlot
Das Package PyPlot bietet eine Schnittstelle zum bewährten Python-Modul matplotlib und zeichnet für grafische Ausgaben zuständig (Listing 10). Das Vorkompilieren erfordert je nach System ein wenig Geduld: Der erstmalige Aufruf von using PyPlot in einer Julia-Sitzung benötigt etwa auf einem RasPi 4 rund 20 Sekunden. Das eigentliche Rendern geht dann aber recht flott.
Listing 10
> using Pkg > Pkg.add("PyPlot")
Die Ausgabe von Listing 11 zeichnet eine Sinuskurve. Das Array x speichert die horizontalen Koordinaten. Das Argument von collect() beschreibt hierfür ein UnitRange-Objekt, das mit einer Schrittweite von 0,01 von -2 Pi bis +2 Pi läuft. Die Methode collect() überführt diesen Wertebereich in ein Array.
Listing 11
using PyPlot
x = collect(-2*pi:0.01:2*pi)
y = sin.(x)
plot(x, y, lw=5)
title("Sinus")
xlabel("x")
ylabel("y = sin(x)")
Das Array y speichert die vertikalen Koordinaten, die der Punkt-Operator aus der Sinusfunktion und den x-Koordinaten gewinnt. Die Funktion plot() erzeugt dann die grafische Darstellung. Das Schlüsselwortargument lw definiert die Strichbreite der Kurve, title() setzt eine Überschrift. Für die Achsenbeschriftung dienen xlabel() und ylabel() (Abbildung 1).
Sofern noch nicht vorhanden, öffnet Julia beim Ausführen eines Grafikbefehls wie plot() ein neues Grafikfenster. Weitere Grafikbefehle beziehen sich dann auf das offene Fenster. Die Software erlaubt es entsprechend, mehrere Kurven (Abbildung 2) in ein Fenster zu zeichnen (Listing 12).

Abbildung 2: Da Julia jeweils ins gleiche Fenster schreibt, lassen sich auch mehrere Sinuskurven einer Abbildung übereinanderlegen.
Listing 12
using PyPlot x = collect(-2*pi:0.01:2*pi) for amp in 1:5 y = sin.(x).*amp plot(x, y, lw=5) end
In Listing 13 löscht der Befehl clf() den Inhalt des Grafikfensters, die Funktion sleep(0.05) unterbricht die Ausführung für 0,05 Sekunden. Mit diesen beiden Funktionen lassen sich im Handumdrehen Animationen bauen.
Listing 13
using PyPlot
x = collect(0:0.01:2*pi)
for freq in 1:0.1:3
y = sin.(freq*x)
clf()
plot(x, y, lw=5)
title("sin(x*"*string(freq)*")")
sleep(0.05)
end
PyPlot stellt eine kaum überschaubare Fülle von Typen und Optionen für die Darstellung bereit. Listing 14 demonstriert in knapper Form ein Histogramm, eine Tortengrafik und eine Heatmap (Abbildung 3). Eine Übersicht weiterer Plot-Funktionen finden Sie auf Github [5].
Listing 14
# Histogramm > arr = rand(50) > hist(arr, bins=10) # Tortengrafik > labels = ["A","B","C","D"] > pie([1,2,3,4], labels=labels) # Heatmap > arr = [sin(x*y) for x in -8:0.1:8, y in -8:0.1:8] > imshow(arr) > colorbar()

Abbildung 3: Mit nur wenigen Zeilen Code generiert Julia verschiedene Grafiken. Im Bild von links nach rechts: ein Histogramm, eine Tortengrafik und eine Heatmap.
Makros
Ähnlich wie bei Lisp handelt es sich bei Julia um eine homoikonische Sprache. Homoikonizität bedeutet, dass Julia-Programme letztlich aus Julia-Datenstrukturen bestehen und sich als solche manipulieren lassen. Diese Fähigkeit machen sich Makros zunutze. Was im Hintergrund bei einem Makro-Aufruf passiert, nehmen wir später noch unter die Lupe. Listing 15 zeigt zunächst einige Makros in Aktion.
Listing 15
> @isdefined Base true > @isdefined foo false > @assert 0==1 "Fehler!" ERROR: AssertionError: Fehler! > @time sleep(1) 1.000443 seconds (157 allocations: 2.141 KiB) > @doc pi The constant pi. Examples ========= > pi Pi = 3.1415926535897...
Das Makro @isdefined stellt fest, ob ein Bezeichner im aktuellen Namensraum bekannt ist. @assert prüft, ob der erste an das Makro übergebene Ausdruck true ergibt. Falls nicht, gibt Julia den zweiten Ausdruck als Fehlermeldung aus. @time misst die Ausführungsdauer und den Speicherverbrauch eines Ausdrucks. @doc zeigt Dokumentationen an.
Möchten Sie ein vertieftes Verständnis darüber erwerben, was hinter den Kulissen bei der Ausführung eines Julia-Ausdrucks passiert, dann helfen die Makros @which und @edit: Sie zeigen an, welche konkrete Methode jeweils zum Zuge kommt und welche Quelldatei sie implementiert. Die mit @code_ beginnenden Makros erlauben es Ihnen, das Ausführen vom Parsing bis hinab zu den blanken Maschinenbefehlen zu verfolgen.
Expressions
Soll Julia einen Ausdruck nicht ausführen, sondern als Ausdruck behandeln, müssen Sie diesen verpacken. Diese Technik bezeichnet man als Quoting. Listing 16 zeigt dafür zwei alternative, gleichwertige Schreibweisen.
Listing 16
> ex = :(1+1); > typeof(ex) Expr > quote 1+1 end; > ex = Meta.parse("f(x) = 3*x"); > eval(ex); > f(7) 21
Mittels Base.parse() lässt sich auch ein String in einen Ausdruck überführen. Die Funktion eval() führt Ausdrücke aus. Durch Kombinieren von Base.parse() und eval() können Sie Metaprogrammierung realisieren, also Programme schreiben, die sich selbst zur Laufzeit modifizieren.
Eigene Makros schreiben
Auf den ersten Blick sehen Makros und Funktionen sehr ähnlich aus, doch dieser Eindruck täuscht. Eine Funktion berechnet anhand von übergebenen Argumenten einen Rückgabewert. Ein Makro baut anhand eines oder mehrerer als Ausdrücke interpretierter Argumente einen neuen Ausdruck zusammen und liefert diesen zurück. Erst diesen zurückgelieferten Ausdruck führt Julia aus.
Eine Anmerkung an C/C++-Entwickler: Makros funktionieren ähnlich wie Präprozessor-Anweisungen. Während jedoch #define im Prinzip bloße Textersetzungen vollzieht, kennen Julias Makros die Ausführungsumgebung und nehmen daher sehr viel differenziertere Modifikationen vor.
Listing 17 zeigt den Nachbau und die Anwendung des in Listing 15 vorgestellten Makros @assert. Die Zeilen 2 bis 6 bestimmen den Ausdruck, den das Makro zurückliefert. Zeile 3 prüft, ob das Argument ex false ergibt; in dem Fall soll Zeile 4 die Fehlermeldung msg ausgeben. Das Dollarzeichen dient, ähnlich wie bei der String-Interpolation in der Bash, als Platzhalter der Argumente. Ohne es würde Julia die Makro-Ausführung mit einem Fehler quittieren: UndefVarError: ex not defined. Der Aufruf von @Makroexpand in Zeile 12 zeigt den Ausdruck an, den das Makro zurückliefert.
Listing 17
Makro myassert(ex, msg)
quote
if !$ex
println($msg)
end
end
end
@myassert 0==1 "fail"
#> fail
@Makroexpand @myassert 0==1 "fail"
#> quote
if !(0 == 1)
Main.println("fail")
end
end
Listing 18 definiert ein Makro, das den im ersten Argument ex übergebenen Ausdruck n-mal wiederholt. Zu guter Letzt definiert Listing 19 ein Makro, das ähnlich wie das eingebaute @time arbeitet und die Ausführungsdauer eines Ausdrucks ermittelt. Die zweite Anwendung von @mytime zeigt zudem, wie Sie mehrzeilige Ausdrücke an ein Makro übergeben, sofern sich diese nicht ohnehin im Körper etwa einer Funktionsdefinition oder einer for-Schleife befinden.
Listing 18
Makro repeat(ex, n)
quote
for n in 1:$n
$ex
end
end
end
@repeat print("ja") 3
#> jajaja
Listing 19
using Dates
Makro mytime(ex)
quote
t1 = now()
result = $ex
t2 = now()
println(t2-t1)
end
end
@mytime rand(100000);
#> 3 milliseconds
@mytime begin
sleep(0.1)
sleep(0.2)
end
#> 352 milliseconds
Fazit
Falls Ihnen die selbstreferenziellen Zusammenhänge zwischen Makros und Ausdrücken Kopfzerbrechen bereiten: Lassen Sie sich nicht zu schnell entmutigen. Makroprogrammierung und die Manipulation von Expressions gehören zu den anspruchsvollsten Julia-Themen. Es lohnt sich aber, am Ball zu bleiben, denn gerade diese Sprachmittel stellen entscheidende Bausteine für Julias Flexibilität und Leistungsfähigkeit dar.
Die gegebenen Beispiele konnten das Thema nicht umfassend vorstellen. Ein dritter Teil dieser Serie liefert hierzu in einer der nächsten Ausgaben einen Nachschlag und zeigt zudem, wie Sie Berechnungen auf mehrere Prozessorkerne verteilen. (tle)
Der Autor
Pit Noack arbeitet als freiberuflicher Medienkünstler, Autor und Dozent, dem es Spaß macht, Programmiersprachen anzutesten.
Infos
-
Julia: Pit Noack, “Einfach schnell”, LU 06/2020, S. 88, https://www.linux-community.de/44408
-
Julia herunterladen: https://julialang.org/downloads/
-
Julia-Dokumentation zum Thema Links: https://docs.julialang.org/en/v1/manual/types/
-
Übersicht verfügbarer Julia-Erweiterungen https://juliaobserver.com/packages
-
Anwendungsbeispiele für das PyPlot-Package: https://gist.github.com/gizmaa/7214002






