← Code Compare

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.

Show: ErlangElixirGleamLFELuerl
Erlang
%% 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).

Elixir
# 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.

Gleam
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.

LFE
;; 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.

Luerl
-- 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.