Types

In the previous sections of this lecture, you’ve already encountered glimpses of Julia’s type system through the different kinds of data we’ve introduced. Whether you’re a beginner or looking to deepen your understanding of Julia’s type system, this page will help you get familiar with the core building blocks for handling data efficiently in Julia.

In particular, we’ll explore four key ideas:

Mastering these concepts will give you deeper insight into Julia’s design and help you write code that is both elegant and performant (which we will go deeper into on next page).

By the end of this page, you’ll have a deeper understanding of Julia’s flexible and powerful type system, which is essential for writing efficient, type-safe code.

Introduction to Types in Julia

Julia is a dynamically typed language, meaning that variable types are determined at runtime. However, Julia also supports strong typing, which means that types are important and can be explicitly specified when needed. Understanding types in Julia is essential for writing efficient code, as the language uses Just-In-Time (JIT) compilation to optimize based on variable types.

Dynamic Typing

In Julia, variables do not require explicit type declarations. The type of a variable is inferred based on the value assigned to it.

x = 10          # x is inferred to be of type Int64
y = 3.14        # y is inferred to be of type Float64
z = "Hello"     # z is inferred to be of type String

typeof(x), typeof(y), typeof(z)
julia> x = 10
julia> y = 3.14
julia> z = "Hello"
julia> (typeof(x), typeof(y), typeof(z)) = (Int64, Float64, String)

Even though Julia automatically infers types, you can still explicitly specify them when necessary, particularly for performance optimization or for ensuring that a variable matches a particular type.

Strong Typing

While Julia uses dynamic typing, it is strongly typed. This means that Julia will enforce type constraints on operations, and will raise errors when an operation is attempted with incompatible types.

You can add an integer and a float,

n = 5           # Integer
x = 2.0         # Float
n + x           # we can add an Int64 and a Float64
julia> n = 5
julia> x = 2.0
julia> n + x = 7.0

but you cannot add an integer and a string:

s = "Hello"     # String
n + s           # Error: does not make sense to add an Int64 and a String
julia> s = "Hello"
julia> n + s
MethodError: no method matching +(::Int64, ::String)
The function `+` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  +(::Any, ::Any, ::Any, ::Any...)
   @ Base operators.jl:596
  +(::ChainRulesCore.NoTangent, ::Any)
   @ ChainRulesCore ~/.julia/packages/ChainRulesCore/XAgYn/src/tangent_arithmetic.jl:59
  +(::Any, ::ChainRulesCore.NotImplemented)
   @ ChainRulesCore ~/.julia/packages/ChainRulesCore/XAgYn/src/tangent_arithmetic.jl:25
  ...


Stacktrace:
 [1] macro expansion
   @ show.jl:1232 [inlined]
 [2] macro expansion
   @ ~/Gitlab/course-tse-julia/assets/julia/myshow.jl:82 [inlined]
 [3] top-level scope
   @ In[53]:3

We see from the error message that we can add an Integer and a Char: +(::Integer, ::AbstractChar) is a valid operation. This is because a Char can be treated as an integer in Julia.

c = 'a'      # Char
c + 128448   # This will work because Char can be treated as an integer
julia> c = 'a'
julia> c + 128448 = '😡'

Julia allows flexibility compared to statically typed languages like C or Java, but still ensures that operations make sense for the types involved.

Type System and Performance

The type system in Julia plays a key role in performance. By inferring or specifying types, Julia’s JIT compiler can optimize code for specific data types, leading to faster execution. For example, when types are known at compile time, Julia can generate machine code tailored for the specific types involved.

Julia’s type system also supports abstract types, allowing for more flexible and generic code, as well as parametric types that let you define functions or types that work with any data type.

Summary

  • Julia is dynamically typed but enforces strong typing.
  • Types are inferred from the values assigned to variables.
  • Julia optimizes performance based on types, making type information crucial.

Type Hierarchies

In Julia, types are organized into a hierarchy with Any as the root. At the top, Any is the most general type, and all other types are subtypes of Any. The type hierarchy enables Julia to provide flexibility while supporting efficient dispatch based on types.

