9  Ein Fallbeispiel: Der parametrisierte Datentyp PComplex

Wir wollen als neuen numerischen Typen komplexe Zahlen in Polardarstellung \(z=r e^{i\phi}=(r,ϕ)\) einführen.

9.1 Die Definition von PComplex

Ein erster Versuch könnte so aussehen:

struct PComplex1{T <: AbstractFloat} <: Number
    r :: T
    ϕ :: T
end

z1 = PComplex1(-32.0, 33.0)
z2 = PComplex1{Float32}(12, 13)
@show z1 z2;
z1 = Main.Notebook.PComplex1{Float64}(-32.0, 33.0)
z2 = Main.Notebook.PComplex1{Float32}(12.0f0, 13.0f0)

Es ist nicht möglich, in einer Julia-Session eine einmal definierte struct später umzudefinieren. Daher verwende ich verschiedene Namen. Eine andere Möglichkeit ist z.B. die Verwendung von ProtoStructs.jl.

Julia stellt automatisch default constructors zur Verfügung:

  • den Konstruktor PComplex1, bei dem der Typ T von den übergebenen Argumenten abgeleitet wird und
  • Konstruktoren PComplex{Float64},... mit expliziter Typangabe. Hier wird versucht, die Argumente in den angeforderten Typ zu konvertieren.

Wir wollen nun, dass der Konstruktor noch mehr tut. In der Polardarstellung soll \(0\le r\) und \(0\le \phi<2\pi\) gelten.

Wenn die übergebenen Argumente das nicht erfüllen, sollten sie entsprechend umgerechnet werden.

Dazu definieren wir einen inner constructor, der den default constructor ersetzt.

  • Ein inner constructor ist eine Funktion innerhalb der struct-Definition.
  • In einem inner constructor kann man die spezielle Funktion new verwenden, die wie der default constructor wirkt.
struct PComplex{T <: AbstractFloat} <: Number
    r :: T
    ϕ :: T

    function PComplex{T}(r::T, ϕ::T) where T<:AbstractFloat
        if r<0            # flip the sign of r and correct phi
            r = -r
            ϕ += π
        end
        if r==0 ϕ=0 end  # normalize r=0 case to phi=0 
        ϕ = mod(ϕ, 2π)   # map phi into interval [0,2pi)
        new(r, ϕ)        # new() ist special function,
    end                  #   available only inside inner constructors

end
z1 = PComplex{Float64}(-3.3, 7π+1)
PComplex{Float64}(3.3, 1.0)

Für die explizite Angabe eines inner constructors müssen wir allerdings einen Preis zahlen: Die sonst von Julia bereitgestellten default constructors fehlen.

Den Konstruktor, der ohne explizite Typangabe in geschweiften Klammern auskommt und den Typ der Argumente übernimmt, wollen wir gerne auch haben:

PComplex(r::T, ϕ::T) where {T<:AbstractFloat} = PComplex{T}(r,ϕ)

z2 = PComplex(2.0, 0.3)
PComplex{Float64}(2.0, 0.3)

9.2 Eine neue Schreibweise

Julia verwendet // als Infix-Konstruktor für den Typ Rational. Sowas Schickes wollen wir auch.

In der Elektronik/Elektrotechnik werden Wechselstromgrößen durch komplexe Zahlen beschrieben.. Dabei ist eine Darstellung komplexer Zahlen durch “Betrag” und “Phase” üblich und sie wird gerne in der sogenannten Versor-Form (engl. phasor) dargestellt: \[ z= r\enclose{phasorangle}{\phi} = 3.4\;\enclose{phasorangle}{45^\circ} \] wobei man in der Regel den Winkel in Grad notiert.

In Julia ist eine große Anzahl von Unicode-Zeichen reserviert für die Verwendung als Operatoren. Die definitive Liste ist im Quellcode des Parsers.

Auf Details werden wir in einem späteren Kapitel noch eingehen.

Das Winkel-Zeichen steht leider nicht als Operatorsymbol zur Verfügung. Wir weichen aus auf . Das kann in Julia als als \lessdot<tab> eingegeben werden.

 (r::Real, ϕ::Real) = PComplex(r, π*ϕ/180)

