Basic Types and Data Structures in Julia

In Julia, understanding the fundamental types and data structures is essential for efficient coding and problem-solving. This page provides an introduction to some of the basic types in Julia, including integers, floating-point numbers, strings, and composite types like arrays and tuples. We’ll also explore more advanced data structures and their practical uses.

You’ll learn about:

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.

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
  +(::Real, ::Complex{Bool})
   @ Base complex.jl:322
  +(::Integer, ::AbstractChar)
   @ Base char.jl:247
  ...


Stacktrace:
 [1] macro expansion
   @ show.jl:1229 [inlined]
 [2] macro expansion
   @ ~/Courses/julia/course-tse-julia/assets/julia/myshow.jl:82 [inlined]
 [3] top-level scope
   @ In[5]: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.

Basic Types

Julia has several basic (or primitive) types that are fundamental to working with the language. These include numerical types, characters, and strings. Understanding these types is crucial as they form the building blocks for more complex data structures.

Common Basic Types

  • Int: Represents integer values. Julia has multiple types of integers, such as Int8, Int16, Int32, and Int64 depending on the desired size. By default, Int refers to the most appropriate integer type for the system (usually Int64 on modern systems).

  • Float64: Represents floating-point numbers with double precision.

  • String: Represents sequences of characters.

  • Bool: Represents Boolean values, i.e., true or false.

  • Char: Represents individual Unicode characters.

Example Usage of Basic Types

# Integer type (default is Int64)
a = 42         # a is of type Int64

# Float type (default is Float64)
b = 3.14       # b is of type Float64

# String type
c = "Hello"    # c is of type String

# Boolean type
d = true       # d is of type Bool

# Char type
e = 'α'        # e is of type Char

These basic types are often used for simple calculations and conditionals. Julia allows operations between different types, but it will raise an error if the types are incompatible.

Collections and Data Structures

Julia as an Array Programming Language

Julia is designed as an array programming language, focusing on operations that apply to entire arrays or subarrays rather than individual elements. This paradigm simplifies code for numerical, scientific, and data-intensive applications. By leveraging features like broadcasting and vectorized operations, Julia allows for efficient and concise code, enhancing performance without sacrificing readability. Array programming is central to Julia’s capabilities, enabling fast computation on large datasets and making it ideal for high-performance scientific computing.

Arrays, Vectors, and Matrices

In Julia, arrays are fundamental data structures that can hold elements of any type. Arrays can be one-dimensional (vectors) or two-dimensional (matrices), and they can hold data of various types.

  • Creating an Array:
arr = [1, 2, 3, 4]  # A simple 1D array (vector)
4-element Vector{Int64}:
 1
 2
 3
 4
matrix = [1 2 3; 4 5 6]  # A 2D array (matrix)
2×3 Matrix{Int64}:
 1  2  3
 4  5  6
  • Accessing Array Elements:
arr[1]   # Access the first element of the array
1
matrix[2, 3]  # Access the element in the second row, third column
6

Slicing of Vectors and Matrices

You can extract slices (sub-arrays) of vectors and matrices in Julia. The slicing syntax allows you to access specific portions of an array.

  • Slicing a vector:
arr[2:4]  # Extracts elements from index 2 to 4: [2, 3, 4]
3-element Vector{Int64}:
 2
 3
 4
  • Slicing a matrix:
matrix[1, :]   # Extracts the first row: [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3
matrix[:, 2]   # Extracts the second column: [2, 5]
2-element Vector{Int64}:
 2
 5

Mutation of Arrays

Arrays in Julia are mutable, meaning their elements can be changed after creation. The .= operator is commonly used to apply element-wise operations.

  • Modify an individual element:
arr[2] = 99  # Change the second element to 99
99
  • Element-wise operation with .=:
arr .+= 10  # Adds 10 to each element of the array, resulting in [11, 12, 13, 14]
4-element Vector{Int64}:
  11
 109
  13
  14
