Skip to content

User Defined Types

It is very common to define new types in Julia. One reason is multiple dispatch, which we discussed when we talked about functions.

Example

julia> struct Point
    x
    y
end
julia> z = Point(1, 2)
Point(1, 2)

We have now defined the type Point. By default, Julia also created a function, called a constructor that "makes" Point objects:

julia> methods(Point)
# 1 method for type constructor:
[1] Point(x, y) in Main at REPL[21]:2

This says that there is a function Point, which is the constructor for the Point data type.

We can now write functions that take Point arguments, such as

julia> function sum_points(p1 :: Point, p2 :: Point)
         return Point(p1.x + p2.x, p1.y + p2.y)
       end
sum_points (generic function with 1 method)

julia> sum_points(Point(1,2), Point(2,3))
Point(3, 5)

But it gets even better: we can "overload" an existing function like so:

julia> function Base.sum(p1 :: Point, p2 :: Point)
         return Point(p1.x + p2.x, p1.y + p2.y)
       end

julia> sum(Point(1,2), Point(2,3))
Point(3, 5)

Now the "built-in" function (in module Base) has an additional method. Before, Julia only knew how to sum numbers (and a few other things). Now Julia knows how to sum points.

This means that any algorithm that makes sense and uses summation can now be applied to our new Point datatype.

Example:

Suppose we wanted to sort Points. We don't have to write a new sorting function. Instead we can define

julia> function Base.isless(p1 :: Point, p2 :: Point)
         if p1.x < p2.x
           return true
         elseif (p1.x == p2.x)  &&  (p1.y < p2.y)
           return true
         else
           return false
         end
       end

julia> isless(Point(1,2), Point(2,3))
true

And, voila, we can sort Vectors of Point:

julia> sort([Point(1,2), Point(3,4), Point(1,3)])
3-element Vector{Point}:
 Point(1, 2)
 Point(1, 3)
 Point(3, 4)

It is worth reading the documentation on types and constructors.

Parametric Types

Clearly, the concept of Point would make sense for different types of coordinates (e.g., Float64, Float32).

We can define a generic version of Point that works for any coordinate type:

struct Point{T}
  x :: T
  y :: T
end
julia> Point(1,2)
Point{Int64}(1, 2)

julia> Point(1.0, 2.0)
Point{Float64}(1.0, 2.0)

This is a parametric type, parameterized by T.

When we define methods, we have

julia> function Base.isless(p1 :: Point{T}, p2 :: Point{T}) where T
         if p1.x < p2.x
           return true
         elseif (p1.x == p2.x)  &&  (p1.y < p2.y)
           return true
         else
           return false
         end
       end

This defines a method for each parameterization of Point.

It is probably obvious why this is essential. Point is a data container that is equally valid for different types of data held. We don't want to be forced to write separate code for each parametric version of Point.

Mutable Types

struct Foo end is immutable. Once a Foo is constructed, it cannot be changed.

mutable struct Foo
    x
end

is mutable. I can change its fields with Foo.x = 1.

Benefits of immutable types:

  1. Efficiency:

    • The compiler can apply many optimizations based on knowing that Foo.x can never change.
    • Immutable types can often be allocated on the "stack" which is much faster than on the "heap".
  2. Easier to reason about code.

Excercise

Construct a parametric type Model that contains a utility function CRRA{T} and a production function CobbDouglas{T} for the same T.

abstract type AbstractUtility{T} end

struct CRRA{T} <: AbstractUtility{T}
    σ :: T
end

abstract type AbstractProdFct{T} end

struct CobbDouglas{T} <: AbstractProdFct{T}
    α :: T
end

# Note the `T` in the parametric type definition
struct Model{T, U <: AbstractUtility{T}, F <: AbstractProdFct{T}}
    util :: U
    prodFct :: F
end

m = Model(CRRA(2.0), CobbDouglas(0.3))
@show m

try
    m2 = Model(CRRA(Float32(2)), CobbDouglas(0.3))
    @show m2
catch
    println("This errors because the parametric types don't match.");
end