using GraphRecipes, Plots
default(size=(800, 800))
plot(AbstractFloat, fontsize=10, nodeshape=:rect, nodesize=0.08)

Abstract and Concrete Types

Types in Julia can be abstract or concrete:

  • Abstract types serve as nodes in the hierarchy but cannot be instantiated. They provide a framework for organizing related types.
  • Concrete types can be instantiated and are the actual types used for values.

For example, Julia’s Real and AbstractFloat types are abstract, while Int64 and Float64 are concrete subtypes.

i::Int64 = 42   # Int64 is a concrete type
typeof(i)       # Output: Int64 (concrete type)
r::Real = 3.14  # Real is an abstract type
typeof(r)       # Output: Float64 (concrete type)
julia> i::Int64 = 42
julia> typeof(i) = Int64
julia> r::Real = 3.14
julia> typeof(r) = Float64

Checking if a Type is Concrete

In Julia, you can use the isconcretetype function to check if a type is concrete (meaning it can be instantiated) or abstract (which serves as a blueprint for other types but cannot be instantiated directly).

isconcretetype(Int64)
isconcretetype(AbstractFloat)
julia> isconcretetype(Int64) = true
julia> isconcretetype(AbstractFloat) = false

The isconcretetype function returns true for concrete types (like Int64 or Float64) and false for abstract types (like AbstractFloat or Real).

Get the Type of a Variable

You can use the typeof() function to get the type of a variable:

a = 42
typeof(a)
julia> a = 42
julia> typeof(a) = Int64

The typeof() function returns the concrete type of the variable.

Example

Let’s instantiate a variable with a specific concrete type, check its type using typeof(), and verify if it’s concrete using isconcretetype:

a = 3.14
typeof(a)
isconcretetype(typeof(a))
julia> a = 3.14
julia> typeof(a) = Float64
julia> isconcretetype(typeof(a)) = true

The isa Operator

The isa operator is used to check if a value is an instance of a specific type:

a = 42
a isa Int64
a isa Number
a isa Float64
julia> a = 42
julia> a isa Int64 = true
julia> a isa Number = true
julia> a isa Float64 = false

The isa operator is often used for type checking within functions or when validating data.

The <: Operator

The <: operator checks if a type is a subtype of another type in the hierarchy. It can be used for checking if one type is a more general or more specific type than another:

Int64 <: Real
Float64 <: Real
Real <: Number
Number <: Real
julia> Int64 <: Real = true
julia> Float64 <: Real = true
julia> Real <: Number = true
julia> Number <: Real = false

Creating Custom Abstract Types

Julia allows you to create your own abstract types. For example, you can define a custom abstract type Shape, and create concrete subtypes like Triangle and Rectangle.

# Define abstract type
abstract type Shape end

# Define concrete subtypes
struct Triangle <: Shape
    base::Float64
    height::Float64
end

struct Rectangle <: Shape
    width::Float64
    height::Float64
end

# Create instances
triangle = Triangle(2.0, 3.0)
rectangle = Rectangle(3.0, 4.0)

# Check if they are subtypes of Shape
triangle isa Shape
rectangle isa Shape
julia> triangle isa Shape = true
julia> rectangle isa Shape = true

Getting Subtypes and Parent Types

In Julia, you can use the subtypes() function to find all direct subtypes of a given type. Additionally, the supertypes() function can be used to get the entire chain of parent (super) types for a given type.

Getting Subtypes

To find all direct subtypes of a specific type, you can use the subtypes() function. Here’s an example:

subtypes(AbstractFloat)
5-element Vector{Any}:
 BigFloat
 Core.BFloat16
 Float16
 Float32
 Float64

This will return all direct subtypes of AbstractFloat. To visualize the type hierarchy, you can use the plot function from the GraphRecipes package or for a textual representation, you can do the following:

using AbstractTrees
AbstractTrees.children(d::DataType) = subtypes(d)
print_tree(Integer)
Integer
├─ Bool
├─ OffsetInteger
├─ OffsetInteger
├─ Signed
│  ├─ BigInt
│  ├─ Int128
│  ├─ Int16
│  ├─ Int32
│  ├─ Int64
│  └─ Int8
└─ Unsigned
   ├─ UInt128
   ├─ UInt16
   ├─ UInt32
   ├─ UInt64
   └─ UInt8

