Equana

Back to Examples

Types & Multiple Dispatch

Define types, write typed functions, and overload operators

Run AllReset

Most languages use single dispatch — when you call a.add(b), the language looks at the type of a to decide which implementation to run. The type of b doesn't matter.

Equana uses multiple dispatch. When you call add(a, b), the runtime examines the types of all arguments and picks the most specific matching implementation. This is a natural fit for numerical computing where operations between different types are the norm.

This tutorial covers how to define types, write typed functions, overload operators, build type hierarchies, and why this design was chosen over traditional OOP.

Why Not OOP?

Object-oriented programming attaches methods to classes. To add two vectors you'd write a.add(b) — the method lives on a's class, and the language dispatches based on a's type alone. This creates several problems for numerical computing:

The asymmetry problem. a + b is symmetric, but a.add(b) isn't. Who "owns" the method when a is a scalar and b is a vector? In OOP you need workarounds like Python's __radd__, double dispatch, or visitor patterns.

The expression problem. Adding a new type that interacts with existing types requires modifying those existing classes — or using multiple inheritance, mixins, and adapter patterns that grow in complexity.

The ceremony problem. Scientific code is about data and operations on data. Wrapping every concept in a class with constructors, inheritance, and access modifiers adds boilerplate without adding clarity.

Multiple dispatch solves all three:

Single Dispatch (OOP)Multiple Dispatch (Equana)
Method lookupBased on receiver type onlyBased on all argument types
scalar * vectorNeeds __rmul__ or visitorJust works — dispatches on (Number, Vec)
Adding new type combosModify existing classesAdd a new function variant
Syntaxa.add(b)a + b (operator) or add(a, b)
InheritanceRequired for polymorphismOptional — abstract type hierarchies when you need them

Key insight: In numerical computing, the operation matters more than which object "owns" it. 2 * v shouldn't belong to the number 2 or to the vector v — it belongs to the mul function, parameterized by the types (Number, Vec).

Defining Types

Structs

A struct defines a named type with typed fields. Instances are created by calling the struct name as a constructor:

Code [1]Run
struct Vec2
    x:Float64
    y:Float64
end

# Positional construction
v1 = Vec2(3.0, 4.0)

# Named-argument construction
v2 = Vec2(x = 1.0, y = 2.0)

println(v1)
println(v2)

Struct constructors validate types at construction time — passing a String where a Float64 is expected produces a clear error.

Abstract Types

An abstract type declares a type that cannot be instantiated but can serve as a parent for structs and other abstract types. This creates type hierarchies for dispatch:

Code [2]Run
abstract type Shape end

struct Circle <: Shape
    radius:Float64
end

struct Rectangle <: Shape
    width:Float64
    height:Float64
end

# Dispatch on the abstract parent type
function r = describe(s:Shape)
    r = "some shape"
end

# More specific variant wins when available
function r = describe(c:Circle)
    r = "circle with radius " + string(c.radius)
end

println(describe(Circle(5.0)))
println(describe(Rectangle(3.0, 4.0)))

The Circle variant is more specific than the Shape variant, so describe(Circle(5.0)) dispatches to the circle-specific method. But describe(Rectangle(3.0, 4.0)) has no rectangle-specific variant, so it falls back to the Shape method.

Abstract types can also form deeper hierarchies:

Code [3]Run
abstract type Shape end
abstract type Polygon <: Shape end

struct Triangle <: Polygon
    a:Float64
    b:Float64
    c:Float64
end

struct Square <: Polygon
    side:Float64
end

# Matches any Shape
function r = kind(s:Shape)
    r = "shape"
end

# Matches any Polygon (more specific than Shape)
function r = kind(p:Polygon)
    r = "polygon"
end

# Matches only Square (most specific)
function r = kind(s:Square)
    r = "square"
end

println(kind(Triangle(3.0, 4.0, 5.0)))
println(kind(Square(2.0)))

Type Annotations on Functions

Add type annotations to function parameters using name: Type syntax. When multiple variants of the same function exist, Equana builds a method table and dispatches to the best match at call time:

Code [4]Run
# Define three variants of the same function
function r = describe(x)
    r = "something else"