matrix .*= 2  # Multiplies each element of the matrix by 2, resulting in [2 4 6; 8 10 12]
2×3 Matrix{Int64}:
 2   4   6
 8  10  12
  • Push an element into an array (mutates the array by adding a new element):
push!(arr, 40)  # Adds 40 to the end of the array
5-element Vector{Int64}:
  11
 109
  13
  14
  40
  • Pop an element from an array (removes the last element):
pop!(arr)  # Removes the last element, which is 40 in this case
40

Mutation Inside a Function

When working with arrays inside a function, it’s important to note that reassigning the entire array (e.g., v = [1, 2]) does not mutate the original array but rather creates a new one locally scoped to the function. To modify the contents of an array in place, use either v[:] = ... or the more flexible broadcasting syntax v .= ....

  • Incorrect way (does not mutate the original vector):
function incorrect_mutate(v)
    v = [1, 2]  # This creates a new array and does not affect the input
end

vec = [10, 20]
incorrect_mutate(vec)
println(vec)  # Outputs: [10, 20]
[10, 20]
  • Correct way using v[:] = ...:
function correct_mutate(v)
    v[:] = [1, 2]  # Mutates the input vector
end

vec = [10, 20]
correct_mutate(vec)
println(vec)  # Outputs: [1, 2]
[1, 2]
  • Using broadcasting (v .= ...):
function flexible_mutate(v)
    v .= [1, 2]  # More flexible, works for both vectors and matrices
end

vec = [10, 20]
flexible_mutate(vec)
println(vec)  # Outputs: [1, 2]

mat = [10 20; 30 40]
mat .= [1 2; 3 4]  # Updates all elements in-place
println(mat)  # Outputs: [1 2; 3 4]
[1, 2]
[1 2; 3 4]

Using v .= ... is preferred for its flexibility, as it works seamlessly for arrays of any shape, including matrices. Without broadcasting, you would need to use M[:, :] = ... for matrices to achieve the same effect.

Special Arrays

Julia has built-in functions to create arrays with predefined values:

  • Create an array of zeros:
zeros(3)  # Creates an array of zeros with 3 elements: [0.0, 0.0, 0.0]
3-element Vector{Float64}:
 0.0
 0.0
 0.0
  • Create an array of ones:
ones(2, 3)  # Creates a 2x3 matrix filled with ones: [1.0 1.0 1.0; 1.0 1.0 1.0]
2×3 Matrix{Float64}:
 1.0  1.0  1.0
 1.0  1.0  1.0

Dictionaries (Dict)

A Dict in Julia is an associative collection that maps keys to values. This allows for efficient lookups, insertions, and deletions based on unique keys.

  • Creating a Dictionary:
d = Dict("name" => "Alice", "age" => 25)
Dict{String, Any} with 2 entries:
  "name" => "Alice"
  "age"  => 25

This creates a dictionary where "name" maps to "Alice" and "age" maps to 25.

  • Accessing Values:

You can access values in a dictionary using their corresponding keys:

d["name"]  # Outputs: "Alice"
"Alice"
  • Adding and Updating Values:

To add a new key-value pair or update an existing one, you can use the following syntax:

d["location"] = "Paris"  # Adds a new key-value pair
d["age"] = 26            # Updates the value associated with the key "age"
display(d)
Dict{String, Any} with 3 entries:
  "name"     => "Alice"
  "location" => "Paris"
  "age"      => 26
  • Removing Key-Value Pairs:

To remove a key-value pair, use the delete! function:

delete!(d, "location")
Dict{String, Any} with 2 entries:
  "name" => "Alice"
  "age"  => 26
  • Iteration over Key-Value Pairs:

You can iterate through the keys, values, or pairs in a dictionary using keys, values, and pairs respectively:

for (k, v) in pairs(d)
    println("Key: $k, Value: $v")
end
Key: name, Value: Alice
Key: age, Value: 26

