9  Example: The Parametric Data Type PComplex

We want to introduce a new numeric type complex numbers in polar representation \(z=r e^{i\phi}=(r,\phi)\).

9.1 The Definition of PComplex

A first attempt could look like this:

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

z1 = PComplex(-32.0, 33.0)
z2 = PComplex{Float32}(12, 13)
@show z1 z2;
z1 = PComplex(-32.0, 33.0)
z2 = PComplex(12.0, 13.0)

Julia automatically provides default constructors:

  • The constructor PComplex infers type T from the arguments, and
  • Constructors like PComplex{Float64} accept explicit type specifications. Arguments are converted to the requested type.

We now want the constructor to do even more. In the polar representation, we want \(0\le r\) and \(0\le \phi<2\pi\) to hold.

If the passed arguments do not satisfy this, they should be recalculated accordingly.

To this end, we define an inner constructor that replaces the default constructor.

  • An inner constructor is a function within the struct definition.
  • In an inner constructor, one can use the special function new, which acts like the default constructor.
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() is special function,
    end                  #   available only inside inner constructors

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

However, explicitly specifying an inner constructor has a consequence: Julia’s default constructors are no longer available.

The constructor without explicit type specification, which infers the type from the arguments, is also needed:

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

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

9.2 A New Notation

Julia uses // as an infix constructor for the type Rational. We want something equally nice.

In electronics/electrical engineering, AC quantities are described by complex numbers. A representation of complex numbers by “magnitude” and “phase” is common and is often represented in so-called phasor form:

\[ z= r\enclose{phasorangle}{\phi} = 3.4\;\enclose{phasorangle}{45^\circ} \]

where the angle is usually noted in degrees.

In Julia, a large number of Unicode characters are reserved for use as operators. The definitive list is in the parser source code.

Details will be discussed in a later chapter.

The angle bracket symbol is not available as a Julia operator. We use as an alternative, entered as \lessdot<Tab>.

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

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

(The type annotation Real instead of AbstractFloat anticipates further constructors. Currently, the operator works only with Float64.)

Of course, we also want the output to look nice. Details can be found in the documentation.

using Printf

function Base.show(io::IO, z::PComplex)
    # print phase in degrees, rounded to one decimal place 
    p = z.ϕ * 180/π
    sp = @sprintf "%.1f" p
    print(io, z.r, "⋖", sp, '°')
end

@show z3;
z3 = 2.0⋖90.0°

9.3 Methods for PComplex

For our type to be a proper member of the family of types derived from Number, additional functionality is required: arithmetic operations, comparison operators, and conversions must all be defined.

We focus on multiplication and square root operations.

NoteModules
  • Adding methods to existing functions requires using their fully qualified names.
  • All objects belong to a namespace or module.
  • Most basic functions belong to Base, which is loaded automatically.
  • Without user-defined modules, definitions reside in Main.
  • The macro @which applied to a name shows its defining module.
f(x) = 3x^3
@which f
Main.Notebook
wp = @which +
ws = @which(sqrt)
println("Module for addition: $wp, Module for sqrt: $ws")
Module for addition: Base, Module for sqrt: Base
sqrt_polar(z::PComplex) = PComplex(sqrt(z.r), z.ϕ / 2)
sqrt_polar (generic function with 1 method)

The function sqrt() already has some methods:

length(methods(sqrt))
19

Adding one more method:

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

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

For multiplication:

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

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

(Since : is not a valid identifier character, it must be qualified with Base.)

However, multiplication with other numeric types is not yet supported. Many corresponding methods could be defined, but Julia provides another mechanism for numeric types that simplifies this:

9.4 Type Promotion and Conversion

Julia supports freely mixing various numeric types:

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

Among the numerous methods defined for + and *, we find a catch-all definition:

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

(The three dots form the splat operator, which decomposes the tuple returned by promote() into its components.)

Since the method with the types (Number, Number) is very general, it is only used when more specific methods do not apply.

What happens here?

9.4.1 The Function promote(x,y,...)

This function attempts to convert all arguments to a common type that can represent all values (as precisely as possible).

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}

The function promote() uses two helpers, the functions promote_type(T1, T2) and convert(T, x)

As usual in Julia, we can extend this mechanism with our own custom promotion rules and convert(T,x) methods.

9.4.2 The Function promote_type(T1, T2,...)

It determines to which type the conversion should take place. Arguments are types, not values.

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

9.4.3 The Function convert(T,x)

The methods of convert(T, x) convert x into an object of type T. Such a conversion should be lossless.

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:923 [inlined]
 [2] convert(::Type{Int64}, x::Float64)
   @ Base ./number.jl:7
 [3] top-level scope
   @ ~/Julia/Book26/JuliaBook/chapters/pcomplex.qmd:312

The special role of convert() is that it is called implicitly at various points:

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.

– and of course in promote()

For user-defined types, convert() can be extended with custom methods.

Within the Number hierarchy, a generic method handles conversions:

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

Therefore: If a type T<:Number has a constructor T(x) accepting a numeric argument, this constructor is automatically used for conversions. (More specific methods for convert() can also be defined and will take priority.)

9.4.4 Further Constructors for PComplex


## (a) Arbitrary real types for r and ϕ (e.g., 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) For conversion from reals: constructor with 
##      only one 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) Conversion 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

Testing the new constructors:


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

Promotion rules are needed to determine the result type of promote(x::T1, y::T2). This mechanism extends promote_type() with the necessary methods.

9.4.5 Promotion rules for 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. Rule: When a PComplex{T} and an S<:Real are combined, both convert to PComplex{U}, where U is the promoted type of S and T.

  2. Rule When a PComplex{T} and a Complex{S} are combined, both convert to PComplex{U}, where U is the promoted type of S and T.

We can now multiply with arbitrary numeric types:

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() is 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)
    # print phase in degrees, rounded to one decimal place 
    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)}