JuliaKurs23/chapters/types.qmd

737 lines
20 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
notebook-links: false
engine: julia
---
```{julia}
#| error: false
#| echo: false
#| output: false
using InteractiveUtils
import QuartoNotebookWorker
Base.stdout = QuartoNotebookWorker.with_context(stdout)
myactive_module() = Main.Notebook
Base.active_module() = myactive_module()
```
# Das Typsystem von Julia
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.
## Die Typhierarchie am Beispiel der numerischen Typen
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.
```{julia}
subtypes(Int64)
```
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).
```{julia}
supertype(Int64)
```
```{julia}
supertype(Signed)
```
```{julia}
supertype(Integer)
```
```{julia}
supertype(Real)
```
```{julia}
supertype(Number)
```
Das wäre übrigens auch schneller gegangen: Die Funktion `supertypes()` (mit Plural-s) zeigt alle Vorfahren an.
```{julia}
supertypes(Int64)
```
Nun kann man sich die Knoten angucken:
{{< embed ../notebooks/nb-types.ipynb#nb3 >}}
Mit einer kleinen rekursiven Funktion kann man schnell einen ganzen (Unter-)Baum ausdrucken:
{{< embed ../notebooks/nb-types.ipynb#nb1 >}}
::::{.content-hidden unless-format="xxx"}
...und natürlich gibt es da auch ein Julia-Paket:
{{< embed ../notebooks/nb-types.ipynb#nb2 >}}
:::
Hier das Ganze nochmal als Bild (gemacht mit LaTeX/[TikZ](https://tikz.dev/tikz-trees))
::: {.content-visible when-format="html"}
![](../images/TypeTree2.png){width=80%}
:::
::: {.content-visible when-format="pdf"}
![Die Hierarchie der numerischen Typen](../images/TypeTree2.png){width=60%}
:::
Natürlich hat Julia nicht nur numerische Typen. Die Anzahl der direkten Abkömmlinge (Kinder) von `Any` ist
```{julia}
length(subtypes(Any))
```
und mit (fast) jedem Paket, das man mit `using ...` lädt, werden es mehr.
## Abstrakte und Konkrete Typen
- Ein Objekt hat immer einen **konkreten** Typ.
- Konkrete Typen haben keine Untertypen mehr, sie sind immer „Blätter“ des Baumes.
- Konkrete Typen spezifizieren eine konkrete Datenstruktur.
:::{.xxx}
:::
- Abstrakte Typen können nicht instanziiert werden, d.h., es gibt keine Objekte mit diesem Typ.
- Sie definieren eine Menge von konkreten Typen und gemeinsame Methoden für diese Typen.
- Sie können daher in der Definition von Funktionstypen, Argumenttypen, Elementtypen von zusammengesetzten Typen u.ä. verwendet werden.
Zum **Deklarieren** *und* **Testen** der "Abstammung" innerhalb der Typhierarchie gibt es einen eigenen Operator:
```{julia}
Int64 <: Number
```
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.
```{julia}
x = 17.2
42 isa Int64, 42 isa Real, x isa Real, x isa Float64, x isa Integer
```
Da abstrakte Typen keine Datenstrukturen definieren, ist ihre Definition recht schlicht. Entweder sie stammen direkt von `Any` ab:
```{julia}
abstract type MySuperType end
supertype(MySuperType)
```
oder von einem anderen abstrakten Typ:
```{julia}
abstract type MySpecialNumber <: Integer end
supertypes(MySpecialNumber)
```
Mit der Definition werden die abstrakten Typen an einer Stelle des Typ-Baums "eingehängt".
## Die numerischen Typen `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`:
```{julia}
true + true + true, false - true, sqrt(true), true/4
```
`Irrational` ist der Typ einiger vordefinierter Konstanten wie `π` und ``.
Laut [Dokumentation](https://docs.julialang.org/en/v1/base/numbers/#Base.AbstractIrrational) 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".*
## Union-Typen
Falls die Baum-Hierarchie nicht ausreicht, kann man auch abstrakte Typen als Vereinigung beliebiger (abstrakter und konkreter) Typen definieren.
```{julia}
IntOrString = Union{Int64,String}
```
:::{.callout-note .titlenormal}
## Beispiel
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
```julia
<(x::Union{Float16, Float32, Float64}, y::BigFloat)
```
eine Methode für den Vergleich einer Maschinenzahl fester Länge mit einer Maschinenzahl beliebiger Länge.
:::
## Zusammengesetzte (_composite_) Typen: `struct`
Eine `struct` ist eine Zusammenstellung von mehreren benannten Feldern und definiert einen konkreten Typ.
```{julia}
abstract type Point end
mutable struct Point2D <: Point
x :: Float64
y :: Float64
end
mutable struct Point3D <: Point
x :: Float64
y :: Float64
z :: Float64
end
```
Wie wir schon bei Ausdrücken der Form `x = Int8(33)` gesehen haben, kann man Typnamen direkt als Konstruktoren einsetzen:
```{julia}
p1 = Point2D(1.4, 3.5)
```
```{julia}
p1 isa Point3D, p1 isa Point2D, p1 isa Point
```
Die Felder einer `struct` können über ihren Namen mit dem `.`-Operator adressiert werden.
```{julia}
p1.y
```
Da wir unsere `struct` als `mutable` deklariert haben, können wir das Objekt `p1` modifizieren, indem wir den Feldern neue Werte zuweisen.
```{julia}
p1.x = 3333.4
p1
```
Informationen über den Aufbau eines Typs oder eines Objekts von diesem Typ liefert `dump()`.
```{julia}
dump(Point3D)
```
```{julia}
dump(p1)
```
## Funktionen und *Multiple dispatch*
:::{.callout-note .titlenormal}
## Objekte, Funktionen, Methoden
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.
- Eine Funktion kann mehrfach für verschiedene Argumentlisten (Typ und Anzahl) definiert werden.
- Die Funktion hat dann mehrere Methoden.
- Beim Aufruf wird an Hand der konkreten Argumente entschieden, welche Methode genutzt wird *(multiple dispatch)*.
- Es ist typisch für Julia, dass für Standardfunktionen viele Methoden definiert sind. Diese können problemlos um weitere Methoden für eigene Typen erweitert werden.
:::
Den Abstand zwischen zwei Punkten implementieren wir als Funktion mit zwei Methoden:
```{julia}
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
```
```{julia}
distance(p1, Point2D(2200, -300))
```
Wie schon erwähnt, zeigt `methods()` die Methodentabelle einer Funktion an:
```{julia}
methods(distance)
```
Das Macro `@which`, angewendet auf einen vollen Funktionsaufruf mmit konkreter Argumentliste, zeigt an, welche Methode zu diesen konkreten Argumenten ausgewählt wird:
```{julia}
@which sqrt(3.3)
```
```{julia}
z = "Hallo" * '!'
println(z)
@which "Hallo" * '!'
```
Methoden können auch abstrakte Typen als Argument haben:
```{julia}
"""
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)
```
:::{.callout-tip collapse="true"}
Ein in *triple quotes* eingeschlossene Text unmittelbat vor der Funktionsdefinition
wird automatisch in die Hilfe-Datenbank von Julia integriert:
```{julia}
?phi_winkel
```
:::
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*](https://docs.julialang.org/en/v1/manual/functions/#man-functions) geschrieben):
```{julia}
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
```
Hier passen die ersten beiden Methoden. Gewählt wird die zweite, da sie spezifischer ist, `Int64 <: Number`.
```{julia}
f("Hallo", 42)
```
Es kann sein, dass diese Vorschrift zu keinem eindeutigen Ergebnis führt, wenn man seine Methoden schlecht gewählt hat.
```{julia}
f(42, 42)
```
## Parametrisierte numerische Typen: `Rational` und `Complex`
- Für rationale Zahlen (Brüche) verwendet Julia `//` als Infix-Konstruktor:
```{julia}
@show Rational(23, 17) 4//16 + 1//3;
```
- Die imaginäre Einheit $\sqrt{-1}$ heißt `im`
```{julia}
@show Complex(0.4) 23 + 0.5im/(1-2im);
```
`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.
```{julia}
x = 2//7
@show typeof(x);
```
```{julia}
y = BigInt(2)//7
@show typeof(y) y^48;
```
```{julia}
x = 1 + 2im
typeof(x)
```
```{julia}
y = 1.0 + 2.0im
typeof(y)
```
Die konkreten Typen `Rational{Int64}`, `Rational{BigInt}`,..., `Complex{Int64}`, `Complex{Float64}}`, ... sind Subtypen von `Rational` bzw. `Complex`.
```{julia}
Rational{BigInt} <: Rational
```
Die Definitionen [sehen etwa so aus](https://github.com/JuliaLang/julia/blob/master/base/rational.jl#L6-L15):
```{julia}
struct MyComplex{T<:Real} <: Number
re::T
im::T
end
struct MyRational{T<:Integer} <: Real
num::T
den::T
end
```
Die erste Definition besagt:
- `MyComplex` hat zwei Felder `re` und `im`, beide vom gleichen Typ `T`.
- Dieser Typ `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`.
- Dieser Typ `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:
```{julia}
z = 3//4 + 5im
dump(z)
```
Diese Strukturen sind ohne das `mutable`-Attribut definiert, also *immutable*:
```{julia}
x = 2.2 + 3.3im
println("Der Realteil ist: $(x.re)")
x.re = 4.4
```
Das ist so üblich. Wir betrachten das Objekt `9` vom Typ `Int64` ja auch als unveränderlich.
Das Folgende geht natürlich trotzdem:
```{julia}
x += 2.2
```
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:
```{julia}
struct MyParms{T <: Union{Float64, Tuple{Int64, Int64}}}
param::T
end
p1 = MyParms(33.3)
p2 = MyParms( (2, 4) )
@show p1.param p2.param;
```
## Typen als Objekte
(1) 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)
```{julia}
@show 23779 isa Int64 Int64 isa DataType;
```
```{julia}
@show 2im isa Complex Complex isa UnionAll;
```
```{julia}
@show 2im isa Complex{Int64} Complex{Int64} isa DataType;
```
Diese 3 konkreten "Meta-Typen" sind übrigens Subtypen des abstrakten "Meta-Typen" `Type`.
```{julia}
subtypes(Type)
```
-----------------
(2) Damit können Typen auch einfach Variablen zugewiesen werden:
```{julia}
x3 = Float64
@show x3(4) x3 <: Real x3==Float64 ;
```
:::{.callout-note collapse="true"}
Dies zeigt auch, dass die [Style-Vorgaben in Julia](https://docs.julialang.org/en/v1/manual/style-guide/#Use-naming-conventions-consistent-with-Julia-base/) 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*.
```{julia}
const MyCmplxF64 = MyComplex{Float64}
z = MyComplex(1.1, 2.2)
typeof(z)
```
--------
(3) Typen können Argumente von Funktionen sein.
```{julia}
function myf(x, S, T)
if S <: T
println("$S is subtype of $T")
end
return S(x)
end
z = myf(43, UInt16, Real)
@show z typeof(z);
```
Wenn man diese Funktion mit Typsignaturen definieren möchte, kann man natürlich
```julia
function myf(x, S::Type, T::Type) ... end
```
schreiben. Üblicher ist hier die (dazu äquivalente) spezielle Syntax
```julia
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:
```julia
function myf(x, ::Type{Int64}, ::Type{Int64}) ... end
```
`Type{Int64}` wirkt wie ein "Meta-Typ", dessen einzige Instanz der Typ `Int64` ist.
-----------------
(4) Es gibt zahlreiche Operationen mit Typen als Argumenten. Wir haben schon `<:(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)`.
## Invarianz parametrisierter Typen {#sec-invariance}
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.
```{julia}
z5 = Complex{Integer}(2, 0x33)
dump(z5)
```
Das ist eine heterogene Struktur. Jede Komponente hat einen individuellen Typ `T`, für den `T<:Integer` gilt.
Nun gilt zwar
```{julia}
Int64 <: Integer
```
aber es gilt nicht, dass
```{julia}
Complex{Int64} <: Complex{Integer}
```
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](https://de.wikipedia.org/wiki/Kovarianz_und_Kontravarianz) **invariant**.
(Wenn aus `S<:T` folgen würde, dass auch `ParamType{S} <: ParamType{T}` gilt, würde man von **Kovarianz** sprechen.)
## Generische Funktionen
Der übliche (und in vielen Fällen empfohlene!) Programmierstil in Julia ist das Schreiben generischer Funktionen:
```{julia}
function fsinnfrei1(x, y)
return x * x * y
end
```
Diese Funktion funktioniert sofort mit allen Typen, für die die verwendeten Operationen definiert sind.
```{julia}
fsinnfrei1( Complex(2,3), 10), fsinnfrei1("Hallo", '!')
```
Man kann natürlich Typ-Annotationen benutzen, um die Verwendbarkeit einzuschränken oder um unterschiedliche Methoden für unterschiedliche Typen zu implementieren:
```{julia}
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);
```
:::{.callout-important}
**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:
```{julia}
#| echo: false
#| output: false
#=
-Measurements.jl/show.jl adds a method
Base.show(io::IO, ::MIME"text/latex", measure::Measurement)
- IJulia sends out a text/latex-cell in addition to the text/plain-cell
"outputs": [
{
"data": {
"text/latex": [
"$2590.0 \\pm 52.0$"
],
"text/plain": [
"2590.0 ± 52.0"
]
},
whenever it finds this method, see
https://github.com/JuliaLang/IJulia.jl/commit/7311e517194ddba07af64952b0f9413401213050
- Quarto takes this latex-cell in its qmd -> ipynb->exec->ipynb -> md pipeline, generating a :::{=tex} -div
- so we delete the method
=#
using Measurements
zz= @which Base.show(stdout, MIME"text/latex"(), 3±2)
Base.delete_method(zz)
```
```{julia}
using Measurements
x = 33.56±0.3
y = 2.3±0.02
fsinnfrei1(x, y)
```
:::
## Typ-Parameter in Funktionsdefinitionen: die `where`-Klausel
Wir wollen eine Funktion schreiben, die für **alle komplexen Integer** (und nur diese) funktioniert, z.B. eine [in [i] mögliche Primfaktorzerlegung](https://de.wikipedia.org/wiki/Gau%C3%9Fsche_Zahl). Die Definition
```{julia}
#| eval: false
function isprime(x::Complex{Integer}) ... end
```
liefert nun nicht das Gewünschte, wie wir in @sec-invariance 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.
```{julia}
#| eval: false
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 Typvariable `T` irgendein Untertyp von `Integer` sein kann.“
Noch ein Beispiel:
```{julia}
#| eval: false
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
```{julia}
#| eval: false
function isprime(x::Complex{T}) where T<:Integer
...
end
```
schreiben. Das lässt sich noch weiter kürzen zu
```{julia}
#| eval: false
function isprime(x::Complex{<:Integer})
...
end
```
Diese verschiedenen Varianten können verwirrend sein, aber das ist nur Syntax.
```{julia}
C1 = Complex{T} where {T<:Integer}
C2 = Complex{T} where T<:Integer
C3 = Complex{<:Integer}
C1 == C2 == C3
```
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:
```{julia}
function fgl(x::T, y::T) where T
println("Glückwunsch! x und y sind vom gleichen Typ!")
end
```
Diese Methode erfordert, dass die Argumente genau den gleichen, aber ansonsten beliebigen Typ haben.
```{julia}
fgl(33, 44)
```
```{julia}
fgl(33, 44.0)
```