Getting the Parent Type

To find the immediate supertype (parent type) of a specific type, you can use the supertype() function. Here’s an example:

supertype(Int64)
Signed

This will return the immediate parent type of Int64.

Getting the List of All Parent Types

To get the entire chain of parent types, you can use the supertypes() function, which directly returns all the parent types of a given type. Here’s an example that shows how to do this for Float64:

supertypes(Float64)
(Float64, AbstractFloat, Real, Number, Any)

This code will return the list of all parent types of Float64, starting from Float64 itself and going up the type hierarchy to Any. This can be useful for understanding the relationships between different types in Julia. To print the list of parent types in a more readable format, you can use the join function:

join(supertypes(Float64), " -> ")
"Float64 -> AbstractFloat -> Real -> Number -> Any"

Type Hierarchies and Performance

The type hierarchy plays a crucial role in enabling multiple dispatch in Julia, allowing for efficient method selection based on the types of function arguments. By organizing types into a well-defined hierarchy, Julia can quickly select the most specific method for a given operation, optimizing performance, especially in scientific and numerical computing.

Type Conversion and Promotion

In Julia, type conversion and promotion are mechanisms that allow for flexibility when working with different types, enabling smooth interactions and arithmetic between varying data types. Conversion changes the type of a value, while promotion ensures two values have a common type for an operation.

Type Conversion

Type conversion in Julia is typically achieved with the convert function, which tries to change a value from one type to another. For conversions between Float64 and Int, methods like round and floor are commonly used to handle fractional parts safely. To convert numbers to strings, use the string() function instead.

round(Int, 3.84)   
floor(Int, 3.14)
convert(Float64, 5)
string(123)
julia> round(Int, 3.84) = 4
julia> floor(Int, 3.14) = 3
julia> convert(Float64, 5) = 5.0
julia> string(123) = "123"

In these examples:

  • round rounds a Float64 to the nearest Int.
  • floor converts a Float64 to the nearest lower Int.
  • Converting an Int to Float64 represents the integer as a floating-point number.
  • string() converts an integer to its string representation.

Automatic Conversion

In many cases, Julia will automatically convert types when it is unambiguous. For instance, you can directly assign an integer to a floating-point variable, and Julia will automatically convert it.

t::Float64 = 10  # The integer 10 is converted to 10.0 (Float64)
julia> t::Float64 = 10.0

Type Promotion

Type promotion is used when combining two values of different types in an operation. Julia promotes values to a common type using the promote function, which returns values in their promoted type. This is useful when performing arithmetic on values of different types.

a, b = promote(3, 4.5)  # Promotes both values to Float64
typeof(a)
typeof(b)
julia> (a, b) = (3.0, 4.5)
julia> typeof(a) = Float64
julia> typeof(b) = Float64

In this example, promote converts both 3 (an Int) and 4.5 (a Float64) to Float64 so they can be added, subtracted, or multiplied without any type conflicts.

Warning

Be aware that promotion has nothing to do with the type hierarchy. For instance, although every Int value can also be represented as a Float64 value, Int is not a subtype of Float64.

Summary

  • convert(Type, value): Converts value to the specified Type, if possible.
  • promote(x, y): Returns both x and y promoted to a common type.
  • Type promotion rules allow Julia to handle operations between different types smoothly, making the language both powerful and flexible for numerical and data processing tasks.

Composite Types

Introduction to struct

In Julia, you can define your own custom data types using the struct keyword. Composite types are user-defined types that group together different pieces of data into one object. A struct is a great way to create a type that can represent a complex entity with multiple fields.

  • Creating a custom struct:
# Define a simple struct for a point in 2D space
struct Point
    x::Float64
    y::Float64
end

Here, we created a Point struct with two fields: x and y, both of which are of type Float64.

  • Creating an instance of a struct:
p = Point(3.0, 4.0)  # Creates a Point with x = 3.0 and y = 4.0
Point(3.0, 4.0)
  • Accessing fields of a struct:
p.x  # Access the 'x' field of the Point instance
p.y  # Access the 'y' field of the Point instance
4.0

