Programmiersprache Julia mit spannenden Funktionen

Aus LinuxUser 06/2020

Programmiersprache Julia mit spannenden Funktionen

© Alphaspirit, 123RF

Einfach schnell

Die noch junge Programmiersprache Julia verspricht, die Flexibilität interpretierter Sprachen wie Python mit der Geschwindigkeit von C zu vereinen.

Python und C gehören nicht nur unter RasPi-Nutzern zu den beliebten Programmiersprachen. Julia tritt mit dem Anspruch an, den Komfort und die Flexibilität dynamisch typisierter und interpretierter Sprachen wie Python oder Ruby mit dem Tempo kompilierter Sprachen wie C zu vereinen. Die Sprache spezialisiert sich dabei auf daten- und rechenintensive Aufgaben sowie das Bearbeiten mathematischer Fragestellungen.

Aktuelle Julia-Versionen laufen problemlos unter 32- und 64-Bit-Linux auf dem PC. Auch der Einsatz unter Raspbian auf dem Raspberry Pi stellt kein Problem dar. Dort gibt es auch Bibliotheken zum Ansprechen der GPIO, darüber hinaus geht etwa das Parallelisieren von Algorithmen auf den vier Kernen aktueller RasPi-Modelle vergleichsweise einfach vonstatten. Grund genug, einen Blick auf die noch junge Programmiersprache zu werfen.

Gestatten: Julia

Seit der Veröffentlichung der ersten Version im Jahr 2012 hat sich die Sprache sehr dynamisch weiterentwickelt. Version 1.0 erschien erst im Herbst 2018. Alle Tutorials, die sich auf frühere Versionen beziehen, sollten Sie mit Vorsicht genießen. Die offizielle Dokumentation [2] halten die Entwickler zwar stets aktuell, sie umfasst aber herausfordernde 1276 Seiten und bringt Entscheidendes nicht immer gut auf den Punkt.

Dieser Artikel bietet einen niedrigschwelligen Einstieg in die Arbeit mit Julia – mit dem Blick für das Wesentliche und dem notwendigen Mut zur Lücke. Er setzt allerdings die Kenntnis grundlegender Begriffe der Software-Entwicklung voraus.

Installation

Alle gängigen Distributionen führen Julia in ihren Repositories, allerdings nur in älteren Versionen der sich rasch entwickelnden Programmiersprache. Eine Ausnahme machen da nur topaktuelle Releases: Unter Ubuntu 20.04 LTS etwa installieren Sie Julia über das Paketmanagement mit dem Aufruf sudo apt install julia und erhalten dann die zum Stand April 2020 aktuelle Version 1.4.1.

Die in diesem Beitrag gezeigten Beispiele sollten zwar auch mit älteren Julia-Versionen funktionieren, bei weitergehenden Julia-Exkursionen empfiehlt sich aber der Einsatz einer aktuellen Variante. Auf der Projektseite [1] finden Sie die Binärdateien der Mitte April 2020 veröffentlichten Version 1.4.1 für alle gängigen Betriebssysteme und Prozessorarchitekturen. Laden Sie die Variante Generic Linux Binaries for x86 je nach Bedarf in der Version für 32- oder 64-Bit-Linux herunter (in Listing 1, Zeile 1 ist es die 64-Bit-Variante) und entpacken Sie sie nach ~/bin/ (Zeile 2). Damit der Befehl julia im Terminal funktioniert, setzen Sie einen symbolischen Link (Zeile 3).

Listing 1

$ wget https://julialang-s3.julialang.org/bin/linux/x64/1.4/julia-1.4.1-linux-x86_64.tar.gz
$ tar -xvzf julia-1.3.1-linux-armv7l.tar.gz -C ~/bin
$ ln -s ~/bin/julia-1.3.1/bin/julia ~/bin/julia

Interaktiver Modus

Mit dem Befehl julia starten Sie nun den Interpreter im Terminal im interaktiven Modus (Abbildung 1). So können Sie einzelne Ausdrücke und Codefragmente ohne den Umweg über eine Datei ausprobieren und mit der Sprache experimentieren. Per Tabulator rufen Sie die Autovervollständigung auf.

Abbildung 1: Im interaktiven Modus nimmt der Interpreter Befehle entgegen und führt sie sofort aus.

