Data Structures#

Every real program spends most of its time moving collections of things around. Julia gives you four you’ll use constantly — arrays, tuples, dictionaries, and sets — plus one that’s a quiet workhorse in data code, the named tuple. This chapter is about picking the right one and knowing what it costs.

Arrays#

The array is Julia’s flagship data structure. It’s the one the language was designed around, and it shows.

balances = [1200, 800, 1500, 300]        # Vector{Int64}
readings = [20.5, 22.3, 19.8]            # Vector{Float64}
accounts = ["GL-4001", "GL-4002"]        # Vector{String}

Vector{Int64} is really an alias for Array{Int64, 1} — a one-dimensional array of Int64. The 1 is the number of dimensions, and it’s part of the type. A matrix is Array{Float64, 2}, aliased as Matrix{Float64}.

Building arrays#

zeros(5)                 # 5 Float64 zeros
zeros(Int, 5)            # 5 Int zeros
ones(3)                  # [1.0, 1.0, 1.0]
fill(-99.0, 4)           # [-99.0, -99.0, -99.0, -99.0]
rand(3)                  # 3 random Float64 in [0,1)
collect(1:5)             # [1, 2, 3, 4, 5] — materializes a range
similar(balances)        # uninitialized array of the same type and size

similar deserves a note: it’s how generic code allocates an output array matching its input, without hardcoding the element type. You’ll see it everywhere in library source.

Indexing and slicing#

balances = [1200, 800, 1500, 300, 950]

balances[1]              # 1200   — remember, 1-indexed
balances[end]            # 950
balances[2:4]            # [800, 1500, 300]
balances[[1, 3, 5]]      # [1200, 1500, 950] — index with a vector
balances[balances .> 900]  # [1200, 1500, 950] — boolean mask

That last one is worth a pause. balances .> 900 produces a vector of Bool, and indexing with a boolean vector selects the elements where it’s true. That’s the whole “filter rows matching a condition” operation, in one expression. The dot in .> is broadcasting, which gets its own chapter next.

Warning

Slicing copies balances[2:4] allocates a new array and copies the data into it. For a five-element vector that’s irrelevant; for a slice of a million-row dataset inside a loop, it’s the difference between fast and unusable.

When you want to look at a slice without copying it, use a view:

v = @view balances[2:4]     # no copy — a window onto the original
v[1] = 0                    # this modifies balances[2]!

@views (plural) applies the same transformation to every slice in a block of code. Reach for views when you’re passing sub-arrays around in hot code. We’ll return to this in the Performance chapter.

Growing and shrinking#

xs = [1, 2, 3]

push!(xs, 4)             # add to the end     → [1,2,3,4]
pushfirst!(xs, 0)        # add to the front   → [0,1,2,3,4]
pop!(xs)                 # remove & return last  → 4
popfirst!(xs)            # remove & return first → 0
append!(xs, [9, 10])     # extend with another collection
deleteat!(xs, 2)         # remove by index
empty!(xs)               # clear it

All mutating, all ending in !, exactly as promised.

Matrices and higher dimensions#

A = [1 2 3
     4 5 6]              # 2×3 Matrix{Int64}

A[1, 2]                  # 2      — row 1, column 2
A[:, 2]                  # [2, 5] — all rows, column 2
A[1, :]                  # [1, 2, 3]
size(A)                  # (2, 3)

Inside [ ], a space separates columns and a newline (or ;) separates rows. This is MATLAB heritage, and it means you can type a matrix that looks like a matrix.

Julia arrays are column-major, meaning consecutive elements of a column sit next to each other in memory. That’s the opposite of C and NumPy’s default. The practical consequence: when you loop over a matrix, put the column index in the outer loop and the row index in the inner loop, so you walk memory in order.

# Fast — walks memory contiguously
for j in axes(A, 2), i in axes(A, 1)
    A[i, j] *= 2
end

Getting this backwards can cost you several times the runtime on a large matrix, purely from cache misses. It’s one of the few “which loop goes outside” rules genuinely worth memorizing.

Linear algebra is right there#

Because arrays are first-class, so is the math:

using LinearAlgebra

A = [2.0 1.0; 1.0 3.0]
b = [5.0, 10.0]

A * b            # matrix-vector product
A'               # transpose (adjoint)
A \ b            # solve Ax = b  — this is the one to remember
inv(A)           # inverse (you almost never actually want this; use \)
det(A)           # determinant

A \ b solves the linear system. It doesn’t compute an inverse and multiply — it picks an appropriate factorization based on the structure of A. That’s a real algorithm-selection decision the language makes for you, and it’s both faster and more numerically stable than inv(A) * b. If you take one thing from this section: use \.

Tuples#

A tuple is a fixed-length, immutable ordered collection whose elements can have different types:

record = (1, "GL-4001", 1200.50)
record[2]                # "GL-4001"
typeof(record)           # Tuple{Int64, String, Float64}