You can access the fields of a struct directly using dot notation, as shown above.

  • Get the names of the fields:
fieldnames(Point)  # Returns the names of the fields in the Point struct
(:x, :y)

Mutability of struct

In Julia, structs are immutable by default, meaning once you create an instance of a struct, its fields cannot be changed. However, you can create mutable structs by using the mutable struct keyword, which allows modification of field values after creation.

  • Creating a mutable struct:
mutable struct MutablePoint
    x::Float64
    y::Float64
end

Now you can modify the fields of MutablePoint instances after they are created.

mp = MutablePoint(1.0, 2.0)
mp.x = 3.0  # Modify the 'x' field

Example: struct for a Circle

We can create a more complex type, such as a Circle, which has a center represented by a Point and a radius:

struct Circle
    center::Point
    radius::Float64
end
  • Creating an instance of Circle:
c = Circle(Point(0.0, 0.0), 5.0)  # Create a circle with center (0, 0) and radius 5
Circle(Point(0.0, 0.0), 5.0)
  • Accessing fields of a nested struct:
c.center.x  # Access the x field of the center of the circle
c.center.y  # Access the y field of the center of the circle
c.radius    # Access the radius of the circle

Constructor methods

Let us look at an example. A Duck is an object that can be described as follows:

  • state: a name (name::String) and number of feathers (nb::Int32);
  • location: a position on a 2-D grid (pt::Point).

In order to create a Duck, it is necessary to define the object (as seen previously with Point).

struct Duck
    name::String
    nb::Int64
    pt::Point
end

In order to create a Duck with the name “Donald”, we simply use the default constructor generated by the Julia language.

donald = Duck("Donald", 10000, Point(0.,0.))
Duck("Donald", 10000, Point(0.0, 0.0))

As any function in Julia, a constructor function can be associated with several constructor methods. The object Duck has been defined. Still, it is possible to add so called outer constructor methods. For example, we can provide a method that takes two Float64 instead of an instance of the Point object.

Duck(name::String, nb::Int64, x::Float64, y::Float64) = Duck(name, nb, Point(x,y));

We can now create another Duck without using the Point object.

scrooge = Duck("Scrooge", 5000, 0., 1.)
Duck("Scrooge", 5000, Point(0.0, 1.0))

Oh! But Scrooge is a cheapskate. Let us look into inner constructor methods in order to avoid any Duck from being called “Scrooge”. An inner constructor can only be defined within the definition of the object. Let us rewrite the Duck object in order to replace the default constructor method by our own constructor method.

struct Duck
    name::String
    nb::Int64
    pt::Point

    function Duck(name::String, nb::Int64, pt::Point)
        if name == "Scrooge"
            error("A Duck can not be a cheapskate!!")
        end
        new(name, nb, pt)
    end
end

Let us try to create a Duck called “Scrooge” now.

scrooge = Duck("Scrooge", 5000, 0., 1.)
A Duck can not be a cheapskate!!

Stacktrace:
 [1] error(s::String)
   @ Base ./error.jl:35
 [2] Duck
   @ ./In[84]:8 [inlined]
 [3] Duck(name::String, nb::Int64, x::Float64, y::Float64)
   @ Main ./In[82]:1
 [4] top-level scope
   @ In[85]:1

Great an error was thrown!

Function-like Object (Callable struct)

In Julia, you can make a struct “callable” by defining the call method for it. This allows instances of the struct to be used like functions. This feature is useful for encapsulating parameters or states in a type while still allowing it to behave like a function.

Here’s an example that demonstrates a callable struct for a linear transformation:

# Define a callable struct for a linear transformation
struct LinearTransform
    a::Float64  # Slope
    b::Float64  # Intercept
end

# Define the call method for LinearTransform
function (lt::LinearTransform)(x::Real)
    lt.a * x + lt.b  # Apply the linear transformation
end
  • Explanation:
    • The LinearTransform struct stores the parameters of the linear function ( y = ax + b ).
    • By defining the call method for the struct, you enable instances of LinearTransform to behave like a function.
  • Usage:
# Create an instance of LinearTransform
lt = LinearTransform(2.0, 3.0)  # y = 2x + 3