Abbildung 1: Im interaktiven Modus nimmt der Interpreter Befehle entgegen und führt sie sofort aus.

Drücken Sie auf einer leeren Zeile die Tabulatortaste, liefert der Interpreter eine Liste aller geladenen Funktionen und Schlüsselwörter zurück. Mit der Eingabe eines Fragezeichens öffnen Sie die Hilfe. Geben Sie Ausdrücke direkt ein, erhalten Sie unmittelbar das Ergebnis (Listing 2). Mit exit() verlassen Sie den Interpreter.

Listing 2

julia> 1 + 1
2
julia> 7 * 8
56
julia> 2 ^ 4
16

Die Division zweier ganzer Zahlen mittels Rechenzeichen ergibt eine Fließkommazahl. Die Funktion div() erledigt eine ganzzahlige Division. Für den Rest nach Division zeichnen der Modulo-Operator % und die Funktion mod() zuständig (Listing 3).

Listing 3

julia> 10 / 2
5.0
julia> div(10, 2)
5
julia> mod(10, 3)
1

Numerische Datentypen

Die Funktion typeof() liefert den Typ eines Werts. Die 32-Bit-Version von Julia interpretiert ganzzahlige Literale als 32-Bit-Zahlen, sofern sie in diesen Wertebereich hineinpassen. Die Funktionen typemin() und typemax() geben Auskunft über die Grenzen des Wertebereichs jedes numerischen Datentyps oder eines seiner Vertreter. Die letzte Zeile in Listing 4 zeigt, dass Julia einen Überlauf nicht abfängt.

Listing 4

julia> typeof(3.14)
Float64
julia> typeof(7)
Int32
julia> typemax(7)
2147483647
julia> typeof(2147483648)
Int64
julia> typemax(7) + 1
-2147483648

Andererseits gilt bei mathematischen Operationen mit Operanden unterschiedlichen Typs: Das Resultat ist immer vom Typ mit der höheren Genauigkeit beziehungsweise dem größeren Wertebereich (Listing 5).

Listing 5

julia> typeof(3 + 4.0)
Float64
julia> typeof(2147483648 + 1)
Int64

Für ganze Zahlen stehen Typen mit 8, 16, 32, 64 und 128 Bit bereit, jeweils mit oder ohne Vorzeichen. Vorzeichenlose Typen führen ein vorgestelltes U für “unsigned”, also etwa UInt8. Julia zeigt sie als hexadezimale Zahlen an (Listing 6). Für Gleitkommazahlen bietet Julia die Typen Float16, Float32 und Float64 an. Die Typen BigInt und BigFloat ermöglichen eine für den normalen Alltag praktisch unbegrenzte Genauigkeit.

Listing 6

julia> typemax(UInt8)
0xff
julia> typemax(UInt16)
0xffff

Variablen und Konstruktoren

Julia ist wie Python eine dynamisch typisierte Sprache; die explizite Angabe des Typs einer Variablen ist nicht notwendig. Die Programmiersprache folgert den Typ aus dem zugewiesenen Ausdruck. Im Beispiel aus Listing 7 unterdrückt ein Semikolon die Ausgabe von Zwischenergebnissen.

Listing 7

julia> a = 1;
julia> b = 2;
julia> c = a + b
3

Möchten Sie explizit mit einem bestimmten Datentyp rechnen, erzeugen Sie Werte per Aufruf mit dem Konstruktor. Diese Konstruktoren eignen sich in vielen Fällen zum Umwandeln von Datentypen (Listing 8).

Listing 8

julia> unsig = UInt8(1)
0x01
julia> sig = Int8(unsig)
1

Dateien ausführen

Dateien mit Julia-Code speichern Sie mit der Endung .jl ab. Das Ausführen geschieht dann über das Kommando julia code.jl im Terminal. In dem Fall müssen Sie – anders als im interaktiven Modus – Ausgaben explizit vorgeben, etwa mit print() oder println().

Auf weitere Argumente auf der Kommandozeile neben dem Pfad oder Dateinamen greifen Sie über das Array ARGS im Programm zu. Listing 9 zeigt Code, der mittels einer For-Schleife sämtliche Argumente zeilenweise ausgibt.

Listing 9

for arg in ARGS
  println(arg)