end
function r = describe(x:Number)
    r = "a number"
end
function r = describe(x:String)
    r = "a string"
end

# The runtime picks the most specific match
println(describe(42))
println(describe("hello"))
println(describe(true))

The untyped variant describe(x) acts as a fallback — it matches any argument but has the lowest priority. When a typed variant matches, it always wins.

Arrow functions support type annotations too, for concise dispatch variants:

Code [5]Run
classify = x -> "unknown"
classify = (x:Int64) -> "an integer"
classify = (x:Float64) -> "a float"

println(classify(42))
println(classify(3.14))
println(classify(true))

Multi-argument dispatch

The dispatch engine selects the variant based on both argument count and argument types:

Code [6]Run
struct Point
    x:Float64
    y:Float64
end

# One-argument variant: distance from origin
function d = distance(p:Point)
    d = sqrt(p.x ^ 2 + p.y ^ 2)
end

# Two-argument variant: distance between two points
function d = distance(a:Point, b:Point)
    d = sqrt((a.x - b.x) ^ 2 + (a.y - b.y) ^ 2)
end

p1 = Point(3.0, 4.0)
p2 = Point(6.0, 8.0)

println(distance(p1))
println(distance(p1, p2))

No overload resolution annotations or special syntax needed — just define the variants you want.

Operator Overloading

Every operator in Equana maps to a named function. When you write a + b, the runtime calls add(a, b) and dispatches based on argument types. If no user-defined variant matches, it falls back to the built-in implementation.

OperatorFunctionOperatorFunction
+add==eq
-sub!=neq
*mul<lt
/div>gt
^pow<=le
%mod>=ge
unary -neg!not

To overload + for a custom type, define a function named add with typed parameters:

Code [7]Run
struct Vec2
    x:Float64
    y:Float64
end

# Overload + for Vec2
function r = add(a:Vec2, b:Vec2)
    r = Vec2(a.x + b.x, a.y + b.y)
end

v1 = Vec2(1.0, 2.0)
v2 = Vec2(3.0, 4.0)

# This dispatches to our add(Vec2, Vec2) function
v3 = v1 + v2
println(v3)

# Built-in + still works for numbers
println(3 + 4)

Where multi-dispatch shines

Consider scalar-vector multiplication. In OOP, you'd need to decide: does Number have a method that knows about Vec2, or does Vec2 have a method that knows about Number? Neither is clean.

With multi-dispatch, you simply define a function for the type pair (Number, Vec2):

Code [8]Run
struct Vec2
    x:Float64
    y:Float64
end

# scalar * vector
function r = mul(a:Number, b:Vec2)
    r = Vec2(a * b.x, a * b.y)
end

v = Vec2(3.0, 4.0)
r = 2 * v
println(r)

The dispatch engine matches 2 * v to the signature mul(Number, Vec2) by examining both arguments. No special reverse-method protocol needed.

You can define as many type combinations as you need — mul(Vec2, Number), mul(Vec2, Vec2) (component-wise), etc. — and the dispatcher picks the right one.

Unary operators and comparison

Unary - maps to neg, and == maps to eq:

Code [9]Run
struct Vec2
    x:Float64
    y:Float64
end

# Negate a vector
function r = neg(v:Vec2)
    r = Vec2(-v.x, -v.y)
end

# Equality check
function r = eq(a:Vec2, b:Vec2)
    r = (a.x == b.x) && (a.y == b.y)
end

v = Vec2(3.0, -4.0)
negated = -v
println(negated)

# Equality
v1 = Vec2(1.0, 2.0)
v2 = Vec2(1.0, 2.0)
v3 = Vec2(1.0, 3.0)
println(v1 == v2)
println(v1 == v3)

Custom Operations

Multiple dispatch lets you define your own operations over custom types. They are called like any other function — cross(a, b), dot(a, b) — and dispatch on their argument types:

Code [10]Run
struct Vec3
    x:Float64
    y:Float64
    z:Float64
end

function r = cross(a:Vec3, b:Vec3)
    r = Vec3(
    a.y * b.z - a.z * b.y,
    a.z * b.x - a.x * b.z,
    a.x * b.y - a.y * b.x
    )
