Files
JuliaKurs23/chapters/types.qmd

737 lines
19 KiB
Plaintext
Raw 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()
```
# The Julia Type System
One can write extensive programs in Julia without using a single type declaration. This is, of course, intentional and should simplify the work of users.
However, we will now take a look under the hood.
## The Type Hierarchy: A Case Study with Numeric Types
The type system has the structure of a tree whose root is the type `Any`. The functions `subtypes()` and `supertype()` can be used to explore the tree. They show all children or the parent of a node.
```{julia}
subtypes(Int64)
```
The result is an empty list of types. `Int64` is a so-called **concrete type** and has no subtypes.
Let's now climb the type hierarchy on this branch to the root (in computer science, trees are always standing on their heads).
```{julia}
supertype(Int64)
```
```{julia}
supertype(Signed)
```
```{julia}
supertype(Integer)
```
```{julia}
supertype(Real)
```
```{julia}
supertype(Number)
```
This would have been faster, by the way: The function `supertypes()` (with plural-s) shows all ancestors.
```{julia}
supertypes(Int64)
```
Now one can look at the nodes:
{{< embed ../notebooks/nb-types.ipynb#nb3 >}}
With a small recursive function, one can quickly print out an entire (sub-)tree:
{{< embed ../notebooks/nb-types.ipynb#nb1 >}}
::::{.content-hidden unless-format="xxx"}
...and of course, there is also a Julia package for this:
{{< embed ../notebooks/nb-types.ipynb#nb2 >}}
::::
Here again as an image (made with LaTeX/[TikZ](https://tikz.dev/tikz-trees))
::: {.content-visible when-format="html"}
![](../images/TypeTree2.png){width=80%}
:::
::: {.content-visible when-format="pdf"}
![The hierarchy of numeric types](../images/TypeTree2.png){width=60%}
:::
Of course, Julia has not only numeric types. The number of direct descendants (children) of `Any` is
```{julia}
length(subtypes(Any))
```
and with (almost) every package loaded with `using ...`, this increases.
## Abstract and Concrete Types
- An object always has a **concrete** type.
- Concrete types have no more subtypes, they are always the "leaves" of the tree.
- Concrete types specify a concrete data structure.
:::{.xxx}
:::
- Abstract types cannot be instantiated, i.e., there are no objects of this type.
- They define a set of concrete types and common methods for these types.
- They can therefore be used in the definition of function types, argument types, element types of composite types, etc.
For **declaring** *and* **testing** the "descent" within the type hierarchy, there is a special operator:
```{julia}
Int64 <: Number
```
To test whether an object has a certain type (or an abstract supertype of it), `isa(object, typ)` is used. It is usually used in infix form and should be read as the question `x is a T?`.
```{julia}
x = 17.2
42 isa Int64, 42 isa Real, x isa Real, x isa Float64, x isa Integer
```
Since abstract types do not define data structures, their definition is quite simple. Either they are derived directly from `Any`:
```{julia}
abstract type MySuperType end
supertype(MySuperType)
```
or from another abstract type:
```{julia}
abstract type MySpecialNumber <: Integer end
supertypes(MySpecialNumber)
```
With the definition, the abstract types are "hung" at a point in the type tree.
## The Numeric Types `Bool` and `Irrational`
Since they are seen in the numeric type tree, they should be briefly explained:
`Bool` is numeric in the sense of `true=1, false=0`:
```{julia}
true + true + true, false - true, sqrt(true), true/4
```
`Irrational` is the type of some predefined constants such as `π` and ``.
According to the [documentation](https://docs.julialang.org/en/v1/base/numbers/#Base.AbstractIrrational), `Irrational` is a *"Number type representing an exact irrational value, which is automatically rounded to the correct precision in arithmetic operations with other numeric quantities".*
## Union Types
If the tree hierarchy is not sufficient, one can also define abstract types as a union of arbitrary (abstract and concrete) types.
```{julia}
IntOrString = Union{Int64,String}
```
:::{.callout-note .titlenormal}
## Example
The command `methods(<)` shows that among the over 70 methods defined for the comparison operator, some also use *union types*, e.g., there is
```julia
<(x::Union{Float16, Float32, Float64}, y::BigFloat)
```
a method for comparing a machine number of fixed length with a machine number of arbitrary length.
:::
## Composite (_composite_) Types: `struct`
A `struct` is a collection of several named fields and defines a concrete type.
```{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
```
As we have already seen with expressions of the form `x = Int8(33)`, type names can be used directly as constructors:
```{julia}
p1 = Point2D(1.4, 3.5)
```
```{julia}
p1 isa Point3D, p1 isa Point2D, p1 isa Point
```
The fields of a `struct` can be addressed by their name with the `.` operator.
```{julia}
p1.y
```
Since we declared our `struct` as `mutable`, we can modify the object `p1` by assigning new values to the fields.
```{julia}
p1.x = 3333.4
p1
```
Information about the structure of a type or an object of that type is provided by `dump()`.
```{julia}
dump(Point3D)
```
```{julia}
dump(p1)
```
## Functions and *Multiple Dispatch*
:::{.callout-note .titlenormal}
## Objects, Functions, Methods
In classical object-oriented languages like C++/Java, objects usually have functions associated with them, which are the methods of the object.
In Julia, methods belong to a function and not to an object.
(An exception is the constructors, i.e., functions that have the same name as a type and create an object of that type.)
Once one has defined a new type, one can add new or existing functions around new methods for this type.
- A function can be defined multiple times for different argument lists (type and number).
- The function then has multiple methods.
- When calling, it is decided based on the concrete arguments which method is used *(multiple dispatch)*.
- It is typical for Julia that for standard functions, many methods are defined. These can be easily extended by further methods for own types.
:::
We implement the distance between two points as a function with two methods:
```{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))
```
As mentioned earlier, `methods()` shows the method table of a function:
```{julia}
methods(distance)
```
The macro `@which`, applied to a full function call with concrete argument list, shows which method is selected for these concrete arguments:
```{julia}
@which sqrt(3.3)
```
```{julia}
z = "Hello" * '!'
println(z)
@which "Hello" * '!'
```
Methods can also have abstract types as arguments:
```{julia}
"""
Calculates the angle ϕ (in degrees) of the polar coordinates (2D) or
spherical coordinates (3D) of a point
"""
function phi_winkel(p::Point)
atand(p.y, p.x)
end
phi_winkel(p1)
```
:::{.callout-tip collapse="true"}
A text enclosed in *triple quotes* immediately before the function definition
is automatically integrated into Julia's help database:
```{julia}
?phi_winkel
```
:::
With *multiple dispatch*, the method is applied that is the most specific among all matching ones. Here is a function with several methods
(all but the last in the short [*assignment form*](https://docs.julialang.org/en/v1/manual/functions/#man-functions) written):
```{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
```
Here, the first two methods match. The second is chosen since it is more specific, `Int64 <: Number`.
```{julia}
f("Hello", 42)
```
It may happen that this rule does not lead to a unique result if one chooses one's methods badly.
```{julia}
f(42, 42)
```
## Parametric Numeric Types: `Rational` and `Complex`
- For rational numbers (fractions), Julia uses `//` as an infix constructor:
```{julia}
@show Rational(23, 17) 4//16 + 1//3;
```
- The imaginary unit $\sqrt{-1}$ is called `im`
```{julia}
@show Complex(0.4) 23 + 0.5im/(1-2im);
```
`Rational` and `Complex` consist, similar to our `Point2D`, of 2 fields: numerator and denominator or real and imaginary part.
However, the type of these fields is not completely fixed. `Rational` and `Complex` are _parametric_ types.
```{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)
```
The concrete types `Rational{Int64}`, `Rational{BigInt}`,..., `Complex{Int64}`, `Complex{Float64}}`, ... are subtypes of `Rational` resp. `Complex`.
```{julia}
Rational{BigInt} <: Rational
```
The definitions [look roughly like this](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
```
The first definition says:
- `MyComplex` has two fields `re` and `im`, both of the same type `T`.
- This type `T` must be a subtype of `Real`.
- `MyComplex` and all its variants like `MyComplex{Float64}` are subtypes of `Number`.
and the second says analogously:
- `MyRational` has two fields `num` and `den`, both of the same type `T`.
- This type `T` must be a subtype of `Integer`.
- `MyRational` and its variants are subtypes of `Real`.
Now $\subset$ , or in Julia notation `Rational <: Real`. Thus, the components of a complex number can also be rational:
```{julia}
z = 3//4 + 5im
dump(z)
```
These structures are defined without the `mutable` attribute, therefore *immutable*:
```{julia}
x = 2.2 + 3.3im
println("The real part is: $(x.re)")
x.re = 4.4
```
This is common. We also consider the object `9` of type `Int64` as immutable.
The following, of course, still works:
```{julia}
x += 2.2
```
Here, a new object of type `Complex{Float64}` is created and `x` is made a reference to this new object.
The possibilities of the type system easily invite to play. Here we define a `struct` that can contain either a machine number or a pair of integers:
```{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;
```
## Types as Objects
(1) Types are also objects. They are objects of one of the three "meta-types"
- `Union` (union types)
- `UnionAll` (parametric types)
- `DataType` (all concrete and other abstract types)
```{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;
```
These 3 concrete "meta-types" are, by the way, subtypes of the abstract "meta-type" `Type`.
```{julia}
subtypes(Type)
```
-----------------
(2) Thus, types can also be easily assigned to variables:
```{julia}
x3 = Float64
@show x3(4) x3 <: Real x3==Float64 ;
```
:::{.callout-note collapse="true"}
This also shows that the [style guidelines in Julia](https://docs.julialang.org/en/v1/manual/style-guide/#Use-naming-conventions-consistent-with-Julia-base/) such as "Types and type variables start with uppercase letters, other variables and functions are written in lowercase." are only conventions and are not enforced by the language.
One should still follow them to keep the code readable.
:::
If such assignments are declared permanent with `const`, a
*type alias* is created.
```{julia}
const MyCmplxF64 = MyComplex{Float64}
z = MyCmplxF64(1.1, 2.2)
typeof(z)
```
--------
(3) Types can be arguments of functions.
```{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);
```
If one wants to define this function with type signatures, of course one can write
```julia
function myf(x, S::Type, T::Type) ... end
```
However, the (equivalent) special syntax
```julia
function myf(x, ::Type{S}, ::Type{T}) where {S,T} ... end
```
is more common here, where one can also impose restrictions on the permissible values of the type variables `S` and `T` in the `where` clause.
How do I define a special method of `myf` that should only be called when `S` and `T` are equal to `Int64`? This is possible as follows:
```julia
function myf(x, ::Type{Int64}, ::Type{Int64}) ... end
```
`Type{Int64}` acts like a "meta-type", whose only instance is the type `Int64`.
-----------------
(4) There are numerous operations with types as arguments. We have already seen `<:(T1, T2)`, `supertype(T)`, `supertypes(T)`, `subtypes(T)`. Mentioned should be still `typejoin(T1,T2)` (next common ancestor in the type tree) and tests like `isconcretetype(T)`, `isabstracttype(T)`, `isstructtype(T)`.
## Invariance of Parametric Types {#sec-invariance}
Can non-concrete types also be substituted into parametric types? Are there `Complex{AbstractFloat}` or `Complex{Union{Float32, Int16}}`?
Yes, they exist; and they are concrete types, one can therefore create objects of this type.
```{julia}
z5 = Complex{Integer}(2, 0x33)
dump(z5)
```
This is a heterogeneous structure. Each component has an individual type `T`, for which `T<:Integer` holds.
Now it holds that
```{julia}
Int64 <: Integer
```
but it does not hold that
```{julia}
Complex{Int64} <: Complex{Integer}
```
These types are both concrete. Therefore, they cannot stand in a sub/supertype relation to each other in Julia's type hierarchy. Julia's parametric types are [in the language of theoretical computer science](https://en.wikipedia.org/wiki/Covariance_and_contravariance) **invariant**.
(If from `S<:T` it would follow that also `ParamType{S} <: ParamType{T}`, one would speak of **covariance**.)
## Generic Functions
The usual (and in many cases recommended!) programming style in Julia is writing generic functions:
```{julia}
function fsinnfrei1(x, y)
return x * x * y
end
```
This function works immediately with all types for which the used operations are defined.
```{julia}
fsinnfrei1( Complex(2,3), 10), fsinnfrei1("Hello", '!')
```
One can of course use type annotations to restrict usability or to implement different methods for different types:
```{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}
**Explicit type annotations are almost always irrelevant for the speed of the code!**
This is one of the most important *selling points* of Julia.
Once a function is called for the first time with certain types, a specialized form of the function is generated and compiled for these argument types. Thus, generic functions are usually just as fast as the specialized functions one writes in other languages.
Generic functions enable the cooperation of the most diverse packages and a high level of abstraction.
A simple example: The `Measurements.jl` package defines a new data type `Measurement`, a value with error, and the arithmetic of this type. Thus, generic functions work automatically:
```{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)
```
:::
## Type Parameters in Function Definitions: the `where` Clause
We want to write a function that works for **all complex integers** (and only these), e.g., a [possible prime factorization in [i](https://en.wikipedia.org/wiki/Gaussian_integer). The definition
```{julia}
#| eval: false
function isprime(x::Complex{Integer}) ... end
```
does not give the desired result, as we saw in @sec-invariance. The function would not work for an argument of type `Complex{Int64}`, since the latter is not a subtype of `Complex{Integer}`.
We must introduce a type variable. The `where` clause serves this purpose.
```{julia}
#| eval: false
function isprime(x::Complex{T}) where {T<:Integer}
...
end
```
This is to be read as:
> "The argument x should be one of the types `Complex{T}`, where the type variable `T` can be any subtype of `Integer`."
Another example:
```{julia}
#| eval: false
function kgV(x::Complex{T}, y::Complex{S}) where {T<:Integer, S<:Integer}
...
end
```
> The arguments x and y can have different types and both must be subtypes of `Integer`.
If there is only one `where` clause as in the last example, one can omit the curly braces and
write
```{julia}
#| eval: false
function isprime(x::Complex{T}) where T<:Integer
...
end
```
This can still be shortened to
```{julia}
#| eval: false
function isprime(x::Complex{<:Integer})
...
end
```
These different variants can be confusing, but it is only syntax.
```{julia}
C1 = Complex{T} where {T<:Integer}
C2 = Complex{T} where T<:Integer
C3 = Complex{<:Integer}
C1 == C2 == C3
```
Short syntax for simple cases, extended syntax for complex variants.
Last, it should be noted that `where T` is the short form of `where T<:Any`, which introduces a completely unrestricted type variable. Thus, something like this is possible:
```{julia}
function fgl(x::T, y::T) where T
println("Congratulations! x and y are of the same type!")
end
```
This method requires that the arguments are exactly the same type, but otherwise arbitrary.
```{julia}
fgl(33, 44)
```
```{julia}
fgl(33, 44.0)
```