Like many of us I have a long and well-documented record of love/hate relationship with macros.
Despite that, the cornerstone of cljd.flutter
(the ClojureDart namespace for making Flutter more palatable) is a macro: f/widget
.
It’s a Flutter-specific threading macro, supercharged with 15+ directives.
Making the macro user-extensible had been on my mind for a long time, but a comment by Balint Erdos on Clojurian’s #clojuredart finally triggered the implementation (the redacted part is what matters).
Don’t get me wrong: it’s not because someone asked for extensibility that I jumped to implementation. It’s because in a moment of clarity I saw a good design for it: a better one that the ones I previously considered and one that could be implemented in minutes.
For the record: while searching the above message in Slack, I found a similar but older message (by Balint too) mentioning extensibility en passant and it didn't trigger anything at the time.
The change is almost trivial—just three extra lines and one line changed but it took me a long journey to come up with: it all starts 14 years ago while trying to define a “flatter cond”.
It all started with me wanting to spice up cond
s with some :let
s to avoid nesting.
(cond
(odd? a) 1
:let [a (quot a 2)]
(odd? a) 2
:else 3)
It escalated quickly: a couple of months later I added :when
and :when-let
and devised a metadata-based extension mechanism to add more. Just in case 🤷♂️. (I don't remember why I didn't use multimethods.)
Mark Engelberg ran with the idea in better-cond and wisely skipped the extension system.
Truth is: I rarely used any "better cond" library. When I needed to, I just added a "better cond" as a private macro to the namespace I was working on.
Yes, as a private macro—not even in a dreaded utils
namespace.
I really believe private macros should be more liberally used:
they can cut down boilerplate in a specific namespace in a very ad-hoc manner,
because they are local to a namespace, there's no need to sweat them too much to make them worth general use (or wishful-reuse utils
grade),
their definition is colocated to their usage.
The very specific and one-off nature of private macros drives the apparition of two strains:
cond
s) whose core implementations are short enough that one reimplements them from namespaces to namespaces, from projects to projects. Always different, always similar.There's evolutionary pressure on this later strain to stay short (easy on memory, easy on typing). This led me to stumble on nest-last <<-
(aka else->>
):
The little ugly macro that clojure pundits don’t want you to hear about :-)
— Christophe Grand (@cgrand) July 10, 2020
(defmacro <<- [& forms] `(->> ~@(reverse forms)))
(defmacro <<- [& forms] `(->> ~@(reverse forms)))
It's just ->>
but applied right to left, so what's the point? Let's consider the expansion of (<<- (if a then-a) (let [some bindings]) (if b then-b) else)
:
(<<- (if a then-a) (let [some bindings]) (if b then-b) else)
(->> else (if b then-b) (let [some bindings]) (if a then-a))
(->> (if b then-b else) (let [some bindings]) (if a then-a))
(->> (let [some bindings] (if b then-b else)) (if a then-a))
(->> (if a then-a (let [some bindings] (if b then-b else))))
(if a then-a (let [some bindings] (if b then-b else)))
So in this case it acted as this extended cond
form:
(cond a then-a :let [some bindings] b then-b :else else)
; vs
(<<- (if a then-a) (let [some bindings]) (if b then-b) else)
(Note that it's barely shorter than the equivalent <<-
form above.)
What's great about <<-
(besides its brevity and virality) is that it doesn't need to be extended: it syntactically composes other forms! You can use if-let
, if-some
, or
... Anything. No need for extension.
It's the main takeaway of this journey: if you keep wanting to make a macro extensible, make it leverage syntactical composition instead!
f/widget
At its core, f/widget
is a threading (or nesting rather) macro. It allows to turn this typical nested Flutter expression:
(m/SizedBox .height 200
.child (m/Center
.child (m/Text "hello")))
into this:
(f/widget
(m/SizedBox .height 200)
m/Center
(m/Text "hello"))
Additional features kept being added to f/widget
: first the ability to thread using another named parameter, then many directives ranging from foundational (like :watch
and :managed
) to mundane (:height
or :let
— :let
again!).
The internal and external pressure to add more directives is real, but one can't say yes to every suggestion.
Besides it's already somehow possible to "extend" it by leveraging the child-nesting! Let's say we want to add an alignment helper:
(defn align [[x y] .child]
(m/Align .alignment (m/Alignment x y) .child child))
It can be used inside a f/widget
but looks like a regular form, not like a directive.
(f/widget
:let [msg "hello"]
:height 200 ; built-in directive, same as (m/SizedBox .height 200)
(align [0 0])
(m/Text msg))
Somehow f/widget
was already extensible but there was a line drawn in the sand between what's core (directives) and what's not (regular forms).
So recently I changed one small behavior: simple directives (non qualified keywords) are reserved for f/widget
future growth, qualified keywords are for users. :ns/name form
will just expand as (ns/name form)
.
Thus the previous snippet can now be written as:
(f/widget
:let [msg "hello"]
:height 200 ; built-in directive, same as (m/SizedBox .height 200)
::align [0 0]
(m/Text msg))
It's a small change but brings symmetry: user code can provide helpers even very ad-hoc (private!) ones without being second class citizen in the system.
In hindsight, the whole notion of directives could have been avoided by leveraging only the compositional nature of f/widget
. Somewhere in the multiverse, on Earth-1337, one writes:
(f/widget-1337
(f/let [msg "hello"])
(f/height 200) ; built-in directive, same as (m/SizedBox .height 200)
(align [0 0])
(m/Text msg))
The f/let
is a bit of an eyesore but it's the price to pay (unless adding a special case to this f/widget-1337
but then the whole point of being regular is defeated).
Anyway de gustibus non est disputandum and at this point removing directives from f/widget
would be like removing them from for
or doseq
.
🤔 A syntactical composable take on for
... that's an interesting challenge... 🤔 Maybe for a next article unless someone picks up the gauntlet!
(syntactical, monads not allowed)