Code Observation: Function Arity in Clojure

Table of Contents
  1. Definitions
  2. Environment
  3. Observations
    1. Discovering a Function's Arity
    2. Nullary or 0-ary Functions
    3. Required Positional Arguments
      1. Default Values
      2. Narrowing Conceptual or Operational Scope
      3. Returning or Accepting Different Types of Values
      4. Language Performance Optimization
    4. Variable Arity
      1. Infinite Arity
      2. Options and Keyword Arguments
  4. Conclusions

Prologue: What's a code observation?

Definitions

A function's arity is the number of arguments it accepts when invoked.

Although every function in Clojure returns a single value (or raises an exception), Clojure supports a variety of parameter specifications for function inputs. Functions can accept (a) zero arguments, (b) one or more required arguments, (c) a variable number of optional arguments, (d) a mixture of required and optional arguments, (e) optional arguments as key-value pairs, and (f) combinations of these variants, i.e., a single function definition can specify multiple arities.

Although in a mathematical sense (and for some programming languages) a function cannot have multiple signatures, Clojure functions can and often do. The Java Virtual Machine (JVM) supports multiple method definitions in a single class with the same name as long as the method signatures differ sufficiently in the number and/or type of arguments. Since Clojure is a dynamically-typed language, Clojure functions (which compile to JVM classes with invocation methods) can be differentiated only by number of arguments and not types of arguments.

In this code observation, we'll explore Clojure's features and constraints for function arity and will try to tease out useful patterns so that our own functions compose ergonomically with those of core Clojure and the greater ecosystem.

Environment

  • Clojure
    • Command: (clojure-version)
    • Value: "1.11.1"
  • JDK
    • Command: java -version
    • Value: OpenJDK 64-Bit Server VM (build 11.0.12+7-Ubuntu-0ubuntu3, mixed mode, sharing)
  • System
    • Command: uname -rpvo
    • Value: 5.13.0-21-generic #21-Ubuntu SMP Tue Oct 19 08:59:28 UTC 2021 x86\_64 GNU/Linux

Observations

Discovering a Function's Arity

The arity of a function is stored in Clojure metadata by the defn macro:

;; Nothing for fn
(:arglists (meta (fn [a b c])))
#_=> nil

;; Metadata for defn
(:arglists (meta (defn my-fn [a b c])))
#_=> ([a b c])

Because :arglists is regular metadata, it can be provided manually or changed after initial definition:

;; Before `defn` is defined in `clojure.core`, Clojure does this:
(source cons)
;; [out] (def
;; [out]  ^{:arglists '([x seq])
;; [out]     :doc "Returns a new seq where x is the first element and seq is
;; [out]     the rest."
;; [out]    :added "1.0"
;; [out]    :static true}
;; [out] 
;; [out]  cons (fn* ^:static cons [x seq] (. clojure.lang.RT (cons x seq))))
#_=> nil

;; When using `def` and higher-order functions:
(source not-every?)
;; [out] (def
;; [out]  ^{:tag Boolean
;; [out]    :doc "Returns false if (pred x) is logical true for every x in
;; [out]   coll, else true."
;; [out]    :arglists '([pred coll])
;; [out]    :added "1.0"}
;; [out]  not-every? (comp not every?))
#_=> nil

