Files
JuliaKurs23/chapters/types.qmd

745 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
#struct M a::Int end; x = M(22); @show x
#should not print "Main.Notebook.M(22)" but only "M(22)"
function Base.show(io::IO, x::T) where T
if parentmodule(T) == @__MODULE__
# Print "TypeName(fields...)" without module prefix
print(io, nameof(T), "(")
fields = fieldnames(T)
for (i, f) in enumerate(fields)
print(io, getfield(x, f))
i < length(fields) && print(io, ", ")
end
print(io, ")")
else
invoke(Base.show, Tuple{IO, Any}, io, x)
end
end
```
# The Julia Type System
One can write extensive programs in Julia without using a single type declaration. This is, of course, intentional and designed to simplify users' work.
However, for a deeper understanding we will now examine the underlying type system.
## 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. `subtypes()` displays all child nodes, while `supertype()` shows the parent.
```{julia}
subtypes(Int64)
```
The result is an empty list of types. `Int64` is a so-called **concrete type** with no subtypes.
Let's now traverse this branch upward to the root (in computer science, trees are typically inverted).
```{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)
```
We can now examine the nodes:
{{< embed ../notebooks/nb-types.ipynb#nb3 >}}
A simple recursive function can display the entire subtree:
{{< 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 >}}
::::
Below is the same hierarchy 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="typst"}
![The hierarchy of numeric types](../images/TypeTree2.png){width=60%}
:::
Beyond numeric types, Julia includes many others. The number of direct descendants (children) of `Any` is
```{julia}
length(subtypes(Any))
```
This number increases with (almost) every package loaded via `using ...`.
## 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; that is, no objects can have an abstract type directly.
- 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.
To **declare** *and* **test** the relationships in the type hierarchy, Julia provides 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 reads as the question `is x 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, they are simple to define. 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)
```
By this definition, the abstract type is attached at a specific point in the type tree.
## The Numeric Types `Bool` and `Irrational`
Though appearing in the numeric type tree, these types warrant brief explanation:
`Bool` is numeric in the sense that `true=1, false=0`:
```{julia}
true + true + true, false - true, sqrt(true), true/4
```
`Irrational` is the type of certain 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
When the tree structure is insufficient, abstract types can be defined as a union of arbitrary (abstract and concrete) types.
```{julia}
IntOrString = Union{Int64,String}
```
:::{.callout-note .titlenormal}
## Example
The command `methods(<)` reveals over 70 methods for the comparison operator, including methods with union type arguments, such as:
```julia
<(x::Union{Float16, Float32, Float64}, y::BigFloat)
```
a method comparing fixed-width machine numbers with arbitrary precision numbers.
:::
## Composite Types: `struct`
A `struct` defines a concrete type as a collection of named fields.
```{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 seen with expressions like `x = Int8(33)`, type names can serve as constructors:
```{julia}
p1 = Point2D(1.4, 3.5)
```
```{julia}
p1 isa Point3D, p1 isa Point2D, p1 isa Point
```
The fields of a `struct` are accessed by name using the `.` operator.
```{julia}
p1.y
```
Because 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
```
The `dump()` function displays structure information for types and objects.
```{julia}
dump(Point3D)
```
```{julia}
dump(p1)
```
## Functions and *Multiple Dispatch*
:::{.callout-note .titlenormal}
## Objects, Functions, and Methods
In classical object-oriented languages (C++, Java, Python), methods are bound to objects.
Julia takes a different approach: methods belong to functions, not to objects.
Constructors (functions sharing a type's name that create instances of that type) are the only exception.
When we define a new type, we can define functions specific to that type but we can also add additional methods to existing functions.
- A single functions can have multiple methods for different argument types.
- At call time, Julia selects the most specific method matching the concrete argument types *(multiple dispatch)*.
- Core functions in Julia often have numerous predefined methods and third-party packages or user code can extend them adding additional methods.
:::
We define a distance 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 `@which` macro, applied to a function call with concrete arguments, shows which method is selected for these arguments:
```{julia}
@which sqrt(3.3)
```
```{julia}
z = "Hello" * '!'
println(z)
@which "Hello" * '!'
```
Methods can also have abstract types as arguments:
```{julia}
"""
Calculate the angle ϕ (in degrees) of the polar coordinates (2D) or
spherical coordinates (3D) of a point
"""
function phi_angle(p::Point)
atand(p.y, p.x)
end
phi_angle(p1)
```
:::{.callout-tip collapse="true"}
Text enclosed in *triple quotes* immediately before the function definition
is automatically integrated into Julia's help database:
```{julia}
?phi_angle
```
:::
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 written in short [*assignment form*](https://docs.julialang.org/en/v1/manual/functions/#man-functions)):
```{julia}
f(x::String, y::Number) = "Args: String + Number"
f(x::String, y::Int64) = "Args: String + Int64"
f(x::Number, y::Int64) = "Args: Number + Int64"
f(x::Int64, y:: Number) = "Args: Int64 + Number"
f(x::Number) = "Arg: a Number"
function f(x::Number, y::Number, z::String)
return "Arg: 2 × Number + String"
end
```
The first two methods match; the second is chosen as it is more specific (`Int64 <: Number`):
```{julia}
f("Hello", 42)
```
Ambiguities may arise if methods are defined poorly:
```{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 denoted `im`
```{julia}
@show Complex(0.4) 23 + 0.5im/(1-2im);
```
Like `Point2D`, both `Rational` and `Complex` consist of two fields: numerator and denominator or real and imaginary parts.
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` and `Complex`, respectively.
```{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`.
Since $\subset$ (or `Rational <: Real` in Julia notation), the components of a complex number can also be rational:
```{julia}
z = 3//4 + 5im
dump(z)
```
These structures are declared without the `mutable` attribute, making them *immutable*:
```{julia}
x = 2.2 + 3.3im
println("The real part is: $(x.re)")
x.re = 4.4
```
This is standard practice. The object `9` (of type `Int64`) is also immutable.
The following, of course, still works:
```{julia}
x += 2.2
```
Here, a new object of type `Complex{Float64}` is created and `x` then references this new object.
The type system's flexibility invites experimentation. 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. Each type is an instance of one of 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 three concrete "meta-types" are, by the way, subtypes of the abstract "meta-type" `Type`.
```{julia}
subtypes(Type)
```
-----------------
(2) Types can be assigned to a variable:
```{julia}
x3 = Float64
@show x3(4) x3 <: Real x3==Float64 ;
```
:::{.callout-note collapse="true"}
This demonstrates that [Julia's style guidelines](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 conventions, not language-enforced rules.
They should still be followed for readability.
:::
Declaring such assignments with `const` creates a *type alias*.
```{julia}
const MyCmplxF64 = MyComplex{Float64}
z = MyCmplxF64(1.1, 2.2)
typeof(z)
```
--------
(3) Types can be function arguments.
```{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);
```
To define this function with type signatures, we 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 we can also impose restrictions on the permissible values of the type variables `S` and `T` in the `where` clause.
How can we 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 functions with types as arguments. We have already seen `<:(T1, T2)`, `supertype(T)`, `supertypes(T)`, `subtypes(T)`. Other useful operations include `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 used as parameters in parametric types? Are there `Complex{AbstractFloat}` or `Complex{Union{Float32, Int16}}`?
Yes, such types exist. They are concrete, and objects can be instantiated.
```{julia}
z5 = Complex{Integer}(2, 0x33)
dump(z5)
```
This is a heterogeneous composite type. Each component has an individual type `T`, for which `T<:Integer` holds.
Note 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 theoretical computer science terminology](https://en.wikipedia.org/wiki/Type_variance), **invariant**.
(If `S<:T` implied `ParamType{S} <: ParamType{T}`, this would be **covariance**.)
## Generic Functions
The usual (and in many cases recommended!) programming style in Julia is writing generic functions:
```{julia}
function fmm(x, y)
return x * x * y
end
```
This function works with any types supporting the required operations.
```{julia}
fmm( Complex(2,3), 10), fmm("Hello", '!')
```
Type annotations can restrict applicability or implement different methods for different types:
```{julia}
function fmm2(x::Number, y::AbstractFloat)
return x * x * y
end
function fmm2(x::String, y::String)
println("Sorry, I don't take strings!")
end
@show fmm2(18, 2.0) fmm2(18, 2);
```
:::{.callout-important}
**Explicit type annotations are almost always irrelevant for the speed of the code!**
This is one of the most important *advantages* 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 seamless integration across packages and support high-level 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
fmm(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., an implementation of [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, each a subtype 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.
Finally, note that `where T` is shorthand for `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 both arguments have exactly the same type; otherwise, any type is accepted.
```{julia}
fgl(33, 44)
```
```{julia}
fgl(33, 44.0)
```