# Call the instance like a function
typeof(lt)  # Output: LinearTransform
y1 = lt(5)   # Calculates 2 * 5 + 3 = 13
y2 = lt(-1)  # Calculates 2 * -1 + 3 = 1
julia> typeof(lt) = LinearTransform
julia> y1 = 13.0
julia> y2 = 1.0

Extending the Concept: Composable Linear Transforms

You can take this idea further by allowing composition of transformations. For example:

# Define a method to compose two LinearTransform objects
function (lt1::LinearTransform)(lt2::LinearTransform)
    LinearTransform(lt1.a * lt2.a, lt1.a * lt2.b + lt1.b)
end

# Example usage
lt1 = LinearTransform(2.0, 3.0)  # y = 2x + 3
lt2 = LinearTransform(0.5, 1.0)  # y = 0.5x + 1

# Compose the two transformations
lt_composed = lt1(lt2)  # Equivalent to y = 2 * (0.5x + 1) + 3

# Call the composed transformation
y = lt_composed(4)  # Calculates 2 * (0.5 * 4 + 1) + 3 = 9
julia> y = 9.0
Note

The previous composition is equivalent in pure Julia to:

y = (lt1  lt2)(4)
9.0

Conclusion

  • In Julia, struct allows you to create complex custom types that can hold different types of data. Custom constructors provide flexibility for struct initialization, allowing validation and preprocessing of input data. This is especially useful for enforcing constraints and ensuring type consistency. By default, structs are immutable, but you can use mutable struct if you need to change the data after creation.
  • Using a callable struct allows you to represent parameterized functions or transformations in a concise and reusable way. The concept can be extended further to support operations like composition or chaining, making it a powerful tool for functional-style programming in Julia.

Parametric Composite Types

A parametric struct can take one or more type parameters:

struct Pair{T, S}
    first::T
    second::S
end

pair1 = Pair(1, "apple")  # Pair of Int and String
pair2 = Pair(3.14, true)  # Pair of Float64 and Bool
julia> pair1 = Pair{Int64, String}(1, "apple")
julia> pair2 = Pair{Float64, Bool}(3.14, true)

In this case, Pair can be instantiated with any two types T and S, making it more versatile.

Parametric Abstract Types

Parametric abstract types allow you to define abstract types that are parameterized by other types.

Syntax:

abstract type AbstractContainer{T} end

Here, AbstractContainer is an abstract type that takes a type parameter T. Any concrete type that is a subtype of AbstractContainer can specify the concrete type for T.

Example:

abstract type AbstractContainer{T} end

struct VectorContainer{T} <: AbstractContainer{T}
    data::Vector{T}
end

struct SetContainer{T} <: AbstractContainer{T}
    data::Set{T}
end

struct FloatVectorContainer <: AbstractContainer{Float64}
    data::Vector{Float64}
end

function print_container_info(container::AbstractContainer{T}) where T
    println("Container holds values of type: ", T)
end

# Usage:
vec = VectorContainer([1, 2, 3])
set = SetContainer(Set([1, 2, 3]))
flo = FloatVectorContainer([1.0, 2.0, 3.0])

print_container_info(vec)
print_container_info(set)
print_container_info(flo)
Container holds values of type: Int64
Container holds values of type: Int64
Container holds values of type: Float64

Explanation:

  • AbstractContainer{T} is a parametric abstract type, where T represents the type of elements contained within the container.
  • VectorContainer and SetContainer are concrete subtypes of AbstractContainer, each using a different data structure (Vector and Set) to store elements of type T.
  • FloatVectorContainer is a concrete subtype of AbstractContainer that specifies Float64 as the type for T.
  • The function print_container_info accepts any container that is a subtype of AbstractContainer and prints the type of elements inside the container.

Constrained Parametric Types

Constrained parametric types allow you to restrict acceptable type parameters using <:, ensuring greater control and type safety.

struct RealPair{T <: Real}
    first::T
    second::T
end

# Valid:
pair = RealPair(1.0, 2.5)

# Constraining a function:****
function sum_elements(container::AbstractContainer{T}) where T <: Real
    return sum(container.data)
