Two sugars with your Contracts for Clojure Maps
Introduction
One of the Clojure projects I’ve been working on uses multi-level (hierarchical) maps i.e. the values of the keys of a map at one level are often maps themselves.
A common enough use case and as Fogus and Chris Houser observe in their book The Joy of Clojure (section 5.6):
It’s difficult to write a program of any significant size without the need for a map of some sort.
The use of maps is ubiquitous in writing software because frankly it’s difficult to imagine a more robust data structure.
Maps have been around quite a while. In their book Introduction To Algorithms Cormen et al say Donald Knuth attributed the invention of maps to Hans Peter Luhn in early 1953.
Using maps is very natural and easy in Clojure and indeed many other languages that support them. Clojure takes Alan Perlis’s famous quote to heart by providing a rich set of functions to manage maps:
It’s better to have 100 functions operate on one data structure than 10 functions on 10 data structures.
But using deep (multi-level) maps with many levels and many keys with long-ish descriptive names can make the code look very cluttered and prone to typos, misremembered key names, juxtaposition of levels, and similar, often silent, errors as e.g. get and get-in will return a value (nil) rather than raising an error, and assoc and assoc-in wont care at all.
Its very easy in Clojure to write function(s) to encapsulate the accesses to a key’s value, especially for multi-level keys, creating “helper” putter and getter accessors.
Arguably, maybe a stretch, you could think of these accessors as _higher order_ functions where the (implicit) input functions are e.g. get or assoc.
For example, and as usual a contrived example, where a deep multi-level map holds all the details of a house:
1
2
3
(defn change-kitchen-temperature
[house-state new-temperature]
(assoc-in house-state [:rooms :kitchen :properties :temperature] new-temperature))
1
2
3
(defn install-kitchen-oven
[house-state new-oven]
(assoc-in house-state [:rooms :kitchen :appliances :cooking :oven] new-oven))
Which is nice (at least to me): semantically named accessors hiding the details of the key hierarchy. If the hierarchy needs to change for any reason, only the accessor functions have to be changed. A familiar enough paradigm from many languages.
The other thing I’ve wanted to do has been to apply contracts to the leaf values in the map. You could of course just use an assert to apply a contract to a value in “open code”. But that means the contract definition (i.e. assertion clause) is scattered over the code base, hard to change, inconsistency may creep in, etc.
Fogus’s clojure.core.contracts library uses Clojure’s :pre and post assertions to apply contracts to a function’s arguments and/or its result (return value).
In my recent sugar post on contracts, I demonstrated a new library I’ve written clojure-contracts-sugar to add some productivity macros (“sugar”) atop Fogus’s library.
In a nice bit of synergy, if you use putter and getter accessors for a map’s leaf values, you can apply also contracts to them.
Enter my new library: clojure-contracts-maps
A quick summary of clojure-contracts-maps
Apart from the ability to apply rich contracts to the leaf values, the new library support some other features.
One feature useful to have is the opportunity to transform map a key’s value. In the case of a getter, the mapper is applied before the value is returned effectively returning a view of the value. In the case of a putter, the mapper can e.g. normalise the value in some way before the map is “updated”.
Its also useful, in a getter, to support static and (per call) dynamic defaults for the key’s value.
You may also want to monitor a key’s access, especially when it changes, and have a unique error message - a telltale - when a value fails a contract.
A few notes on contracts and constraints
A contract is made up of one or more constraints.
Each contract can have constraints of two types: suck constraints applied to a function’s arguments and spit constraints applied to the function’s result
Putters have a suck contract for the key’s new value i.e the new value is an argument to the putter.
Getters have spit contracts as the value of the key (or its default) is the result of the getter.
If you want to use a mnemonic (see later) for a contract to apply to both the getter and putter, the mnemonic must have identical constraints for both suck and spit.
The macro define-mnemonics will define a symmetric contract. For example, to define a symmetric mnemonic :key-value-is-a-map-with-numeric-values
1
2
(define-mnemonics
key-mnemonics {:key-value-is-a-map-with-numeric-values [map? (every? number? (vals arg0))]})
This is the same as the explicit call below to configure-contracts-store in clojure-contracts-sugar
1
2
3
4
5
(configure-contracts-store
aspect-mnemonic-definitions
{:key-value-is-a-map-with-numeric-values
{:suck [map? (every? number? (vals arg0))]
:spit [map? (every? number? (vals arg0))]}})
Note you can define multiple mnemonics is the call to define-mnemonics, just add more key-value pairs in the key-mnemonics map.
The Code
Jar is on Clojars
The jar is on Clojars:
Leiningen dependency information:
1
[name.rumford/clojure-contracts-maps "0.1.0"]
Maven dependency information:
1
2
3
4
5
<dependency>
<groupId>name.rumford</groupId>
<artifactId>clojure-contracts-maps</artifactId>
<version>0.1.0</version>
</dependency>
Code is on Github
The code can be found on github as a Leiningen project so you’ll want Leiningen installed.
The project structure is Maven style but there is only Clojure today i.e. ./src/main/clojure and ./src/test/clojure.
Tests
There are a number of tests that can be run offering reasonable coverage:
1
lein test
Examples
The examples below can be found in the repo’s examples folder (specifically ./examples/map-examples) and they can be run using lein:
1
2
cd ./examples/map-examples
lein run -m map-examples1
The examples use a couple of harness functions - will-work and will-fail - to run tests.
will-work takes as arguments the expected result, the accessor function and a list of the accessor’s arguments.
will-fail takes just the accessor function and its arguments and catches the AssertionError expected to be thrown.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;; Helper for accessor examples expected to work. Returns the expected result, else fails
(defn will-work
[expected-result fn-accessor & fn-args]
(assert (= expected-result (apply fn-accessor fn-args)))
(println "will-work" "worked as expected" "expected-result" expected-result "fn-accessor" fn-accessor "fn-args" fn-args)
expected-result)
;; Helper for accessor examples expected to fail. Catches the expected AssertionError, else fails.
(defn will-fail
[fn-accessor & fn-args]
(try
(do
(apply fn-accessor fn-args)
(assert (println "will-fail" "DID NOT FAIL" "did not cause AssertionError" "fn-accessor" fn-accessor "fn-args" fn-args)))
(catch AssertionError e
(println "will-fail" "failed as expected" "fn-accessor" fn-accessor "fn-args" fn-args))))
The ./doc folder contains the source of this post: it is an emacs org file tangled to generate the examples project.
Getters with Simple Contracts
To define a getter call the define-map-get-accessor macro with its (minimum) arguments:
-
the key’s name; and
-
the contract (constraints) to enforce on the key’s value
Remember: getter contracts have spit constraints - the contract is applied to the result of the getter.
Example - a getter for a key with a numeric value
For example to ensure the value of key :a is a number:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;; Example - a getter for a key with a numeric value
;; This example shows how to define a getter function that ensures the
;; returned value of key :a is a number:
(def get-key-a (define-map-get-accessor :a :number))
;; Explcitly call the function
(get-key-a {:a 1})
;; =>
1
;; But lets use the will-work helper to ensure the result is as expected
(will-work 1 get-key-a {:a 1})
;; =>
1
Its worth noting that :number here implies {:spit number?}
Example - a getter using static and/or dynamic defaults
You can define the getter accessor with a static default value to be returned if the key is not present in the map (exact the same semantics as get with a default value).
Note the contract is applied to the result of the accessor so defaults must comply with the contract.
This is lazy though - if a default is never needed, the contract will never be applied to it.
To provide a static default use the optional parameter default on the call to define-map-get-accessor.
1
2
3
4
5
6
7
8
9
10
11
12
13
;; Example - a getter with a static default for key :d
(def get-key-d (define-map-get-accessor :d :number default 42))
;; This will work
(will-work 42 get-key-d {})
;; =>
42
;; Note the key is present but its value of nil will fail the :number contract
(will-fail get-key-d {:d nil})
Alternatively a dynamic default can be provided as the second argument in a call to the getter.
The dynamic default takes precedence over the static one (if supplied).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
;; Example - a getter called with a dynamic, per call, default for key :d
;; The key's value, if present, alway takes precedence over any default
(will-work 55 get-key-d {:d 55} 99)
;; =>
55
;; The static default (if supplied) is used if the key is not present
(will-work 42 get-key-d {})
;; =>
42
;; But a per-call dynamic default take precedence over the static one
(will-work 99 get-key-d {} 99)
;; =>
99
(will-work 567 get-key-d {} 567)
;; =>
567
Putters with Simple Contracts
A simple putter uses Clojure’s assoc function and returns the updated map; the original map is, of course, unchanged.
You can define equivalent putter accessors, constrained in the same way as getters, by calling the define-map-put-accessor macro with its (minimum) parameters (same as for a getter):
-
the key’s name; and
-
the contract (constraints) to enforce on the key’s value
Remember: putter contracts have suck constraints - the contract is applied to the argument with (new) value of the key.
Example - a putter for a key with a numeric value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
;; Example - define a putter for the value of key :d which must be a number
(def put-key-d (define-map-put-accessor :d :number))
;; Create a new map with the new value for key :d
(def map-with-old-value-of-d {:d 99})
(def map-with-new-value-of-d (put-key-d map-with-old-value-of-d 123))
;; Using the getter on the updated map will return the new value of :d
(will-work 123 get-key-d map-with-new-value-of-d)
;; =>
123
;; The old map is of course unchanged
(will-work 99 get-key-d map-with-old-value-of-d)
;; =>
99
Using a telltale to aid diagnosis of assertion errors
Errors generated by Clojure’s pre and post assertions are of type AssertionError.
Although they produce a (Java) stack track and precisely specify the assertion causing the error, they do not provide any information to identify the context of the error i.e. which key suffered the error?
As an aid to providing some context and help identify the cause of the error, you can provide an optional telltale parameter to the accessor function definition. The telltale is a description (string) to be printed if/when an AssertionError occurs.
Example - a getter with a telltale
1
2
3
4
5
6
7
8
9
;; Example - a getter with a telltale
(def get-key-d (define-map-get-accessor :d :number default 42 telltale "The value of key :d was not a number"))
;; The call to get-key-d below will fail with an asertion error
(will-fail get-key-d {:d "value of d must be a string else will fail"})
;; => will fail with message something like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:d "value of d must be a string else will fail"}< REASON The value of key :d was not a number
Example - a putter with a telltale
1
2
3
4
5
6
7
8
9
;; Example - a putter with a telltale
(def put-key-e (define-map-put-accessor :e :string telltale "The new value of key :e was not a string"))
;; The call to put-key-e below will fail with an asertion error
(will-fail put-key-e {:e ":e is always a string"} 123)
;; => will fail with message something like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:d ":e is always a string"}< REASON The new value of key :e was not a string
Contracts for Keys with multiple constraints
In the examples so far the contract (constraints) applied to the value of a key has been simple - e.g. just a number or string.
In fact, contracts can be far “richer”: You can use anything supported by clojure-contracts-sugar.
Rich contracts were illustrated in my contracts sugar post and allow one or more constraints to be applied to the key’s value.
Multiple constraints can be specified in the definition of the accessor simply as a vector of the individual constraints.
Or you can use mnemonics to “package” rich, complex contracts with multiple constraints, again as described in the sugar post.
Some examples should help flesh this out.
Example - a getter for a positive numeric key value
To ensure a key’s value is a positive number, the contract’s vector of constraints would be:
1
[:number :pos]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;; Example - a getter for a positive numeric key value
(def get-key-m (define-map-get-accessor :m [:number :pos] telltale ":m must be a positive number"))
;; This works
(will-work 3 get-key-m {:m 3})
;; =>
3
;; But this will fail
(will-fail get-key-m {:m -3})
;; => And should produce a message like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:m -3}< KEY :m REASON :m must be a positive number
Example - a getter to ensure a key’s value is a map with keyword keys and numeric values
Using an example based on one in the sugar post, this one needs to ensure a key’s new value is a map, and its keys are keywords and the values are numbers. The contract is:
1
[:map (every? keyword? (keys arg0)) (every? number? (vals arg0))]
Note the key’s value is available for use explicitly in the contract as arg0 - see the sugar post for an explanation of the use of relative argument names such as arg0.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;; Example - a getter to ensure a key's value is a map with keyword keys and numeric values
;; Note the constraint form uses arg0 to refer to the passed map
(def get-key-n (define-map-get-accessor :n [:map (every? keyword? (keys arg0)) (every? number? (vals arg0))] telltale ":n must be a map with keywords keys and numeric values"))
;; This works
(will-work {:a 1 :b 2 :c 3} get-key-n {:n {:a 1 :b 2 :c 3}})
;; =>
{:a 1 :b 2 :c 3}
;; But this will fail
(will-fail get-key-n {:n {"x" 1 "y" 2 "z" 3}})
;; => And should produce a message like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:n {"x" 1, "y" 2, "z" 3}}< KEY :n REASON :n must be a map with keywords keys and numeric values
Example - a getter with a custom predicate
You can define your own predicate functions, not just use Clojure’s “built-ins” (e.g. map?, number?, string?, etc). For example, a predicate function to ensure the value is a map with keywords keys and numeric values would be something like this:
1
2
3
4
5
6
;; Example - a custom predicate to ensure a map's keys are keywords and values are numeric
(defn is-map-with-keyword-keys-and-numeric-values?
[source-map]
{:pre [(map? source-map) (every? keyword? (keys source-map)) (every? number? (vals source-map))]}
source-map)
The custom predicate can be used in the accessor definition just like a “built-in”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;; Example - a rich getter using a custom predicate
(def get-key-p (define-map-get-accessor :p is-map-with-keyword-keys-and-numeric-values? telltale ":p failed predicate is-map-with-keyword-keys-and-numeric-values?"))
;; This works
(will-work {:a 1 :b 2 :c 3} get-key-p {:p {:a 1 :b 2 :c 3}})
;; =>
{:a 1 :b 2 :c 3}
;; But this will fail
(will-fail get-key-p {:p {"x" 1 "y" 2 "z" 3}})
;; => And should produce a message like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:p {"x" 1, "y" 2, "z" 3}}< KEY :p REASON :p failed predicate is-map-with-keyword-keys-and-numeric-values?
Example - a getter with a custom mnemonic for the key
Mnemonics are a feature of clojure-contracts-sugar for defining, re-using and composing contracts, usually with multiple constraints.
This example again is based loosely on one in my sugar post and demonstrates how to implement the is-map-with-keyword-keys-and-numeric-values? predicate function using a mnemonic.
Note the example uses the sugar macro define-mnemonics to simplify the definition of a symmetric (i.e. suck and spit) contract suitable for use in the definition of both a getter (spit) and putter (suck).
1
2
3
4
5
6
;; Define a custom mnemonic map-special ensuring a map with keyword keys and numeric values.
;; Note the mnemonic is suitable for a both a getter and putter i.e it has the same *suck* and *spit* constraints
(define-mnemonics
key-mnemonics {:key-value-is-a-map-with-numeric-values [map? (every? number? (vals arg0))]})
The example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;; Example - a getter with a custom mnemonic for the key
;; Use the :key-value-is-a-map-with-numeric-values mnemonic for the key contract
;; to ensure the key's value is a map with numeric values.
(def get-key-q (define-map-get-accessor :q :key-value-is-a-map-with-numeric-values telltale ":q failed contract key-value-is-a-map-with-numeric-values"))
;; This works
(will-work {:a 1 :b 2 :c 3} get-key-q {:q {:a 1 :b 2 :c 3}})
;; =>
{:a 1 :b 2 :c 3}
;; But this will fail
(will-fail get-key-q {:q {:a :one :b :two :c :three}})
;; => And should produce a message like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:q {:a :one :b :two :c :three}}< KEY :q REASON :q failed contract key-value-is-a-map-with-numeric-values
Contracts for the Map
So far, I’ve not said anything about the map argument itself, or whether a contract (constraints) is applied to it.
In fact, behind the scenes, a contract is applied automatically to the map but its minimal: just map?
But the default contract for the map can be overidden using the map-contract parameter on the call to e.g. define-map-get-accessor.
Just like contracts for a key, map contracts can be anything supported by clojure-contracts-sugar, especially mnemonics.
Example - applying a contract to the map itself
In the example below the sugar macro define-mnemonics defines a contract suitable for the map argument (specifically a suck-only contract).
It also defines a key mnemonic to ensure the key’s value is a map with positive numeric values.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
;; Example - applying a contract to the map itself
;; Define the mnemonics
(define-mnemonics
map-mnemonics {:map-with-keyword-keys [map? (every? keyword? (keys arg0))]}
key-mnemonics {:key-is-a-map-with-positive-numeric-values [map? (every? number? (vals arg0)) (every? pos? (vals arg0))] })
;; Use both contracts
(def get-key-q (define-map-get-accessor :q :key-is-a-map-with-positive-numeric-values
map-contract :map-with-keyword-keys telltale ":q failed key contract key-is-a-map-with-positive-numeric-values or map contract map-with-keyword-keys"))
;; This works
(will-work {:a 1 :b 2 :c 3} get-key-q {:q {:a 1 :b 2 :c 3}})
;; =>
{:a 1 :b 2 :c 3}
;; But this will fail as the value of :a is -1
(will-fail get-key-q {:q {:a -1 :b 2 :c 3}})
;; => And should produce a message like:
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:q {:a -1 :b 2 :c 3}}}< KEY :q REASON :q failed key contract :key-is-a-map-with-positive-numeric-values or map contract map-with-keyword-keys
Using Transformation Functions (mappers)
Example - putters with mappers
Sometimes its useful to be also to transform - map - the (new) value of a key before putting into the map. For example to normalise the value in some way (e.g. - trivially - string-ify and lower case).
To provide a transformation function, use the mapper parameter on the call to define-map-putter. Your can specify more than one mapper - just provide a vector of them. Multiple mappers are applied in the same order as comp i.e. rightmost first.
Note the key’s contract is applied to the transformed value.
The example has a mapper that just converts the argument into a string and counts the number of characters. (Note the stringified keyword includes the leading colon.)
1
2
3
4
5
6
7
8
;; Example - putters with mappers
(def put-key-f (define-map-put-accessor :f :number mapper (fn [s] (count (str s))) telltale ":f must be a number"))
;; These will work
(will-work {:f 6} put-key-f {} "6chars")
(will-work {:f 7} put-key-f {} :7chars)
Example - getters with mappers
In a similar vein, for a getter, there may be times when it is useful to transform the key’s value before it is returned.
For example, defining additional getters for the same key that holds a map to return views of the value e.g. just the keys, just the values, the sum of the values, whatever. Or, perhaps, create a derivative value e.g. instantiate a Java class instance.
When using a mapper with a getter, the contract (e.g. :number) is applied to the transformed value, not the value itself (e.g. :map). (You could apply a contract to the key’s actual value using the map contract.)
Note in the example below a key mnemonic is applied to the map. This is ok; only the suck constraints in the key mnemonic will be applied to the map.
The example also shows how mnemonics can be composed - see the sugar post for details. Composed mnemonics in the call to define-mnemonics are:
1
:key-is-a-map-with-keyword-keys-and-postive-numeric-values [:map-with-keyword-keys :map-with-positive-numeric-values]
and
1
:collection-of-positive-numeric-values [:collection-of-numeric-values (every? pos? arg0)]
The example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
;; Example - getters with mappers
;; Define some mnemonics. Note a key contract mnemonic is applied to the
;; map. Also mnemonics are composed
(define-mnemonics
key-mnemonics {:map-with-keyword-keys [map? (every? keyword? (keys arg0))]
:map-with-positive-numeric-values [map? (every? number? (vals arg0)) (every? pos? (vals arg0))]
:key-is-a-map-with-keyword-keys-and-postive-numeric-values [:map-with-keyword-keys :map-with-positive-numeric-values]
:collection-of-keywords [(coll? arg0) (every? keyword? arg0)]
:collection-of-numeric-values [(coll? arg0) (every? number? arg0)]
:collection-of-positive-numeric-values [:collection-of-numeric-values (every? pos? arg0)]})
(def get-key-g (define-map-get-accessor :g
:key-is-a-map-with-keyword-keys-and-postive-numeric-values
map-contract :map-with-keyword-keys telltale ":g must be a map with keyword keys and postivie values"))
;; Define extra getters for the keys and values of :g's map,
;; both of which must be collections (coll?) of keywords or positive
;; numeric values
(def get-key-g-keys (define-map-get-accessor :g :collection-of-keywords
map-contract :map-with-keyword-keys mapper (fn [m] (keys m)) telltale ":g keys must be a collection"))
(def get-key-g-vals (define-map-get-accessor :g :collection-of-positive-numeric-values
map-contract :map-with-keyword-keys mapper (fn [m] (vals m)) telltale ":g values must be a collection"))
;; Test data
(def test-map1 {:g {:a 1 :b 2 :c 3}})
(def test-map1-g-keys (keys (:g test-map1)))
(def test-map1-g-vals (vals (:g test-map1)))
;; Test the keys
(will-work test-map1-g-keys get-key-g-keys test-map1)
;; =>
'(:a :c :b)
;; Test the values
(will-work test-map1-g-vals get-key-g-vals test-map1)
;; =>
'(1 3 2)
;; Another getter to sum the values
(def get-key-g-sum-vals (define-map-get-accessor :g :number mapper (fn [m] (apply + (vals m))) telltale ":g values sum must be a number"))
(will-work 6 get-key-g-sum-vals test-map1)
;; =>
6
Using a Monitor
A monitor provides a hook to call an arbitrary function in an accessor. The result of a monitor is ignored. Monitors can be used for any purpose e.g. logging, diagnostics, communications with other processes, whatever.
A monitor is specified using the monitor parameter.
Example - a getter with a monitor
If provided, the monitor function in a getter is called with at least the following arguments:
-
the key name;
-
the key value (transformed if required); and
-
the original (argument) map.
You can, optionally, add your own additional arguments using the monitor-args parameter, and these are passed “as-is” to the monitor function after the other arguments. The monitor-args should be a vector.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;; Example - a getter with a monitor
;; This is the monitor function
(defn monitor-get-key-j
[key-name key-value arg-map & opt-args]
(println "monitor-get-key-j" "key-name" key-name "key-value" key-value "arg-map" arg-map "opt-args" (count opt-args) opt-args))
(def get-key-j (define-map-get-accessor :j :number monitor monitor-get-key-j monitor-args ["opt arg1" 2 :three]))
;; The getter works as usual
(will-work 456 get-key-j {:j 456})
;; =>
456
;; And should display the monitor message:
;; monitor-get-key-j key-name :j key-value 456 arg-map {:j 456} opt-args 3 (opt-arg1 2 :three)
Example - a putter with a monitor
If provided, a putter’s monitor function is called always with the following arguments:
-
the key name;
-
the key value (transformed if require);
-
the original (argument) map; and
-
the new (updated) map.
As with a getter, and optionally, you pass your own additional arguments using the monitor-args parameter.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;; Example - a putter with a monitor
;; This is the monitor function
(defn monitor-put-key-k
[key-name key-value arg-map new-map]
(println "monitor-put-key-k" "key-name" key-name "key-value" key-value "arg-map" arg-map "new-map" new-map))
(def put-key-k (define-map-put-accessor :k :string monitor monitor-put-key-k))
;; The putter works as usual
(will-work {:k "new value of key :k"} put-key-k {:k "old value of key :k"} "new value of key :k")
;; =>
{:k "new value of key :k"}
;; And should produce message like:
;; monitor-put-key-k key-name :j key-value 456 arg-map {:k "old value of key :k"} new-map {:k "new value of key :k"}
Multi-Level Keys
You can also define accessors with multilevel keys and use (define) them in an equivalent way as when using get-in and assoc-in directly by providing a vector containing the key hierarchy in a call to e.g. define-map_get_accessor.
Example - Explicit Multi-Level Getters
In these getter examples the value of multilevel key [:a :b :c] must be a string:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
;; Example - Explicit Multi-Level Getters
;; This example shows how to define a getter function that ensures the
;; returned value of multi-level key [:a :b :c] is a string. It also supplies a
;; static default.
(def get-key-abc (define-map-get-accessor [:a :b :c] :string default "static default for multilevel key [:a :b :c]" telltale "The value of multilevel key [:a :b :c] must be a string"))
(will-work "value for multilevel key [:a :b :c]" get-key-abc {:a {:b {:d 4 :c "value for multilevel key [:a :b :c]"}}})
;; =>
"value for multilevel key [:a :b :c]"
;; The below will fail as [:a :b :c] is not a string
(will-fail get-key-abc {:a {:b {:d "value of [:a :b :d]" :c 99}}})
;; => message something like
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:a {:b 99}}< KEY :a :b REASON The value of multilevel key [:a :b :c] must be a string
;; Static Defaults work as expected
(will-work "static default for multilevel key [:a :b :c]" get-key-abc {})
;; =>
"static default for multilevel key [:a :b :c]"
;; Dynnamic Defaults work as expected
(will-work "dynamic default for multilevel key [:a :b :c]" get-key-abc {} "dynamic default for multilevel key [:a :b :c]")
;; =>
"dynamic default for multilevel key [:a :b :c]"
;; This will also work because, although the map does not have enough levels, the static default will be returned
(will-work "static default for multilevel key [:a :b :c]" get-key-abc {:a {:b "value of b is not a map so key c can not exist"}})
;; =>
"static default for multilevel key [:a :b :c]"
Example - Explicit Multi-Level Putters
Multilevel putters works equivalently.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;; Example - Explicit Multi-Level Putters
;; This example shows how to define a putter function for the string multilevel key [:a :b]:
(def put-key-ab (define-map-put-accessor [:a :b] :string telltale "The value of multilevel key [:a :b] must be a string"))
(will-work {:a {:b "cd"}} put-key-ab {:a {:b "ab"}} "cd")
;; =>
{:a {:b "cd"}}
;; The below will fail as [:a :b] is not a string
(will-fail put-key-ab {:a {:b "ab"}} 99)
;; => message something like
;; Contract Failure Value >class clojure.lang.PersistentArrayMap< >{:a {:b "ab"}}< KEY :a :b REASON The value of multilevel key [:a :b] must be a string
Composing Multi-Level Accessors
You might find it useful to be able to compose a (new) accessor using an existing one.
For example you may want to define a “leaf” accessor for a key at the lowest level and then compose the leaf accessor with keys from the other, higher levels. The point being that if the leaf key is used in more than one multilevel map, the leaf accessor only has to be defined once.
Your can also e.g. use a mapper with a composed accessor and enforce a different contract on the mapped (derived) key value. In the example below the composed accessor get-key-z-from-pq defines a mapper to count the characters in the expected string and applies a :number contract to the mapped value. The leaf accessor get-key-z ensures the value fed into the mapper is a :string. (Note you can’t use a dynamic default in this case because the :number contract will fail a :string supplied as the dynamic default.)
Example - composing a getter
In this example the leaf accessor is for key :z.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
;; Example - composing a getter
;; Define the leaf accessor for key :z
(def get-key-z (define-map-get-accessor :z :string default "static default for key :z" telltale "The value of :z must be a string"))
;; Compose the leaf with a keys [:a :b]
(def get-key-z-from-ab (compose-map-get-accessor [:a :b] get-key-z))
;; This will work
(will-work "multilevel key [:a :b :z] value" get-key-z-from-ab {:a {:b {:d 4 :z "multilevel key [:a :b :z] value"}}})
;; =>
"multilevel key [:a :b :z] value"
;; The below will fail as [:a :b :z] is not a string
(will-fail get-key-z-from-ab {:a {:b {:d "value of [:a :b :d]" :z 99}}})
;; Defaults are hierachical and the "leafiest" one wins
(will-work "static default for key :z" get-key-z-from-ab {})
;; =>
"static default for key :z"
(will-work "dynamic default for key :z" get-key-z-from-ab {} "dynamic default for key :z")
;; =>
"dynamic default for key :z"
;; Compose the leaf with keys [:p :q] *and* use a mapper to return a number
(def get-key-z-from-pq (compose-map-get-accessor [:p :q] get-key-z mapper (fn [x] (count x)) key-contract :number))
(will-work 20 get-key-z-from-pq {:p {:q {:z "a string of 20 chars"}}} )
;; =>
20
Finally, there is no actual restriction on leaf accessors being for just one level; they can be multilevel.
Defining both putter and getter
The definitions of a getter and putter for the same key share common arguments and its likely both accessors would be required. As a convenience, you can define both the putter and getter in one call to the define-map-accessors macro.
The base name of the accessors can be supplied using the optional name argument and the getter and putter names are generated from it. For example if name is the-v-key then the putter name will be put-the-v-key and the getter get-the-v-key. The value of name can be anything, it is “stringified” (using str) as necessary.
Alternatively, you can explicitly specify the names of each accessor using the get-name and put-name parameters respectively. These take priority over name.
If no name, get-name or put-name parameters are provided, the name will be derived from the name of the key. So if the key’s name is :x, the accessors will be get-x and put-x.
Other, accessor-specific parameters can be provided using the regular parameter (e.g. telltale monitor mapper etc) prefixed by get- or put-.
Example - defining both accessors together
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
;; Example - defining both accessors together
;; The base name of the accessors has been provided: "the-v-key"
(define-map-accessors :v is-map-with-keyword-keys-and-numeric-values? name the-v-key telltale ":p failed predicate is-map-with-keyword-keys-and-numeric-values?")
;; This getter will work
(will-work {:a 1 :b 2 :c 3} get-the-v-key {:v {:a 1 :b 2 :c 3}})
;; =>
{:a 1 :b 2 :c 3}
;; But this will fail as expected
(will-fail get-the-v-key {:v {"x" 1 "y" 2 "z" 3}})
;; This putter will work
(will-work {:v {:a 1 :b 2 :c 3}} put-the-v-key {} {:a 1 :b 2 :c 3})
;; =>
{:v {:a 1 :b 2 :c 3}}
Example - defining both accessors using the key’s name to name the accessors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
;; Example - defining both accessors using the key's name to name the accessors
;; Note since no name parameters have been provided, the getter and
;; putter will derived from the key's name and be called get-x and put-x respectively.
(define-map-accessors :x is-map-with-keyword-keys-and-numeric-values?
get-telltale "the value of :x or a default was not a map with keyword keys and numeric values"
put-telltale "the new value of :x must be a map with keyword keys and numeric values"
)
;; This getter will work
(will-work {:a 1 :b 2 :c 3} get-x {:x {:a 1 :b 2 :c 3}})
;; =>
{:a 1 :b 2 :c 3}
;; But this will fail as expected
(will-fail get-x {:x {"x" 1 "y" 2 "z" 3}})
;; This putter will work
(will-work {:x {:a 1 :b 2 :c 3}} put-x {} {:a 1 :b 2 :c 3})
;; =>
{:x {:a 1 :b 2 :c 3}}
Final Words
clojure-contracts-maps provides a useful additional feature layer sitting atop normal Clojure maps.
Using the library, its possible to apply Clojure’s pre and post conditions to a map’s key accesses in the same way as they can be applied to a function’s arguments. The power of clojure-contracts-sugar (and implicitly core.contracts) allows for a very rich sets of constraints to be applied to a key’s value.
The opportunity to define the access “semantics” of a map’s key via regular functions, ensuring the semantics are adhered to and/or applied consistently, is a useful and easy-to-use program-correctness aid.
Using mappers with a getter provide a simple way of generating views of a key’s value, without affecting the original value. Conversely, mappers with a putter facilitate e.g normalising the key’s (new) value before storing in the (updated) map.
Monitors provide a simple way of adding arbitrary, but neutral non-affecting logic, to the key’s access. And a telltale helps pin down where things went wrong.
Final, Final Words
I’ve done a fair amount of Ruby meta programming. An essential difference is Clojure macros are compile-time whereas Ruby is run-time.
But doing macro metaprogramming feels quite different: Clojure works with values (homoiconicity) whereas Ruby metaprogramming works with text that is eval-ed.
One of the most noticeable differences I’ve found though (maybe just my style) is whereas in Ruby you build the complete code-as-text, with macros you can take an iterative approach i.e a top level macro returns one or more “calls” to lower level more-focused macros.
There does seem to be a bit of a meme in the community along the lines of “If you are using a macro you are doing it wrong”. But, used wisely, macros are a fantastic (and sometimes essential) tool.
But the objectives are the same whether Ruby or Clojure - removing boilerplate and getting to the heart of the problem rather than distracted by implementation details.