"opts" concept - map or vector with additional parameters as last function argument

Hello Clojurists,

In other languages there is a known concept of using “opts” map/vector as last (and optional) argument for functions that would have to have too many arguments without it. I looked at Clojure community style guide and it is mentioning opts but without any details about it.

I’m not sure what is the best description of opts so I’ll explain it by the following example:

(defn get-car-wheel [type opts]
  (if-let [number (:number-of-whells opts)]
    number
    (if (= type :truck) 6 4)))

(defn get-engine [opts]
  (if (:premium opts)
    {:type :fast :horse-power 100}
    {:type :standard :horse-power 80}))

(defn make-car [type opts]
  (println "Making" type "car")
  {:type type
   :wheels (get-car-wheel type opts)
   :engine (get-engine opts)})

(make-car :truck {})
;; => {:type :truck,
;;     :wheels 6
;;     :engine {:type :standard, :horse-power 80}}

(make-car :truck {:number-of-whells 8})
;; => {:type :truck
;;     :wheels 8
;;     :engine {:type :standard, :horse-power 80}}

(make-car :sport-car {:premium true})
;; => {:type :sport-car
;;     :wheels 4
;;     :engine {:type :fast, :horse-power 100}}

As you can see, the main function make-car doesn’t understand opts and doesn’t have to have a use for it. But other functions may understand some parameters in opts. And of course those other functions might be functions in function in function…

Could you please point me to some library/app that utilizes such concept so I don’t have to reinvent the wheel?

Thank you.

Adam

You can see the state of the art here:

https://clojure.org/news/2021/03/18/apis-serving-people-and-programs

1 Like

And as a shorthand, you can say:

(defn make-car [type & {:as opts}] ...)

That will treat any trailing named arguments as a hash map without needing specific destructuring. That would allow:

(make-car :truck)
(make-car :truck :number-of-wheels 8)
;; or, on Clojure 1.11 only:
(make-car :truck {:number-of-wheels 8})

Clojure 1.10 and earlier make you choose between an options hash map and named arguments. Clojure 1.11 (currently in Alpha, but we’re already using it in production) allows you to declare functions as if they take named arguments (above) but call them with either named arguments or a hash map.

In Clojure 1.10 and earlier, you could do this if you wanted to pass an options hash map:

(defn make-car [type & [opts]] ...)
;; and call it like this:
(make-car :truck) ; opts will be nil
(make-car :truck {:number-of-wheels 8}) ; pass opts explicitly

That would not accept named arguments though.

Thank you @Phill , with Sean’s example it make sense to me now.

And thank you very much @seancorfield for the examples. Suuuuuper useful. I did know about destructuring and about arbitrary number of arguments in function but I didn’t connect those two. And also I didn’t know & [opts] - that was new to me. Thx!

I took your example a bit further and decided to add default values for opts. First I tried

(defn make-car4 [type & {:or {number-of-wheels 4
                              color :black
                              tyres "Dunlop"}
                         :as opts}] ...

realizing that that’s not what I want.

:or specifies defaults for destructured values - usually used with keys like {:keys [x y] :or {x 1 y 2} :as opts} and that’s not what I’m after.

So I tried multi-arity:

(def car-default-opts
  {:number-of-wheels 4
   :color :black
   :tyres "Dunlop"})

;; map with default values
;;   a map with any number of keys replaces all default values
(defn make-car6
  ([type] (make-car6 type car-default-opts))
  ([type & {:as opts}]
   ... ))

and

;; map with default values
;;   a map with a single or more keys overrides default values with the same keys as specified
(defn make-car7
  ([type] (make-car7 type car-default-opts))
  ([type & {:as opts}]
   (let [[opts] (merge car-default-opts opts)]
     ... )))

Unfortunately now, as you mentioned, it doesn’t work (I’m testing that on 1.10). But it should work on 1.11 and that’s super exciting.

Thank you Sean.

I think what you have is sometimes what people recommend for < 1.11 versions of Clojure. And your question is why 1.11 added their feature.

So there’s no rule, but it can be a good idea to stick to an options map as the last argument, instead of using var-args.

Why it can be a good idea is mostly because a map argument lets the caller work with data more conveniently too, and functions can be easier to compose and thread that way.

Thank you @didibus . I’m not 100% sure you mean by that, I’m assuming you’re referring to the default values for opts that I created using multi-arity.

Thanks to @seancorfield I managed to install 1.11 alfa 1 so I tested those functions that I pasted yesterday and I realized that the second doesn’t have to be mulit-arity. I ended up with:

;; Clojure v1.11.0-alpha1

(def car-default-opts
  {:number-of-wheels 4
   :color :black
   :tyres "Dunlop"})

;;; map with default values
;;   a map with a single or more keys overrides default values
;;     with the same keys as specified
(defn make-car1 [type & {:as opts}]
   (let [opts (merge car-default-opts opts)]
     (str "type: " type ", opts (" (count opts) "): " opts)))

(make-car1 :truck)
;; => "type: :truck, opts (3): {:number-of-wheels 4, :color :black, :tyres \"Dunlop\"}"

(make-car1 :truck :number-of-wheels 8)
;; => "type: :truck, opts (3): {:number-of-wheels 8, :color :black, :tyres \"Dunlop\"}"

(make-car1 :truck :number-of-wheels 8 :color :red :tyres "Michelin")
;; => "type: :truck, opts (3): {:number-of-wheels 8, :color :red, :tyres \"Michelin\"}"

(make-car1 :truck {:number-of-wheels 8})
;; => "type: :truck, opts (3): {:number-of-wheels 8, :color :black, :tyres \"Dunlop\"}"

(make-car1 :truck {:number-of-wheels 8 :color :red :tyres "Michelin"})
;; => "type: :truck, opts (3): {:number-of-wheels 8, :color :red, :tyres \"Michelin\"}"

and in case I want any opts to completely replace the default values I can use:

;; Clojure v1.11.0-alpha1

(def car-default-opts
  {:number-of-wheels 4
   :color :black
   :tyres "Dunlop"})

;;; map with default values
;;   a map with any number of keys replaces
;;     all default values
(defn make-car2
  ([type] (make-car2 type car-default-opts))
  ([type & {:as opts}]
   (str "type: " type ", opts (" (count opts) "): " opts)))

(make-car2 :truck)
;; => "type: :truck, opts (3): {:number-of-wheels 4, :color :black, :tyres \"Dunlop\"}"

(make-car2 :truck :number-of-wheels 8)
;; => "type: :truck, opts (1): {:number-of-wheels 8}"

(make-car2 :truck :number-of-wheels 8 :color :red :tyres "Michelin")
;; => "type: :truck, opts (3): {:number-of-wheels 8, :color :red, :tyres \"Michelin\"}"

(make-car2 :truck {:number-of-wheels 8})
;; => "type: :truck, opts (1): {:number-of-wheels 8}"

(make-car2 :truck {:number-of-wheels 8 :color :red :tyres "Michelin"})
;; => "type: :truck, opts (3): {:number-of-wheels 8, :color :red, :tyres \"Michelin\"}"

but that is probably less useful.

I think I’ll mostly use the first option make-car1 that merges default values with opts which means that keys specified in opts override default values (but ONLY the specified keys). Seems like a good idea but I’m open to suggestions if you have a different opinion ;-). Thank you.