end

vec = VectorContainer([1.0, 2.0, 3.0])
println(sum_elements(vec))  # Outputs: 6.0
6.0

In this example, RealPair is a struct that only accepts type parameters that are subtypes of Real. Similarly, the sum_elements function only works with containers that hold elements of type T that are subtypes of Real. The following code will throw an error because String is not a subtype of Real:

# Invalid (throws an error):
invalid_pair = RealPair("a", "b")
MethodError: no method matching RealPair(::String, ::String)
The type `RealPair` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  RealPair(::T, ::T) where T<:Real
   @ Main In[92]:2


Stacktrace:
 [1] top-level scope
   @ In[93]:2

Constraints enhance type safety, clarify requirements, and support robust generic programming.

Exercises

Exercise 1: Creating a Shape System

Create a system to represent different geometric shapes (like a Square, Circle, and Point) using the following requirements:

  1. Define a Point struct with x and y coordinates of type Float64.
  2. Define a Square struct with the field width of type Float64. Use the Point struct to represent the bottom-left corner of the square.
  3. Define a Circle struct with a Point for the center and a radius of type Float64.
  4. Write a function area(shape) that computes the area of the given shape:
    • The area of a square is width * width.
    • The area of a circle is π * radius^2.
  • Use struct to define Point, Square, and Circle.
  • Use dot notation to access the fields of the structs.
  • Use conditional logic (e.g., typeof()) to handle different shapes in the area function.
  • For the circle, use π = 3.141592653589793.
# Define the Point struct
struct Point
    x::Float64
    y::Float64
end

# Define the Square struct
struct Square
    bottom_left::Point
    width::Float64
end

# Define the Circle struct
struct Circle
    center::Point
    radius::Float64
end

# Function to calculate the area
function area(shape)
    if typeof(shape) == Square
        return shape.width * shape.width
    elseif typeof(shape) == Circle
        return π * shape.radius^2
    else
        throw(ArgumentError("Unsupported shape"))
    end
end

# Example usage
p1 = Point(0.0, 0.0)
r1 = Square(p1, 3.0)
c1 = Circle(p1, 5.0)

println("Area of square: ", area(r1))  # Should print 12.0
println("Area of circle: ", area(c1))     # Should print 78.53981633974483
Area of square: 9.0
Area of circle: 78.53981633974483

Exercise 2: Working with Complex Numbers and Arrays

  1. Create two complex numbers z1 and z2 of type Complex{Float64}.
  2. Write a function add_complex(z1, z2) that adds two complex numbers and returns the result.
  3. Create an array of complex numbers and use the map function to add 2.0 to the real part of each complex number.
  4. Create a function max_real_part that returns the complex number with the largest real part from an array of complex numbers.
  • Use the Complex{T} type to create complex numbers.
  • You can access the real and imaginary parts of a complex number with real(z) and imag(z).
  • Use the map function to apply a transformation to each element of an array.
  • Compare the real parts of the complex numbers using real(z) to find the maximum.
# Create two complex numbers
z1 = Complex{Float64}(3.0, 4.0)  # z1 = 3.0 + 4.0im
z2 = Complex{Float64}(1.0, 2.0)  # z2 = 1.0 + 2.0im

# Function to add two complex numbers
function add_complex(z1, z2)
    return z1 + z2
end

# Add 2.0 to the real part of each complex number in an array
arr = [Complex{Float64}(3.0, 4.0), Complex{Float64}(1.0, 2.0), Complex{Float64}(5.0, 6.0)]
new_arr = map(z -> Complex(real(z) + 2.0, imag(z)), arr)

println("New array with modified real parts: ", new_arr)

# Function to find the complex number with the largest real part
function max_real_part(arr)
    max_z = arr[1]
    for z in arr
        if real(z) > real(max_z)
            max_z = z
        end
    end
    return max_z
end

# Find the complex number with the largest real part
max_z = max_real_part(arr)
println("Complex number with the largest real part: ", max_z)
New array with modified real parts: ComplexF64[5.0 + 4.0im, 3.0 + 2.0im, 7.0 + 6.0im]
Complex number with the largest real part: 5.0 + 6.0im
Back to top