z3 = 2.  90.
PComplex{Float64}(2.0, 1.5707963267948966)

(Die Typ-Annotation – Real statt AbstractFloat – ist ein Vorgriff auf kommende weitere Konstruktoren. Im Moment funktioniert der Operator erstmal nur mit Floats.)

Natürlich wollen wir auch die Ausgabe so schön haben. Details dazu findet man in der Dokumentation.

using Printf

function Base.show(io::IO, z::PComplex)
    # wir drucken die Phase in Grad, auf Zehntelgrad gerundet, 
    p = z.ϕ * 180/π
    sp = @sprintf "%.1f" p
    print(io, z.r, "⋖", sp, '°')
end

@show z3;
z3 = 2.0⋖90.0°

9.3 Methoden für PComplex

Damit unser Typ ein anständiges Mitglied der von Number abstammenden Typfamilie wird, brauchen wir allerdings noch eine ganze Menge mehr. Es müssen Arithmetik, Vergleichsoperatoren, Konvertierungen usw. definiert werden.

Wir beschränken uns auf Multiplikation und Quadratwurzeln.

  • Um die methods der existierenden Funktionen und Operationen zu ergänzen, muss man diese mit ihrem ‘vollen Namen’ ansprechen.
  • Alle Objekte gehören zu einem Namensraum oder module.
  • Die meisten Basisfunktionen gehören zum Modul Base, welches standardmäßig immer ohne explizites using ... geladen wird.
  • Solange man keine eigenen Module definiert, sind die eigenen Definitionen im Modul Main.
  • Das Macro @which, angewendet auf einen Namen, zeigt an, in welchem Modul der Name definiert wurde.
f(x) = 3x^3
@which f
Main.Notebook
wp = @which +
ws = @which(sqrt)
println("Modul für Addition: $wp, Modul für sqrt: $ws")
Modul für Addition: Base, Modul für sqrt: Base
qwurzel(z::PComplex) = PComplex(sqrt(z.r), z.ϕ / 2)
qwurzel (generic function with 1 method)

Die Funktion sqrt() hat schon einige Methoden:

length(methods(sqrt))
19

Jetzt wird es eine Methode mehr:

Base.sqrt(z::PComplex) = qwurzel(z)

length(methods(sqrt))
20
sqrt(z2)
1.4142135623730951⋖8.6°

und nun zur Multiplikation:

Base.:*(x::PComplex, y::PComplex) = PComplex(x.r * y.r, x.ϕ + y.ϕ)

@show z1 * z2;
z1 * z2 = 6.6⋖74.5°

(Da das Operatorsymbol kein normaler Name ist, muss der Doppelpunkt bei der Zusammensetzung mit Base. sein.)

Wir können allerdings noch nicht mit anderen numerischen Typen multiplizieren. Dazu könnte man nun eine Vielzahl von entsprechenden Methoden definieren. Julia stellt für numerische Typen noch einen weiteren Mechanismus zur Verfügung, der das etwas vereinfacht.

9.4 Typ-Promotion und Konversion

In Julia kann man bekanntlich die verschiedensten numerischen Typen nebeneinander verwenden.

1//3 + 5 + 5.2 + 0xff
265.53333333333336

Wenn man in die zahlreichen Methoden schaut, die z.B. für + und * definiert sind, findet man u.a. eine Art ‘catch-all-Definition’

+(x::Number, y::Number) = +(promote(x,y)...)
*(x::Number, y::Number) = *(promote(x,y)...)

(Die 3 Punkte sind der splat-Operator, der das von promote() zurückgegebene Tupel wieder in seine Bestandteile zerlegt.)

Da die Methode mit den Typen (Number, Number) sehr allgemein ist, wird sie erst verwendet, wenn spezifischere Methoden nicht greifen.

Was passiert hier?

9.4.1 Die Funktion promote(x,y,...)

Diese Funktion versucht, alle Argumente in einen gemeinsamen Typen umzuwandeln, der alle Werte (möglichst) exakt darstellen kann.

