Equana

Back to Tutorials

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
}

# 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 Shape

struct Circle is Shape {
  radius: Float64
}

struct Rectangle is Shape {
  width: Float64
  height: Float64
}

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

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

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 Shape
abstract Polygon is Shape

struct Triangle is Polygon {
  a: Float64
  b: Float64
  c: Float64
}

struct Square is Polygon {
  side: Float64
}

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

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

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

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" }
function r = describe(x: Number) { r = "a number" }
function r = describe(x: String) { r = "a string" }

# 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
}

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

# 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)
}

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
}

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

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
}

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

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
}

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

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

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 Infix Operators

Any binary function can be turned into an infix operator using the @infix(n) decorator, where n is the precedence (higher binds tighter):

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

@infix(25)
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
  )
}

@infix(25)
function r = dot(a: Vec3, b: Vec3) {
  r = a.x * b.x + a.y * b.y + a.z * b.z
}

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

# Use as infix — reads like math notation
println(u cross v)
println(u dot v)

The precedence value determines how tightly the operator binds relative to other operators. For reference, + has precedence 10 and * has precedence 20.

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 }

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 Shape

struct Circle is Shape {
  radius: Float64
}

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

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")

Namespaces

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

Code [14]Run
namespace Geometry {
  export struct Point {
    x: Float64
    y: Float64
  }

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

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" }

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

# 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 Shape, struct Circle is Shape)
  • Operator overloading — define add, mul, neg, etc. for custom types
  • Custom infix@infix(n) turns any binary function into an operator
  • 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