end

Im Workshop kam der Editor Nano zum Bearbeiten der Dateien zum Einsatz. Das Syntax-Highlighting richten Sie ein, indem Sie die online verfügbare Datei julia.nanorc [3] ins Verzeichnis ~/.nanorc kopieren (Abbildung 2).

Abbildung 2: Nach der Integration der Datei <code>julia.nanorc</code> zeigt Nano eine entsprechende Datei mit farbiger Syntax an.

Abbildung 2: Nach der Integration der Datei julia.nanorc zeigt Nano eine entsprechende Datei mit farbiger Syntax an.

Mittels der Funktion include("code.jl") binden Sie Codedateien in andere Files ein oder führen sie im interaktiven Modus aus. Das Verarbeiten von Argumenten auf der Kommandozeile sieht Julia dabei nicht vor. Beim Ausführen per include() im interaktiven Modus gibt der Interpreter das Ergebnis des letzten Ausdrucks im Terminal aus. Die in der eingebundenen Datei getroffenen Definitionen hält er aber im Speicher.

Kontrollstrukturen

Listing 10 zeigt weitere Beispiele für die Anwendung von For-Schleifen. Die erste gibt die Zahlen von 1 bis 3 aus, die zweite läuft von 0 bis 30 in Zehnerschritten, die dritte geht in Viertelschritten von 0 nach 1.

Listing 10

for i in 1:3
  println(i)
end
for i in 0:10:30
  println(i)
end
for f in 0:0.25:1
  println(f)
end

Der Typ Bool speichert Wahrheitswerte, die Literale schreiben sich true und false. Es gelten die üblichen Operatoren für Logik und Vergleich. Allerdings sind im Unterschied etwa zu Python, Ruby oder C lediglich boolesche Ausdrücke wahrheitsfähig. if 10 ... oder Ähnliches erzeugt also eine Fehlermeldung. Listing 11 zeigt die Anwendung boolescher Ausdrücke in einer If-Konstruktion.

Listing 11

for i in -3:3
  if i < 2
    println(i, " ist kleiner 2!")
  elseif i > 2
    println(i, " ist größer 2!")
  else
    println(i, " ist gleich 2!")
  end
end

Listing 12 demonstriert eine While-Schleife in Aktion. In Zeile 3 sehen Sie auch, wie Julia Nutzereingaben mit readline() abfragt. Da die Eingabe zunächst als String vorliegt, erfordert die weitere Verarbeitung das Umwandeln in eine ganze Zahl.

Listing 12

while true
  println("Bitte gib eine Zahl ein, die durch 7 teilbar ist!")
  x = readline()
  if tryparse(Int32, x) == nothing
    println("Das war keine Zahl!")
    continue
  end
  x = parse(Int32, x)
  if mod(x,7) == 0
    println(x, " ist durch 7 teilbar!")
    break
  end
end

Ob das mit dem jeweiligen String überhaupt funktioniert, prüft das Skript mittels der Funktion tryparse() in Zeile 4. Falls nicht, gibt es eine Warnung aus und führt mit continue den aktuellen Durchgang fort. Ist die Eingabe vom Typ Int32, schreibt die Funktion parse() in Zeile 8 den Wert in die Variable x. Zeile 9 prüft, ob es möglich ist, den Wert ohne Rest durch 7 zu teilen. Gelingt das, sorgt break für das Verlassen der While-Schleife und damit für das Ende des Programms.

Funktionen definieren

Julia bietet drei verschiedene Möglichkeiten, um Funktionen zu definieren. Listing 13 demonstriert diese drei Schreibweisen für eine identisch arbeitende Funktion, die das Argument x mit 3 multiplizieren soll. Die erste Variante bietet sich an, wenn der Funktionskörper nur aus einer Zeile besteht. Sie macht sehr knappen und gut lesbaren Code möglich. Die zweite empfiehlt sich bei mehrzeiligen Funktionskörpern. Auf den Nutzen der dritten, anonymen Variante kommen wir später zurück.

Listing 13

f1(x) = x*3
function f2(x)
  x*3
end
f3 = x -> 3*x
println(f1(3))
#> 9
println(f2(7))
#> 21
println(f3(9))
#> 27