Tuples and Named Tuples

Julia Tuples are ordered collections of elements, while Named Tuples are tuples where elements are associated with names (keys). Tuples are immutable, meaning their elements cannot be changed after creation.

Basic usage of Tuples and Named Tuples

  • Tuple is an ordered collection of elements, which can hold elements of different types.
t = (1, "Julia", true)  # A tuple with three elements
(1, "Julia", true)
  • NamedTuple is a special kind of tuple where elements are associated with names (keys).
nt = (name = "Alice", age = 25)  # A NamedTuple with named fields
(name = "Alice", age = 25)

You can access the elements by their name:

nt.name  # Access the field 'name' of the NamedTuple, returns "Alice"
"Alice"
  • Mutation of a Tuple: Tuples are immutable, so attempting to change their elements will result in an error.
t[1] = 99  # Trying to modify a tuple element will result in an error
LoadError: MethodError: no method matching setindex!(::Tuple{Int64, String, Bool}, ::Int64, ::Int64)
The function `setindex!` exists, but no method is defined for this combination of argument types.
MethodError: no method matching setindex!(::Tuple{Int64, String, Bool}, ::Int64, ::Int64)
The function `setindex!` exists, but no method is defined for this combination of argument types.

Stacktrace:
 [1] top-level scope
   @ In[33]:1

The above line will raise an error because tuples are immutable in Julia, and their elements cannot be modified after creation.

Tuples in Function

In Julia, tuples and named tuples play an important role in function definitions and return values.

  • Positional and Keyword Arguments:

When defining functions with a variable number of arguments, Julia uses tuples to capture positional arguments and named tuples for keyword arguments:

function example_function(args...; kwargs...)
    println("Positional arguments type: ", typeof(args))
    println("Keyword arguments type: ", typeof(kwargs))
    return kwargs
end

kw = example_function(1, 2, 3; name="Alice", age=30)
Positional arguments type: Tuple{Int64, Int64, Int64}
Keyword arguments type: Base.Pairs{Symbol, Any, Tuple{Symbol, Symbol}, @NamedTuple{name::String, age::Int64}}

In this example, args is of type Tuple, containing all positional arguments, while kwargs is based on a NamedTuple, containing all keyword arguments. The arguments are captured using the args... and kwargs... syntax. The kwargs argument is actually a ‘Base.Pairs’:

kw
pairs(::NamedTuple) with 2 entries:
  :name => "Alice"
  :age  => 30
typeof(kw)
kw isa Base.Pairs
kw isa AbstractDict
keys(kw)
values(kw)
pairs(kw)
kw[:name]
kw[:age]
julia> typeof(kw) = Base.Pairs{Symbol, Any, Tuple{Symbol, Symbol}, @NamedTuple{name::String, age::Int64}}
julia> kw isa Base.Pairs = true
julia> kw isa AbstractDict = true
julia> keys(kw) = (:name, :age)
julia> values(kw) = (name = "Alice", age = 30)
julia> pairs(kw) = Base.Pairs{Symbol, Any, Tuple{Symbol, Symbol}, @NamedTuple{name::String, age::Int64}}(:name => "Alice", :age => 30)
julia> kw[:name] = "Alice"
julia> kw[:age] = 30

Actually, the values are a NamedTuple:

typeof(values(kw))
@NamedTuple{name::String, age::Int64}

Contrary to a Dict, you cannot add entries to a Base.Pairs:

kw[:height] = 5.9
LoadError: MethodError: no method matching setindex!(::@NamedTuple{name::String, age::Int64}, ::Float64, ::Symbol)
The function `setindex!` exists, but no method is defined for this combination of argument types.
MethodError: no method matching setindex!(::@NamedTuple{name::String, age::Int64}, ::Float64, ::Symbol)
The function `setindex!` exists, but no method is defined for this combination of argument types.

