ToC

Steel beams, SI units and Clojure multimethods: a match made in heaven

Flexible software must be more flexible than all known cases.

I'm paraphrasing Software Design for Flexibility by Chris Hanson and Gerald Jay Sussman from memory, don't expect to find the exact quote.

I believe they are right. Flexible systems must be more general than their application. Otherwise the flexible system is not an abstraction, it's just a collection of code.

Clojure is a flexible language

In a discussion with a friend who enjoys programming greatly, I said "I don't really care about syntax". That statement surprised him. Frankly, the statement surprised me too. Syntax isn't irrelevant. But it's not the goal. Some programming languages depend greatly on the syntax. Others don't.

Clojure establishes syntax for sequential data, associative data, sets, function calls and macro calls.

(ns steel-beams-si-units-clojure-multimethods
(:refer-clojure :exclude [* / + -])
(:require
[clojure.java.io :as io]
[clojure.math :as math]
[clojure.set :as set]
[clojure.string :as str]
[nextjournal.clerk :as clerk]
[taoensso.nippy :as nippy]))
Examples
[1 2 3]
[1 2 3]
{:name "Teodor",
:thesis
{:title
"Finite element implementation of lower-order strain gradient plasticity in Abaqus",
:url "https://www.teodorheggelund.com/static/heggelund15.pdf"}}
{:name "
Teodor"
:thesis {:title "
Finite element implementation of lower-order strain gradient plasticity in Abaqu1 more elided"
:url "
https://www.teodorheggelund.com/static/heggelund15.pdf"
}
}
#{:untyped-fp :cqrs :typed-fp}
#{:cqrs :typed-fp :untyped-fp}
(last [1 2 3])
3
(set/union #{:untyped-fp :cqrs :typed-fp} #{:shekshuka :spaghetti})
#{:cqrs :shekshuka :spaghetti :typed-fp :untyped-fp}
(let
[topics
(set/union #{:untyped-fp :cqrs :typed-fp} #{:shekshuka :spaghetti})]
(str "consider exploring " (name (rand-nth (vec topics))) " today!"))
"
consider exploring cqrs today!"

If you accept these five decisions about syntax, you can mostly do whatever you want afterwards.

You can define your own functions, and your own macros. last is clojure.core/last, set/union is clojure.set/union. Let is clojure.core/let, a macro. The only reason you can write let and have that automatically refer to clojure.core/let is that all vars from clojure.core are required by default (which can be disabled).

What about syntax for function definitions? Syntax for type definitions? Syntax for loop constructs? Where are they?

  • There is no syntax for function definition — fn is a macro.
  • There is no syntax for type definitions — you can choose a library for checking data (like clojure spec or malli, if you want. If you do need types, there's deftype, defrecord and defprotocol, all three are macros.
  • There is no syntax for for loops — for is a plain macro
  • There is no syntax for while loops — loop and reduce are macros.

This is good!

Take the time to learn and understand vectors (sequential data), maps (associative data), sets (unique elements only) and functions. Idiomatic Clojure code is functions transforming data. Leave the rest for when you actually need it!

Steel beams

Wait, what about the steel beams? I thought there was supposed to be steel beams.

Steel beams coming right up!

Flexible languages can solve a wide variety of problems. A problem you might pick for yourself is to design steel beams. Let's see if Clojure is a good fit.

This is a steel beam:

Steel beam supporting the first floor of a house

Image from Wikipedia, retreived 2023-10-28, licensed CC BY-SA 3.0.

This a figure of steel beam's cross section:

image/svg+xml Web Flange Flange thickness (t) Flange width (b) Beam height (h) Web thickness (s)
Cross section of an I-shape beam

Image from Wikipedia, retreived 2023-10-28, licensed CC BY-SA 3.0.

Let's make a similar figure ourselves. We will represent beams as maps.

DescriptionLabelMath notation
Cross section area:aLoading...
Beam cross section width:bLoading...
Beam cross section height:hLoading...
Strong axis bending stiffness:iyLoading...
Weak axes bending stiffness:izLoading...
Beam profile name prefix, eg IPE or HEA:prefixLoading...
Profile number, eg 300 for IPE300:profileLoading...
Curve radius between flanges and beams:rLoading...
Web thickness:sLoading...
Flange thickness:tLoading...
Strong axis bending moment resistance:wyLoading...
Weak axis bending moment resistance:wzLoading...

This map is an IPE300 beam:

{:a 5.38 :b 150 :h 300 :iy 83.6 :iz 6.04 :prefix "
IPE"
:profile 300 :r 15 :s 7.1 :t 10.7 2 more elided}

Let's draw breams with SVG.

(defn i-shape-steel-beam->svg [beam]
(let [plus clojure.core/+
minus clojure.core/-
div clojure.core//
mult clojure.core/*
margin 4.5
margin*2 (mult margin 2)
;; Profile parameters
{:keys [h b s t r]} beam
;;
;; Notation for paths with SVG
;; See
;;
;; https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths
;;
;; to learn how to work with SVG pathsSVG paths
M "M"
l "l"
a "a"
-r (minus r)
r*2 (mult r 2)
flange-tip-length (div (minus b s) 2)
web-inner-height (minus h (mult 2 t))
path [M margin margin
l b 0
l 0 t
l (minus (minus flange-tip-length r)) 0 ; top right corner
;; MDN explains how to draw curve segments, _arcs_:
;;
;; https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#arcs
;; a rx ry x-axis-rotation large-arc-flag sweep-flag dx dy "a" r r 0
a r r 0 0 0 -r r
l 0 (minus web-inner-height r*2) ; web, right side
a r r 0 0 0 r r
l (minus flange-tip-length r) 0
l 0 t
l (minus b) 0
l 0 (minus t)
l (minus flange-tip-length r) 0
a r r 0 0 0 r (minus r)
l 0 (minus (minus web-inner-height r*2)) ;; web, left side
a r r 0 0 0 (minus r) (minus r)
l (minus (minus flange-tip-length r)) 0
"Z"
]]
[:svg {:width (plus margin*2 (:b beam))
:height (plus margin*2 (:h beam))}
[:path {:d (str/join " " path)
:fill "transparent"
:stroke "black"}]]))
#object[steel_beams_si_units_clojure_multimethods$i_shape_steel_beam__GT_svg 0x4b018d62 "
steel_beams_si_units_clojure_multimethods$i_shape_steel_beam__GT_svg@4b018d62"
]
(let [ipe300 {:r 15, :wy 557, :s 7.1, :prefix "IPE", :wz 80.5, :h 300, :b 150, :iz 6.04, :t 10.7, :iy 83.6, :profile 300, :a 5.38}]
(clerk/caption "My SVG of a steel beam!"
(clerk/html (i-shape-steel-beam->svg ipe300))))
My SVG of a steel beam!

Amazing! It works! Clojure is great! Data is great! SVG is great! Clerk is great!

Now, what is wrong with the function above? Don't scroll. No, stop. Think.

We've complected two things. Give yourself a solid 20 seconds staring out a window to figure out which two things are currently complected.

Active reading seriously helps, I promise!

We've complected pixels and millimeters. Why is the figure 300 pixels high? I have no idea!

Or, I know why, but it's a very bad reason. It's because we've assumed that millimeters and pixels are the same thing.

Millimeters and pixels are not the same thing.

Let's fix that.

Numbers with unit

Our solution is to invent a number type that respects units. We will name our "number with unit" type "with-unit".

To support equality, we implement hashCode and equals.

(deftype WithUnit [number unit]
Object
(hashCode [_] (bit-xor (hash number) (hash unit)))
(equals [self other]
(and (instance? WithUnit other)
(= (.number self) (.number other))
(= (.unit self) (.unit other)))))
steel_beams_si_units_clojure_multimethods.WithUnit

We represent a unit as a map from a base unit to exponent.

:name:value
meter{:si/m 1}
square meter{:si/m 2}
second{:si/s 1}
per second{:si/s -1}
meters per square second{:si/m 1 :si/s -2}
Examples of units as maps from base unit to exponent

Let's not invent types when we don't have to.

We can now represent 300 mm and preserve the unit:

(str (WithUnit. 0.3 {:si/m 1}))
"
steel_beams_si_units_clojure_multimethods.WithUnit@d5dfc758"

Perhaps we can persuade Clerk to show our units in a more appealing way? We start by looking at Clerk's provided viewers.

clerk/default-viewers
[{:name nextjournal.clerk.viewer/header-viewer :transform-fn #object[clojure.core$comp$fn__5876 0x7d2f0161 "
clojure.core$comp$fn__5876@7d2f0161"
]
}
{:name nextjournal.clerk.viewer/char-viewer :pred #object[clojure.core$char_QMARK___5473 0x7748c0a1 "
clojure.core$char_QMARK___5473@7748c0a1"
]
:render-fn (fn [c] [:span.cmt-string.inspected-value "
\"
c])}
{:closing-paren "
""
:name nextjournal.clerk.viewer/string-viewer :opening-paren "
""
:page-size 80 :pred #object[clojure.core$string_QMARK___5475 0x57f791c6 "
clojure.core$string_QMARK___5475@57f791c6"
]
:render-fn nextjournal.clerk.render/render-quoted-string}
{:name nextjournal.clerk.viewer/number-viewer :pred #object[clojure.core$number_QMARK_ 0x76092dd "
clojure.core$number_QMARK_@76092dd"
]
:render-fn nextjournal.clerk.render/render-number :transform-fn #object[nextjournal.clerk.viewer$update_val$fn__14902 0x404d65ce "
nextjournal.clerk.viewer$update_val$fn__14902@404d65ce"
]
}
{:name nextjournal.clerk.viewer/number-hex-viewer :render-fn (fn [num] (nextjournal.clerk.render/render-number (str "
0x"
(.toString (js/Number. num) 16))))}
{:name nextjournal.clerk.viewer/symbol-viewer :pred #object[clojure.core$symbol_QMARK_ 0x13579834 "
clojure.core$symbol_QMARK_@13579834"
]
:render-fn (fn [x] [:span.cmt-keyword.inspected-value (str x)])}
{:name nextjournal.clerk.viewer/keyword-viewer :pred #object[clojure.core$keyword_QMARK_ 0x456c40af "
clojure.core$keyword_QMARK_@456c40af"
]
:render-fn (fn [x] [:span.cmt-atom.inspected-value (str x)])}
{:name nextjournal.clerk.viewer/nil-viewer :pred #object[clojure.core$nil_QMARK_ 0x7490cd6b "
clojure.core$nil_QMARK_@7490cd6b"
]
:render-fn (fn [_] [:span.cmt-default.inspected-value "
nil"])}
{:name nextjournal.clerk.viewer/boolean-viewer :pred #object[clojure.core$boolean_QMARK_ 0x5d8445d7 "
clojure.core$boolean_QMARK_@5d8445d7"
]
:render-fn (fn [x] [:span.cmt-bool.inspected-value (str x)])}
{:name nextjournal.clerk.viewer/map-entry-viewer :page-size 2 :pred #object[clojure.core$map_entry_QMARK_ 0x77655f5e "
clojure.core$map_entry_QMARK_@77655f5e"
]
:render-fn (fn [xs opts] (into [:<>] (comp (nextjournal.clerk.render/inspect-children opts) (interpose "
"))
xs))}
{:name nextjournal.clerk.viewer/var-from-def-viewer :pred #object[nextjournal.clerk.viewer$var_from_def_QMARK_ 0x5c2d3769 "
nextjournal.clerk.viewer$var_from_def_QMARK_@5c2d3769"
]
:transform-fn #object[nextjournal.clerk.viewer$update_val$fn__14902 0x7b637d7b "
nextjournal.clerk.viewer$update_val$fn__14902@7b637d7b"
]
}
{:name nextjournal.clerk.viewer/read+inspect-viewer :render-fn (fn [x] (try 2 more elided))} {6 more elided} {6 more elided} {6 more elided} {5 more elided} {4 more elided} {4 more elided} {6 more elided} {4 more elided} 22 more elided]

A viewer is a map. What keys are used by other viewers?

(->> clerk/default-viewers
(mapcat keys)
(into #{})
sort)
(:add-viewers :closing-paren :name :opening-paren :page-size :pred :render-fn :transform-fn :var-from-def?)

For our SI Unit viewer, I believe we need:

  • :name — a viewer name
  • :pred — a predicate that tells Clerk when to use the viewer
  • A way to actually implement our viewer, perhaps :render-fn or :transform-fn.

The Clerk Docs provide an example of a simple viewer using :transform. From the example, we make our own little viewer:

(clerk/with-viewer {:transform-fn (clerk/update-val (constantly "LOL IT'S A JOKE"))}
"A very serious sentence")
"
LOL IT'S A JOKE"

Nice! We made a toy viewer in one line of code.

Can viewers show Math?

(clerk/with-viewer {:transform-fn (clerk/update-val
(constantly (clerk/tex "a^2 + b^2 = c^2")))}
"A very serious sentence that is totally ignored in favor of math.")
Loading...

It's math! But:

  1. We don't want to set the viewer manually on each expression we write.
  2. We don't want to break existing viewers.

I'm thinking we want to write a viewer that applies only to our new unit type.

(defn with-unit? [x] (instance? WithUnit x))
#object[steel_beams_si_units_clojure_multimethods$with_unit_QMARK_ 0x9ea0ce9 "
steel_beams_si_units_clojure_multimethods$with_unit_QMARK_@9ea0ce9"
]
(clerk/example
(with-unit? (WithUnit. 0.3 {:si/m 1}))
(with-unit? 3)
(with-unit? "iiiiiiiiiiiiiiiiiiiiii"))
Examples
(with-unit? (WithUnit. 0.3 #:si{:m 1}))
true
(with-unit? 3)
false
(with-unit? "iiiiiiiiiiiiiiiiiiiiii")
false

Looks about right. Can we render m^2/s?

(defn unit->tex
"Convert from a unit as data to unit as TeX.
Unit-as-map is from base unit to exponent:
{:m 2 :s -1}
In TeX, we render m^2 above the line, and s below:
\"\\frac{\\operatorname{m}^{2}}{\\operatorname{s}}\""
[unit]
(when (map? unit)
(let [numerator (filter (comp pos? val) unit)
denominator (->> unit
(filter (comp neg? val))
(map (fn [[baseunit exponent]]
[baseunit (clojure.core/- exponent)])))
base+exp->tex
(fn [[base exp]]
(str "\\operatorname{" (name base) "}"
(when (not= 1 exp)
(str "^{" exp "}"))))
numerator-string (if (seq numerator)
(str/join " " (map base+exp->tex numerator))
"1")]
(if-not (seq denominator)
numerator-string
(str "\\frac{" numerator-string "}"
"{" (str/join " " (map base+exp->tex denominator)) "}")))))
#object[steel_beams_si_units_clojure_multimethods$unit__GT_tex 0x17686276 "
steel_beams_si_units_clojure_multimethods$unit__GT_tex@17686276"
]
(let [unit {:si/m -1 :si/s -1 :si/kg 1 :si/A 1}]
(unit->tex unit))
"
\frac{\operatorname{kg} \operatorname{A}}{\operatorname{m} \operatorname{s}}"
(let [unit {:si/m -1 :si/s -1}]
(unit->tex unit))
"
\frac{1}{\operatorname{m} \operatorname{s}}"
Examples
(unit->tex {:m 2, :s -1})
"
\frac{\operatorname{m}^{2}}{\operatorname{s}}"
(clerk/tex (unit->tex {:m 2, :s -1}))
Loading...

That looks like what I had in mind! We also want numbers with SI units.

(defn with-unit->tex [with-unit]
(str (cond-> (.number with-unit)
ratio? double)
" "
(unit->tex (.unit with-unit))))

For with-units (what a weird noun), the raw TeX and the rendered TeX look different:

Examples
(with-unit->tex (WithUnit. 0.3 #:si{:m 1}))
"
0.3 \operatorname{m}"
(clerk/tex (with-unit->tex (WithUnit. 0.3 #:si{:m 1})))
Loading...

Finally, we can create a viewer.

(def with-unit-viewer
{:name `with-unit-viewer
:pred with-unit?
:transform-fn (clerk/update-val (fn [unit] (clerk/tex (with-unit->tex unit))))})
(clerk/add-viewers! [with-unit-viewer])
(WithUnit. 0.3 {:si/m 1})
Loading...

It's working! Time to implement *. We're going to use multimethods to support plain numbers numbers without units.

First, we need a dispatch fn for two-arg type-based multimethods. Note that multimethod dispatch functions take one arg: the dispatch vector.

(defn ^:private both-types [a b]
[(type a) (type b)])
(clerk/example
(both-types 1 1)
(both-types 1 (WithUnit. 0.3 {:si/m 1})))
Examples
(both-types 1 1)
[java.lang.Long java.lang.Long]
(both-types 1 (WithUnit. 0.3 #:si{:m 1}))
[java.lang.Long steel_beams_si_units_clojure_multimethods.WithUnit]

Note: since we're using defrecord to implement our SI units, we will inherit Clojure's value-based equality. That's not what we want! Here's an example:

(=
(WithUnit. 0.3 {:si/m 1})
(WithUnit. 0.3 {:si/m 1 :si/s 0}))
false

Our problem is zero exponents in the exponent map. We can fix this with a contructor that conforms units to the representation we want.

(defn ^:private simplify-unit [x]
(into {} (remove (fn [[_ exponent]] (= exponent 0)) x)))
#object[steel_beams_si_units_clojure_multimethods$simplify_unit 0x7d03051a "
steel_beams_si_units_clojure_multimethods$simplify_unit@7d03051a"
]
(do
(defmulti simplify type)
(defmethod simplify Number [n] n)
(defmethod simplify WithUnit [x]
(let [simplified-unit (simplify-unit (.unit x))]
(if (= {} simplified-unit)
(.number x)
(WithUnit. (.number x)
simplified-unit)))))

This implementation simplifies unitless numbers to plain numbers:

(simplify (WithUnit. 0.3 {:si/m 0 :si/s 0}))
0.3

Then we implement a constructor in terms of the simplifier.

(defn with-unit [number unit]
(simplify (WithUnit. number unit)))
#object[steel_beams_si_units_clojure_multimethods$with_unit 0x25370090 "
steel_beams_si_units_clojure_multimethods$with_unit@25370090"
]

If we always use with-unit and consider WithUnit. an implementation detail, equality will work as expected.

(do
(defmulti multiply both-types)
(defmethod multiply [Number Number]
[a b]
(clojure.core/* a b))
(defmethod multiply [Number WithUnit]
[a b]
(with-unit
(clojure.core/* a (.number b))
(.unit b)))
(defmethod multiply [WithUnit Number]
[a b]
(with-unit
(clojure.core/* (.number a) b)
(.unit a)))
(defmethod multiply [WithUnit WithUnit]
[a b]
(with-unit
(clojure.core/* (.number a) (.number b))
(merge-with clojure.core/+
(.unit a)
(.unit b)))))
#object[clojure.lang.MultiFn 0x2eb82078 "
clojure.lang.MultiFn@2eb82078"
]

Finally, we can multiply numbers!

(multiply 100 (with-unit 0.3 {:si/m 1}))
Loading...
(defn *
([a] a)
([a b] (multiply a b))
([a b & args] (reduce multiply (multiply a b) args)))
#object[steel_beams_si_units_clojure_multimethods$_STAR_ 0x6ee36431 "
steel_beams_si_units_clojure_multimethods$_STAR_@6ee36431"
]
(multiply 100 (WithUnit. 0.3 {:si/m 1}))
Loading...
(let [height (with-unit 0.3 {:si/m 1})]
(clerk/example
(* height 0.5)
(* height height)))
Examples
(* height 0.5)
Loading...
(* height height)
Loading...

Arithmetic for numbers with units

To divide, we use 1-arity inversion and then rely on our multiplication.

(do
(defmulti invert type)
(defmethod invert Number
[a]
(clojure.core// a))
(defmethod invert WithUnit
[a]
(with-unit (clojure.core// (.number a))
(update-vals (.unit a) clojure.core/-))))
#object[clojure.lang.MultiFn 0x32bba3e4 "
clojure.lang.MultiFn@32bba3e4"
]
Examples
(invert 3)
1/3
(invert (with-unit 1 #:si{:m 1}))
Loading...
(defn /
([a] (invert a))
([a b] (* a (invert b)))
([a b & rest] (* a (invert b) (reduce * (map invert rest)))))
#object[steel_beams_si_units_clojure_multimethods$_SLASH_ 0x941076 "
steel_beams_si_units_clojure_multimethods$_SLASH_@941076"
]
(let [kg (with-unit 1 {:si/kg 1})
m (with-unit 1 {:si/m 1})
s (with-unit 1 {:si/s 1})
N (/ (* kg m)
(* s s))
kN (* 1000 N)
mm (/ m 1000)
mm2 (* mm mm)]
(/ (* 80 kN)
(* 300 mm2)))
Loading...

let's factor that into MPa instead:

(let [kg (with-unit 1 {:si/kg 1})
m (with-unit 1 {:si/m 1})
s (with-unit 1 {:si/s 1})
N (/ (* kg m)
(* s s))
kN (* 1000 N)
mm (/ m 1000)
mm2 (* mm mm)
MPa (/ N mm2)]
(/ (/ (* 80 kN)
(* 300 mm2))
MPa))
800/3

Steel beams with units

Above, we called this an IPE300 beam:

{:a 5.38 :b 150 :h 300 :iy 83.6 :iz 6.04 :prefix "
IPE"
:profile 300 :r 15 :s 7.1 :t 10.7 2 more elided}

But what units do :r, :wy and :iz have? Let's make a new map where values have SI units.

Meters, millimeters and square millimeters are convenient to define with multiplication:

(let [m (with-unit 1 {:si/m 1})
mm (* 10e-3 m)
mm2 (* mm mm)]
(clerk/row m mm mm2))
Loading...
Loading...
Loading...

But for Loading... and Loading... using multiplication is annoying. Let's fix that by defining exponentiation for WithUnit.

The implementation is quite similar to multiply, except that we don't allow numbers with units as exponents. We'll rely on clojure.math/pow under the hood.

(do
(defmulti pow both-types)
(defmethod pow [Number Number]
[base exponent]
(math/pow base exponent))
(defmethod pow [Number WithUnit]
[base exponent]
(throw (ex-info "WithUnit as exponent is not supported"
{:base base :exponent exponent})))
(defmethod pow [WithUnit Number]
[base exponent]
(with-unit
(math/pow (.number base) exponent)
(update-vals (.unit base) (partial * exponent))))
(defmethod pow [WithUnit WithUnit]
[base exponent]
(throw (ex-info "WithUnit as exponent is not supported"
{:base base :exponent exponent}))))
#object[clojure.lang.MultiFn 0x752a6b5a "
clojure.lang.MultiFn@752a6b5a"
]

Does it work as expected?

(let [m (with-unit 1 {:si/m 1})
mm (* 10e-3 m)]
(clerk/example mm
(* mm mm)
(pow mm 2)
(= (* mm mm) (pow mm 2))))
Examples
mm
Loading...
(* mm mm)
Loading...
(pow mm 2)
Loading...
(= (* mm mm) (pow mm 2))
true

Looks all right to me!

Time to revisit our IPE beam. This time, we add units.

{:a Loading... :b Loading... :h Loading... :iy Loading... :iz Loading... :prefix "
IPE"
:profile 300 :r Loading... :s Loading... :t Loading... 2 more elided}

Thank you

To Sam Ritchie, Martin Kavalar and Jack Rusher for making good tools, and for helping people who want to learn. To Gerald Jay Sussman for improving the way we think about programming, and expanding the range of problems we can solve with programming. To Eugene Pakhomov, Joshua Suskalo and Ethan McCue for helping me understand how Java types, Clojure multimethod type hierarchies are connected.

Further reading

Want to dig deeper?

  • To learn more about designing flexible software, read Software Design for Flexibility. It's good!

  • To see how a real-world flexible system is built, explore Emmy's architecture. Emmy is inspired by Gerald Jay Sussman's scmutils, and I'm strongly guessing experience building scmutils informed the writing of Software Design for Flexibility.