Für Funktionen gilt: Das Ergebnis des zuletzt ausgewerteten Ausdrucks entspricht dem Rückgabewert der Funktion; das Schlüsselwort return ist optional. In manchen Situationen hilft es, explizit aus der Funktion herauszuspringen. Für Funktionen, die nichts zurückliefern sollen, schreiben Sie return nothing.

Typisierung von Funktionen

Optional gestattet Julia das Typisieren von Funktionen. Die Variante aus Listing 14 akzeptiert lediglich Argumente vom Typ Int32; einen Funktionsaufruf mit einer Fließkommazahl als Argument quittiert Julia dann mit einer Fehlermeldung. Diese weicht in einer aktuellen Version von Julia von der gezeigten ab.

Listing 14

julia> f(x::Int32) = 3*x
f (generic function with 1 method)
julia> f(3.0)
ERROR: MethodError: no method matching f(::Float64)
[...]

Die Begriffe Funktion und Methode haben in Julia sehr präzise Bedeutungen, die deutlich von denen in anderen Programmiersprachen abweichen. Funktion meint den Bezeichner einer Funktion. Eine Methode hingegen meint die konkrete Implementation einer Funktion auf Argumente eines bestimmten Typs. Definieren Sie in der Sitzung aus Listing 14 f() auch noch für ein Argument vom Typ Float32, erhält die Funktion f() zwei Methoden (Listing 15).

Listing 15

[...]
julia> f(x::Float32) = 3*x
f (generic function with 2 methods)

Die Funktion methods() gibt Auskunft über die Anzahl und Signatur der zu einer Funktion gehörenden Methoden (Listing 16).

Listing 16

julia> methods(f)
# 2 methods for generic function "f":
[1] f(x::Float32) in Main at REPL[2]:1
[2] f(x::Int32) in Main at REPL[1]:1

Auch + ist eine Funktion, wie der Ausdruck julia> +(1, 2) verdeutlicht. Sie besitzt insgesamt 166 Methoden, die Sie mit julia> methods(+) auflisten. Möchten Sie den Typ des Rückgabewerts einer Funktion angeben, tippen Sie julia> f(x::Int32)::Int32 = 3*x.

Anders als bei dem von Java oder C++ bekannten Überladen entscheidet Julia nicht beim Kompilieren, welche Methode zum Zuge kommt, sondern zur Laufzeit. Dieses Verfahren heißt Multiple Dispatch (Mehrfachverteilung).

Methoden können sich dabei genauso auf konkrete wie auf abstrakte Typen und Mischungen von beiden beziehen. Ein abstrakter Typ ist zum Beispiel Signed, von dem alle ganzzahligen Typen mit Vorzeichen abgeleitet sind. Der abstrakte Typ Integer dient als Supertyp aller Ganzzahlen (Listing 17). Mit supertype() bringen Sie den direkten Vorfahren eines Typs in Erfahrung. Der Typ Any ist der Supertyp aller Typen.

Listing 17

julia> supertype(Int64)
Signed
julia> supertype(Int8)
Signed
julia> supertype(Signed)
Integer

Listing 18 definiert neun verschiedene Methoden für eine Funktion g() und zeigt einige Beispiele für Methodenaufrufe. Versuchen Sie nachzuvollziehen, welche Methode für welche Argumente gültig ist.

Listing 18

g(x::String) = println("String")
g(x::Float32) =  println("Float32")
g(x::Unsigned) = println("Unsigned")
g(x::Integer) = println("Integer")
g(x::AbstractFloat) = println("AbstractFloat")
g(x::Real) = println("Real")
g(x::Any, y::Float32) =  println("Any, Float32")
g(x::Float32, y::Any) = println("Float32, Any")
g(x::Any, y::Any) = println("Any, Any")
g(10)
#> Integer
g(UInt(64))
#> Unsigned
g("hallo")
#> String
g(22.0)
#> AbstractFloat
g(7, 8.9)
#> Any, Any
g(7, Float32(8.9))
#> Any, Float32

Komplexere Funktionen

Listing 19 definiert mit make_adder() eine Funktion, die Funktionen mittels anonymer Definition erzeugt und als Rückgabewert zurückliefert. In den Zeilen 3 und 4 liefert make_adder() die Funktionen add7() und add12(). Die Zeilen 6 und 8 zeigen die Anwendung.

