Andrey Listopadov

Function that can be called a limited amount of times

@programming clojure lisp ~3 minutes read

Recently I’ve stumped upon a Reddit thread about defining a function that you can call a limited amount of times in Rust, with compile-time check, and I wondered if I can make the same thing in Clojure.

Here’s the code:

(defmacro def-n-times-fn
  [n name & fnspec]
  `(let [x# (atom ~n)            ; A compile-time counter.
         f# (fn ~name ~@fnspec)] ; The function object, that will be called at runtime.
     (defmacro ~name [& args#]   ; Generation of a new macro with the name of the function
                                 ; that will manage the counter.
       (when-not (pos? (deref x#)) ; And if the counter isn't greater than zero
         (throw                    ; an exception is thrown
          (ex-info ~(format "can't use `%s` more than %s times!" (str name) n) {})))
       (swap! x# dec)      ; otherwise, we decrement the counter
       (list* f# args#)))) ; and generate a call

So, this is a macro-generating macro! I’ll go into details in a bit, but let’s just appreciate the power of macros here. Not only we can run all kinds of code at compile time, but we can also use all features that we often rely on at runtime, like lexical scope and closures! And even though I wouldn’t really use a such macro in real code, I find the ability to do such things fascinating.

We can use the macro like this:

(def-n-times-fn 4
  vaiv [x]
  (println x))

It acts similarly to defn macro in Clojure, except instead of a function it generates a macro, named vaiv in this case, that will be later expanded to an actual function call. For example, we can wrap calls to it in a function, ensuring that the actual calls are delayed, and only compilation of these calls occurs. I’ve put all of this into a file foo.clj:

(ns foo)
;; ---8<---snip---8<---
;; (defmacro defn-n-times-fn ...)
;; (def-n-times-fn 4 ...)
;; ---8<---snap---8<---
(defn _ []
  (vaiv 1)
  (vaiv 2)
  (vaiv 3)
  (vaiv 4)
  (vaiv 5))

Now if we try to compile it, we’ll get an error:

$ clojure -M -e "(compile 'foo)"
Syntax error macroexpanding vaiv at (foo.clj:25:3).
can't use `vaiv` more than 4 times!

Nice! And if we comment out one of the calls to vaiv the compilation is successful:

$ clojure -M -e "(compile 'foo)"

So what exactly happens here? If we macroexpand1 the call to defn-n-times-fn we’ll see that it just generates a new vaiv macro and a function:

(let* [x__5686__auto__ (atom 4)
       f__5687__auto__ (fn vaiv [x] (println x))]
  (defmacro vaiv [& args__5688__auto__]
    (when-not (pos? @x__5686__auto__)
      (throw (ex-info "can't use `vaiv` more than 4 times!" {})))
    (swap! x__5686__auto__ dec)
    (list* f__5687__auto__ args__5688__auto__)))

So far nothing related to compile time usage checking happened just yet. This changes whenever later uses of vaiv itself expand to calls:

foo> (macroexpand '(vaiv 42))
(#function[foo/eval5694/vaiv--5695] 42)

There’s no check in the generated code either, because the check happened during the expansion of the macro, and is invisible to the user. Every expansion of the vaiv macro decrements the counter, and once we’ve expanded the code enough times it will fire an error.

Pretty simple! (If you can look past the meta-meta-programming, that is.)

Though, this comes with limitations, such that this function can’t be passed into other functions, like map or filter. Both because this is not actually a function, but a macro that expands to function call, and because it’s impossible to know at compile time how many times map will invoke this function.

There’s a way to outsmart the check by using any kind of loop:

(dotimes [i 5]
  (vaiv i))

This works, because from the macro-expansion standpoint there’s only one invocation:

foo> (clojure.walk/macroexpand-all '(dotimes [i 5] (vaiv i)))
(let* [n__6088__auto__ (long 5)]
  (loop* [i 0]
    (if (< i n__6088__auto__)
      (do (#function[foo/eval13775/vaiv--13776] i) ; (vaiv i)
          (recur (unchecked-inc i))))))

Nevertheless, this was an interesting exercise in writing macros.

  1. I’ve slightly edited the output of macroexpand, mainly removing a lot of clojure.core/ prefixes for a more concise code. ↩︎