Behaviours & Protocols
How do you say "any type that can do X" - polymorphic dispatch over a shared contract? The task is the same everywhere: an area operation that works for both a circle and a rectangle. The BEAM languages split sharply here. Erlang and LFE use behaviours - a module declares -callback signatures and each shape is its own callback module, with dispatch done by calling Mod:area(...). Elixir reaches for a protocol, which dispatches on the runtime type of its first argument. Statically typed Gleam has no typeclasses, so the idiom is a custom type with one case per variant and a plain function that pattern-matches it. Lua (Luerl) leans on duck typing: any table that carries an area method just works, no declared interface at all.
%% The behaviour module defines the contract...
-module(shape).
-export([area/1]).
-callback area(State :: term()) -> float().
%% ...and dispatches to whichever callback module is passed in.
area({Mod, State}) -> Mod:area(State).
%% --- a circle callback module ---
-module(circle).
-behaviour(shape).
-export([area/1]).
area(R) -> math:pi() * R * R.
%% --- a rectangle callback module ---
-module(rectangle).
-behaviour(shape).
-export([area/1]).
area({W, H}) -> W * H.
%% shape:area({circle, 2.0}) => 12.566...
%% shape:area({rectangle, {3,4}}) => 12
An Erlang -callback declares the contract a module must satisfy, and -behaviour(shape) opts a module in so the compiler warns about missing callbacks; dispatch is explicit - you carry the module name as a tag and call Mod:area(State).
# A protocol declares the contract once.
defprotocol Shape do
@spec area(t) :: float()
def area(shape)
end
defmodule Circle, do: defstruct(r: 0.0)
defmodule Rectangle, do: defstruct(w: 0.0, h: 0.0)
# One implementation per type; dispatch is on the first arg's type.
defimpl Shape, for: Circle do
def area(%Circle{r: r}), do: :math.pi() * r * r
end
defimpl Shape, for: Rectangle do
def area(%Rectangle{w: w, h: h}), do: w * h
end
[%Circle{r: 2.0}, %Rectangle{w: 3.0, h: 4.0}]
|> Enum.map(&Shape.area/1)
# => [12.566370614359172, 12.0]
An Elixir defprotocol defines the contract and each defimpl ... for: Type supplies it for one struct, so Shape.area/1 dispatches automatically on the runtime type of its argument.
import gleam/io
import gleam/list
import gleam/float
// No typeclasses: model the closed set of shapes as one type.
pub type Shape {
Circle(r: Float)
Rectangle(w: Float, h: Float)
}
// One function pattern-matches every variant; exhaustiveness checked.
pub fn area(shape: Shape) -> Float {
case shape {
Circle(r:) -> 3.14159 *. r *. r
Rectangle(w:, h:) -> w *. h
}
}
pub fn main() {
[Circle(r: 2.0), Rectangle(w: 3.0, h: 4.0)]
|> list.map(area)
|> list.each(fn(a) { io.println(float.to_string(a)) })
// prints 12.56636 then 12.0
}
Gleam has no typeclasses, so the idiom is a single custom type with one constructor per shape and a function that case-matches it; the compiler forces the match to be exhaustive, so adding a variant fails to compile until area handles it.
;; The behaviour module declares the callback contract.
(defmodule shape
(export (area 1) (behaviour_info 1)))
;; behaviour_info/1 lists the callbacks each shape must implement.
(defun behaviour_info
(('callbacks) (list #(area 1)))
((_) 'undefined))
;; Dispatch by carrying the callback module as a tag.
(defun area
((`#(,mod ,state)) (call mod 'area state)))
;; --- circle callback module ---
(defmodule circle
(behaviour shape)
(export (area 1)))
(defun area (r) (* (math:pi) r r))
;; --- rectangle callback module ---
(defmodule rectangle
(behaviour shape)
(export (area 1)))
(defun area
((`#(,w ,h)) (* w h)))
;; (shape:area #(circle 2.0)) => 12.566...
;; (shape:area #(rectangle #(3 4))) => 12
LFE compiles to the same BEAM behaviour mechanism as Erlang: an exported behaviour_info/1 function returning (#(area 1)) declares the contract, (behaviour shape) opts a module in, and (call mod 'area state) does the dynamic Mod:area dispatch.
-- Duck typing: no declared interface, just tables with an area method.
local Circle = {}
Circle.__index = Circle
function Circle.new(r) return setmetatable({ r = r }, Circle) end
function Circle:area() return math.pi * self.r * self.r end
local Rectangle = {}
Rectangle.__index = Rectangle
function Rectangle.new(w, h)
return setmetatable({ w = w, h = h }, Rectangle)
end
function Rectangle:area() return self.w * self.h end
-- Anything responding to :area() is a "shape".
local shapes = { Circle.new(2.0), Rectangle.new(3, 4) }
for _, s in ipairs(shapes) do
print(s:area())
end
--> 12.566370614359 then 12
Lua has no contract to declare: a metatable makes :area() a method on each table, and the loop simply calls s:area() on anything that happens to provide it - if it walks like a shape, it is one.