Tutorial: Procedures
The new ModuleMixins system in version 0.4 has two components: a procedural part completely based on composition and dispatch, and a syntactic part that relieves some of the boilerplate.
Composing Structs
We start small. We have two modules A and B, each with a struct S. The struct needs to be defined @kwdef.
#| file: examples/tutorial1.jl
#| id: tutorial1
module A
@kwdef struct S
x::Int
end
end
module B
@kwdef struct S
y::Int
end
endNow we implement a module C that merges A and B. We indicate this relation by dispatching on the requires method.
#| id: tutorial1
using ModuleMixins
module C
import ..requires, ..A, ..B
requires(::Val{C}) = (A, B)
endWe can now instantiate a composite type using the record_type function.
#| id: tutorial1
const Model = record_type(:S, C)
m = Model(x = 6, y = 7)
print("x = $(m.x), y = $(m.y)")It is the responsibility of the user to have only unique field names. Having two parts contributing the same name to a record type is an error.
Inheritance
Now, suppose both A and B depend on a common component Time.
#| file: examples/tutorial2.jl
#| id: tutorial2
using ModuleMixins
module Time
@kwdef struct S
t::Int
end
end
module A
import ..requires, ..Time
requires(::Val{A}) = (Time,)
@kwdef struct S
x::Int
end
end
module B
import ..requires, ..Time
requires(::Val{B}) = (Time,)
@kwdef struct S
y::Int
end
end Now we can create our model again, this time directly from A and B.
#| id: tutorial2
const Model = record_type(:S, A, B)
m = Model(t = 1, x = 6, y = 7)
println("t = $(m.t), x = $(m.x), y = $(m.y)")The Time component is included once as a transitive requirement.
#| id: tutorial2
@show parts(m)And we can lift parts of the struct.
#| id: tutorial2
@show component(m, A)
@show component(m, Time)Abstraction
Instead of directly depending on a module, it is possible to depend on a Role.
#| file: examples/tutorial3.jl
#| id: tutorial3
using ModuleMixins
abstract type AbstractBuffer end
buffer_push!(m, i) = buffer_push!(component(m, AbstractBuffer), i)
buffer_pop!(m) = buffer_pop!(component(m, AbstractBuffer))The implements method is used to indicate if a components implements a certain Role. Here we define two modules that implement the AbstractBuffer role. This role has two interfacing methods, for which we forward the call to their specialization.
The ConstBuffer ignores everything that comes in and just returns a preset buffer_value.
#| id: tutorial3
module ConstBuffer
import ..buffer_push!, ..buffer_pop!, ..implements, ..AbstractBuffer
@kwdef struct S
buffer_value::Int = 0
end
buffer_push!(s::S, item::Int) = nothing
buffer_pop!(s::S) = s.buffer_value
implements(::Val{ConstBuffer}, ::Type{AbstractBuffer}) = true
endThe VectorBuffer pushes and pops from a vector like you would expect.
#| id: tutorial3
module VectorBuffer
import ..buffer_push!, ..buffer_pop!, ..implements, ..AbstractBuffer
@kwdef struct S
buffer::Vector{Int} = []
end
buffer_push!(s::S, item::Int) = push!(s.buffer, item)
buffer_pop!(s::S) = pop!(s.buffer)
implements(::Val{VectorBuffer}, ::Type{AbstractBuffer}) = true
endThe main module A depends on AbstractBuffer and uses the defined methods to run a little loop.
#| id: tutorial3
module A
import ..requires, ..AbstractBuffer, ..buffer_push!, ..buffer_pop!
@kwdef struct S
repeat::Int
end
requires(::Val{A}) = (AbstractBuffer,)
function run(model)
for i in 1:model.repeat
buffer_push!(model, i)
r = buffer_pop!(model)
println(r)
end
end
endNow we can compose two versions of our final "Model".
#| id: tutorial3
const ConstModel = record_type(:S, A, ConstBuffer)
A.run(ConstModel(repeat=5, buffer_value=9))
const VectorModel = record_type(:S, A, VectorBuffer)
A.run(VectorModel(repeat=5))It is illegal to instantiate a Composite type where not all required roles are fulfilled. Running record_type(:S, A) will trigger the error: ERROR: no component implementing role AbstractBuffer was selected, and there is no default.
Default Implementation
You may specify a default implementation of a role by dispatching the default_implementation method.
#| id: tutorial3
import ModuleMixins: default_implementation
default_implementation(::Type{AbstractBuffer}) = ConstBuffer
const DefaultModel = record_type(:S, A)
A.run(DefaultModel(repeat=5))Multiple Types
In some cases components affect not a single type, but a family of interacting types. For instance, in CarboKitten (the motivating case for ModuleMixins) we have a large Input struct that contains a list of Facies definitions. Similarly, when doing agent-based modelling, you'd have a list of agent specifications in your input.
We need the AgentSpec type to be a type variable of the Input type. The parameter name AgentSpec must be the same as the struct, as it is used to insert the correct composite type when record_type is called.
#| file: examples/tutorial4.jl
#| id: tutorial4
using ModuleMixins
module AgentBase
@kwdef struct AgentSpec
name::String
end
@kwdef struct Input{AgentSpec}
agent_specs::Vector{AgentSpec}
end
endWe can now expand on both the Input and AgentSpec structs.
#| id: tutorial4
module Movement
import ..requires, ..AgentBase
@kwdef struct AgentSpec
speed::Float64 = 1.0
end
requires(::Val{Movement}) = (AgentBase,)
end
module Lifetime
import ..requires, ..AgentBase
@kwdef struct AgentSpec
max_age::Int
end
@kwdef struct Input
n_steps::Int
end
requires(::Val{Lifetime}) = (AgentBase,)
endThe rest still works as expected.
#| id: tutorial4
const Model = record_type(:Input, Movement, Lifetime)
m = Model(
n_steps = 10,
agent_specs = [
(name = "rabbit", speed = 2.0, max_age = 5),
(name = "turtle", speed = 0.1, max_age = 100)])
for a in m.agent_specs
println("$(a.name): speed $(a.speed), lives $(a.max_age) steps")
endA type parameter that isn't named after a record is set by a keyword argument to record_type, for instance record_type(:State, Model; Storage = Vector{Float64}). Components that use the same parameter name share the setting. This is useful for storage that can live on the CPU or a GPU; see Example: GPU storage.
Composing Methods
Suppose we have a method that is defined on multiple component parts, and we want to call this method on every part automatically. One example we often see returning is to create a constructor for the composite type. In this case we turn an Input struct into a mutable State struct, using the initial_state method.
#| file: examples/tutorial5.jl
import ModuleMixins: map_components, record_type, Composite
initial_state(part, input) = nothing
initial_state(input::Composite) = map_components(p -> initial_state(p, input), input)
module A
import ..initial_state
@kwdef struct Input
x0::Int
end
@kwdef mutable struct State
x::Int
end
initial_state(::Input, input) = State(x = input.x0)
end
module B
import ..initial_state
@kwdef struct Input
y0::Int
end
@kwdef mutable struct State
y::Int
end
initial_state(::Input, input) = State(y = input.y0)
end
module Model
import ..record_type, ..A, ..B
const Input = record_type(:Input, A, B)
const State = record_type(:State, A, B)
end
println(initial_state(Model.Input(x0=6, y0=7)))