Why are `do` and `let` special forms?

I want to create a similar thing to let(that I want to call do) where I just have the order reversed.
I want the function body first and the bindings later. So being new to clojure I got excited to write my first macro ever but then paused when I saw, that let and do are NOT macros, but special forms.

So again: Why are do and let special forms and not macros?

and as a follwup question, can I create my own do macro that is semantically equivalent to let but works like this:

;; instead of
(let [helper-function1 (fn [] ...)]
     (do-something-with helperfunction-1)
     (and return something else))

;; i want
   (do-something-with helperfunction-1)
   (and return something else)
   :where [helper-function1 (fn [] ...)])

I think I could write the macro in terms of let, right?
I want it to me smart enough to understand that without the :where followed by a vector in the end, it behaves just like to original do

1 Like

clojure.core/let is a weird special form, it’s implemented as a macro (on jvm clojure), but has metadata indicating it’s treated as a special form. So this is a weird case (implementation detail) where we can look at the source of a “special form”:

user=> (use 'clojure.repl)
user=> (source clojure.core/let)
(defmacro let
  "binding => binding-form init-expr

  Evaluates the exprs in a lexical context in which the symbols in
  the binding-forms are bound to their respective init-exprs or parts
  {:added "1.0", :special-form true, :forms '[(let [bindings*] exprs*)]}
  [bindings & body]
     (vector? bindings) "a vector for its binding"
     (even? (count bindings)) "an even number of forms in binding vector")
  `(let* ~(destructure bindings) ~@body))

At least in the case of let, it’s feasible to define your own macro called let. To prevent name collisions, we can use the refer-clojure options available in the ns macro to allow us to bring in everything from clojure.core “except” let, then we can write a macro that will delegate work to clojure.core/let:

(ns custom
  (:refer-clojure :exclude [let]))

(defmacro let [bindings & body]
  (println [:bindings bindings])
  `(clojure.core/let ~bindings ~@body))

Perhaps in another namespace, we’d like to consume this macro instead of clojure.core/let; we can use exclusions and refers to pull off the same trick. This is probably undesirable (at least, I think I would probably do it another way that I’ll show after):

(ns other
  (:refer-clojure :exclude [let])
  (:require [custom :refer [let]]))

(let [x 2]

;;[:bindings [x 2]]

You could also just go ahead and (defmacro let ....) in any ns, and you’d get the warning:

WARNING: let already refers to: #'clojure.core/let in namespace: user, being replaced by: #'user/let

This is the same result you get when re-binding any var that is already referred in the current ns (common with clojure.core since it’s typically implicitly referring every public macro and function).

I often find it preferable to alias using the require options for ns, and have a bit of a trail of what I’m calling (as opposed to implicit refers). I think if you are overloading the meaning of let and any other core macros with your own stuff, it is helpful to call this out. There are some cases (e.g. with the primitive math lib) where convenience dictates a broad use of refers; it’s up to you though.

(ns obvious
  (:require [custom]))

(custom/let [x 2]

;;[:bindings [x 2]]

;;let still binds to clojure.core/let

(let [x 2]

do is more complicated because like let* it exists beneath the macro level. We can still define macros that work with:

(ns doing)

(defmacro do [& forms]
  (println forms)
  `(do ~@forms))

;;no warning about rebinding here, since do is not bound to clojure.core/do, the symbol has special meaning though

  (println ["hello"])
  (+ 2 3))

;; ((println [hello]) (+ 2 3))
;; [hello]
;; 5

;;do still refers to the special form though, so unless we explicitly invoke
;;the macro doing/do, we hit the special form do path.

(do (println ["hello"]) (+ 2 3))
;; [hello]
;; 5

Why are do and let special forms and not macros

It’s an implementation detail. For any lisp you need a set of proto forms, that is, some predefined building materials that deviate from the standard evaluation rules (maybe don’t eval the arguments, only eval if a condition is met, etc.). These things are common for mechanisms of control flow and the like, and they establish the base language to which all other forms can be reduced and passed for interpretation or compilation. Macroexpansion still has to eventually reduce to something in the base language expressed in special forms.



This is an interesting read about all this: ``Special Forms in Lisp'' by Kent Pitman (August, 1980)

Basically, function application is the “normal evaluation model”. You evaluate arguments left to right, and then feed them to the function the symbol refers too, doing so recursively so that function calls can be nested as both arguments or function symbol position.

I believe this is enough for lambda calculus, that means it is turning complete.

But certain things get very tricky very fast, especially loops, conditionals and symbolic computation.

In practice, nobody wants to encode loops, conditionals and all that in lambda calculus.

That’s why a practical programming language will want to add special forms, which we will first simply consider that a special form is any form that doesn’t follow function application normal evaluation semantics.

Those will let you do things more simply, such as conditionals, loops, local bindings, anonymous inline function definitions, symbolic computation, etc. They might even let you do things like evaluate things at compile time or at read time, and not always at function call time.

Now the question is, how do you implement those special forms? One way is to do so directly in the compiler, or directly in the runtime. These are going to be your “primitive special forms”, the language makes them available to you so that writing functions and writing your own custom “special forms” is already more convenient and practical.

Which primitive special forms the language makes available to you is up to each Lisp to decide. I think the only required special form is lambda, or a form that defines a lambda, and then you can technically as a user use that language to implement anything through lambda calculus, but no one would want to use such a language.

Anyways, so Clojure decided on a set of primitive special forms that the reader and compiler and runtime implement directly, that means the symbols aren’t resolved to anything defined in the language itself, they are special symbols that the reader, compiler and runtime look for directly.

Nowadays, people call macros such special forms that the user can implement using primitive special forms + normal functions offered by the language itself. But as my link shows you, there exist other mechanisms in some Lisps that can also provide the user ways to add custom special forms.

Anyways, in Clojure special forms tend to refer to primitive special forms, it seems let is an exception as it is a macro but also tagged as a special form in metadata, not sure why.

Bottom line, special forms are there to make using the language in its “bare form” more practical and convenient. It can also help a compiler, especially one that doesn’t actually compile to lambda calculus and then runs using a lambda calculus resolver machine. Translating from raw lambda calculus to a turing machine with x86 instruction set isn’t that simple, not sure it’s even doable. So it becomes much easier to have an actual conditional form that can be compiled to a conditional instruction set in the assembly, then to have to use lambda encoding and resolving for it.

That’s an important point, because I think some of Clojure’s special forms were chosen to make it easy or possible to compile to JVM bytecode and support interop and other such things.

Hope that helped.

1 Like