Look at that type. The length and the type of every position are part of the type itself. That means the compiler knows everything about a tuple statically, so tuples are essentially free — often they live entirely in CPU registers with no heap allocation at all.

Tuples are how Julia functions return multiple values:

function minmax_balance(xs)
    return minimum(xs), maximum(xs)     # this is a tuple
end

lo, hi = minmax_balance([1200, 800, 1500])    # destructuring

That destructuring assignment works on any iterable, which is why for (i, x) in enumerate(xs) reads so naturally.

Use a tuple when the collection is small, fixed-size, and heterogeneous. Use an array when it’s homogeneous and might grow.

Named tuples#

A named tuple is a tuple whose slots have names. In practice, it’s the lightest possible “record” type:

entry = (account = "GL-4001", amount = 1200.50, posted = true)

entry.account            # "GL-4001"
entry.amount             # 1200.5

Named tuples are immutable and, like tuples, fully known to the compiler — so they’re fast. I reach for them constantly for function returns that would otherwise be an unlabelled tuple you have to remember the order of. Compare:

return n, total, mean          # caller has to know the order
return (count = n, total = total, mean = mean)   # self-documenting

They’re also the row type of most Julia table packages, so if you go on to do dataframe work, you’ll be seeing a lot of them.

Dictionaries#

A Dict maps keys to values, with fast lookup:

ages = Dict("Amit" => 42, "Sam" => 28)

ages["Amit"]                     # 42
ages["Priya"] = 35               # insert
haskey(ages, "Sam")              # true
get(ages, "Nobody", 0)           # 0 — default instead of an error
delete!(ages, "Sam")

for (name, age) in ages
    println("$name is $age")
end

Two practical notes.

Order is not guaranteed. A Dict iterates in whatever order its internal hash table happens to use, and that order can change when the dictionary grows. If you need insertion order preserved, use OrderedDict from the OrderedCollections package.

Give it concrete types when you can. Dict{String, Int}() tells the compiler exactly what’s inside. Dict() with no types produces a Dict{Any, Any}, which is slow for the same reason Vector{Any} is slow — every value is a pointer to somewhere else.

counts = Dict{String, Int}()      # good
counts = Dict()                   # works, but Dict{Any,Any}

A pattern I use constantly, counting occurrences:

function tally(items)
    counts = Dict{eltype(items), Int}()
    for item in items
        counts[item] = get(counts, item, 0) + 1
    end
    return counts
end

tally(["a", "b", "a", "c", "a"])   # Dict("a" => 3, "b" => 1, "c" => 1)

Sets#

A Set holds unique values and answers “is this in here?” fast:

seen = Set([1, 2, 2, 3])         # Set([1, 2, 3]) — duplicate dropped
push!(seen, 4)
2 in seen                        # true
length(seen)                     # 4

union(Set([1,2]), Set([2,3]))       # Set([1,2,3])
intersect(Set([1,2]), Set([2,3]))   # Set([2])
setdiff(Set([1,2]), Set([2,3]))     # Set([1])

The reason to reach for a Set over an array isn’t just uniqueness — it’s the membership test. x in some_array scans the whole array (O(n)); x in some_set is a hash lookup (O(1)). If you find yourself repeatedly checking membership inside a loop, converting to a Set first can turn a quadratic algorithm into a linear one. That’s a bigger win than any micro-optimization.

Choosing the right one#

You need

Reach for

Many items, same type, might grow

Vector{T}

Numerical data, math operations

Vector / Matrix

A few items, different types, fixed

Tuple

A record with named fields, lightweight

NamedTuple

A record with named fields, reusable across your code

a struct (next chapter but one)

Lookup by key

Dict{K,V}

Uniqueness, or fast membership tests

Set{T}

Try it yourself#

  1. Given amounts = [1200, 800, 1500, 300, 950], use a boolean mask to select only the values above 900, then sum them.

  2. Build a Dict mapping account codes to balances, then print every account whose balance is negative.

  3. Given two vectors of customer IDs, find the IDs present in both, and the IDs present in only the first.

  4. Write a function that returns the count, min, and max of a vector as a named tuple.

Solutions#

# 1.
amounts = [1200, 800, 1500, 300, 950]
sum(amounts[amounts .> 900])          # 3650

# 2.
ledger = Dict("GL-4001" => 1200.0, "GL-4002" => -350.0, "GL-4003" => 90.0)
for (acct, bal) in ledger
    bal < 0 && println("$acct is overdrawn: $bal")
end

# 3.
a = Set([101, 102, 103, 104])
b = Set([103, 104, 105])
intersect(a, b)     # Set([103, 104])
setdiff(a, b)       # Set([101, 102])

# 4.
stats(xs) = (count = length(xs), min = minimum(xs), max = maximum(xs))
stats([1200, 800, 1500])   # (count = 3, min = 800, max = 1500)

You now have the containers. Next we’ll look at the syntax that makes working with them feel less like programming and more like writing equations: comprehensions and broadcasting.