promote(12, 34.555, 77/99, 0xff)
(12.0, 34.555, 0.7777777777777778, 255.0)
z = promote(BigInt(33), 27)
@show z typeof(z);
z = (33, 27)
typeof(z) = Tuple{BigInt, BigInt}

Die Funktion promote() verwendet dazu zwei Helfer, die Funktionen promote_type(T1, T2) und convert(T, x)

Wie üblich in Julia, kann man diesen Mechanismus durch eigene promotion rules und convert(T,x)-Methoden erweitern.

9.4.2 Die Funktion promote_type(T1, T2,...)

Sie ermittelt, zu welchem Typ umgewandelt werden soll. Argumente sind Typen, nicht Werte.

@show promote_type(Rational{Int64}, ComplexF64, Float32);
promote_type(Rational{Int64}, ComplexF64, Float32) = ComplexF64

9.4.3 Die Funktion convert(T,x)

Die Methoden von convert(T, x) wandeln x in ein Objekt vom Typ T um. Dabei sollte eine solche Umwandlung verlustfrei möglich sein.

z = convert(Float64, 3)
3.0
z = convert(Int64, 23.00)
23
z = convert(Int64, 2.3)
InexactError: Int64(2.3)
Stacktrace:
 [1] Int64
   @ ./float.jl:994 [inlined]
 [2] convert(::Type{Int64}, x::Float64)
   @ Base ./number.jl:7
 [3] top-level scope
   @ ~/Julia/23/Book-ansipatch/chapters/pcomplex.qmd:294

Die spezielle Rolle von convert() liegt darin, dass es an verschiedenen Stellen implizit und automatisch eingesetzt wird:

The following language constructs call convert:

  • Assigning to an array converts to the array’s element type.
  • Assigning to a field of an object converts to the declared type of the field.
  • Constructing an object with new converts to the object’s declared field types.
  • Assigning to a variable with a declared type (e.g. local x::T) converts to that type.
  • A function with a declared return type converts its return value to that type.

– und natürlich in promote()

Für selbstdefinierte Datentypen kann man convert() um weitere Methoden ergänzen.

Für Datentypen innerhalb der Number-Hierarchie gibt es wieder eine ‘catch-all-Definition’

convert(::Type{T}, x::Number) where {T<:Number} = T(x)

Also: Wenn für einen Typen T aus der Hierarchie T<:Number ein Konstruktor T(x) mit einem numerischen Argument x existiert, dann wird dieser Konstruktor T(x) automatisch für Konvertierungen benutzt. (Natürlich können auch speziellere Methoden für convert() definiert werden, die dann Vorrang haben.)

9.4.4 Weitere Konstruktoren für PComplex


## (a) r, ϕ beliebige Reals, z.B.  Integers, Rationals

PComplex{T}(r::T1, ϕ::T2) where {T<:AbstractFloat, T1<:Real, T2<: Real} = 
    PComplex{T}(convert(T, r), convert(T, ϕ))

PComplex(r::T1, ϕ::T2) where {T1<:Real, T2<: Real} =  
    PComplex{promote_type(Float64, T1, T2)}(r, ϕ)   

## (b) Zur Umwandlung von Reals: Konstruktor mit 
##      nur einem Argument r

PComplex{T}(r::S) where {T<:AbstractFloat, S<:Real} = 
    PComplex{T}(convert(T, r), convert(T, 0)) 

PComplex(r::S)  where {S<:Real} = 
    PComplex{promote_type(Float64, S)}(r, 0.0)

## (c) Umwandlung Complex -> PComplex

PComplex{T}(z::Complex{S}) where {T<:AbstractFloat, S<:Real} = 
    PComplex{T}(abs(z), angle(z))

PComplex(z::Complex{S}) where {S<:Real} = 
    PComplex{promote_type(Float64, S)}(abs(z), angle(z))
PComplex

Ein Test der neuen Konstruktoren:


3//5  45,  PComplex(Complex(1,1)),   PComplex(-13) 
(0.6⋖45.0°, 1.4142135623730951⋖45.0°, 13.0⋖180.0°)

