Tutorial: Syntax
This tutorial repeats the previous one step by step, using the @component macro from ModuleMixins.Syntactic. The macro is shorthand only: every @component module expands to a plain module plus a few methods of requires and implements, exactly the ones we wrote by hand before. You can mix both styles.
Composing Structs
Modules A and B each define a struct S. Since they have no dependencies, @component doesn't add anything yet.
#| file: examples/tutorial-syntax1.jl
#| id: tutorial-syntax1
using ModuleMixins
@component module A
@kwdef struct S
x::Int
end
end
@component module B
@kwdef struct S
y::Int
end
endModule C merges A and B. Instead of importing requires and extending it, we write @mixin A, B. This imports A and B from the parent module and declares them as requirements.
#| id: tutorial-syntax1
@component module C
@mixin A, B
endThe record type is created just like before.
#| id: tutorial-syntax1
const Model = record_type(:S, C)
m = Model(x = 6, y = 7)
print("x = $(m.x), y = $(m.y)")Inheritance
Both A and B depend on a common component Time.
#| file: examples/tutorial-syntax2.jl
#| id: tutorial-syntax2
using ModuleMixins
@component module Time
@kwdef struct S
t::Int
end
end
@component module A
@mixin Time
@kwdef struct S
x::Int
end
end
@component module B
@mixin Time
@kwdef struct S
y::Int
end
endThe @mixin declaration expands to the same requires(::Val{A}) = (Time,) that we wrote by hand.
#| id: tutorial-syntax2
@show requires(A)The rest is unchanged.
#| id: tutorial-syntax2
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)")
@show parts(m)
@show component(m, A)
@show component(m, Time)Abstraction
The role AbstractBuffer and its interface are defined as before. Defining a role doesn't involve any macros.
#| file: examples/tutorial-syntax3.jl
#| id: tutorial-syntax3
using ModuleMixins
abstract type AbstractBuffer end
function buffer_push! end
function buffer_pop! end
buffer_push!(m, i) = buffer_push!(component(m, AbstractBuffer), i)
buffer_pop!(m) = buffer_pop!(component(m, AbstractBuffer))A component declares that it fills a role with @implements. We still need to import the role and the interface methods we extend: @component only takes care of requires and implements.
#| id: tutorial-syntax3
@component module ConstBuffer
import ..buffer_push!, ..buffer_pop!, ..AbstractBuffer
@implements AbstractBuffer
@kwdef struct S
buffer_value::Int = 0
end
buffer_push!(s::S, item::Int) = nothing
buffer_pop!(s::S) = s.buffer_value
end
@component module VectorBuffer
import ..buffer_push!, ..buffer_pop!, ..AbstractBuffer
@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)
endA dependency on a role is declared with @requires. Where @mixin names a component and imports it, @requires takes any requirement, component or role, and imports nothing.
#| id: tutorial-syntax3
@component module A
import ..AbstractBuffer, ..buffer_push!, ..buffer_pop!
@requires AbstractBuffer
@kwdef struct S
repeat::Int
end
function run(model)
for i in 1:model.repeat
buffer_push!(model, i)
r = buffer_pop!(model)
println(r)
end
end
endComposing the models is the same as before.
#| id: tutorial-syntax3
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))Default Implementation
A default implementation of a role doesn't belong to any single component, so there's no syntax for it: we extend default_implementation just like in the procedural version.
#| id: tutorial-syntax3
import ModuleMixins: default_implementation
default_implementation(::Type{AbstractBuffer}) = ConstBuffer
const DefaultModel = record_type(:S, A)
A.run(DefaultModel(repeat=5))The macro also catches some mistakes early. For instance, @implements with something that isn't a type, or @mixin with something that isn't a module, raises an error as soon as the component is defined, instead of when the model is composed.
Multiple Types
Now for the family of interacting types: an Input containing a list of agent specifications. Each component may contribute fields to the Input, to the AgentSpec, or to both.
#| file: examples/tutorial-syntax4.jl
#| id: tutorial-syntax4
using ModuleMixins
@component module AgentBase
@kwdef struct AgentSpec
name::String
end
@kwdef struct Input
agent_specs::Vector{AgentSpec} = []
end
endIn the procedural version we had to make AgentSpec a type parameter of Input ourselves. The @component macro sees that a field of Input mentions AgentSpec, another struct in the same module, and adds the type parameter for us, so Input is really Input{AgentSpec}.
#| id: tutorial-syntax4
@show AgentBase.Input isa UnionAll
@show AgentBase.Input.varOther components add their own fields to AgentSpec and Input by defining structs of the same name. A component doesn't need to contribute to every record: Movement only adds a field to AgentSpec.
#| id: tutorial-syntax4
@component module Movement
@mixin AgentBase
@kwdef struct AgentSpec
speed::Float64 = 1.0
end
end
@component module Lifetime
@mixin AgentBase
@kwdef struct AgentSpec
max_age::Int
end
@kwdef struct Input
n_steps::Int
end
endWhen we create the Input record, AgentSpec is filled in with the composite of all AgentSpec parts. The agent specifications can be given as named tuples, which are converted to the composite type.
#| id: tutorial-syntax4
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")
endComposing Methods
To call a method on every part of a composite, we use map_components. Here initial_state turns an Input into a mutable State. Every component turns its own Input part into its own State part, and the results are composed into a new Composite. The method definitions are plain Julia, so they look the same as in the procedural version.
#| file: examples/tutorial-syntax5.jl
using ModuleMixins
initial_state(part, input) = nothing
initial_state(input::Composite) = map_components(p -> initial_state(p, input), input)
@component 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
@component 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
@component module Model
@mixin A, B
end
const Input = record_type(:Input, Model)
const State = record_type(:State, Model)
state = initial_state(Input(x0 = 6, y0 = 7))
println(state)
@show state isa State