Multiple dispatching.
One of the key features of the Julia language is multiple dispatch, a mechanism for selecting a method based on the types of all function arguments. Unlike object-oriented languages, where a method is bound to the first argument (self), Julia allows you to combine types of an arbitrary number of arguments, which gives you exceptional flexibility in designing abstractions. This approach is ideal for engineering modeling, where a single interface for different physical domains (electricity, mechanics, hydraulics, etc.) is often required.
The presented code demonstrates the creation of a hierarchy of types for modeling electrical and mechanical components, the use of parametric types with constraints, and the expansion of built-in functions (+, *, ==, show, getindex and others), as well as the resolution of conflicts of methods. The entire code is built around the concept of "impedance", a generalized characteristic that is expressed in ohms for electrical components and in newton seconds per meter for mechanical components.
1. Imports and the hierarchy of abstract types
using LinearAlgebra
import Base: show, +, *, ==, zero, one, getindex, setindex!
abstract type AbstractComponent end
abstract type ElectricalComponent <: AbstractComponent end
abstract type MechanicalComponent <: AbstractComponent end
The first lines connect the module LinearAlgebra to work with matrices and import functions from Base, which we will expand (redefine) for our types. Next, the hierarchy of abstract types is defined: AbstractComponent — root type for all physical components. Two subtypes are inherited from it: ElectricalComponent and MechanicalComponent. This hierarchy allows you to further write methods that work with all components, either specific to electrical or mechanical ones.
2. Parametric type SystemState with internal and external constructors
struct SystemState{T<:Real, N<:AbstractMatrix{T}}
values::N
time::Float64
function SystemState{T,N}(values::N, time::Float64) where {T<:Real, N<:AbstractMatrix{T}}
@assert time >= 0 "Time cannot be negative"
new(values, time)
end
end
SystemState(values::AbstractMatrix{T}, time::Float64) where {T<:Real} =
SystemState{T, typeof(values)}(values, time)
SystemState(values::AbstractVector{T}, time::Float64) where {T<:Real} =
SystemState(reshape(values, length(values), 1), time)
SystemState — a parametric type representing the state of the system at a certain point in time. Parameters: T — element type (must be a subtype Real), N — type of matrix (subtype AbstractMatrix{T}). The internal constructor checks that the time is non-negative and saves the passed values. External constructors add convenience: the first takes an arbitrary matrix, the second a vector, which is automatically converted into a column (a matrix with one column). This is an example of using multiple constructors to create objects with different input methods.
3. Expansion of functions show, getindex, setindex! for SystemState
function show(io::IO, s::SystemState)
println(io, "SystemState at t = ", s.time, " :")
show(io, s.values)
end
getindex(s::SystemState, i::Int, j::Int) = s.values[i, j]
getindex(s::SystemState, i::Int) = s.values[i]
setindex!(s::SystemState, v, i::Int, j::Int) = setindex!(s.values, v, i, j)
setindex!(s::SystemState, v, i::Int) = setindex!(s.values, v, i)
For a custom type SystemState we define our own display in the console (show), so that the timestamp and status values are displayed. We also implement indexing (getindex) and index assignment (setindex!), delegating them to the field values. This allows you to access the state elements as an ordinary matrix or vector, which makes the code more natural.
4. Specific types of electrical and mechanical components
struct Resistor{T<:Real} <: ElectricalComponent
resistance::T
function Resistor{T}(R::T) where {T<:Real}
@assert R > 0 "The resistance should be positive"
new(R)
end
end
Resistor(R::Real) = Resistor{typeof(R)}(R)
struct Capacitor{T<:Real} <: ElectricalComponent
capacitance::T
function Capacitor{T}(C::T) where {T<:Real}
@assert C > 0 "The capacity must be positive"
new(C)
end
end
Capacitor(C::Real) = Capacitor{typeof(C)}(C)
struct Inductor{T<:Real} <: ElectricalComponent
inductance::T
function Inductor{T}(L::T) where {T<:Real}
@assert L > 0 "The inductance must be positive"
new(L)
end
end
Inductor(L::Real) = Inductor{typeof(L)}(L)
struct Mass{T<:Real} <: MechanicalComponent
mass::T
function Mass{T}(m::T) where {T<:Real}
@assert m > 0 "The mass must be positive"
new(m)
end
end
Mass(m::Real) = Mass{typeof(m)}(m)
struct Spring{T<:Real} <: MechanicalComponent
stiffness::T
function Spring{T}(k::T) where {T<:Real}
@assert k > 0 "The stiffness should be positive"
new(k)
end
end
Spring(k::Real) = Spring{typeof(k)}(k)
struct Damper{T<:Real} <: MechanicalComponent
damping::T
function Damper{T}(c::T) where {T<:Real}
@assert c >= 0 "The damping cannot be negative"
new(c)
end
end
Damper(c::Real) = Damper{typeof(c)}(c)
Each component is defined as parametric struct with a single field storing the numeric value of the parameter (resistance, capacitance, etc.). The internal constructor checks the physical correctness (positivity of the values) and creates an instance. The external constructor allows you to create components without explicitly specifying the type: for example, Resistor(100.0) it will automatically determine the type of the parameter. Inheritance from ElectricalComponent or MechanicalComponent allows them to be used in domain-specific methods.
5. Function impedance — multiple dispatching in action
impedance(comp::AbstractComponent, ω::Real) = error("Not implemented for ", typeof(comp))
impedance(R::Resistor, ω::Real) = complex(R.resistance, 0.0)
impedance(C::Capacitor, ω::Real) = complex(0.0, -1.0 / (ω * C.capacitance))
impedance(L::Inductor, ω::Real) = complex(0.0, ω * L.inductance)
impedance(M::Mass, ω::Real) = complex(0.0, ω * M.mass)
impedance(S::Spring, ω::Real) = complex(0.0, -S.stiffness / ω)
impedance(D::Damper, ω::Real) = complex(D.damping, 0.0)
response(comp::AbstractComponent, input::SystemState, t::Float64) =
error("Not implemented for ", typeof(comp))
The general function is defined impedance, which must be implemented for each specific component. If it is not defined, an error is thrown. Then the methods for each type follow. Please note: Julia selects the desired method based on the type of the first argument (comp) is an example of multiple dispatch, where the method is determined by all arguments (there are two here). This makes it easy to add new types of components by simply defining a method for them. impedance. Function response it was left as a stub, but in real code it would use impedance to calculate the response to the input signal.
6. Operator Expansion +, *, ==, zero, one
function +(c1::T, c2::T) where {T<:ElectricalComponent}
return (c1, c2)
end
function *(c1::T, c2::T) where {T<:ElectricalComponent}
return (c1, c2)
end
+(c1::M, c2::M) where {M<:MechanicalComponent} = (c1, c2)
*(c1::M, c2::M) where {M<:MechanicalComponent} = (c1, c2)
==(c1::Resistor, c2::Resistor) = c1.resistance == c2.resistance
==(c1::Capacitor, c2::Capacitor) = c1.capacitance == c2.capacitance
==(c1::Inductor, c2::Inductor) = c1.inductance == c2.inductance
==(c1::Mass, c2::Mass) = c1.mass == c2.mass
==(c1::Spring, c2::Spring) = c1.stiffness == c2.stiffness
==(c1::Damper, c2::Damper) = c1.damping == c2.damping
zero(::SystemState{T,N}) where {T,N} = SystemState(zero(N), 0.0)
one(::Type{Resistor{T}}) where {T} = Resistor(one(T))
Overloading of standard operators is demonstrated here. + and * for electrical and mechanical components, so far only a tuple of two components is returned, which symbolizes a serial and parallel connection (in a real application, a special composite type should be created). == redefined for each specific component to compare parameter values. zero defined for SystemState — returns a state with zero values and a time of 0.0. one defined for the type Resistor — returns a resistor with a single resistance (useful, for example, for single elements in circuits).
7. Demonstration of potential conflicts of methods
function parallel_impedance(a::ElectricalComponent, b::ElectricalComponent)
return 1.0 / (1.0 / impedance(a, 1.0) + 1.0 / impedance(b, 1.0))
end
println("Checking ambiguities of methods (for educational purposes):")
println("You can run `Test.detect_ambiguities(EngineeringSimulation)`")
This block shows an example of a method for parallel impedance of electrical components. In the comments (which we deleted), the possibility of ambiguity was discussed if too general methods were defined. Only the code left here demonstrates how Julia can warn about conflicts, and how they can be caught using Test.detect_ambiguities. Consideration of potential conflicts of methods is critical in the design process.
8. Simulation of an RLC circuit and a mechanical oscillator
function total_impedance(components::Vector{<:AbstractComponent}, ω::Real)
Z = 0.0im
for comp in components
Z += impedance(comp, ω)
end
return Z
end
R = Resistor(100.0)
C = Capacitor(1e-6)
L = Inductor(0.1)
components_elec = [R, C, L]
ω = 2π * 50
Z_total = total_impedance(components_elec, ω)
println("\Nelectric RLC circuit:")
println(" Components: R = ", R.resistance, " Om, C = ", C.capacitance, " F, L = ", L.inductance, " Gn")
println(" Frequency: ", ω/(2π), " Hz")
println(" Total impedance: ", Z_total, " Om")
m = Mass(10.0)
k = Spring(1000.0)
c = Damper(50.0)
components_mech = [m, k, c]
Z_mech = total_impedance(components_mech, ω)
println("Mechanical system (mass-spring-damper):")
println(" m = ", m.mass, " kg, k = ", k.stiffness, " N/m, c = ", c.damping, " N·s/m")
println(" Mechanical impedance: ", Z_mech, " N·s/m")
Function total_impedance demonstrates polymorphism: it accepts a vector of any components (heirs AbstractComponent) and summarizes their impedances. This is possible due to the fact that a method is defined for each type. impedance. Then specific components are created and their total impedance is calculated for a frequency of 50 Hz. The result is displayed on the screen, which shows how a single approach works for different physical domains.
9. Working with SystemState and indexing
state_mech = SystemState([0.0; 0.0], 0.0)
println("\The state of the mechanical system at the initial moment:")
show(state_mech)
println()
state_mech[1] = 0.01
state_mech[2] = 0.5
println("After the change:")
show(state_mech)
println()
The state of the system is created in the form of a two-element vector (displacement and velocity) with a time of 0. Due to the redefined show the output contains a timestamp. After a certain period setindex! you can change the state elements by accessing the index, just like a regular array.
10. Multiple dispatch: polymorphic function describe_impedance
function describe_impedance(comp::AbstractComponent, ω::Real)
Z = impedance(comp, ω)
println(" ", typeof(comp), " at ω = ", ω, " It has an impedance ", Z)
end
println("Dispatching for different types of components:")
describe_impedance(R, ω)
describe_impedance(C, ω)
describe_impedance(L, ω)
describe_impedance(m, ω)
describe_impedance(k, ω)
describe_impedance(c, ω)
Function describe_impedance accepts any component and outputs its impedance. A specialized method is called for each passed object. impedance, which is the essence of multiple dispatch. The output shows how Julia automatically selects the correct implementation depending on the type of argument.
Conclusion
The example successfully demonstrates:
- the hierarchy of abstract types of AbstractComponent
- parametric SystemState types with where constraints
- internal/external constructors with checks
- extension of Base functions: show, getindex, setindex!, +, *, ==, zero, one
- multiple dispatching using the example of impedance for different components
- the concept of method conflict resolution
Julia's multiple dispatching allows you to create flexible and extensible systems in which behavior is determined by a combination of argument types. The considered example of modeling electrical and mechanical components clearly shows the advantages of this approach.:
- Single interface — function
impedanceIt is defined for all components, but its implementation is specific for each type. - Extensibility — adding a new type of component only requires defining its method
impedanceand, if necessary, operator overrides. - Encapsulation of checks — internal constructors ensure that only physically correct objects are created.
- Natural syntax — overloading of standard functions (
getindex,show,+,*) makes working with custom types as convenient as with built-in types.
Using abstract types and parametric structures allows you to create hierarchies that reflect real physical relationships. At the same time, multiple dispatching becomes a key mechanism for organizing code, ensuring its clarity and modularity. This approach is especially valuable in engineering applications where modeling of heterogeneous physical domains within a single software platform is required.