Protocols
Protocols are Cure’s mechanism for ad-hoc polymorphism. They define a set of functions that can have different implementations for different types, all resolved at compile time into guard-dispatched BEAM functions.
Defining a protocol
A protocol declares a type parameter and one or more function signatures:
proto Show(T)
fn show(x: T) -> String
T is a type variable. Each implementation substitutes a concrete type for T.
A protocol can declare multiple methods:
proto Collection(C)
fn size(c: C) -> Int
fn is_empty(c: C) -> Bool
Implementing a protocol
Use impl to provide a concrete implementation for a specific type:
impl Show for Int
fn show(x: Int) -> String = Std.String.from_int(x)
impl Show for Float
fn show(x: Float) -> String = Std.String.from_float(x)
impl Show for String
fn show(x: String) -> String = "\"" <> x <> "\""
impl Show for Bool
fn show(x: Bool) -> String = if x then "true" else "false"
impl Show for Atom
fn show(x: Atom) -> String = ":" <> Std.String.from_atom(x)
The function body inside an impl block has the same syntax as any other
function. You can use pattern matching, guards, let bindings, and call
other functions.
How dispatch works: guard compilation
The compiler does not generate vtables or dictionary lookups. Instead, it compiles each protocol method into a multi-clause BEAM function with Erlang guard tests.
Given the Show protocol above, the codegen produces roughly:
show(X) when is_integer(X) -> show__for__int(X);
show(X) when is_boolean(X) -> show__for__bool(X);
show(X) when is_float(X) -> show__for__float(X);
show(X) when is_binary(X) -> show__for__string(X);
show(X) when is_atom(X) -> show__for__atom(X).
Each show__for__<type> is a private function containing the implementation
body. The dispatch function is exported.
The guard mapping is:
-
Int->is_integer -
Float->is_float -
String->is_binary -
Bool->is_boolean -
Atom->is_atom -
List->is_list -
Tuple->is_tuple -
Map->is_map -
Pid->is_pid -
Ref->is_reference
Clause ordering by type specificity
On the BEAM, true and false are atoms. That means is_atom(true) returns
true. If the Atom clause came before the Bool clause, boolean values
would dispatch to the wrong implementation.
The compiler sorts clauses by type specificity:
-
Boolhas specificity 0 (checked first) -
Int,Float,String,List,Tuple,Map,Pid,Refhave specificity 1 -
Atomhas specificity 10 (checked last among primitives)
This ordering is automatic. You never need to worry about the order in which
you write impl blocks.
The Eq protocol
mod Std.Eq
proto Eq(T)
fn eq(a: T, b: T) -> Bool
impl Eq for Int
fn eq(a: Int, b: Int) -> Bool = a == b
impl Eq for Float
fn eq(a: Float, b: Float) -> Bool = a == b
impl Eq for String
fn eq(a: String, b: String) -> Bool = a == b
impl Eq for Bool
fn eq(a: Bool, b: Bool) -> Bool = a == b
impl Eq for Atom
fn eq(a: Atom, b: Atom) -> Bool = a == b
fn ne(a: T, b: T) -> Bool = if eq(a, b) then false else true
The ne function is not part of the protocol declaration – it’s a regular
function that calls the dispatched eq. Any module can define helper functions
alongside protocol definitions.
The Ord protocol
mod Std.Ord
proto Ord(T)
fn compare(a: T, b: T) -> Atom
impl Ord for Int
fn compare(a: Int, b: Int) -> Atom =
if a < b then :lt else if a > b then :gt else :eq
impl Ord for Float
fn compare(a: Float, b: Float) -> Atom =
if a < b then :lt else if a > b then :gt else :eq
impl Ord for String
fn compare(a: String, b: String) -> Atom =
if a < b then :lt else if a > b then :gt else :eq
impl Ord for Atom
fn compare(a: Atom, b: Atom) -> Atom =
if a < b then :lt else if a > b then :gt else :eq
fn lt(a: T, b: T) -> Bool = compare(a, b) == :lt
fn le(a: T, b: T) -> Bool = compare(a, b) != :gt
fn gt(a: T, b: T) -> Bool = compare(a, b) == :gt
fn ge(a: T, b: T) -> Bool = compare(a, b) != :lt
compare returns :lt, :eq, or :gt. The helper functions derive boolean
comparisons from it.
The Functor protocol
mod Std.Functor
proto Functor(F)
fn fmap(container: F, f: A -> B) -> F
impl Functor for List
fn fmap(container: List, f: A -> B) -> List =
Std.List.map(container, f)
fmap maps a function over a container. Currently only List has an
implementation. As new container types are added (Option wrappers, tree
structures), they can implement Functor without modifying existing code.
Cross-module protocol registry (v0.13)
Before v0.13, protocol dispatch was module-local. If Std.Show defined
impl Show for Int, another module could not call show(42) without
importing Std.Show and hoping the clauses merged correctly.
v0.13 introduced a global ETS-backed protocol registry. During compilation,
every impl block registers itself:
ProtocolRegistry.register_impl("Show", "show", "Int", :std_show)
Other modules can query the registry:
{:ok, :std_show} = ProtocolRegistry.lookup_impl("Show", "show", "Int")
true = ProtocolRegistry.has_impl?("Show", "Int")
The registry maps {protocol_name, method_name, for_type} to the module
atom that provides the implementation. This enables cross-module dispatch
resolution in the codegen phase.
Derive mechanism
For record types, you can derive a protocol implementation automatically:
@derive(Show)
record Point
x: Int
y: Int
The compiler generates a show implementation that prints the record’s
fields. The derived output for Point{x: 3, y: 4} would be
"Point{x: 3, y: 4}".
Derive works with any protocol that has a sensible default implementation
for record types. Currently Show and Eq support derivation.
Building a protocol from scratch: Stringify
Here is a complete example of defining and using a custom protocol:
mod MyApp.Stringify
# Define the protocol
proto Stringify(T)
fn stringify(x: T) -> String
# Implement for Int
impl Stringify for Int
fn stringify(x: Int) -> String =
"Int(" <> Std.String.from_int(x) <> ")"
# Implement for Float
impl Stringify for Float
fn stringify(x: Float) -> String =
"Float(" <> Std.String.from_float(x) <> ")"
# Implement for String
impl Stringify for String
fn stringify(x: String) -> String =
"String(\"" <> x <> "\")"
# Implement for Bool
impl Stringify for Bool
fn stringify(x: Bool) -> String =
if x then "Bool(true)" else "Bool(false)"
# Implement for Atom
impl Stringify for Atom
fn stringify(x: Atom) -> String =
"Atom(:" <> Std.String.from_atom(x) <> ")"
# Convenience: stringify with newline
fn stringify_line(x: T) -> String = stringify(x) <> "\n"
# Use it
fn main() -> Atom =
let s = stringify(42) <> " " <> stringify(3.14) <> " " <> stringify(:hello)
Std.Io.println(s)
Calling main() prints: Int(42) Float(3.14) Atom(:hello)
The generated BEAM code has zero runtime overhead beyond the guard checks that the BEAM JIT optimizes into jump tables.
Where constraints (planned)
A planned feature allows constraining type variables in function signatures:
fn display(x: T) -> String where Show(T) =
"[" <> show(x) <> "]"
The where Show(T) clause tells the compiler that T must have a Show
implementation. At call sites, the compiler verifies the constraint is
satisfied. This is not yet implemented – currently, protocol calls resolve
dynamically based on the guard dispatch.
Protocol design guidelines
One type parameter. Protocols take a single type parameter. Multi-parameter type classes are not supported.
Keep methods minimal. Define the smallest set of methods in the protocol,
then build helper functions on top. See how Std.Ord defines only compare
in the protocol and derives lt, le, gt, ge as regular functions.
Bool before Atom. If your protocol has both Bool and Atom
implementations, the compiler handles clause ordering automatically. But be
aware that on the BEAM, booleans are atoms.
Name mangling. Implementation methods are compiled as private functions
named method__for__type (e.g., show__for__int). The dispatch function is
the original method name. You cannot call mangled names directly.