;; To provide a better user-facing signature:
(println
  (let [s (source-fn 'defmulti)]
    (str (subs s 0 21) "..." (subs s 811 916) "\n..." (char 41))))
;; [out] (defmacro defmulti
;; [out]   ...
;; [out]   {:arglists '([name docstring? attr-map? dispatch-fn & options])
;; [out]    :added "1.0"}
;; [out]   [mm-name & options]
;; [out] ...)
#_=> nil

This metadata is descriptive, not prescriptive; take care to edit it correctly so that users are not misled.

The only definitive way to determine a function's arity is to call it. Calling a function with the wrong arity throws an exception, but notably that exception does not indicate any legal arities of the function in question:

;; I've often wanted assoc-in to work like assoc:
(try
  (assoc-in {} [:a :b] :c [:d :e] :f)
  (catch Exception e (.getMessage e)))
#_=> "Wrong number of args (5) passed to: clojure.core/assoc-in"

However, functions with variable arity extend the RestFn class and thus also have a getRequiredArity method which you can interrogate to discover the maximum number of required positional arguments the function expects:

;; Signature for clojure.core/+
(:arglists (meta #'+))
#_=> ([] [x] [x y] [x y & more])

;; Maximum number of required arguments for +
(.getRequiredArity +)
#_=> 2

;; Signature for clojure.core/assoc
(:arglists (meta #'assoc))
#_=> ([map key val] [map key val & kvs])

;; Maximum number of required arguments for assoc
(.getRequiredArity assoc)
#_=> 3

;; Non-variable-arity function...
(:arglists (meta #'assoc-in))
#_=> ([m [k & ks] v])

;; Does not support getRequiredArgs
(try
  (.getRequiredArity assoc-in)
  (catch IllegalArgumentException e (.getMessage e)))
#_=> "No matching field found: getRequiredArity for class clojure.core$assoc_in"

;; Which super classes or interfaces do variable arity fns have that others
;; don't?
(clojure.set/difference
  (supers (class @#'assoc))
  (supers (class @#'assoc-in)))
#_=> #{clojure.lang.RestFn}

Alternatively, we can inspect the JVM bytecode that Clojure emits when compiling functions:

;; Decompiled using clj-java-decompiler.core/decompile
(decompile
  (fn insert
    ([a]   [::insert a])
    ([a b] [::insert a b])))
;; [out] 
;; [out] // Decompiling class: dev$insert__60703
;; [out] import clojure.lang.*;
;; [out] 
;; [out] public final class dev$insert__60703 extends AFunction
;; [out] {
;; [out]     public static final Keyword const__0;
;; [out]     
;; [out]     public static Object invokeStatic(final Object a, final Object b) {
;; [out]         return Tuple.create(dev$insert__60703.const__0, a, b);
;; [out]     }
;; [out]     
;; [out]     @Override
;; [out]     public Object invoke(final Object a, final Object b) {
;; [out]         return invokeStatic(a, b);
;; [out]     }
;; [out]     
;; [out]     public static Object invokeStatic(final Object a) {
;; [out]         return Tuple.create(dev$insert__60703.const__0, a);
;; [out]     }
;; [out]     
;; [out]     @Override
;; [out]     public Object invoke(final Object a) {
;; [out]         return invokeStatic(a);
;; [out]     }
;; [out]     
;; [out]     static {
;; [out]         const__0 = RT.keyword("dev", "insert");
;; [out]     }
;; [out] }
#_=> nil

Finally, there are invocable things in Clojure that are not functions defined by def or defn, and which we're left to experiment with to understand their invocation signatures:

;; Keywords are invocable, pulling values from maps that have them as keys:
(:a {:a "alpha"})
#_=> "alpha"

;; Keywords also support a `get`-style default-if-not-found argument:
(:b {:a "alpha"} "NOT-FOUND")
#_=> "NOT-FOUND"

;; Maps are functions of their keys:
({:a "alpha"} :a)
#_=> "alpha"

;; Maps also support a default-if-not-found argument:
({:a "alpha"} :b "NOT-FOUND")
#_=> "NOT-FOUND"

;; Sets are functions of their members:
(#{:c :b :a} :a)
#_=> :a

;; Sets do NOT support a default-if-not-found argument:
(try (#{:c :b :a} :d :NOT-FOUND) (catch Exception e (.getMessage e)))
#_=> "Wrong number of args (2) passed to: clojure.lang.PersistentHashSet"

;; Vectors are functions of their indices:
([:a :b :c] 1)
#_=> :b

;; Vectors do NOT support a default-if-not-found argument:
(try ([:a :b :c] 1 :NOT-FOUND) (catch Exception e (.getMessage e)))
#_=> "Wrong number of args (2) passed to: clojure.lang.PersistentVector"

Nullary or 0-ary Functions

Clojure supports functions defined to accept zero arguments. Clojure itself ships with around 51 such functions:

Nullary core Clojure functions

Using Metazoa and clj-kondo:

;; Current Clojure version for this project:
@(def clj-version
  (get-in (edn/read-string (slurp "deps.edn")) [:deps 'org.clojure/clojure :mvn/version]))
#_=> "1.11.0-alpha3"

;; Local Clojure JAR file:
(def clj-jar-file
  (io/file (format "%s/.m2/repository/org/clojure/clojure/%s/clojure-%s.jar"
                   (System/getenv "HOME") clj-version clj-version)))
#_=> #'dev/clj-jar-file

;; Use clj-kondo to statically analyze the JAR contents:
(defonce clj-analysis
  (:analysis (kondo/run! {:config {:output {:analysis true}}
                          :lint [clj-jar-file]})))
#_=> nil

;; Set of all namespaces defined in org.clojure/clojure JAR:
@(def clj-namespaces
  ;; str and not ns-name to avoid requiring all of them
  (into #{}
        (map :name)
        (:namespace-definitions clj-analysis)))
#_=> #{clojure.java.shell
#_=>   clojure.datafy
#_=>   clojure.reflect
#_=>   clojure.java.javadoc
#_=>   clojure.zip
#_=>   clojure.xml
#_=>   clojure.core.server
#_=>   clojure.stacktrace
#_=>   clojure.inspector
#_=>   clojure.core.reducers
#_=>   clojure.test
#_=>   clojure.core.protocols
#_=>   clojure.java.browse
#_=>   clojure.core
#_=>   clojure.set
#_=>   clojure.main
#_=>   clojure.test.tap
#_=>   clojure.parallel
#_=>   clojure.pprint
#_=>   clojure.java.browse-ui
#_=>   clojure.uuid
#_=>   clojure.edn
#_=>   clojure.java.io
#_=>   clojure.instant
#_=>   clojure.java.math
#_=>   clojure.repl
#_=>   clojure.string
#_=>   clojure.data
#_=>   clojure.test.junit
#_=>   clojure.walk
#_=>   clojure.template}

;; Predicate to limit queries to core Clojure namespaces:
(defn core-package? [ns]
  (clj-namespaces (ns-name ns)))
#_=> #'dev/core-package?

;; All nullary functions in core Clojure:
(sort-by
  (juxt (comp ns-name :ns meta) (comp :name meta))
  (meta/query '[:find [?imeta ...]
                :in $ core-package? nullary? ns-class
                :where
                [?e :ns ?ns]
                [(core-package? ?ns)]
                [?e :arglists ?arglists]
                [(nullary? ?arglists)]
                (not [?e :macro])
                [?e :imeta/this ?imeta]]
              core-package?
              (fn nullary?
                [arglists]
                (some empty? arglists))
              clojure.lang.Namespace))
#_=> (#'clojure.core/*
#_=>  #'clojure.core/*'
#_=>  #'clojure.core/+
#_=>  #'clojure.core/+'
#_=>  #'clojure.core/all-ns
#_=>  #'clojure.core/array-map
#_=>  #'clojure.core/clojure-version
#_=>  #'clojure.core/comp
#_=>  #'clojure.core/concat
#_=>  #'clojure.core/conj
#_=>  #'clojure.core/conj!
#_=>  #'clojure.core/dedupe
#_=>  #'clojure.core/distinct
#_=>  #'clojure.core/flush
#_=>  #'clojure.core/gensym
#_=>  #'clojure.core/get-thread-bindings
#_=>  #'clojure.core/hash-map
#_=>  #'clojure.core/hash-set
#_=>  #'clojure.core/interleave
#_=>  #'clojure.core/into
#_=>  #'clojure.core/loaded-libs
#_=>  #'clojure.core/make-hierarchy
#_=>  #'clojure.core/newline
#_=>  #'clojure.core/pop-thread-bindings
#_=>  #'clojure.core/pr
#_=>  #'clojure.core/promise
#_=>  #'clojure.core/rand
#_=>  #'clojure.core/random-uuid
#_=>  #'clojure.core/range
#_=>  #'clojure.core/read
#_=>  #'clojure.core/read+string
#_=>  #'clojure.core/read-line
#_=>  #'clojure.core/release-pending-sends
#_=>  #'clojure.core/shutdown-agents
#_=>  #'clojure.core/str
#_=>  #'clojure.core/vector
#_=>  #'clojure.core.server/repl
#_=>  #'clojure.core.server/repl-init
#_=>  #'clojure.core.server/stop-server
#_=>  #'clojure.core.server/stop-servers
#_=>  #'clojure.edn/read
#_=>  #'clojure.main/repl-prompt
#_=>  #'clojure.pprint/fresh-line
#_=>  #'clojure.repl/pst
#_=>  #'clojure.repl/set-break-handler!
#_=>  #'clojure.repl/thread-stopper
#_=>  #'clojure.set/union
#_=>  #'clojure.stacktrace/e
#_=>  #'clojure.test/run-all-tests
#_=>  #'clojure.test/run-tests
#_=>  #'clojure.test/testing-contexts-str
#_=>  #'clojure.xml/sax-parser)

The following shows either the value returned from invoking these functions and/or an explanation of their side-effects:

(*) ; 1
(*') ; 1
(+) ; 0
(+') ; 0
(count (all-ns)) ; 278
(array-map) ; {}
(clojure-version) ; "1.11.0-alpha3"
(comp) ; #function[clojure.core/identity]
(concat) ; ()
(conj) ; []
(conj!) ; #object[clojure.lang.PersistentVector$TransientVector 0x4c429547 "clojure.lang.PersistentVector$TransientVector@4c429547"]
(dedupe) ; #function[clojure.core/dedupe/fn--8807]
(distinct) ; #function[clojure.core/distinct/fn--6442]
(flush) ; nil (side-effects)
(gensym) ; G__31812
(get-thread-bindings) ; map of vars to their bindings for current thread
(hash-map) ; {}
(hash-set) ; #{}
(interleave) ; ()
(into) ; []
(count (loaded-libs)) ; 275
(make-hierarchy) ; {:parents {}, :descendants {}, :ancestors {}}
(newline) ; nil (prints \newline)
(pop-thread-bindings) ; (pair with push-thread-bindings)
(pr) ; nil (side-effects)
(println) ; nil (prints \newline)
(promise) ; #<Promise@46486879: :not-delivered>
(rand) ; 0.9911368140031207
(random-uuid) ; #uuid "d656637f-9ebe-4777-999c-97c993485e03"
(range) ; infinite sequence of integers starting at 0
(read) ; (read next object from *in*)
(read+string) ; (read next object from *in*)
(read-line) ; (read up to next newline from *in*)
(release-pending-sends) ; 0
(shutdown-agents) ; (side-effects, usually done at system shutdown)
(str) ; ""
(vector) ; []
(clojure.core.server/repl) ; (starts REPL)
(clojure.core.server/repl-init) ; nil
(clojure.core.server/stop-server) ; (stops server in *session*)
(clojure.core.server/stop-servers) ; (stops all servers)
(clojure.edn/read) ; (read next object from *in*)
(clojure.main/repl-prompt) ; nil
(clojure.pprint/fresh-line) ; nil
(clojure.repl/pst) ; nil
(clojure.repl/set-break-handler!) ; (set thread stopper as INT handler)
(clojure.repl/thread-stopper) ; (unary function taking exception message when stopping current thread)
(clojure.set/union) ; #{}
(clojure.stacktrace/e) ; nil
(clojure.test/run-all-tests) ; (runs all tests on classpath)
(clojure.test/run-tests) ; (runs all tests in current namespace)
(clojure.test/testing-contexts-str) ; ""

Functions with an arity consisting entirely of optional arguments (specified with &) may support nullary invocation. If the above query is adjusted such that nullary? checks for (some (fn [arglist] (= '& (first arglist))) arglists) then we find the following core Clojure functions that have an arity consisting entirely of optional arguments. The following shows either the value returned from nullary invocation, a description of side-effects, or the exception thrown in cases where the arguments are not truly optional:

(array-map) ; {}
(await) ; nil
(bound?) ; true
(create-struct) ; (IllegalArgumentException)
(get-proxy-class) ; (NullPointerException)
(hash-map) ; {}
(hash-set) ; #{}
(list) ; ()
(load) ; nil
(merge) ; nil
(pcalls) ; ()
(pr-str) ; ""
(print) ; nil
(print-str) ; ""
(println) ; nil
(println-str) ; "\n"
(prn) ; nil
(prn-str) ; "\n"
(require) ; (Syntax error)
(sorted-map) ; {}
(sorted-set) ; #{}
(thread-bound?) ; true
(use) ; (Syntax error)
(clojure.core.server/io-prepl) ; (start prepl using *in* and *out*)
(clojure.java.shell/sh) ; (ClassCastException)
(clojure.main/main) ; (starts default REPL)
(clojure.main/repl) ; (starts default REPL)
(clojure.test/run-tests) ; (same as nullary, runs tests in current namespace)

In practice, functions that accept zero arguments prove useful for:

  • Definitional defaults
  • Producing or leveraging side effects
  • Reading data from the runtime environment
;; Provide definitional default values when using `reduce`
(reduce + (range 5))
#_=> 10

;; Provide definitional default values when using `apply`
(apply * [])
#_=> 1

;; Constructor for an empty hierarchy
(make-hierarchy)
#_=> {:parents {}, :descendants {}, :ancestors {}}

;; Side effects like flushing *out*
(flush)
#_=> nil

;; Stringified *clojure-version*
(clojure-version)
#_=> "1.11.1"

;; Random UUID (new in Clojure 1.11)
(random-uuid)
#_=> #uuid "565cdda7-0982-4a94-a0c4-409fe94acb94"

Clojure's repeatedly function invokes a nullary function repeatedly:

(let [[id1 id2 id3] (repeatedly random-uuid)]
  [id1 id2 id3])
#_=> [#uuid "2b3cb6d4-b7d3-4a8d-a221-5f73a27e115c"
#_=>  #uuid "4517bc01-55c4-4a0b-b44c-d47a760ef1a7"
#_=>  #uuid "8c9157cd-3f75-45b9-b518-485cb57d068c"]

Clojure's pcalls invokes zero or more nullary functions in parallel (via pmap):

;; Mock job state:
(def jobs (atom (range 24)))
#_=> #'dev/jobs

;; Function to process it:
(defn do-job []
  (Thread/sleep (rand-int 1000))
  (swap! jobs butlast))
#_=> #'dev/do-job

(apply pcalls (repeat 24 do-job))
#_=> ((0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18)
#_=>  (0 1 2 3 4 5)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21)
#_=>  (0 1 2 3 4 5 6 7)
#_=>  (0 1 2)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15)
#_=>  (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20)
#_=>  (0 1 2 3 4 5 6 7 8 9 10)
#_=>  (0 1 2 3 4 5 6)
#_=>  (0 1 2 3 4 5 6 7 8)
#_=>  (0 1 2 3 4 5 6 7 8 9)
#_=>  (0 1 2 3 4)
#_=>  (0)
#_=>  nil
#_=>  (0 1)
#_=>  (0 1 2 3))

For the sake of completeness, observe the following behavior for nullary functions that do not specify a return value:

;; Anonymous function, no return specified, defined via #()
(#())
#_=> ()

;; Anonymous function, no return specified, defined via fn
((fn []))
#_=> nil

;; Named function, no return specified, defined via defn
((defn nullary []))
#_=> nil

Finally, we should note that a function with an arity consisting only of optional arguments may be a nullary function (its signature supports this), but such functions may also raise an exception if their implementation requires at least one of the "optional" arguments:

;; Variable arity function that supports nullary invocation
(hash-set)
#_=> #{}

;; Variable arity function that raises an exception when invoked with no args
(try (create-struct) (catch IllegalArgumentException e (.getMessage e)))
#_=> "Must supply keys"

Required Positional Arguments

A Clojure function may define up to 201 required positional arguments:

;; Function with 20 required arguments
(defn twenty-arg [a b c d e f g h i j k l m n o p q r s t])
#_=> #'dev/twenty-arg

;; Function with 21 required arguments
(as-> '(defn twenty-one-arg [a b c d e f g h i j k l m n o p q r s t u]) $
  (try
    (eval $)
    (catch Exception e (.getMessage e)))
  (subs $ 0 29)
  (str $ "..."))
#_=> "Syntax error compiling fn* at..."

A Clojure function may support multiple arities, provided each arity differs unambiguously in its number of arguments. Core Clojure ships with around 165 functions that support more than one arity:

Core Clojure functions with multiple arities

Using Metazoa:

;; From clojure.pprint:
(defn print-table [aseq column-width]
  (binding [*out* (pprint/get-pretty-writer *out*)]
    (doseq [row aseq]
      (doseq [col row]
        (pprint/cl-format true "~4D~7,vT" col column-width))
      (prn))))
#_=> #'dev/print-table

;; Find all fns with (count arglists) greater than 1:
(as-> (sort-by
        (juxt (comp ns-name :ns meta) (comp :name meta))
        (meta/query '[:find [?imeta ...]
                      :in $ core-package? multiple-arities ns-class
                      :where
                      [?e :ns ?ns]
                      [(core-package? ?ns)]
                      [?e :arglists ?arglists]
                      [(multiple-arities ?arglists)]
                      (not [?e :macro])
                      [?e :imeta/this ?imeta]]
                    core-package?
                    (fn multiple-arities
                      [arglists]
                      (> (count arglists) 1))
                    clojure.lang.Namespace))
  $
  (partition-all 4 $)
  (print-table $ 39))
;; [out] #'clojure.core/*                              #'clojure.core/*'                      #'clojure.core/+                       #'clojure.core/+'                      
;; [out] #'clojure.core/-                              #'clojure.core/-'                      #'clojure.core//                       #'clojure.core/<                       
;; [out] #'clojure.core/<=                             #'clojure.core/=                       #'clojure.core/==                      #'clojure.core/>                       
;; [out] #'clojure.core/>=                             #'clojure.core/aget                    #'clojure.core/ancestors               #'clojure.core/apply                   
;; [out] #'clojure.core/array-map                      #'clojure.core/aset                    #'clojure.core/aset-boolean            #'clojure.core/aset-byte               
;; [out] #'clojure.core/aset-char                      #'clojure.core/aset-double             #'clojure.core/aset-float              #'clojure.core/aset-int                
;; [out] #'clojure.core/aset-long                      #'clojure.core/aset-short              #'clojure.core/assoc                   #'clojure.core/assoc!                  
;; [out] #'clojure.core/atom                           #'clojure.core/bit-and                 #'clojure.core/bit-and-not             #'clojure.core/bit-or                  
;; [out] #'clojure.core/bit-xor                        #'clojure.core/boolean-array           #'clojure.core/byte-array              #'clojure.core/char-array              
;; [out] #'clojure.core/comp                           #'clojure.core/completing              #'clojure.core/concat                  #'clojure.core/conj                    
;; [out] #'clojure.core/conj!                          #'clojure.core/dedupe                  #'clojure.core/deref                   #'clojure.core/derive                  
;; [out] #'clojure.core/descendants                    #'clojure.core/disj                    #'clojure.core/disj!                   #'clojure.core/dissoc                  
;; [out] #'clojure.core/dissoc!                        #'clojure.core/distinct                #'clojure.core/distinct?               #'clojure.core/doall                   
;; [out] #'clojure.core/dorun                          #'clojure.core/double-array            #'clojure.core/drop                    #'clojure.core/drop-last               
;; [out] #'clojure.core/drop-while                     #'clojure.core/every-pred              #'clojure.core/ex-info                 #'clojure.core/filter                  
;; [out] #'clojure.core/find-keyword                   #'clojure.core/float-array             #'clojure.core/fnil                    #'clojure.core/gensym                  
;; [out] #'clojure.core/get                            #'clojure.core/get-in                  #'clojure.core/halt-when               #'clojure.core/hash-map                
;; [out] #'clojure.core/hash-set                       #'clojure.core/int-array               #'clojure.core/interleave              #'clojure.core/intern                  
;; [out] #'clojure.core/interpose                      #'clojure.core/into                    #'clojure.core/into-array              #'clojure.core/isa?                    
;; [out] #'clojure.core/juxt                           #'clojure.core/keep                    #'clojure.core/keep-indexed            #'clojure.core/keyword                 
;; [out] #'clojure.core/list*                          #'clojure.core/long-array              #'clojure.core/make-array              #'clojure.core/map                     
;; [out] #'clojure.core/map-indexed                    #'clojure.core/mapcat                  #'clojure.core/mapv                    #'clojure.core/max                     
;; [out] #'clojure.core/max-key                        #'clojure.core/min                     #'clojure.core/min-key                 #'clojure.core/not=                    
;; [out] #'clojure.core/ns-resolve                     #'clojure.core/nth                     #'clojure.core/parents                 #'clojure.core/partial                 
;; [out] #'clojure.core/partition                      #'clojure.core/partition-all           #'clojure.core/partition-by            #'clojure.core/pmap                    
;; [out] #'clojure.core/pr                             #'clojure.core/rand                    #'clojure.core/random-sample           #'clojure.core/range                   
;; [out] #'clojure.core/re-find                        #'clojure.core/read                    #'clojure.core/read+string             #'clojure.core/read-string             
;; [out] #'clojure.core/reduce                         #'clojure.core/reductions              #'clojure.core/ref                     #'clojure.core/ref-max-history         
;; [out] #'clojure.core/ref-min-history                #'clojure.core/remove                  #'clojure.core/repeat                  #'clojure.core/repeatedly              
;; [out] #'clojure.core/replace                        #'clojure.core/resolve                 #'clojure.core/rsubseq                 #'clojure.core/seque                   
;; [out] #'clojure.core/sequence                       #'clojure.core/short-array             #'clojure.core/some-fn                 #'clojure.core/sort                    
;; [out] #'clojure.core/sort-by                        #'clojure.core/str                     #'clojure.core/subs                    #'clojure.core/subseq                  
;; [out] #'clojure.core/subvec                         #'clojure.core/swap!                   #'clojure.core/swap-vals!              #'clojure.core/symbol                  
;; [out] #'clojure.core/take                           #'clojure.core/take-nth                #'clojure.core/take-while              #'clojure.core/trampoline              
;; [out] #'clojure.core/transduce                      #'clojure.core/underive                #'clojure.core/update                  #'clojure.core/vector                  
;; [out] #'clojure.core/vector-of                      #'clojure.core.protocols/coll-reduce   #'clojure.core.server/stop-server      #'clojure.edn/read                     
;; [out] #'clojure.edn/read-string                     #'clojure.java.io/file                 #'clojure.java.io/resource             #'clojure.pprint/pprint                
;; [out] #'clojure.pprint/print-table                  #'clojure.repl/pst                     #'clojure.repl/set-break-handler!      #'clojure.repl/thread-stopper          
;; [out] #'clojure.set/difference                      #'clojure.set/intersection             #'clojure.set/join                     #'clojure.set/union                    
;; [out] #'clojure.stacktrace/print-cause-trace        #'clojure.stacktrace/print-stack-trace #'clojure.string/index-of              #'clojure.string/join                  
;; [out] #'clojure.string/last-index-of                #'clojure.string/split                 #'clojure.test/run-all-tests           #'clojure.test/run-tests               
;; [out] #'clojure.xml/parse                           
#_=> nil

Due to the 20-argument limit mentioned above, a Clojure function may define as many as 21 signatures total—20 with required arguments, and a 21st with a variable number of arguments at the end:

;; Don't do this.
(defn please-dont
  ([a])                                 ([a b])                                 ([a b c])                                 ([a b c d])
  ([a b c d e])                         ([a b c d e f])                         ([a b c d e f g])                         ([a b c d e f g h])
  ([a b c d e f g h i])                 ([a b c d e f g h i j])                 ([a b c d e f g h i j k])                 ([a b c d e f g h i j k l])
  ([a b c d e f g h i j k l m])         ([a b c d e f g h i j k l m n])         ([a b c d e f g h i j k l m n o])         ([a b c d e f g h i j k l m n o p])
  ([a b c d e f g h i j k l m n o p q]) ([a b c d e f g h i j k l m n o p q r]) ([a b c d e f g h i j k l m n o p q r s]) ([a b c d e f g h i j k l m n o p q r s t])
  ([a b c d e f g h i j k l m n o p q r s t & args]))
#_=> #'dev/please-dont

Common reasons for defining multiple arities of a single conceptual function include:

  • Providing default values for unspecified arguments
  • Narrowing the scope of a function's operation
  • Returning a value of a different type
  • Optimizing for language runtime performance

We will study each of these in some depth, because their nuances are hallmarks of the careful design of Clojure as a language.

Default Values

Syntactically, positional arguments cannot be given default values in a Clojure function's signature. To establish default values for required positional arguments, we define arities that re-invoke the function with default values in the desired positions. We are free to write these arities in whatever order is most natural, although for the vast majority of cases the shorter function signatures are written before the longer ones.

Providing default values is the most common reason for defining multiple arities for a function, but upon further inspection there are many different categories of defaults worth exploring.

Definitional Defaults: Some functions have definitional defaults from their respective domains. Clojure's arithmetic and some higher-order functions are prime examples:

;; 0-ary Identity for +
(+)
#_=> 0

;; 1-ary Identity for +
(+ 3)
#_=> 3

;; 0-ary Identity for *
(*)
#_=> 1

;; 1-ary Identity for *
(* 3)
#_=> 3

;; Identity for * supporting zero-or-more args at runtime
(apply * [])
#_=> 1

;; Comparisons are true by default
(= true (= 3) (< 3) (<= 3) (> 3) (>= 3))
#_=> true

;; Identity for function composition
(comp)
#_=> #object[clojure.core$identity 0xce1c86 "clojure.core$identity@ce1c86"]

;; Partially-applied function with no args to apply is the same function
(= @#'+ (partial +))
#_=> true

These definitional defaults support dynamic Clojure programs where the number of arguments to these functions is known only at runtime. Clojure programmers are not on the hook for remembering to (apply + 0 some-args) or (apply * 1 some-args).

The following functions all provide what I would term definitional defaults:

  • *
  • *'
  • +
  • +'
  • -
  • -'
  • /
  • <
  • <=
  • =
  • ==
  • >
  • >=
  • comp
  • completing
  • concat
  • conj
  • conj!
  • disj
  • disj!
  • dissoc
  • dissoc!
  • distinct?
  • interleave
  • into
  • juxt
  • max
  • max-key
  • min
  • min-key
  • not=
  • partial
  • range
  • str

Default to First of a Collection: Some functions provide arities of fewer arguments that will use the first item in a collection as a default value, but also feature an arity where that default can be provided explicitly.

Consider these functions:

  • into-array when invoked with two arguments treats the first as the type of the constructed array. If only the collection argument is provided, if empty the array is considered an Object array, if not empty the type of the first argument is used to determine the array's type.
  • reduce when invoked with two arguments, will apply the reducing function to the first two items of the collection to establish an accumulator. When invoked with three arguments, the second is the starting accumulator for the reduction.
  • reductions treats its arguments like reduce.
  • transduce works like reduce except it has an additional xform transducer argument that always comes first.

Sharing State: The functions that operate on Clojure hierarchies are the main example from Clojure's code base of this pattern:

  • ancestors
  • derive
  • descendants
  • isa?
  • parents
  • underive

If provided, each of these functions works on a hierarchy passed as the first argument. If omitted, the default clojure.core/global-hierarchy object is used.

Note that this means these are not referentially transparent functions if you do not specify the hierarchy as an argument, and derive and underive alter the global-hierarchy var itself. This is intuitive if you are using these functions at the top level of a namespace to set up program-level invariants about your domain in your source code, but can be surprising if you're manipulating the global hierarchy dynamically at runtime.

User-provided Defaults: Functions which locate and return items from collections often have an arity that allows the function caller to bypass the normal behavior when desired items are missing. Normal behavior for such functions is either returning nil or raising an exception, but nil can be ambiguous (is nil a legal return value for the item you're looking for?) and not finding an item isn't always exceptional:

;; nil when not found
(= nil
   (get {:a "A"} :z)
   ({:a "A"} :z)
   (:z {:a "A"}))
#_=> true

;; user-supplied default
(= "NOT-FOUND"
   (get {:a "A"} :z "NOT-FOUND")
   ({:a "A"} :z "NOT-FOUND")
   (:z {:a "A"} "NOT-FOUND"))
#_=> true

;; nth, exception when not found
(try
  (nth [:a] 1)
  (catch IndexOutOfBoundsException _ "index out of bounds"))
#_=> "index out of bounds"

;; nth, user-supplied default
(nth [:a] 1 "NOT-FOUND")
#_=> "NOT-FOUND"

Other functions that allow for user-provided overrides to defaults include:

  • intern which associates a var with a namespace and allows for an optional root binding value (the var is considered unbound if no value is provided).
  • gensym which supports a user-provided prefix string to be incorporated into the generated symbol (defaults to G__).

Narrowing Conceptual or Operational Scope

Some functions have a conceptual or operational scope that is large or even boundless. These functions often define additional arities to narrow or refine this scope.

Conceptual Scope: Consider the following functions:

  • ex-info
  • keyword
  • symbol

Each of these returns a value that has optional additional data that can be associated with it that narrows the conceptual scope of the return value:

  • ex-info accepts a third argument specifying the exception that was the cause of this one. If none provided, there is no default, so this is treated as a "root cause" exception.
  • keyword when invoked with two arguments sets the namespace of the keyword. If not provided, there is no default namespace, its namespace is nil and thus in an implicit global namespace.
  • symbol behaves like keyword.

The find-keyword, ns-resolve, and resolve functions straddle the boundary between conceptual and operational narrowing.

Operational Scope: Functions that work with lazy sequences can return sequences that are theoretically infinite in length. Functions that produce lazy sequences often provide an arity to limit the length of those sequences:

  • range produces an infinite lazy sequence from a starting value (defaulting to 0) increasing by 1, unless you provide an end value (exclusive).
  • repeat produces an infinite lazy sequence unless you supply a number of items for it to produce.
  • repeatedly invokes a function repeatedly, returning an infinite lazy sequence of its return values unless you supply a number of values for it to produce.

Other functions also support longer arities whose implementations further restrict the range of the function's return value:

  • Array constructors boolean-array byte-array char-array double-array float-array int-array long-array and short-array allow building an array from an existing sequence, or by specifying the size of the array followed by an initialization value or sequence to prepopulate the array with.
  • partition has arities [n coll] [n step coll] [n step pad coll] where the default step if not provided is the same as n, and where no padding of the final partition is performed unless pad is provided.
  • rand returns a value between 0 and 1, multiplied by n if provided.
  • subs returns the entire remainder of the string provided unless an end index is supplied. subvec behaves the same but works on vectors.

Returning or Accepting Different Types of Values

The most important category of functions that define multiple arities for the purposes of returning different types of values in core Clojure are the sequence API functions. These act on concrete collections with their full arities, but if lacking a collection argument return a special type of function called a transducer.

The following core functions exhibit this:

  • dedupe
  • distinct
  • drop
  • drop-while
  • filter
  • interpose
  • into
  • keep
  • keep-indexed
  • map
  • mapv
  • map-indexed
  • mapcat
  • partition-all
  • partition-by
  • random-sample
  • remove
  • replace
  • sequence
  • take
  • take-nth
  • take-while

The functions returned by these functions are themselves higher order functions that adhere to a specific function signature contract, so that they can be composed with each other and leveraged by functions like transduce, sequence, and into that apply transducers to concrete sequential data at runtime.

Though more rare, functions can use different arities to support accepting completely different argument types. The re-find function accepts either a single Matcher object, or when passed two arguments expects them to be a regular expression and a string to find a (partial) match within.

Language Performance Optimization

Many functions that have an arity that is variable do so after defining a few arities that have only required positional arguments, but which technically could have been encoded with just one variable arity. For example:

;; Q: Why wouldn't we just use a signature of [& args] ?
(source every-pred)
;; [out] (defn every-pred
;; [out]   "Takes a set of predicates and returns a function f that returns true if all of its
;; [out]   composing predicates return a logical true value against all of its arguments, else it returns
;; [out]   false. Note that f is short-circuiting in that it will stop execution on the first
;; [out]   argument that triggers a logical false result against the original predicates."
;; [out]   {:added "1.3"}
;; [out]   ([p]
;; [out]      (fn ep1
;; [out]        ([] true)
;; [out]        ([x] (boolean (p x)))
;; [out]        ([x y] (boolean (and (p x) (p y))))
;; [out]        ([x y z] (boolean (and (p x) (p y) (p z))))
;; [out]        ([x y z & args] (boolean (and (ep1 x y z)
;; [out]                                      (every? p args))))))
;; [out]   ([p1 p2]
;; [out]      (fn ep2
;; [out]        ([] true)
;; [out]        ([x] (boolean (and (p1 x) (p2 x))))
;; [out]        ([x y] (boolean (and (p1 x) (p1 y) (p2 x) (p2 y))))
;; [out]        ([x y z] (boolean (and (p1 x) (p1 y) (p1 z) (p2 x) (p2 y) (p2 z))))
;; [out]        ([x y z & args] (boolean (and (ep2 x y z)
;; [out]                                      (every? #(and (p1 %) (p2 %)) args))))))
;; [out]   ([p1 p2 p3]
;; [out]      (fn ep3
;; [out]        ([] true)
;; [out]        ([x] (boolean (and (p1 x) (p2 x) (p3 x))))
;; [out]        ([x y] (boolean (and (p1 x) (p1 y) (p2 x) (p2 y) (p3 x) (p3 y))))
;; [out]        ([x y z] (boolean (and (p1 x) (p1 y) (p1 z) (p2 x) (p2 y) (p2 z) (p3 x) (p3 y) (p3 z))))
;; [out]        ([x y z & args] (boolean (and (ep3 x y z)
;; [out]                                      (every? #(and (p1 %) (p2 %) (p3 %)) args))))))
;; [out]   ([p1 p2 p3 & ps]
;; [out]      (let [ps (list* p1 p2 p3 ps)]
;; [out]        (fn epn
;; [out]          ([] true)
;; [out]          ([x] (every? #(% x) ps))
;; [out]          ([x y] (every? #(and (% x) (% y)) ps))
;; [out]          ([x y z] (every? #(and (% x) (% y) (% z)) ps))
;; [out]          ([x y z & args] (boolean (and (epn x y z)
;; [out]                                        (every? #(every? % args) ps))))))))
#_=> nil

Why all of this repetition?

When variable arity is specified, Clojure has to produce a sequence of the arguments to bind to the symbol after the &. Normal positional function parameters, however, do not necessitate this intermediate work; all of the arguments are passed directly to the underlying invoke method. So function authors can ensure better invocation performance2 of functions with variable arity if they supply the first few arities by hand with required positional parameters in their Clojure source, followed by a final arity that is variable.

The following functions fall into this category:

  • apply
  • every-pred
  • fnil
  • juxt
  • list*
  • map
  • mapv
  • partial
  • some-fn
  • swap!
  • swap-vals!
  • trampoline
  • vector
  • vector-of

This leads us into a review of variable arity functions, which will conclude our analysis of Clojure function arity.

Variable Arity

A function may accept a variable number of arguments by including in its signature the & symbol followed by another symbol to which will be bound a sequence of the arguments provided. If map destructuring is used after the & symbol, the function will accept either an inline sequence treated as key, value, key, value, etc., or it will accept a concrete map.3

;; Vararg treated as sequence
((fn [& args] args) 1 2 3)
#_=> (1 2 3)

;; Vararg treated as map
((fn [& {:keys [a b], :or {b 42}, :as opts}] [a b opts]) :a 1)
#_=> [1 42 {:a 1}]

;; Vararg treated as map and passed as map (new in 1.11)
((fn [& {:keys [a b], :or {b 42}, :as opts}] [a b opts]) {:a 1})
#_=> [1 42 {:a 1}]

;; How do I use `apply` with sequential variable arity?
(letfn [(f [& args] args)]
  (apply f 0 [1 2 3]))
#_=> (0 1 2 3)

;; How do I use `apply` with map-based variable arity?
(letfn [(f [& {:keys [a b] :or {b 42} :as opts}] [a b opts])]
  (apply f :a 8 {:b 9}))
#_=> [8 9 {:a 8, :b 9}]

;; What is the type of `args` for sequential variable arity?
((fn [& args] (type args)) 1 2 3)
#_=> clojure.lang.ArraySeq

;; What is the type of `args` for map-based variable arity?
((fn [& {:keys [a b], :or {b 42}, :as opts}] (type opts)) :a 1)
#_=> clojure.lang.PersistentArrayMap

;; What is the type if no arguments are passed in?
((fn [& args] (type args)))
#_=> nil

There are over 130 functions in core Clojure that leverage variable arity:

Core Clojure functions with variable arity
;; Find all fns with variable arity:
(as-> (sort-by
        (juxt (comp ns-name :ns meta) (comp :name meta))
        (meta/query '[:find [?imeta ...]
                      :in $ core-package? var-args? ns-class
                      :where
                      [?e :ns ?ns]
                      [(core-package? ?ns)]
                      [?e :arglists ?arglists]
                      [(var-args? ?arglists)]
                      (not [?e :macro])
                      [?e :imeta/this ?imeta]]
                    core-package?
                    (fn var-args?
                      [arglists]
                      (some #(some #{'&} %) arglists))
                    clojure.lang.Namespace))
  $
  (partition-all 4 $)
  (print-table $ 39))
;; [out] #'clojure.core/*                              #'clojure.core/*'                      #'clojure.core/+                       #'clojure.core/+'                      
;; [out] #'clojure.core/-                              #'clojure.core/-'                      #'clojure.core//                       #'clojure.core/<                       
;; [out] #'clojure.core/<=                             #'clojure.core/=                       #'clojure.core/==                      #'clojure.core/>                       
;; [out] #'clojure.core/>=                             #'clojure.core/agent                   #'clojure.core/aget                    #'clojure.core/alter                   
;; [out] #'clojure.core/alter-meta!                    #'clojure.core/alter-var-root          #'clojure.core/apply                   #'clojure.core/array-map               
;; [out] #'clojure.core/aset                           #'clojure.core/aset-boolean            #'clojure.core/aset-byte               #'clojure.core/aset-char               
;; [out] #'clojure.core/aset-double                    #'clojure.core/aset-float              #'clojure.core/aset-int                #'clojure.core/aset-long               
;; [out] #'clojure.core/aset-short                     #'clojure.core/assoc                   #'clojure.core/assoc!                  #'clojure.core/atom                    
;; [out] #'clojure.core/await                          #'clojure.core/await-for               #'clojure.core/bit-and                 #'clojure.core/bit-and-not             
;; [out] #'clojure.core/bit-or                         #'clojure.core/bit-xor                 #'clojure.core/bound?                  #'clojure.core/commute                 
;; [out] #'clojure.core/comp                           #'clojure.core/concat                  #'clojure.core/conj                    #'clojure.core/construct-proxy         
;; [out] #'clojure.core/create-struct                  #'clojure.core/disj                    #'clojure.core/disj!                   #'clojure.core/dissoc                  
;; [out] #'clojure.core/dissoc!                        #'clojure.core/distinct?               #'clojure.core/every-pred              #'clojure.core/extend                  
;; [out] #'clojure.core/format                         #'clojure.core/get-proxy-class         #'clojure.core/hash-map                #'clojure.core/hash-set                
;; [out] #'clojure.core/interleave                     #'clojure.core/iteration               #'clojure.core/juxt                    #'clojure.core/list                    
;; [out] #'clojure.core/list*                          #'clojure.core/load                    #'clojure.core/make-array              #'clojure.core/map                     
;; [out] #'clojure.core/mapcat                         #'clojure.core/mapv                    #'clojure.core/max                     #'clojure.core/max-key                 
;; [out] #'clojure.core/merge                          #'clojure.core/merge-with              #'clojure.core/min                     #'clojure.core/min-key                 
;; [out] #'clojure.core/not=                           #'clojure.core/partial                 #'clojure.core/pcalls                  #'clojure.core/pmap                    
;; [out] #'clojure.core/pr                             #'clojure.core/pr-str                  #'clojure.core/print                   #'clojure.core/print-str               
;; [out] #'clojure.core/printf                         #'clojure.core/println                 #'clojure.core/println-str             #'clojure.core/prn                     
;; [out] #'clojure.core/prn-str                        #'clojure.core/ref                     #'clojure.core/refer                   #'clojure.core/require                 
;; [out] #'clojure.core/restart-agent                  #'clojure.core/send                    #'clojure.core/send-off                #'clojure.core/send-via                
;; [out] #'clojure.core/sequence                       #'clojure.core/slurp                   #'clojure.core/some-fn                 #'clojure.core/sorted-map              
;; [out] #'clojure.core/sorted-map-by                  #'clojure.core/sorted-set              #'clojure.core/sorted-set-by           #'clojure.core/spit                    
;; [out] #'clojure.core/str                            #'clojure.core/struct                  #'clojure.core/struct-map              #'clojure.core/swap!                   
;; [out] #'clojure.core/swap-vals!                     #'clojure.core/thread-bound?           #'clojure.core/trampoline              #'clojure.core/update                  
;; [out] #'clojure.core/update-in                      #'clojure.core/use                     #'clojure.core/vary-meta               #'clojure.core/vector                  
;; [out] #'clojure.core/vector-of                      #'clojure.core/with-bindings*          #'clojure.core.server/io-prepl         #'clojure.core.server/prepl            
;; [out] #'clojure.core.server/remote-prepl            #'clojure.java.io/copy                 #'clojure.java.io/delete-file          #'clojure.java.io/file                 
;; [out] #'clojure.java.io/input-stream                #'clojure.java.io/make-parents         #'clojure.java.io/output-stream        #'clojure.java.io/reader               
;; [out] #'clojure.java.io/writer                      #'clojure.java.shell/sh                #'clojure.main/main                    #'clojure.main/repl                    
;; [out] #'clojure.main/report-error                   #'clojure.pprint/cl-format             #'clojure.pprint/write                 #'clojure.set/difference               
;; [out] #'clojure.set/intersection                    #'clojure.set/union                    #'clojure.test/run-tests               #'clojure.zip/edit                     
#_=> nil

Variable arity functions are useful but not strictly necessary. One could specify a required positional parameter that is a sequence of values, rather than relying on variable arity.

In practice, variable arity provides an ergonomic way to accomplish the following:

  • Accept an unlimited number of arguments reified as a collection, even if the normal use-case for a function involves only one or two arguments.
  • Accept an open-ended number of optional arguments, often interposed with keywords to support keyword arguments that are reified as a map.

Infinite Arity

Functions that act as constructors often accept an unlimited number of items for populating the data structure they produce:

  • array-map
  • create-struct which returns a "structure basis object" to be used with struct and struct-map for creating an instance of a structmap.4 This function supports a variable number of keys as the basis.
  • hash-map
  • hash-set
  • list
  • list* which behaves like list except it treats its last argument as a sequence to which are appended the preceding arguments.
  • make-array which uses variable arity to support N dimensions for a multi-dimensional array.
  • struct which expects initialization values in the order declared by create-struct, like ->-prefixed record constructor functions.
  • struct-map which expects key-value pairs, like map->-prefixed record constructor functions.
  • vector
  • vector-of

Many core functions support variable arity to make the transition from "do it once" to "do it many times" both syntactically convenient (just add more arguments to the same call site) and more performant (every function invocation involves runtime cost, so better to have one invocation with many arguments than many invocations, each with a small number of arguments). The functions that follow, though they cover a wide range of the core Clojure API, fall into this category:

  • aget
  • aset aset-boolean aset-byte aset-char aset-double aset-float aset-int aset-long aset-short
  • assoc and assoc!, but notably not assoc-in.
  • await await-for
  • bit-and bit-and-not bit-or bit-xor
  • bound?
  • concat
  • conj conj!, providing an alternative to using concat if items are known statically.
  • disj disj!
  • dissoc dissoc!
  • distinct?, but be careful to remember that, unlike distinct, this function expects separate arguments and not a collection of items.
  • extend
  • format printf which accept a variable number of arguments, as many as there are format patterns in the format string.
  • get-proxy-class
  • interleave
  • load
  • map mapcat mapv pmap and sequence, where the function you map with needs to accept as many arguments as the number of collections you pass in.
  • max max-key
  • merge merge-with
  • min min-key
  • not=
  • pcalls
  • pr pr-str print print-str println println-str prn prn-str which print a space between each of the arguments provided.
  • require use
  • str
  • thread-bound?

The format function is unique, in that there's a notion of delegation of "function invocation", if we think about the format string as a function of its format patterns. In this vein, there are a number of Clojure functions that act as special higher-order function callers and use variable arity so as to support functions of any arity that are passed in:

  • alter
  • alter-meta!
  • alter-var-root
  • commute
  • construct-proxy though strictly this is invoking a constructor method.
  • send
  • send-off
  • send-via
  • swap!
  • swap-vals!
  • update
  • update-in
  • trampoline
  • vary-meta
  • with-bindings*

Options and Keyword Arguments

Function signatures involving multiple arities and arities with a large number of arguments are difficult to remember how to invoke. If a function can accept 1, 2, or 3 arguments, it will often be written using multiple arities with positional required arguments, to create the effect of "optional arguments" as described earlier in this article.

However, from my experience in Clojure code bases, functions with arities that sport 4 or more arguments are often refactored to accept only 0, 1, or 2 positional required arguments and the remainder as entries in a map. If that map is a system map (or sub-selection thereof), it will often be the first argument to a function and be named context, state, system, or similar.

If that map represents optional parameters to the function, it will often be the last argument or it will be specified using map-based variable arity (which also necessarily comes at the end of the function signature). These are often referred to as "keyword arguments" or "kwargs" as written in many function signatures. Since Clojure 1.11 supports passing in either a variable number of key-value pairs or a single map to functions that use map-based variable arity, using variable arity simply gives you more flexibility in how your function can be called.5

Destructuring in function signatures is functionally equivalent to destructuring available in forms like let, so you can leverage that for variable arity functions to bind important values to symbols and provide default values in the function signature. See my article on destructuring for a deeper dive.

Conclusions

As in human languages that encourage ambiguity for word definitions, Clojure' support of various arity configurations and multiple arities per function allows us to have fewer function names in our programs, but those names bear multiple meanings, are callable in multiple ways, and can return values of different types depending on how they're called. A diligent study of the patterns and categories of function arity described in this code observation will help you leverage this flexibility in ways familiar to other Clojure developers, making your functions more coherent with Clojure's design and users' expectations.


  1. This is hard-coded in Clojure's compiler. Use & to specify a variable number of arguments if your function (for whatever likely-insane reason) needs to accept more than 20 positional argumnts.

  2. David Chelimsky has published a GitHub gist with relevant microbenchmarks.

  3. This is true as of Clojure version 1.11. Before that release, only inline values treated as key-value pairs were accepted. Try java -cp $HOME/.m2/repository/org/clojure/clojure/1.10.1/clojure-1.10.1.jar:$HOME/.m2/repository/org/clojure/spec.alpha/0.2.176/spec.alpha-0.2.176.jar:$HOME/.m2/repository/org/clojure/core.specs.alpha/0.2.44/core.specs.alpha-0.2.44.jar clojure.main or equivalent for a non-1.11 version of Clojure, and then compare ((fn [& {:keys [a b] :or {b 42} :as opts}] [a b opts]) :a 1) which works, with ((fn [& {:keys [a b] :or {b 42} :as opts}] [a b opts]) {:a 1}) which does not.

  4. As a general rule, you should use defrecord along with the -> and map-> prefixed record constructor functions, rather than create-struct, struct, and struct-map. Structmaps preceded the introduction of records and protocols, and records supersede them both functionally and in their synergy with the rest of Clojure's design.

  5. Whereas before Clojure 1.11, map-based variable arity required (mapcat identity <your-hash-map>) gymnastics to use apply with it: (apply (fn [& {:keys [a b] :or {b 42} :as opts}] [a b opts]) (mapcat identity {:a 1 :b 2}))


Tags: variable-arity clojure code-observation function-composition function function-signature arity

Copyright © 2024 Daniel Gregoire