Funktionsorientiert und schnell: Die Programmiersprache Julia

Julia ist eine flexible und performante Programmiersprache, die unterschiedliche Konzepte verbindet. Trotz ihrer wissenschaftlichen Ausrichtung eignet sie sich auch für allgemeine Entwickleraufgaben.

In Pocket speichern vorlesen Druckansicht 31 Kommentare lesen
Funktionsorientiert und schnell: Die Programmiersprache Julia
Lesezeit: 20 Min.
Von
  • Simon Danisch
Inhaltsverzeichnis

Die Programmiersprache Julia ist relativ jung. Sie vereint die Vorteile dynamischer und statischer Sprachen und soll die Geschwindigkeit von C/C++ erreichen, dabei aber einen mit Python vergleichbar geringem Programmieraufwand erfordern. Zusätzlich bietet Julia von Lisp inspirierte Metaprogrammierung sowie unterschiedliche Wege, Code parallel und verteilt auszuführen. Die Standardbibliothek bindet bewährte Open-Source-Bibliotheken für wissenschaftliches Programmieren ein und implementiert viele Funktionen direkt. Julia ist merklich auf wissenschaftliche Aufgaben fokussiert, eignet sich aber auch für generelle Programmieraufgaben. Durch ihren Git-basierten Paketmanager lassen sich Pakete auf einfache Weise ausprobieren und erweitern. Das erlaubt Entwicklern einen schnellen Einstieg.

Historisch ist Julia aus der Notwendigkeit geboren, eine schnelle und einfach nutzbare Sprache für wissenschaftliches und numerisches Programmieren zu schaffen. Sie ist als Reaktion auf den Trend zu sehen, einen mathematisch korrekten Prototypen in einer expressiven Sprache wie Python zu entwickeln und ihn, wenn er zu langsam läuft, teilweise in Fortran beziehungsweise C/C++ neu zu schreiben. Dieses Muster zeigt sich besonders in Anwendungsbereichen, die auf gute Performance angewiesen sind, wie Machine Learning, Statistik und Simulationen. Durch diese Vorgehensweise können sich Entwickler zwar recht einfach auf die korrekte Implementierung der Algorithmen konzentrieren, der Preis ist jedoch der erhöhte Zeitaufwand und die kompliziertere Wartung. Julia verspricht eine elegante Umsetzung innerhalb einer Programmiersprache. Da das System zur Laufzeit (JIT, Just-in-time) mit LLVM kompiliert, dem Compiler-Backend von Clang für C/C++, schafft sie exzellente Auto-SIMD-Vektorisierungen und höchste Performance. SIMD-Vektorisierung ist besonders wichtig, um moderne Prozessoren auszulasten. Sie erlaubt es, mehrere CPU-Instruktionen gleichzeitig auszuführen. Dieses Compiler-Feature verlangt rigide Laufzeitgarantien und ist deswegen in kaum einer dynamischen Sprache vorzufinden.

Daher lassen sich sogar die performancekritischen Teile der Standardbibliothek in Julia schreiben. Kombiniert mit der freien MIT-Lizenz ist es somit einfach, Julia zu erweitern und für die eigene Nutzung anzupassen. Das belegt die Zahl der Mitarbeitenden (604 beim Schreiben dieses Artikels) auf der GitHub-Seite.

Julia ist nicht strikt objektorientiert, sondern bietet als Sprachkonstrukte Typen an, die in der einfachsten Form Structs in C ähneln. Der Methoden-Dispatch bezieht alle Typen der Signatur mit ein und es lassen sich abstrakte und konkrete Typen frei kombinieren. Dieses Prinzip ist als Multiple Dispatch bekannt. Dass Funktionen in Julia First-Class-Objekte sind, ermöglicht die funktionale Programmierung. Beim Verzicht auf Selbstreferenzierung (wie this in Java) sind aber auch viele Konzepte aus der objektorientierten Welt nutzbar.

Julia bietet neben struct-artigen Typen Abstrakte, Bits, Union und Tupel. Die unterschiedlichen Typen lassen sich folgendermaßen nutzen:

# Ein mutierbarer Typ mit Typ-Parametern
mutable struct Point{N, T}
values::NTuple{N, T} # Tuple-Typ mit homogenen elementen
end
# ein bits-Typ mit einer beliebigen Menge an 'raw bits' 
# Erben kann ein Typ von abstrakten Typen
# und wird mit <: symbolisiert
bitstype 8 SmallBool2 <: Integer
# 2 abstrakte Typen, von denen man erben kann
abstract type Number end
abstract type Real <: Number end
# Ein nicht mutierbares struct, 
# das oft Stack-allokiert werden kann
# Parameter T ist auf Reals begrenzt,
# und Complex selbst erbt von Number
struct Complex{T <: Real} <: Number
real::T
imaginary::T
end
# definiere eine Funktion für die Typen.
# Deklaration der Typparameter ist optional,
# sie können aber mit der where-Syntax deklariert werden
function plus(z::Complex, w::Complex{T}) where T <: AbstractFloat
Complex(z.real + w.real, z.imag + w.imag)
end
# man kann auch Funktionen auf abstrakten Typen
# definieren (hier in Kurzform):
wer_bin_ich(x::Number) =
println("Ich bin eine Zahl mit typ: ", typeof(x))

Ein Großteil der Performance ist dem Typensystem geschuldet, das dabei hilft, spezialisierten Code zu erzeugen. Beispielsweise lässt sich der Code für Array-Typen Array{ElementTyp, Dimensionalität} auf die passenden Parameter spezialisieren.

Da die Typenparameter innerhalb einer Funktion konstante Ausdrücke sind, lässt sich der sie verwendende Code oft komplett statisch eliminieren (constant folding). Das gilt sogar beim Einsatz von Reflexionen. Daher kann der Compiler folgenden Code optimieren:

function constant_fold(A::Array{T, N}) 
x = T(1) # konvertiere zum element-Typ
return x + (typeof(A) == Array{Float32, 2} ? 1f0 : 2f0
end
# ein weiteres Werkzeug zur Inspektion von Julia-Code,
# das die LLVM-Zwischenrepresentation (LLVM-IR)
# einer Methode anzeigt:
julia>array = rand(Float32, 2, 2) # erstellt ein
# 2D-Float32-Array
# mit zufälligen Zahlen
julia> @code_llvm constant_fold(array)
define float @julia_constant_fold_71694(%jl_value_t*) #0 {
top:
ret float 3.000000e+00
}


Hier ist erkennbar, dass der komplette Funktionskörper eines Array{Float32, 2} zu return 3f0 reduziert wird. Dasselbe funktioniert sogar ohne jegliche Typendeklaration. Letztere ist optional und hilft vor allem für Dispatch, Dokumentation und um der Inferenz auf die Sprünge zu helfen.