end

function r = dot(a:Vec3, b:Vec3)
    r = a.x * b.x + a.y * b.y + a.z * b.z
end

u = Vec3(1.0, 0.0, 0.0)
v = Vec3(0.0, 1.0, 0.0)

println(cross(u, v))
println(dot(u, v))

Function Aliases

The alias keyword creates a new name for an existing function. The alias inherits all dispatch variants:

Code [11]Run
function r = double(x)
    r = x * 2
end

alias twice = double

println(twice(5))
println(twice(3.14))

Dispatch Specificity

When multiple variants match a call, the dispatcher picks the most specific one. The specificity hierarchy from most to least specific:

  1. Concrete struct typesVec2, Circle (created via struct)
  2. Abstract typesShape, Number (created via abstract)
  3. Untyped / Any — parameters with no type annotation

A struct subtype is always preferred over its parent abstract type:

Code [12]Run
abstract type Shape end

struct Circle <: Shape
    radius:Float64
end

function r = area(s:Shape)
    r = 0.0
end
function r = area(c:Circle)
    r = 3.14159 * c.radius ^ 2
end

println(area(Circle(5.0)))

If no variant matches the argument types at all, the runtime throws a clear error:

Code [13]Run
# Only accepts Int64
strict = (x:Int64) -> x * 2

# This works
println(strict(5))

# This would error: No matching method for 'strict' with arguments (String)
# strict("hello")

Modules

Modules group related types and functions together. Use export to make members accessible from outside:

Code [14]Run
module Geometry
    export struct Point
        x:Float64
        y:Float64
    end

    export function d = distance(a:Point, b:Point)
        d = sqrt((a.x - b.x) ^ 2 + (a.y - b.y) ^ 2)
    end
end

p1 = Geometry.Point(0.0, 0.0)
p2 = Geometry.Point(3.0, 4.0)
println(Geometry.distance(p1, p2))

Higher-Order Functions and Lambdas

Functions are first-class values. Arrow syntax -> creates anonymous functions, and they participate in dispatch just like named functions:

Code [15]Run
# Arrow functions
double = x -> x * 2
add_pair = (a, b) -> a + b

println(double(21))
println(add_pair(3, 4))

# Pass functions to higher-order functions
arr = [1, 2, 3, 4, 5]
println(map(arr, x -> x^2))
println(filter(arr, x -> x > 2))
println(reduce(arr, (a, b) -> a + b, 0))

Gradual Typing

You don't have to type everything. Untyped functions continue to work exactly as before — the type system is opt-in. You can start with untyped code and add types incrementally where they provide value:

Code [16]Run
# Start with a single untyped function
function r = identify(x)
    r = "something"
end

# Later, add typed variants for specific behavior
function r = identify(x:Int64)
    r = "an integer"
end
function r = identify(x:String)
    r = "a string"
end

# Typed variants win when they match; untyped is the fallback
println(identify(42))
println(identify("hi"))
println(identify(true))

The untyped variant becomes a fallback that handles any type not covered by a typed variant. This lets you progressively add type safety without rewriting existing code.

Summary

Key Concepts

  • Multiple dispatch — function selection based on all argument types, not just one
  • Structs — nominal types with typed fields and constructor validation
  • Abstract types — type hierarchies for dispatch (abstract type Shape end, struct Circle <: Shape)
  • Operator overloading — define add, mul, neg, etc. for custom types
  • Aliasesalias twice = double creates alternate names
  • Specificity — concrete struct > abstract type > untyped
  • Gradual typing — opt-in; untyped code works alongside typed code

Why Not OOP?

  • Numerical operations are symmetric — a + b shouldn't "belong" to a
  • Multi-dispatch handles mixed-type operations naturally (Number * Vec2)
  • No inheritance, no method resolution order, no diamond problem
  • Functions are first-class — define new type combinations without touching existing code

Operator → Function Mapping

OperatorFunctionOperatorFunction
+add==eq
-sub!=neq
*mul<lt
/div>gt
^pow<=le
%mod>=ge
unary -neg!not

Further Reading

Workbench

Clear
No variables in workbench

Next Steps