Structs
Immutable by default
struct Point
x::Float64
y::Float64
end
p = Point(1.0, 2.0)
p.x # 1.0
p.x = 5.0 # ERROR: immutable struct of type Point cannot be changed
Prefer immutable. An immutable struct of concrete fields is stored inline (in an array, in another struct, on the stack), needs no allocation, and can be freely shared without defensive copying. mutable struct becomes a heap-allocated object with a pointer.
To “change” one, rebuild it:
q = Point(5.0, p.y)
using Accessors # if you do this a lot
q = @set p.x = 5.0 # returns a new Point
q = @set p.nested.deep.field = 1 # works through nesting
Mutable, and const fields
mutable struct Counter
n::Int
const name::String # 1.8+: this field alone is immutable
end
c = Counter(0, "hits")
c.n += 1
c.name = "x" # ERROR
Mutable structs also get identity: two Counter(0,"x") are !== each other, whereas two identical immutable Points may be indistinguishable.
Field types matter for performance
struct Fast
x::Float64 # concrete
end
struct AlsoFast{T <: Real}
x::T # concrete once instantiated
end
struct Slow
x # ::Any — boxed
end
struct AlsoSlow
x::Real # abstract — boxed
end
struct SlowContainer
v::AbstractVector # abstract container type — inference dies here
end
struct FastContainer{V <: AbstractVector}
v::V # parametric — keeps the concrete type
end
The parametric form is the one to reach for whenever a field holds something whose exact type varies. It costs you nothing at the call site and everything downstream stays inferable.
Constructors
Every struct gets a default constructor taking all fields in order. Two ways to add more.
Outer constructors are just extra methods. Defaults, alternative argument forms, computed fields.
struct Rect
w::Float64
h::Float64
end
Rect(s) = Rect(s, s) # square
Rect(; w, h) = Rect(w, h) # keyword form
Inner constructors live inside the struct block, have access to new, and are the only place you can enforce invariants — because defining one replaces the default constructor, so no path exists around it.
struct Celsius
deg::Float64
function Celsius(d)
d < -273.15 && throw(ArgumentError("below absolute zero"))
new(d)
end
end
new is special: it’s the raw allocator, only callable inside an inner constructor. For parametric types, new{T}(...) when you need to name the parameter:
struct Ordered{T}
lo::T
hi::T
function Ordered(lo::T, hi::T) where {T}
lo > hi && ((lo, hi) = (hi, lo))
new{T}(lo, hi)
end
end
new also lets you build partially-initialised mutable structs (for self-referential data like linked lists) by passing fewer arguments than there are fields.
Keyword structs
Base.@kwdef struct Config
host::String = "localhost"
port::Int = 8080
tls::Bool = false
retries::Int # no default — required keyword
end
Config(retries = 3)
Config(host = "example.com", port = 443, tls = true, retries = 3)
This is the closest thing to a config object with defaults, and it composes with @set for overriding.
Composition instead of inheritance
You cannot subtype a concrete struct. To reuse data, embed and forward:
struct Base_
id::Int
name::String
end
struct Employee
base::Base_
salary::Float64
end
# forward the accessors you care about
id(e::Employee) = e.base.id
name(e::Employee) = e.base.name
If you’re forwarding many methods, Lazy.jl’s @forward or ReusePatterns.jl automate it. Usually the honest answer is that you wanted an abstract type plus duck-typed accessors, not inheritance.
Custom display
Two methods, two contexts:
# compact, used inside arrays and interpolation
Base.show(io::IO, m::Money) = print(io, "£", m.cents / 100)
# verbose, used when the object is the REPL result
function Base.show(io::IO, ::MIME"text/plain", m::Money)
println(io, "Money")
println(io, " cents: ", m.cents)
end
Respect the io argument rather than printing to stdout, and honour get(io, :compact, false) if you want to be polite inside arrays.
Equality and hashing
The default == for structs is field-wise for immutable structs and identity for mutable ones. If you define ==, define hash to match, or Dict and Set will behave incorrectly:
Base.:(==)(a::Money, b::Money) = a.cents == b.cents
Base.hash(m::Money, h::UInt) = hash(m.cents, hash(:Money, h))
Base.isequal(a::Money, b::Money) = isequal(a.cents, b.cents)
Callable structs (functors)
Any object can be made callable by defining a method on its type. This is how closures, neural network layers, and optimisation objectives are usually modelled.
struct Polynomial{T}
coeffs::Vector{T}
end
(p::Polynomial)(x) = evalpoly(x, p.coeffs)
p = Polynomial([1, 2, 3])
p(2.0) # 17.0
map(p, 1:5)
Enums
@enum Suit hearts diamonds clubs spades
@enum Status::UInt8 pending = 1 active = 2 done = 3
s = hearts
s isa Suit
Int(spades) # 3
instances(Suit)
Suit(0) # hearts
@enum values are their own type, so you can dispatch on them: handle(::Val{pending}) or more simply handle(s::Status) with an if-chain. For richer sum types, define an abstract type with singleton structs, or use SumTypes.jl.
Singletons
struct Verbose end
struct Quiet end
log(::Verbose, msg) = println(msg)
log(::Quiet, msg) = nothing
Zero-size, zero-cost, dispatch-only. Used constantly for options, traits, and flags — this is how you avoid passing boolean flags that force runtime branches.