2 — Multiple dispatch vs OOP

Where the verb lives: left vs right of the data

Emmanuel Pilliat — ENSAI 3A

Goals

  • Verb after the object (OOP) vs before it (dispatch).
  • Multiple dispatch: method chosen from all argument types.
  • Type unknown (abstract container, global): instability.
  • A language point: no parallelism yet.

▶ Script · Notebook · 🐍 Python · ✏️ Activity

One argument

Where does the verb go?

Area of a shape, two ways:

call verb
OOP (Python) shape.area() right, after the dot
dispatch (Julia) area(shape) left, in front

OOP: the method lives in the class

import math

class Shape: ...

class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self): return math.pi * self.r**2

class Rectangle(Shape):
    def __init__(self, L, w): self.L, self.w = L, w
    def area(self): return self.L * self.w

Circle(2.0).area()      # left of dot picks
  • Method owned by the class.
  • Chosen from one object: left of the dot.

Julia: data and functions are separate

abstract type Shape end                # no fields
struct Circle    <: Shape; r::Float64;             end
struct Rectangle <: Shape; L::Float64; w::Float64; end
area(c::Circle)    = π * c.r^2       # one method
area(r::Rectangle) = r.L * r.w       # per type

shapes = [Circle(2.0), Rectangle(3.0, 4.0), Circle(1.0)]
area.(shapes)                        # [12.57, 12.0, 3.14]

area lives outside the types; method picked from the argument type.

▶ Script: cell “1. One argument: one function, one method per type”

Two arguments

Dispatch on two arguments

Collision depends on both shapes:

collide(a::Circle,    b::Circle)    = "two circles"
collide(a::Circle,    b::Rectangle) = "circle and rectangle"
collide(a::Rectangle, b::Rectangle) = "two rectangles"

collide(Circle(1.0), Rectangle(2.0, 3.0))   # "circle and rectangle"
  • Method picked from the pair of types: multiple dispatch.
  • Python a.collide(b) sees only a; b needs isinstance.

▶ Script: cell “2. Two arguments: dispatch on the PAIR of types”

The pair we did not define

collide(Rectangle(1.0, 1.0), Circle(2.0))

Bet first: what happens? Fix in one line?

▶ Script: cells “The pair we did not define: what happens?” and “Add the missing pair in one line”

The missing pair — debrief

MethodError: no method matching collide(::Rectangle, ::Circle)

Fix by symmetry:

collide(a::Rectangle, b::Circle) = collide(b, a)

Missing method = run-time error. No coverage check.

A new operation, without touching the types

perimeter(c::Circle)    = 2π * c.r
perimeter(r::Rectangle) = 2 * (r.L + r.w)

perimeter.([Circle(2.0), Rectangle(3.0, 4.0)])   # [12.57, 14.0]
  • Types not edited.
  • Python: edit every class (or isinstance ladder).
add a type add an operation
OOP easy (new class) edit every class
dispatch easy (new struct) easy (new function)

Who knows which methods?

methods(area)

▶ Script: cell “Who knows which methods? How many for area?”

A function = a collection of methods; a call picks one.

Dispatch and speed: when is the type known?

What container did we build?

shapes = [Circle(2.0), Rectangle(3.0, 4.0), Circle(1.0)]
typeof(shapes)                    # ?
isconcretetype(eltype(shapes))    # ?

Bet first: circles and a rectangle: element type?

▶ Script: cells “3. Dispatch and speed: what container did shapes build?” and “Is its element type concrete?”

Same data, two containers

Vector{Shape}: Shape is abstract, so which area?

total(v) = sum(area, v)
radii    = rand(1000)
concrete = [Circle(r) for r in radii]          # Vector{Circle}
mixed    = Shape[Circle(r) for r in radii]     # Vector{Shape}

Bet first: which total is faster, by how much? Then @code_typed.

▶ Script: cells “Same data, two containers: predict which is faster, and by how much” and “Why? Find the call to area in each”

What we saw: the abstract container

Vector{Shape}: ~10× slower than Vector{Circle}.

  • concrete: area chosen once, at compile time, inlined.
  • mixed: run-time lookup in the loop, like Python.

Dispatch is fast when types are concrete.

Another unknown type: a global

glob = rand(1000)
function sum_global()
    s = 0.0
    for i in eachindex(glob)
        s += glob[i]
    end
    return s
end
function sum_arg(x)
    s = 0.0
    for i in eachindex(x)
        s += x[i]
    end
    return s
end

Only difference: glob is a global, x an argument.

@code_warntype: spot the red Any

@code_warntype sum_global()
@code_warntype sum_arg(glob)

@code_warntype = @code_typed, uncertain types in color.

Bet first: which has red Anys? How big is the time gap?

▶ Script: cells “4. Type instability — same sum, global vs argument” to “The argument version”

What we saw: type instability

  • sum_global: glob::Any, s::Any, Body::Any · sum_arg: all concrete
  • A global can change type → compiler can’t know → Any
  • Every + = run-time dispatch, like Vector{Shape}, like Python
  • ~120 µs vs ~0.9 µs: > 100×, ~4000 allocations

The golden rule

Note

Work in functions, data as arguments.

Same reason for @btime mysum($v) (measuring properly): $ makes v a known value.

Summary

Python (OOP) Julia (dispatch)
Method obj.f(), inside f(obj), outside
Chosen from one object all arguments
New op edit every class new function
2 args isinstance method per pair
Speed lookup per call compiled if types known

Later: same * picks the GPU method on CuArray (GPU module).

Going further