Zeile 11 speichert ein Array unter dem Namen arr. Die Funktion map() erwartet als erstes Argument eine anonyme Funktion oder einen Funktionsnamen und als zweites ein Array oder einen Vertreter eines anderen aufzählbaren Typs. Die Zeilen 13 und 16 zeigen die Anwendung von map() mit den zur Laufzeit erzeugten Funktionen und (in Zeile 19) die Anwendung mit einer anonymen Funktion.

Listing 19

make_adder(val) = x -> x+val
add7 = make_adder(7)
add12 = make_adder(12)
add7(3)
#> 10
add12(12)
#> 24
arr = [1, 2, 3]
map(add7, arr)
#> [8, 9, 10]
map(add12, arr)
#> [13, 14, 15]
map(x -> 2*x, arr)
#> [2, 4, 6]

Königsdisziplin Arrays

Der flexible Umgang mit Arrays beliebiger Dimension gehört zu den starken Seiten von Julia. Die Funktionen zum Erzeugen und Modifizieren von Arrays böten Stoff für einen eigenen Artikel. Listing 19 stellte bereits die einfache Literal-Schreibweise für Arrays vor. Wenn Sie sie im interaktiven Modus verwenden, erhalten Sie ein Ergebnis wie in den Zeilen 1 bis 5 von Listing 20.

Listing 20

julia> [1,2,3]
3-element Array{Int32,1}:
 1
 2
 3
julia> ["x", 1]
2-element Array{Any,1}:
  "x"
 1
julia> [7, 8, 9][1]
7

An der Ausgabe sehen Sie, dass der Datentyp Array parametrisch ist. Der Inhalt der geschweiften Klammer gibt darüber Auskunft, dass dieses Array nur Elemente vom Typ Int32 enthält und eindimensional ist.

In den Zeilen 6 bis 9 von Listing 20 hat das Array den Typ Any, da es keinen anderen Datentyp gibt, der die Typen String und Int32 zusammenfasst. Es versteht sich fast von selbst: Wenn es um das flotte Bearbeiten großer Datenmengen geht, sollten Sie nach Möglichkeit mit homogenen Arrays und Typen fester Länge arbeiten.

Sie sprechen Elemente wie in vielen anderen Programmiersprachen über einen Index an, den Sie in eckigen Klammern schreiben. Der gewichtige Unterschied: Bei Julia besitzt das erste Element den Index 1 (Listing 20, Zeile 10).

Initialisieren

Listing 21 zeigt einige Varianten zum Initialisieren von Arrays. Die Zeilen 1 und 5 erzeugen Arrays per Listen-Abstraktion. Für die String-Darstellung eindimensionaler Arrays funktioniert println() gut, bei mehreren Dimensionen fallen die Ergebnisse von display() aber oft leserlicher aus.

Listing 21

arr = [x for x in 1:3];
println(arr)
#> [1, 2, 3]
arr = [10* x + y for x in 1:3, y in 1:3];
display(arr)
#> 3×3 Array{Int32,2}:
#> 11  12  13
#> 21  22  23
#> 31  32  33
zeros(3)
#> [0.0, 0.0, 0.0]
ones(3)
#> [1.0, 1.0, 1.0]
zeros(Int32, 3)
#> [0, 0, 0]
zeros(UInt8, 2, 2, 2)
#> 2×2×2 Array{UInt8,3}:
#> [:, :, 1] =
#> 0x00  0x00
#> 0x00  0x00
#>
#> [:, :, 2] =
#> 0x00  0x00
#> 0x00  0x00
rand()
#> 0.395879...
rand(Bool)
#> false
rand(UInt8)
#> 0xad
rand(Int8, 2, 2, 2)
#> 2×2×2 Array{Int8,3}:
#> [:, :, 1] =
#> -117    59
#>  -84  -101
#>
#>[:, :, 2] =
#>  -80   93
#>  -64  -19

Die Funktionen zeros() und ones() erzeugen mit Nullen oder Einsen gefüllte Arrays. Mit dem ersten Argument spezifizieren Sie den Typ. Hat das erste Argument keinen Typ, füllt der Interpreter das Array mit Werten vom Typ Float64.

