Everyone Got the OOP vs FP Debate Wrong — Because They Never Met Real Functional Programming
Everyone Got the OOP vs FP Debate Wrong — Because They Never Met Real Functional Programming
Multiple Dispatch, Closures, and what Lisp programmers settled 40 years ago (while the rest of us argued)
You’ve heard the same hallway fight a hundred times.
“Objects model the real world.”
“Pure functions prevent bugs.”
“Encapsulation!”
“Immutability!”
It’s ritual theatre: the OOP guild vs. the FP guild. And it always ends with the diplomatic shrug — “Both are tools; use what works.”
That comfortable middle sounds wise only if your idea of FP begins and ends with Haskell (or Erlang or OCaml) and a strict ban on state. That’s the public misconception: Haskell == FP and immutability is mandatory. It isn’t. FP is older, broader, and far more practical. And the people who actually built much of computer science — the Lisp crowd — chose FP as a foundation not because it’s austere, but because it’s extensible.
Here’s the big correction:
Multiple Dispatch is FP applied to OOP.
It’s verb-centric design with first-class functions, generic methods, and true polymorphism. Objects are just data containers; the “methods” live with the verbs (the generic functions). That design is what makes the Open–Closed Principle (OCP) actually achievable and solves the Expression Problem in practice.
Let’s make this concrete. We’ll build a tiny “Shapes” system in five languages — Common Lisp, Julia, Clojure, R (S4), and Python (with plum-dispatch) — and show how you can extend types and extend verbs without touching original code. Along the way you’ll see closure-based encapsulation (FP can encapsulate, elegantly) and why multiple dispatch (representing FP) is “the better OOP.”
The story: a tiny library grows up
Imagine a small base package that defines two shapes (Circle, Rectangle) and two operations (area, perimeter). It ships, gets tests, and users depend on it.
A month later a different team wants to add a new shape (Triangle) and a new operation (draw) — without modifying the base package or breaking tests. That’s the Open–Closed Principle in the wild.
With single-dispatch OOP (the normal classes in Python, C/C++, Java, ... actuall in all classical OOP languages), you’re stuck reopening classes or sprinkling if ladders. With multiple dispatch, you just add new methods in your extension package and everything composes.
Let’s walk it in five dialects.
Common Lisp (CLOS): the original playbook
Base package (published, tested, frozen):
;;; geometry.lisp
(defpackage :geometry
(:use :cl) ;; you import the base common lisp language as `cl`
(:export #:shape #:circle #:rectangle
#:make-circle #:make-rectangle
#:area #:perimeter)) ;; the public symbols of the package
(in-package :geometry) ;; we enter the package definition
;; define class `shape`
(defclass shape () ())
;; inherit `shape` and build new classes `circle` and `rectangle`
(defclass circle (shape) ((radius :initarg :radius :reader radius)))
(defclass rectangle (shape) ((width :initarg :width :reader width)
(height :initarg :height :reader height)))
;; `circle` has the attribute `radius`
;; `reactangle` has the attributes `width` and `height`
;; `:reader` defines the name of the accessor function of the attribute
;; `:initarg` is the default value - here empty symbols - literal symbols
;; start in common lisp with `:` - they could be any null value
;; but it's convention to use the name's symbol for the attribute
;; the constructor functions for the classes are defined
(defun make-circle (r) (make-instance 'circle :radius r))
(defun make-rectangle (w h) (make-instance 'rectangle :width w :height h))
;; we define the generic functions with their arguments
;; s stands for shape
(defgeneric area (s))
(defgeneric perimeter (s))
;; using the generic functions, we define the specific dispatch functions
(defmethod area ((c circle)) (* pi (expt (radius c) 2)))
(defmethod area ((r rectangle)) (* (width r) (height r)))
(defmethod perimeter ((c circle)) (* 2 pi (radius c)))
(defmethod perimeter ((r rectangle)) (* 2 (+ (width r) (height r))))Extension package (no edits to geometry):
;;; geometry-extended.lisp
(defpackage :geometry-extended
(:use :cl)
(:import-from :geometry ;; from the geometry package all classes
:shape :circle :rectangle ;; and generic methods are imported
:area :perimeter :radius :width :height)
(:export #:triangle #:make-triangle #:draw)) ;; what we want to export
(in-package :geometry-extended) ;; enter the package defintion
;; add a new class/type and its constructor
(defclass triangle (shape) ((base :initarg :base :reader base)
(height :initarg :height :reader height)))
(defun make-triangle (b h) (make-instance 'triangle :base b :height h))
;; Extend the imported, existing verbs for the new type
(defmethod area ((t triangle)) (/ (* (base t) (height t)) 2))
(defmethod perimeter ((t triangle)) (* 3 (base t))) ;; toy
;; Add a brand-new verb/generic function
;; and implement it for old and new types/classes
(defgeneric draw (s))
(defmethod draw ((c circle)) (format t "Circle r=~a~%" (radius c)))
(defmethod draw ((r rectangle)) (format t "Rect ~ax~a~%" (width r) (height r)))
(defmethod draw ((t triangle)) (format t "Triangle base=~a h=~a~%" (base t) (height t)))No original code modified. Both new types and new verbs compose cleanly. That’s OCP, done.
Julia: multiple dispatch as the language
Base module:
module Geometry # start of a module definition with its name
export Shape, Circle, Rectangle, area, perimeter
abstract type Shape end # abstrac class/type
struct Circle <: Shape; radius::Float64; end
struct Rectangle <: Shape; width::Float64; height::Float64; end
# `<: Shape` means: "inherits the Shape class"
# function and class/structure definitions of Julia end with `end`
# this is Ruby syntax imitated (to evade Python's indentation syntax).
# define the generic functions - base case
area(s::Shape) = error("area not implemented for $(typeof(s))")
perimeter(s::Shape) = error("perimeter not implemented for $(typeof(s))")
# define the dispatch - specific cases
# for single expression functions - like mathematical functions, we can use
# `=` for the definition - no `end` needed then.
area(c::Circle) = π * c.radius^2
perimeter(c::Circle) = 2π * c.radius
area(r::Rectangle) = r.width * r.height
perimeter(r::Rectangle) = 2(r.width + r.height)
end # the end of the module defintion needs also an `end`Extension module:
module GeometryExtended # start the new module definiton
using ..Geometry # import the Geometry package
export Triangle, draw # what should be visible outside of this module
# we define a new class - since the class has only attributes, a `struct`
# does it, already.
struct Triangle <: Geometry.Shape
base::Float64
height::Float64
end
# area and perimeter are from the namespace of Geometry
# we define the special case for Triangle for the generic functions
# in the Geometry package - true extension cross-package!
Geometry.area(t::Triangle) = 0.5 * t.base * t.height
Geometry.perimeter(t::Triangle) = 3t.base # toy
# define a new generic function for Shape which we defined in Geometry
draw(s::Geometry.Shape) = error("draw not implemented for $(typeof(s))")
# now, we can extend old structures/classes by the new function
draw(c::Geometry.Circle) = "Drawing Circle r=$(c.radius)"
draw(r::Geometry.Rectangle) = "Drawing Rectangle $(r.width)x$(r.height)"
# and also for the new structure/class
draw(t::Triangle) = "Drawing Triangle base=$(t.base) h=$(t.height)"
end # end of the moduleAgain: new type + new verb without touching the base. Julia’s everyday.
Clojure: closures for encapsulation, protocols for verbs
First, closure-based encapsulation — FP’s version of “private state”:
;; Encapsulation via closure + atom
(defn make-counter []
(let [state (atom 0)]
{:next (fn [] (swap! state inc))
:get (fn [] @state)}))
(def c (make-counter))
((:next c)) ;; 1
(:get c) ;; 1state is lexically private; nothing can touch it except the returned functions. That’s true encapsulation without classes.
Now, verb-centric polymorphism:
;; Base "package"
(defprotocol ShapeOps
(area [s])
(perimeter [s]))
(defrecord Circle [radius])
(defrecord Rectangle [width height])
(extend-type Circle
ShapeOps
(area [c] (* Math/PI (* (:radius c) (:radius c))))
(perimeter [c] (* 2 Math/PI (:radius c))))
(extend-type Rectangle
ShapeOps
(area [r] (* (:width r) (:height r)))
(perimeter [r] (* 2 (+ (:width r) (:height r)))))Extension adds a new type and a new verb:
(defrecord Triangle [base height])
(extend-type Triangle
ShapeOps
(area [t] (/ (* (:base t) (:height t)) 2.0))
(perimeter [t] (* 3.0 (:base t)))) ;; toy
(defprotocol Drawable
(draw [s]))
(extend-protocol Drawable
Circle (draw [c] (str "Circle r=" (:radius c)))
Rectangle (draw [r] (str "Rect " (:width r) "x" (:height r)))
Triangle (draw [t] (str "Triangle b=" (:base t) " h=" (:height t))))We didn’t alter the original definitions. We extended verbs and added a type independently. Clojure does this with protocols and records; the spirit is the same: verbs at the center.
R (S4): formal generics and methods
Base:
setClass("Shape", slots=list())
setClass("Circle", contains="Shape", slots=list(radius="numeric"))
setClass("Rectangle", contains="Shape", slots=list(width="numeric", height="numeric"))
setGeneric("area", function(s) standardGeneric("area"))
setGeneric("perimeter", function(s) standardGeneric("perimeter"))
setMethod("area", "Circle", function(s) pi * s@radius^2)
setMethod("area", "Rectangle", function(s) s@width * s@height)
setMethod("perimeter", "Circle", function(s) 2*pi*s@radius)
setMethod("perimeter", "Rectangle", function(s) 2*(s@width + s@height))Extension:
setClass("Triangle", contains="Shape", slots=list(base="numeric", height="numeric"))
setMethod("area", "Triangle", function(s) 0.5 * s@base * s@height)
setMethod("perimeter", "Triangle", function(s) 3 * s@base) # toy
setGeneric("draw", function(s) standardGeneric("draw"))
setMethod("draw", "Circle", function(s) sprintf("Circle r=%.2f", s@radius))
setMethod("draw", "Rectangle", function(s) sprintf("Rect %.2fx%.2f", s@width, s@height))
setMethod("draw", "Triangle", function(s) sprintf("Triangle b=%.2f h=%.2f", s@base, s@height))S4 generics give you multiple dispatch across classes with formal method selection. Same idea, different syntax.
Python: bring multiple dispatch with
plum
Python’s classes are single-dispatch by default, but we can flip the design with plum-dispatch.
Base:
from dataclasses import dataclass
from plum import dispatch
class Shape: ...
@dataclass
class Circle(Shape): radius: float
@dataclass
class Rectangle(Shape): width: float; height: float
@dispatch
def area(s: Shape):
raise NotImplementedError
@dispatch
def perimeter(s: Shape):
raise NotImplementedError
@dispatch
def area(c: Circle): return 3.141592653589793 * c.radius * c.radius
@dispatch
def perimeter(c: Circle): return 2 * 3.141592653589793 * c.radius
@dispatch
def area(r: Rectangle): return r.width * r.height
@dispatch
def perimeter(r: Rectangle): return 2 * (r.width + r.height)Extension (separate module, no edits to base):
from dataclasses import dataclass
from plum import dispatch
from geometry import Shape, Circle, Rectangle, area, perimeter # base imports
@dataclass
class Triangle(Shape): base: float; height: float
@dispatch
def area(t: Triangle):
return 0.5 * t.base * t.height
@dispatch
def perimeter(t: Triangle):
return 3 * t.base # toy
@dispatch
def draw(s: Shape):
raise NotImplementedError
@dispatch
def draw(c: Circle):
return f"Circle r={c.radius}"
@dispatch
def draw(r: Rectangle):
return f"Rect {r.width}x{r.height}"
@dispatch
def draw(t: Triangle):
return f"Triangle b={t.base} h={t.height}"We’ve added a new type and introduced a brand-new verb that works for old types — without ever editing their classes. Python, wearing FP’s jacket, behaves like CLOS/Julia.
Why this wins: Expression Problem & Open–Closed in practice
The Expression Problem asks:
How can we add new variants (types) and new operations (verbs) to a program, independently, without modifying existing code, while preserving static checks / safety?
- Single-dispatch OOP makes you choose: easy new types, hard new verbs (or vice versa).
- Multiple dispatch lets you add both. Independently. Forever.
That’s what the Lisp lineage optimized for: verbs in the center (generic functions), data as simple containers, and methods defined externally. Once you see it, the OOP vs FP debate evaporates. FP didn’t “compromise” with OOP — it absorbed it.
And the usual worries?
- Encapsulation? FP does it with closures and lexical scope (see the Clojure make-counter). It’s tighter than most class privacy.
- State? FP isn’t allergic to it. Common Lisp, Julia, and R are mutable; Clojure is immutable-by-default with controlled mutability (atoms/refs/agents).
- Performance? The “Lisp is slow” folklore is historical. Modern Lisps and Julia are blisteringly fast; R and Python have their own well-known performance escape hatches.
A tiny “two-argument” moment (why multiple dispatch feels natural)
Try defining “interacts_with(a, b)” in single-dispatch OOP. You’ll reach for double-dispatch or the Visitor pattern quickly. With multiple dispatch:
- CLOS/Julia/R S4/Plum: just add another method specialized on (TypeA, TypeB).
- The runtime does the right thing based on both arguments.
- Your brain relaxes; the code says what it means.
That’s what “true polymorphism” feels like.
Practical guidance (how to use this tomorrow)
- In Julia: you’re already there. Keep data in structs; make verbs generic; add methods in your package or someone else’s.
- In Clojure: use protocols for verbs, records for data, and closures for encapsulation. Extend without reopening.
- In R: prefer S4 generics for extensible libraries; define setGeneric once, pile on setMethod.
- In Python: keep classes as dumb data; make verbs generic with plum-dispatch. Organize base vs extension modules cleanly.
If you build libraries, document your verbs as stable extension points and let downstreams register methods for their types. That’s how ecosystems compound.
Closing the loop
The old debate framed OOP and FP as rival tribes. The Lisp tradition shows that’s the wrong frame. FP is not “no state” and “only map/filter/reduce.” It’s a design stance: put verbs at the center, compose behavior with generic functions, encapsulate with closures, and keep data simple. That stance makes the Open–Closed Principle real and the Expression Problem boring.
So yes, FP “wins” — not as purity theatre, but as the architecture that already solved what OOP wanted to be.
If you learned OOP first, this isn’t a repudiation; it’s the upgrade path. Keep your nouns. Move your verbs to the front. Let your system grow without surgery.
And next time that hallway fight kicks off, skip the shrug. Show a two-line method with multiple dispatch and watch the argument end itself.