subtypes(Int64)
Type[]
Man kann umfangreiche Programme in Julia schreiben, ohne auch nur eine einzige Typdeklaration verwenden zu müssen. Das ist natürlich Absicht und soll die Arbeit der Anwender vereinfachen.
Wir blicken jetzt trotzdem mal unter die Motorhaube.
Das Typsystem hat die Struktur eines Baums, dessen Wurzel der Typ Any
ist. Mit den Funktionen subtypes()
und supertype()
kann man den Baum erforschen. Sie zeigen alle Kinder bzw. die Mutter eines Knotens an.
subtypes(Int64)
Type[]
Das Ergebnis ist eine leere Liste von Typen. Int64
ist ein sogenannter konkreter Typ und hat keine Untertypen.
Wir klettern jetzt mal die Typhierarchie auf diesem Ast nach oben bis zur Wurzel (Informatiker-Bäume stehen bekanntlich immer auf dem Kopf).
supertype(Int64)
Signed
supertype(Signed)
Integer
supertype(Integer)
Real
supertype(Real)
Number
supertype(Number)
Any
Das wäre übrigens auch schneller gegangen: Die Funktion supertypes()
(mit Plural-s) zeigt alle Vorfahren an.
supertypes(Int64)
(Int64, Signed, Integer, Real, Number, Any)
Nun kann man sich die Knoten angucken:
Mit einer kleinen rekursiven Funktion kann man schnell einen ganzen (Unter-)Baum ausdrucken:
Hier das Ganze nochmal als Bild (gemacht mit LaTeX/TikZ)
Natürlich hat Julia nicht nur numerische Typen. Die Anzahl der direkten Abkömmlinge (Kinder) von Any
ist
length(subtypes(Any))
625
und mit (fast) jedem Paket, das man mit using ...
lädt, werden es mehr.
Zum Deklarieren und Testen der “Abstammung” innerhalb der Typhierarchie gibt es einen eigenen Operator:
Int64 <: Number
true
Zum Testen, ob ein Objekt einen bestimmten Typ (oder einen abstrakten Supertyp davon) hat, dient isa(object, typ)
. Es wird meist in der Infix-Form verwendet und sollte als Frage x is a T?
gelesen werden.
= 17.2
x
42 isa Int64, 42 isa Real, x isa Real, x isa Float64, x isa Integer
(true, true, true, true, false)
Da abstrakte Typen keine Datenstrukturen definieren, ist ihre Definition recht schlicht. Entweder sie stammen direkt von Any
ab:
abstract type MySuperType end
supertype(MySuperType)
Any
oder von einem anderen abstrakten Typ:
abstract type MySpecialNumber <: Integer end
supertypes(MySpecialNumber)
(MySpecialNumber, Integer, Real, Number, Any)
Mit der Definition werden die abstrakten Typen an einer Stelle des Typ-Baums “eingehängt”.
Bool
und Irrational
Da sie im Baum der numerischen Typen zu sehen sind, seien sie kurz erklärt:
Bool
ist numerisch im Sinne von true=1, false=0
:
true + true + true, false - true, sqrt(true), true/4
(3, -1, 1.0, 0.25)
Irrational
ist der Typ einiger vordefinierter Konstanten wie π
und ℯ
. Laut Dokumentation ist Irrational
ein “Number type representing an exact irrational value, which is automatically rounded to the correct precision in arithmetic operations with other numeric quantities”.
Falls die Baum-Hierarchie nicht ausreicht, kann man auch abstrakte Typen als Vereinigung beliebiger (abstrakter und konkreter) Typen definieren.
= Union{Int64,String} IntOrString
Union{Int64, String}
Das Kommando methods(<)
zeigt, dass unter den über 70 Methoden, die für den Vergleichsoperator definiert sind, einige auch union types verwenden, z.B. ist
<(x::Union{Float16, Float32, Float64}, y::BigFloat)
eine Methode für den Vergleich einer Maschinenzahl fester Länge mit einer Maschinenzahl beliebiger Länge.
struct
Eine struct
ist eine Zusammenstellung von mehreren benannten Feldern und definiert einen konkreten Typ.
abstract type Point end
mutable struct Point2D <: Point
:: Float64
x :: Float64
y end
mutable struct Point3D <: Point
:: Float64
x :: Float64
y :: Float64
z end
Wie wir schon bei Ausdrücken der Form x = Int8(33)
gesehen haben, kann man Typnamen direkt als Konstruktoren einsetzen:
= Point2D(1.4, 3.5) p1
Point2D(1.4, 3.5)
p1 isa Point3D, p1 isa Point2D, p1 isa Point
(false, true, true)
Die Felder einer struct
können über ihren Namen mit dem .
-Operator adressiert werden.
p1.y
3.5
Da wir unsere struct
als mutable
deklariert haben, können wir das Objekt p1
modifizieren, indem wir den Feldern neue Werte zuweisen.
= 3333.4
p1.x p1
Point2D(3333.4, 3.5)
Informationen über den Aufbau eines Typs oder eines Objekts von diesem Typ liefert dump()
.
dump(Point3D)
Point3D <: Point
x::Float64
y::Float64
z::Float64
dump(p1)
Point2D
x: Float64 3333.4
y: Float64 3.5
In klassischen objektorientierten Sprachen wie C++/Java haben Objekte üblicherweise mit ihnen assoziierte Funktionen, die Methoden des Objekts.
In Julia gehören Methoden zu einer Funktion und nicht zu einem Objekt. (Eine Ausnahme sind die Konstruktoren, also Funktionen, die genauso heißen wie ein Typ und ein Objekt dieses Typs erzeugen.)
Sobald man einen neuen Typ definiert hat, kann man sowohl neue als auch bestehende Funktionen um neue Methoden für diesen Typ ergänzen.
Den Abstand zwischen zwei Punkten implementieren wir als Funktion mit zwei Methoden:
function distance(p1::Point2D, p2::Point2D)
sqrt((p1.x-p2.x)^2 + (p1.y-p2.y)^2)
end
function distance(p1::Point3D, p2::Point3D)
sqrt((p1.x-p2.x)^2 + (p1.y-p2.y)^2 + (p1.z-p2.z)^2)
end
distance (generic function with 2 methods)
distance(p1, Point2D(2200, -300))
1173.3319266090054
Wie schon erwähnt, zeigt methods()
die Methodentabelle einer Funktion an:
methods(distance)
Das Macro @which
, angewendet auf einen vollen Funktionsaufruf mmit konkreter Argumentliste, zeigt an, welche Methode zu diesen konkreten Argumenten ausgewählt wird:
@which sqrt(3.3)
= "Hallo" * '!'
z println(z)
@which "Hallo" * '!'
Hallo!
Methoden können auch abstrakte Typen als Argument haben:
"""
Berechnet den Winkel ϕ (in Grad) der Polarkoordinaten (2D) bzw.
Kugelkoordinaten (3D) eines Punktes
"""
function phi_winkel(p::Point)
atand(p.y, p.x)
end
phi_winkel(p1)
0.0601593431937626
Ein in triple quotes eingeschlossene Text unmittelbat vor der Funktionsdefinition wird automatisch in die Hilfe-Datenbank von Julia integriert:
?phi_winkel
search: phi_winkel
Berechnet den Winkel ϕ (in Grad) der Polarkoordinaten (2D) bzw. Kugelkoordinaten (3D) eines Punktes
Beim multiple dispatch wird die Methode angewendet, die unter allen passenden die spezifischste ist. Hier eine Funktion mit mehreren Methoden (alle bis auf die letzte in der kurzen assignment form geschrieben):
f(x::String, y::Number) = "Args: String + Zahl"
f(x::String, y::Int64) = "Args: String + Int64"
f(x::Number, y::Int64) = "Args: Zahl + Int64"
f(x::Int64, y:: Number) = "Args: Int64 + Zahl"
f(x::Number) = "Arg: eine Zahl"
function f(x::Number, y::Number, z::String)
return "Arg: 2 x Zahl + String"
end
f (generic function with 6 methods)
Hier passen die ersten beiden Methoden. Gewählt wird die zweite, da sie spezifischer ist, Int64 <: Number
.
f("Hallo", 42)
"Args: String + Int64"
Es kann sein, dass diese Vorschrift zu keinem eindeutigen Ergebnis führt, wenn man seine Methoden schlecht gewählt hat.
f(42, 42)
MethodError: f(::Int64, ::Int64) is ambiguous.
Candidates:
f(x::Int64, y::Number)
@ Main.Notebook ~/Julia/23/Book-ansipatch/chapters/types.qmd:314
f(x::Number, y::Int64)
@ Main.Notebook ~/Julia/23/Book-ansipatch/chapters/types.qmd:313
Possible fix, define
f(::Int64, ::Int64)
Stacktrace:
[1] top-level scope
@ ~/Julia/23/Book-ansipatch/chapters/types.qmd:328
Rational
und Complex
//
als Infix-Konstruktor:@show Rational(23, 17) 4//16 + 1//3;
Rational(23, 17) = 23//17
4 // 16 + 1 // 3 = 7//12
im
@show Complex(0.4) 23 + 0.5im/(1-2im);
Complex(0.4) = 0.4 + 0.0im
23 + (0.5im) / (1 - 2im) = 22.8 + 0.1im
Rational
und Complex
bestehen, ähnlich wie unser Point2D
, aus 2 Feldern: Zähler und Nenner bzw. Real- und Imaginärteil.
Der Typ dieser Felder ist allerdings nicht vollständig festgelegt. Rational
und Complex
sind parametrisierte Typen.
= 2//7
x @show typeof(x);
typeof(x) = Rational{Int64}
= BigInt(2)//7
y @show typeof(y) y^48;
typeof(y) = Rational{BigInt}
y ^ 48 = 281474976710656//36703368217294125441230211032033660188801
= 1 + 2im
x typeof(x)
Complex{Int64}
= 1.0 + 2.0im
y typeof(y)
ComplexF64 (alias for Complex{Float64})
Die konkreten Typen Rational{Int64}
, Rational{BigInt}
,…, Complex{Int64}
, Complex{Float64}}
, … sind Subtypen von Rational
bzw. Complex
.
Rational{BigInt} <: Rational
true
Die Definitionen sehen etwa so aus:
struct MyComplex{T<:Real} <: Number
::T
reim::T
end
struct MyRational{T<:Integer} <: Real
::T
num::T
denend
Die erste Definition besagt:
MyComplex
hat zwei Felder re
und im
, beide vom gleichen Typ T
.T
muss ein Untertyp von Real
sein.MyComplex
und alle seine Varianten wie MyComplex{Float64}
sind Untertypen von Number
.und die zweite besagt analog:
MyRational
hat zwei Felder num
und den
, beide vom gleichen Typ T
.T
muss ein Untertyp von Integer
sein.MyRational
und seine Varianten sind Untertypen von Real
.Nun ist ℚ\(\subset\) ℝ, oder auf julianisch Rational <: Real
. Also können die Komponenten einer komplexen Zahl auch rational sein:
= 3//4 + 5im
z dump(z)
Complex{Rational{Int64}}
re: Rational{Int64}
num: Int64 3
den: Int64 4
im: Rational{Int64}
num: Int64 5
den: Int64 1
Diese Strukturen sind ohne das mutable
-Attribut definiert, also immutable:
= 2.2 + 3.3im
x println("Der Realteil ist: $(x.re)")
= 4.4 x.re
Der Realteil ist: 2.2
setfield!: immutable struct of type Complex cannot be changed
Stacktrace:
[1] setproperty!(x::ComplexF64, f::Symbol, v::Float64)
@ Base ./Base.jl:53
[2] top-level scope
@ ~/Julia/23/Book-ansipatch/chapters/types.qmd:418
Das ist so üblich. Wir betrachten das Objekt 9
vom Typ Int64
ja auch als unveränderlich. Das Folgende geht natürlich trotzdem:
+= 2.2 x
4.4 + 3.3im
Hier wird ein neues Objekt vom Typ Complex{Float64}
erzeugt und x
zur Referenz auf dieses neue Objekt gemacht.
Die Möglichkeiten des Typsystems verleiten leicht zum Spielen. Hier definieren wir eine struct
, die wahlweise eine Maschinenzahl oder ein Paar von Ganzzahlen enthalten kann:
struct MyParms{T <: Union{Float64, Tuple{Int64, Int64}}}
::T
paramend
= MyParms(33.3)
p1 = MyParms( (2, 4) )
p2
@show p1.param p2.param;
p1.param = 33.3
p2.param = (2, 4)
Typen sind ebenfalls Objekte. Sie sind Objekte einer der drei “Meta-Typen”
Union
(Union-Typen)UnionAll
(parametrisierte Typen)DataType
(alle konkreten und sonstige abstrakte Typen)@show 23779 isa Int64 Int64 isa DataType;
23779 isa Int64 = true
Int64 isa DataType = true
@show 2im isa Complex Complex isa UnionAll;
2im isa Complex = true
Complex isa UnionAll = true
@show 2im isa Complex{Int64} Complex{Int64} isa DataType;
2im isa Complex{Int64} = true
Complex{Int64} isa DataType = true
Diese 3 konkreten “Meta-Typen” sind übrigens Subtypen des abstrakten “Meta-Typen” Type
.
subtypes(Type)
4-element Vector{Any}:
Core.TypeofBottom
DataType
Union
UnionAll
= Float64
x3 @show x3(4) x3 <: Real x3==Float64 ;
x3(4) = 4.0
x3 <: Real = true
x3 == Float64 = true
Dies zeigt auch, dass die Style-Vorgaben in Julia wie „Typen und Typvariablen starten mit Großbuchstaben, sonstige Variablen und Funktionen werden klein geschrieben.“ nur Konventionen sind und von der Sprache nicht erzwungen werden.
Man sollte sie trotzdem einhalten, um den Code lesbar zu halten.
Wenn man solche Zuweisungen mit const
für dauerhaft erklärt, entsteht ein
type alias.
const MyCmplxF64 = MyComplex{Float64}
= MyCmplxF64(1.1, 2.2)
z typeof(z)
MyComplex{Float64}
function myf(x, S, T)
if S <: T
println("$S is subtype of $T")
end
return S(x)
end
= myf(43, UInt16, Real)
z
@show z typeof(z);
UInt16 is subtype of Real
z = 0x002b
typeof(z) = UInt16
Wenn man diese Funktion mit Typsignaturen definieren möchte, kann man natürlich
function myf(x, S::Type, T::Type) ... end
schreiben. Üblicher ist hier die (dazu äquivalente) spezielle Syntax
function myf(x, ::Type{S}, ::Type{T}) where {S,T} ... end
bei der man in der where
-Klausel auch noch Einschränkungen an die zulässigen Werte der Typvariablen S
und T
stellen kann.
Wie definiere ich eine spezielle Methode von myf
, die nur aufgerufen werden soll, wenn S
und T
gleich Int64
sind? Das ist folgendermaßen möglich:
function myf(x, ::Type{Int64}, ::Type{Int64}) ... end
Type{Int64}
wirkt wie ein “Meta-Typ”, dessen einzige Instanz der Typ Int64
ist.
<:(T1, T2)
, supertype(T)
, supertypes(T)
, subtypes(T)
gesehen. Erwähnt seien noch typejoin(T1,T2)
(nächster gemeinsamer Vorfahre im Typbaum) und Tests wie isconcretetype(T)
, isabstracttype(T)
, isstructtype(T)
.Kann man in parametrisierten Typen auch nicht-konkrete Typen einsetzen? Gibt es Complex{AbstractFloat}
oder Complex{Union{Float32, Int16}}
?
Ja, die gibt es; und es sind konkrete Typen, man kann also Objekte von diesem Typ erzeugen.
= Complex{Integer}(2, 0x33)
z5 dump(z5)
Complex{Integer}
re: Int64 2
im: UInt8 0x33
Das ist eine heterogene Struktur. Jede Komponente hat einen individuellen Typ T
, für den T<:Integer
gilt.
Nun gilt zwar
Int64 <: Integer
true
aber es gilt nicht, dass
Complex{Int64} <: Complex{Integer}
false
Diese Typen sind beide konkret. Damit können sie in der Typhierarchie von Julia nicht in einer Sub/Supertype-Relation zueinander stehen. Julias parametrisierte Typen sind in der Sprache der theoretischen Informatik invariant. (Wenn aus S<:T
folgen würde, dass auch ParamType{S} <: ParamType{T}
gilt, würde man von Kovarianz sprechen.)
Der übliche (und in vielen Fällen empfohlene!) Programmierstil in Julia ist das Schreiben generischer Funktionen:
function fsinnfrei1(x, y)
return x * x * y
end
fsinnfrei1 (generic function with 1 method)
Diese Funktion funktioniert sofort mit allen Typen, für die die verwendeten Operationen definiert sind.
fsinnfrei1( Complex(2,3), 10), fsinnfrei1("Hallo", '!')
(-50 + 120im, "HalloHallo!")
Man kann natürlich Typ-Annotationen benutzen, um die Verwendbarkeit einzuschränken oder um unterschiedliche Methoden für unterschiedliche Typen zu implementieren:
function fsinnfrei2(x::Number, y::AbstractFloat)
return x * x * y
end
function fsinnfrei2(x::String, y::String)
println("Sorry, I don't take strings!")
end
@show fsinnfrei2(18, 2.0) fsinnfrei2(18, 2);
fsinnfrei2(18, 2.0) = 648.0
MethodError: no method matching fsinnfrei2(::Int64, ::Int64)
The function `fsinnfrei2` exists, but no method is defined for this combination of argument types.
Closest candidates are:
fsinnfrei2(::Number, ::AbstractFloat)
@ Main.Notebook ~/Julia/23/Book-ansipatch/chapters/types.qmd:589
fsinnfrei2(::String, ::String)
@ Main.Notebook ~/Julia/23/Book-ansipatch/chapters/types.qmd:593
Stacktrace:
[1] macro expansion
@ show.jl:1232 [inlined]
[2] top-level scope
@ ~/Julia/23/Book-ansipatch/chapters/types.qmd:598
Explizite Typannotationen sind fast immer irrelevent für die Geschwindigkeit des Codes!
Dies ist einer der wichtigsten selling points von Julia.
Sobald eine Funktion zum ersten Mal mit bestimmten Typen aufgerufen wird, wird eine auf diese Argumenttypen spezialisierte Form der Funktion generiert und compiliert. Damit sind generische Funktionen in der Regel genauso schnell, wie die spezialisierten Funktionen, die man in anderen Sprachen schreibt.
Generische Funktionen erlauben die Zusammenarbeit unterschiedlichster Pakete und eine hohe Abstraktion.
Ein einfaches Beispiel: Das Paket Measurements.jl
definiert einen neuen Datentyp Measurement
, einen Wert mit Fehler, und die Arithmetik dieses Typs. Damit funktionieren generische Funktionen automatisch:
using Measurements
= 33.56±0.3
x = 2.3±0.02
y
fsinnfrei1(x, y)
2590.0 ± 52.0
where
-KlauselWir wollen eine Funktion schreiben, die für alle komplexen Integer (und nur diese) funktioniert, z.B. eine in ℤ[i] mögliche Primfaktorzerlegung. Die Definition
function isprime(x::Complex{Integer}) ... end
liefert nun nicht das Gewünschte, wie wir in Kapitel 8.9 gesehen haben. Die Funktion würde für ein Argument vom Typ Complex{Int64}
nicht funktionieren, da letzteres kein Subtyp von Complex{Integer}
ist.
Wir müssen eine Typ-Variable einführen. Dazu dient die where
-Klausel.
function isprime(x::Complex{T}) where {T<:Integer}
...
end
Das ist zu lesen als:
„Das Argument x soll von einem der Typen
Complex{T}
sein, wobei die TypvariableT
irgendein Untertyp vonInteger
sein kann.“
Noch ein Beispiel:
function kgV(x::Complex{T}, y::Complex{S}) where {T<:Integer, S<:Integer}
...
end
Die Argumente x und y können verschiedene Typen haben und beide müssen Subtypen von
Integer
sein.
Wenn es nur eine where
-Klausel wie im vorletzten Beispiel gibt, kann man die geschweiften Klammern weglassen und
function isprime(x::Complex{T}) where T<:Integer
...
end
schreiben. Das lässt sich noch weiter kürzen zu
function isprime(x::Complex{<:Integer})
...
end
Diese verschiedenen Varianten können verwirrend sein, aber das ist nur Syntax.
= Complex{T} where {T<:Integer}
C1 = Complex{T} where T<:Integer
C2 = Complex{<:Integer}
C3
== C2 == C3 C1
true
Kurze Syntax für einfache Fälle, ausführliche Syntax für komplexe Varianten.
Als letztes sein bemerkt, dass where T
die Kurzform von where T<:Any
ist, also eine völlig unbeschränkte Typvariable einführt. Damit ist sowas möglich:
function fgl(x::T, y::T) where T
println("Glückwunsch! x und y sind vom gleichen Typ!")
end
fgl (generic function with 1 method)
Diese Methode erfordert, dass die Argumente genau den gleichen, aber ansonsten beliebigen Typ haben.
fgl(33, 44)
Glückwunsch! x und y sind vom gleichen Typ!
fgl(33, 44.0)
MethodError: no method matching fgl(::Int64, ::Float64)
The function `fgl` exists, but no method is defined for this combination of argument types.
Closest candidates are:
fgl(::T, ::T) where T
@ Main.Notebook ~/Julia/23/Book-ansipatch/chapters/types.qmd:721
Stacktrace:
[1] top-level scope
@ ~/Julia/23/Book-ansipatch/chapters/types.qmd:732