Die nachfolgenden Argumente bestimmen die Anzahl der Dimensionen und deren Umfang. Der Befehl zeros(UInt8, 2, 2, 2) in Zeile 19 liefert ein dreidimensionales Array, bei dem jede Dimension eine Ausdehnung von zwei Elementen vom Typ UInt8 besitzt. Alle Elemente haben den Wert 0, also 0x00. Das Beispiel zeigt, wie Julia Arrays mit Dimensionen größer 2 per display() darstellt, nämlich scheibchenweise.

Die Funktion rand() (ab Zeile 29) liefert ohne Argument eine Zufallszahl zwischen 0 und 1 vom Typ Float64. Wie bei zeros() und ones() legt ein optionales erstes Argument den Typ des Rückgabewerts fest. Bei numerischen Typen, die keine Fließkommazahlen darstellen, wählt rand() aus dem kompletten Wertebereich des jeweiligen Typs aus. Sprich: rand(Int8) gibt Werte zwischen -128 und 127 zurück.

Auch für rand() gilt: Nachfolgende ganzzahlige Argumente bestimmen Dimension und Ausdehnung. Das demonstriert Zeile 36 aus Listing 21.

Punkt-Operator

Listing 22 zeigt, wie Sie Funktionen mit einem Argument auf Arrays beliebiger Dimensionalität anwenden: Schreiben Sie statt f(arr) einfach f.(arr) und schon multipliziert in diesem Fall Julia jedes Element des zweidimensionalen Arrays mit 10.

Listing 22

f(x) = x*10
arr = rand(Int8, 2, 2)
#> 2×2 Array{Int8,2}:
#> -21  79
#> 102  40
arr = f.(arr)
#> 2×2 Array{Int32,2}:
#> -210  790
#> 1020  400

Zu guter Letzt zeigt Listing 23 einige Möglichkeiten, auf Elemente von Arrays zuzugreifen und Arrays zu modifizieren. Eine knappe, aber gehaltvolle Übersicht zu Julia im Allgemeinen und Array-Funktionen im Besonderen bietet die Kurzreferenz “The Julia Express” von Bogumil Kaminski [4].

Listing 23

arr = [10*x+y for x in 1:3, y in 1:3]
3×3 Array{Int32,2}:
 11  12  13
 21  22  23
 31  32  33
# Zugriff
arr[1]    #> 11
arr[9]    #> 33
arr[2, 2] #> 22
# Ausschneiden
arr[1:2, 1:2]
#> 2×2 Array{Int32,2}:
#> 11  12
#> 21  99
# Zuweisung
arr[2, 2] = 99
# Einfügen
arr = [1, 2, 3]
push!(arr, 40)
#> [1, 2, 3, 40]

Fazit und Ausblick

Trotz der vielen Beispiele konnten wir hier einige fundamentale Datentypen wie String, Dict, Tupel oder Rational nur am Rande oder gar nicht ansprechen. Auch gewährt der Artikel nur einen grundlegenden Einblick in die Methodik der Programmiersprache, wobei sich aber trotzdem zeigt, welches Potenzial in ihr schlummert. Dem Autor gefielen die eleganten Methoden zum Initialisieren von Arrays und der dynamische Umgang mit Funktionen und Funktionsaufrufen. Wir hoffen, dass es Ihnen ähnlich ergeht und Sie interessiert sind, mehr über Julia zu erfahren. (tle)

Der Autor

Pit Noack ist ein freiberuflicher Medienkünstler, Autor und Dozent, dem es Spaß macht, Programmiersprachen anzutesten.

Infos

  1. Julia-Binaries: https://julialang.org/downloads/

  2. Handbuch (englisch): https://docs.julialang.org/en/v1/

  3. Syntax-Highlighting für Nano: https://github.com/Naereen/nanorc/blob/master/julia.nanorc

  4. Kurzreferenz “The Julia Express”: http://bogumilkaminski.pl/files/julia_express.pdf

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDF
LinuxUser 06/2020 KAUFEN
EINZELNE AUSGABE
ABONNEMENTS
TABLET & SMARTPHONE APPS
E-Mail Benachrichtigung
Benachrichtige mich zu:

Hinweis: Dieser Artikel ist älter als ein Jahr, enthaltene Informationen sind möglicherweise veraltet.

0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben