← Code Compare

Interfaces, Traits & Duck Typing

The same task in every language: model "any shape with an area", give a Circle and a Square their own area, then dispatch over a heterogeneous list and sum the results. Watch how each language abstracts over behavior rather than data - a Go interface, a Rust trait, an OCaml object's structural type, Perl's and Python's duck typing, and - because user-defined traits are deferred in Guji v0 - a Guji enum with a method that matches on itself.

Show: GujiGoOCamlHaskellPerlRakuRustPython
Guji
# Guji has no user-defined traits/interfaces yet - they stay deferred (spec
# §21). The idiomatic way to abstract over "a shape with an area" is an
# `enum`: a Shape *is one of* these variants, and a method dispatches by
# matching on $self. The compiler checks the match is exhaustive.
enum Shape {
    Circle($radius: Float)
    Square($side: Float)

    sub area($self): Float {
        match $self {
            Circle($r) { 3.14159 * $r * $r }
            Square($s) { $s * $s }
        }
    }

    sub name($self): Str {
        match $self {
            Circle($r) { "circle" }
            Square($s) { "square" }
        }
    }
}

sub main() {
    @shapes = [Circle(1.0), Square(2.0), Circle(3.0)]
    mut $total = 0.0
    for $shape in @shapes {
        print("{ $shape.name() } has area { $shape.area() }")
        $total = $total + $shape.area()
    }
    print("total area: $total")
}

Guji (v0.1-alpha, a statically-typed compiled language) has no user-defined traits or interfaces yet - they stay deferred (spec §21), so there is no user polymorphism beyond generics. The idiom is the sum type: a Shape enum whose area and name methods take $self and match on it, the construct the spec uses for dispatch by a value's shape. One enum holds every variant, a List[Shape] is naturally heterogeneous, and a for loop dispatches each $shape.area() / $shape.name() - the same loop-and-accumulate shape as the Go and Rust versions. The trade-off versus an open interface: a Shape is closed - adding a variant means editing the enum, but then the compiler forces every match to handle it. The whole program compiles ahead-of-time to a native binary (guji build) and runs identically under the reference interpreter.

Go
package main

import "fmt"

// An interface lists the methods a type must have. Any type with an
// Area() and Name() method satisfies Shape *implicitly* - no "implements".
type Shape interface {
	Area() float64
	Name() string
}

type Circle struct{ Radius float64 }

func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
func (c Circle) Name() string  { return "circle" }

type Square struct{ Side float64 }

func (s Square) Area() float64 { return s.Side * s.Side }
func (s Square) Name() string  { return "square" }

func main() {
	shapes := []Shape{Circle{1.0}, Square{2.0}, Circle{3.0}}
	total := 0.0
	for _, s := range shapes {
		fmt.Printf("%s has area %g\n", s.Name(), s.Area())
		total += s.Area()
	}
	fmt.Printf("total area: %g\n", total)
}

Go interfaces are satisfied structurally and implicitly: Circle and Square never name Shape, yet because each has the right methods they are usable as a Shape. A []Shape is therefore a heterogeneous list, and each call dispatches dynamically through the interface's method table. This duck-typing-with-static-checking is Go's only polymorphism mechanism besides generics, and the closest mainstream analogue to a Rust trait or a Guji enum method.

OCaml
(* OCaml objects are structurally typed: any object with the right methods
   has the right type, no class hierarchy needed. The list's element type
   < area : float; name : string > is inferred from the methods called. *)
let circle r =
  object
    method area = 3.14159 *. r *. r
    method name = "circle"
  end

let square s =
  object
    method area = s *. s
    method name = "square"
  end

let () =
  let shapes = [ circle 1.0; square 2.0; circle 3.0 ] in
  List.iter
    (fun s -> Printf.printf "%s has area %g\n" s#name s#area)
    shapes;
  let total = List.fold_left (fun acc s -> acc +. s#area) 0.0 shapes in
  Printf.printf "total area: %g\n" total

OCaml's object system gives a structural interface: an object's type is just the set of methods it answers to, so circle and square share the inferred type < area : float; name : string > and live in one list without any declared Shape type or inheritance. Methods are called with #, as in s#area. This row-polymorphic, duck-typed flavor of objects is closer to Go interfaces than to nominal classes - the functional default in OCaml would instead be a variant + a function, much like Guji's enum.

Haskell
{-# LANGUAGE ExistentialQuantification #-}
import Text.Printf (printf)

-- A type class declares the behavior "a shape with an area".
class Shape a where
  area :: a -> Double
  name :: a -> String

data Circle = Circle Double
data Square = Square Double

instance Shape Circle where
  area (Circle r) = 3.14159 * r * r
  name _ = "circle"

instance Shape Square where
  area (Square s) = s * s
  name _ = "square"

-- A heterogeneous list needs an existential wrapper.
data AnyShape = forall a. Shape a => AnyShape a

main :: IO ()
main = do
  let shapes = [AnyShape (Circle 1.0), AnyShape (Square 2.0), AnyShape (Circle 3.0)]
  mapM_ (\(AnyShape s) -> printf "%s has area %g\n" (name s) (area s)) shapes
  let total = sum [area s | AnyShape s <- shapes]
  printf "total area: %g\n" total

Haskell's interface is a type class: class Shape a declares area/name, and each type opts in with an instance block, an explicit "this type satisfies that class" much like a Rust trait impl. A list must be homogeneous, so mixing Circle and Square needs an existential wrapper - data AnyShape = forall a. Shape a => AnyShape a (the ExistentialQuantification extension) - which hides the concrete type while keeping the Shape dictionary for dispatch. Type classes give compile-time (static) dispatch by default; the existential box is what buys the dynamic, heterogeneous collection.

Perl
use strict;
use warnings;

# Perl polymorphism is pure duck typing: any object whose package defines
# area() and name() can stand in as a "shape" - there is no interface type
# and no shared base class. If it can ->area, it is a shape.
package Circle {
    sub new  { my ($class, $r) = @_; bless { radius => $r }, $class }
    sub area { my $self = shift; 3.14159 * $self->{radius} ** 2 }
    sub name { "circle" }
}
package Square {
    sub new  { my ($class, $s) = @_; bless { side => $s }, $class }
    sub area { my $self = shift; $self->{side} ** 2 }
    sub name { "square" }
}

my @shapes = (Circle->new(1.0), Square->new(2.0), Circle->new(3.0));
my $total = 0;
for my $shape (@shapes) {
    printf "%s has area %s\n", $shape->name, $shape->area;
    $total += $shape->area;
}
print "total area: $total\n";

Perl has no interface or trait declaration: dispatch is by method name at run time, so a list of mixed Circle and Square objects works simply because each responds to ->area and ->name. Nothing is checked ahead of time - calling a missing method is a run-time error, the cost of this freedom. Modern Perl can add structure with Role::Tiny roles or Moose, but the bare blessed-object idiom shows duck typing in its purest form.

Raku
# A `role` is Raku's trait/interface: it declares required behavior, and a
# class `does` it. A stub method (`{...}`) makes implementing it mandatory.
role Shape {
    method area( --> Numeric) { ... }
    method name( --> Str)     { ... }
}

class Circle does Shape {
    has $.radius;
    method area { 3.14159 * $.radius ** 2 }
    method name { "circle" }
}
class Square does Shape {
    has $.side;
    method area { $.side ** 2 }
    method name { "square" }
}

my Shape @shapes = Circle.new(radius => 1.0),
                   Square.new(side => 2.0),
                   Circle.new(radius => 3.0);
my $total = 0;
for @shapes -> $shape {
    say "{ $shape.name } has area { $shape.area }";
    $total += $shape.area;
}
say "total area: $total";

Raku spells an interface as a role: Shape declares area/name as stub methods ({ ... }, the "yada" operator), and a class that does Shape must supply them or fail to compose. Roles are flatter and more flexible than classes - they mix in behavior and can be composed into several classes - and a typed Shape @shapes array then holds any of them. This role/does model is the direct conceptual ancestor of the traits Guji defers to a later version.

Rust
// A trait declares the behavior a type must provide.
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Circle { radius: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
    fn name(&self) -> &str { "circle" }
}
impl Shape for Square {
    fn area(&self) -> f64 { self.side * self.side }
    fn name(&self) -> &str { "square" }
}

fn main() {
    // A heterogeneous list needs trait objects: Box<dyn Shape>.
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 1.0 }),
        Box::new(Square { side: 2.0 }),
        Box::new(Circle { radius: 3.0 }),
    ];
    let mut total = 0.0;
    for s in &shapes {
        println!("{} has area {}", s.name(), s.area());
        total += s.area();
    }
    println!("total area: {}", total);
}

Rust's interface is a trait, implemented for each type in a separate impl Shape for Circle block - the impl keyword is the explicit "this type satisfies that trait" that Go leaves implicit. To put differing types in one list you need a trait object Box<dyn Shape>, which boxes each value and dispatches dynamically through a vtable; without dyn, generics would monomorphize and could not mix types. Traits also carry static dispatch and bounds (T: Shape), making them Rust's single, central abstraction mechanism.

Python
import math
from typing import Protocol

# A Protocol describes a *structural* interface (PEP 544): any object with
# area() and name() is a Shape to a type checker - no inheritance required.
# At run time it is plain duck typing: if it has the methods, it works.
class Shape(Protocol):
    def area(self) -> float: ...
    def name(self) -> str: ...

class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    def area(self) -> float:
        return math.pi * self.radius ** 2
    def name(self) -> str:
        return "circle"

class Square:
    def __init__(self, side: float):
        self.side = side
    def area(self) -> float:
        return self.side ** 2
    def name(self) -> str:
        return "square"

shapes: list[Shape] = [Circle(1.0), Square(2.0), Circle(3.0)]
total = 0.0
for s in shapes:
    print(f"{s.name()} has area {s.area()}")
    total += s.area()
print(f"total area: {total}")

Python dispatches by duck typing at run time: a list of mixed Circle and Square objects works because each defines area and name - neither class inherits anything. A typing.Protocol (PEP 544) adds an optional, structural interface a checker like mypy can verify, mirroring Go interfaces and OCaml objects, but it is erased at run time and imposes nothing. Older code used abc.ABC for a nominal base class; Protocol is the modern way to name a duck-typed contract.