Wir brauchen nun noch promotion rules, die festlegen, welcher Typ bei promote(x::T1, y::T2) herauskommen soll. Damit wird promote_type() intern um die nötigen weiteren Methoden erweitert.

9.4.5 Promotion rules für PComplex

Base.promote_rule(::Type{PComplex{T}}, ::Type{S}) where {T<:AbstractFloat,S<:Real} = 
    PComplex{promote_type(T,S)}

Base.promote_rule(::Type{PComplex{T}}, ::Type{Complex{S}}) where 
    {T<:AbstractFloat,S<:Real} = PComplex{promote_type(T,S)}
  1. Regel:
    Wenn ein PComplex{T} und ein S<:Real zusammentreffen, dann sollen beide zu PComplex{U} umgewandelt werden, wobei U der Typ ist, zu dem S und T beide umgewandelt (promoted) werden können.
  2. Regel
    Wenn ein PComplex{T} und ein Complex{S} zusammentreffen, dann sollen beide zu PComplex{U} umgewandelt werden, wobei U der Typ ist, zu dem S und T beide umgewandelt werden können.

Damit klappt nun die Multiplikation mit beliebigen numerischen Typen.

z3, 3z3
(2.0⋖90.0°, 6.0⋖90.0°)
(3.0+2im) * (1230.3),  12sqrt(z2)
(43.26661530556787⋖64.0°, 16.970562748477143⋖8.6°)
struct PComplex{T <: AbstractFloat} <: Number
    r :: T
    ϕ :: T

    function PComplex{T}(r::T, ϕ::T) where T<:AbstractFloat
        if r<0            # flip the sign of r and correct phi
            r = -r
            ϕ += π
        end
        if r==0 ϕ=0 end  # normalize r=0 case to phi=0 
        ϕ = mod(ϕ, 2π)   # map phi into interval [0,2pi)
        new(r, ϕ)        # new() ist special function,
    end                  #   available only inside inner constructors

end

# additional constructors
PComplex(r::T, ϕ::T) where {T<:AbstractFloat} = PComplex{T}(r,ϕ)


PComplex{T}(r::T1, ϕ::T2) where {T<:AbstractFloat, T1<:Real, T2<: Real} = 
    PComplex{T}(convert(T, r), convert(T, ϕ))

PComplex(r::T1, ϕ::T2) where {T1<:Real, T2<: Real} =  
    PComplex{promote_type(Float64, T1, T2)}(r, ϕ)   


PComplex{T}(r::S) where {T<:AbstractFloat, S<:Real} = 
    PComplex{T}(convert(T, r), convert(T, 0)) 

PComplex(r::S)  where {S<:Real} = 
    PComplex{promote_type(Float64, S)}(r, 0.0)


PComplex{T}(z::Complex{S}) where {T<:AbstractFloat, S<:Real} = 
    PComplex{T}(abs(z), angle(z))

PComplex(z::Complex{S}) where {S<:Real} = 
    PComplex{promote_type(Float64, S)}(abs(z), angle(z))

# nice input
(r::Real, ϕ::Real) = PComplex(r, π*ϕ/180)

# nice output
using Printf

function Base.show(io::IO, z::PComplex)
    # wir drucken die Phase in Grad, auf Zehntelgrad gerundet, 
    p = z.ϕ * 180/π
    sp = @sprintf "%.1f" p
    print(io, z.r, "⋖", sp, '°')
end

# arithmetic
Base.sqrt(z::PComplex) = PComplex(sqrt(z.r), z.ϕ / 2)

Base.:*(x::PComplex, y::PComplex) = PComplex(x.r * y.r, x.ϕ + y.ϕ)

# promotion rules
Base.promote_rule(::Type{PComplex{T}}, ::Type{S}) where 
    {T<:AbstractFloat,S<:Real} = PComplex{promote_type(T,S)}

Base.promote_rule(::Type{PComplex{T}}, ::Type{Complex{S}}) where 
    {T<:AbstractFloat,S<:Real} = PComplex{promote_type(T,S)}