Stacktrace:
 [1] setindex!(v::@Kwargs{name::String, age::Int64}, value::Float64, key::Symbol)
   @ Base.Iterators ./iterators.jl:325
 [2] top-level scope
   @ In[38]:1
  • Returning Tuples:

In Julia, when a function returns multiple values separated by commas, they are automatically returned as a tuple:

function return_multiple_values()
    return 1, "Julia", true
end

result = return_multiple_values()
println("Result type: ", typeof(result))
Result type: Tuple{Int64, String, Bool}

So, return_multiple_values() returns a tuple with three elements.

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

Adding a Custom Constructor

Julia allows you to define custom constructors for structs. These constructors enable additional logic during object creation, such as validating inputs or providing default values. Here’s an example of a custom constructor for a Circle that ensures the radius is always positive and converts the center coordinates to Float64 if they are not already:

struct Circle
    center::Point
    radius::Float64
end

# Define a custom constructor
function Circle(x::Real, y::Real, radius::Real)
    if radius <= 0
        throw(DomainError(radius, "Radius must be positive"))
    end
    Circle(Point(float(x), float(y)), float(radius))
end
  • Explanation:
    • The custom constructor accepts the center’s x and y coordinates and the radius as inputs.
    • It checks if the radius is positive, throwing an error otherwise.
    • It converts the inputs to Float64 using float, ensuring consistency with the field types defined in the Circle struct.
  • Usage:
# Create a Circle using the custom constructor
c = Circle(0, 0, 5)  # Creates a Circle with center (0.0, 0.0) and radius 5.0

# Attempt to create a Circle with an invalid radius
c = Circle(0, 0, -3)  # Throws an error: "Radius must be positive"
julia> c = Circle(Point(0.0, 0.0), 5.0)
julia> c = Circle(0, 0, -3)
LoadError: DomainError with -3:
Radius must be positive
DomainError with -3:
Radius must be positive

Stacktrace:
 [1] Circle(x::Int64, y::Int64, radius::Int64)
   @ Main ./In[49]:9
 [2] macro expansion
   @ ~/Courses/julia/course-tse-julia/assets/julia/myshow.jl:53 [inlined]
 [3] top-level scope
   @ In[50]:7

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.

Exercises

Exercise 1: Creating a Shape System

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

  1. Define a Point struct with x and y coordinates of type Float64.
  2. Define a Rectangle struct with fields length and width of type Float64. Use the Point struct to represent the bottom-left corner of the rectangle.
  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 rectangle is length * width.
    • The area of a circle is π * radius^2.
  • Use struct to define Point, Rectangle, 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 Rectangle struct
struct Rectangle
    bottom_left::Point
    length::Float64
    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) == Rectangle
        return shape.length * 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 = Rectangle(p1, 3.0, 4.0)
c1 = Circle(p1, 5.0)

println("Area of rectangle: ", area(r1))  # Should print 12.0
println("Area of circle: ", area(c1))     # Should print 78.53981633974483
Area of rectangle: 12.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

Exercise 3: Manipulating Tuples

  1. Create a tuple t with three elements: a string, an integer, and a float.
  2. Try to mutate the first element of the tuple and handle any errors using a try-catch block.
  3. Create a NamedTuple nt with fields name, age, and height, and initialize it with your details.
  • Remember that tuples are immutable, so you can’t modify their elements.
  • Use a try-catch block to catch errors if an operation fails.
# Create a tuple with three elements: a string, an integer, and a float
t = ("John", 25, 5.9)

# Attempt to mutate the first element of the tuple with error handling
try
    t[1] = "Alice"  # This will raise an error because tuples are immutable
catch e
    println("Error: ", e)
end

# Create a NamedTuple with fields: name, age, and height
nt = (name = "John", age = 25, height = 5.9)

println("NamedTuple: ", nt)
Error: MethodError(setindex!, (("John", 25, 5.9), "Alice", 1), 0x00000000000068b9)
NamedTuple: (name = "John", age = 25, height = 5.9)
Back to top