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:
Type hierarchies, which define how Julia organizes types and supports generalization;
Type conversion and promotion, which explain how Julia decides what happens when different types interact in operations;
Composite types, which let us bundle related data into new structures;
Parametric types, which provide a way to write generic and efficient code by making types themselves more flexible.
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 Int64y =3.14# y is inferred to be of type Float64z ="Hello"# z is inferred to be of type Stringtypeof(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# Integerx =2.0# Floatn + 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"# Stringn + 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...)
@Baseoperators.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'# Charc +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.
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 typetypeof(i) # Output: Int64 (concrete type)r::Real =3.14# Real is an abstract typetypeof(r) # Output: Float64 (concrete type)
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).
The isa operator is used to check if a value is an instance of a specific type:
a =42a isa Int64a isa Numbera 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<: RealFloat64<: RealReal<: NumberNumber<: 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 typeabstract type Shape end# Define concrete subtypesstruct Triangle <: Shape base::Float64 height::Float64endstruct Rectangle <: Shape width::Float64 height::Float64end# Create instancestriangle =Triangle(2.0, 3.0)rectangle =Rectangle(3.0, 4.0)# Check if they are subtypes of Shapetriangle isa Shaperectangle 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:
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:
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.
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 Float64typeof(a)typeof(b)
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 spacestruct Point x::Float64 y::Float64end
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 instancep.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.
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::Float64end
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 circlec.center.y # Access the y field of the center of the circlec.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::Pointend
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.
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::PointfunctionDuck(name::String, nb::Int64, pt::Point)if name =="Scrooge"error("A Duck can not be a cheapskate!!")endnew(name, nb, pt)endend
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 transformationstruct LinearTransform a::Float64 # Slope b::Float64 # Interceptend# Define the call method for LinearTransformfunction (lt::LinearTransform)(x::Real) lt.a * x + lt.b # Apply the linear transformationend
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 LinearTransformlt =LinearTransform(2.0, 3.0) # y = 2x + 3# Call the instance like a functiontypeof(lt) # Output: LinearTransformy1 =lt(5) # Calculates 2 * 5 + 3 = 13y2 =lt(-1) # Calculates 2 * -1 + 3 = 1
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 objectsfunction (lt1::LinearTransform)(lt2::LinearTransform)LinearTransform(lt1.a * lt2.a, lt1.a * lt2.b + lt1.b)end# Example usagelt1 =LinearTransform(2.0, 3.0) # y = 2x + 3lt2 =LinearTransform(0.5, 1.0) # y = 0.5x + 1# Compose the two transformationslt_composed =lt1(lt2) # Equivalent to y = 2 * (0.5x + 1) + 3# Call the composed transformationy =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:
structPair{T, S} first::T second::Sendpair1 =Pair(1, "apple") # Pair of Int and Stringpair2 =Pair(3.14, true) # Pair of Float64 and Bool
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.
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::Tend# Valid:pair =RealPair(1.0, 2.5)# Constraining a function:****functionsum_elements(container::AbstractContainer{T}) where T <: Realreturnsum(container.data)endvec =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
@MainIn[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:
Define a Point struct with x and y coordinates of type Float64.
Define a Square struct with the field width of type Float64. Use the Point struct to represent the bottom-left corner of the square.
Define a Circle struct with a Point for the center and a radius of type Float64.
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.
Hint for Exercise 1:
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.
Correction of Exercise 1:
# Define the Point structstruct Point x::Float64 y::Float64end# Define the Square structstruct Square bottom_left::Point width::Float64end# Define the Circle structstruct Circle center::Point radius::Float64end# Function to calculate the areafunctionarea(shape)iftypeof(shape) == Squarereturn shape.width * shape.widthelseiftypeof(shape) == Circlereturnπ* shape.radius^2elsethrow(ArgumentError("Unsupported shape"))endend# Example usagep1 =Point(0.0, 0.0)r1 =Square(p1, 3.0)c1 =Circle(p1, 5.0)println("Area of square: ", area(r1)) # Should print 12.0println("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
Create two complex numbersz1 and z2 of type Complex{Float64}.
Write a function add_complex(z1, z2) that adds two complex numbers and returns the result.
Create an array of complex numbers and use the map function to add 2.0 to the real part of each complex number.
Create a function max_real_part that returns the complex number with the largest real part from an array of complex numbers.
Hint for Exercise 2:
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.
Correction of Exercise 2:
# Create two complex numbersz1 =Complex{Float64}(3.0, 4.0) # z1 = 3.0 + 4.0imz2 =Complex{Float64}(1.0, 2.0) # z2 = 1.0 + 2.0im# Function to add two complex numbersfunctionadd_complex(z1, z2)return z1 + z2end# Add 2.0 to the real part of each complex number in an arrayarr = [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 partfunctionmax_real_part(arr) max_z = arr[1]for z in arrifreal(z) >real(max_z) max_z = zendendreturn max_zend# Find the complex number with the largest real partmax_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