Lenses are a functional abstraction that have proven very useful to us
in several projects. With them, you can very effectively define, query,
and especially modify complex properties of larger data structures.
Lenses make properties first-class citizens that you can abstract
over and combine.
This article aims to show what lenses are and how to use them. The
programming language used is Clojure, which we
currently use in a lot and enjoy. Some tutorials for the language
can be found, for example, here.
Motivation
As a motivating example, let‘s imagine we have a simple phone book as a
data structure of the following type:
(def book-1
{"Mike" #{[:work "071170709468"] [:home "07071xxx"]}
"David" #{[:home "07121xxx"]}})
Thus, the phone book is a map with names as keys and a set of entries as
values. Each entry consists of a tuple of the entry type and a string
with the phone number itself.
As an example task, let‘s imagine two functions: one checks whether a specific
entry exists for a name, the other adds an entry. It shouldn‘t matter
whether a name is already in the phone book or not:
(defn has-entry? [book name kind number] ...)
(defn add-entry [book name kind number] ...)
What are Lenses?
First of all, the analogy with real lenses is this:
you hold a lens in front of something large
and see a smaller part of it. From a programming perspective, it‘s
primarily about extracting a value from a data structure using a
lens. In Clojure, you could define it like this:
(defprotocol Lens
(yank [this data]))
This defines lenses as a protocol that can be implemented for different
types by defining a function yank with an additional parameter data
for values of this type. The first parameter of a protocol function in
Clojure is always the concrete value of the respective type, and this
is an appropriate name for it.
But that alone wouldn‘t be worth mentioning, of course. The crucial
thing is that a lens also allows modifying the value it
focuses on! Modifying in functional programming means
creating a new data structure that contains a new value at the focused
location. So we add another function shove for inserting a new
value:
(defprotocol Lens
(yank [this data])
(shove [this data v]))
One way to create concrete lenses is to explicitly define the two
functions yank and shove:
(defrecord ExplicitLens
[yanker shover]
Lens
(yank [this data] (yanker data))
(shove [this data v] (shover data v)))
(defn lens
[yanker shover]
(ExplicitLens. yanker shover))
The record type ExplicitLens has the two fields yanker and
shover, and implements the Lens protocol directly with these two
functions. The function lens constructs a value of type
ExplicitLens.
Application
Defining the right properties as lenses is not always so easy. The
first one we‘ll need for our example is the value stored in a map for a
specific key. For this, we write a function member that takes a key
and a default value and creates a lens that, when held over a concrete
map, focuses the associated value:
(defn member
[key & [default]]
(lens #(get % key default)
#(if (= %2 default)
(dissoc %1 key)
(assoc %1 key %2))))
Member thus takes a parameter key and an optional parameter
default, and calls lens with corresponding yanker and shover
functions (# together with % or %1 and %2 is Clojure‘s
shorthand notation for „lambda expressions“ with one or multiple
parameters.)
The yank function of the member lens returns the value matching the
key (or the default value if the key is not in the map); the shove
function changes the value for a key or removes the key and value from
the map when we pass the default value.
With this, we can define the first interesting property of a phone book
as a lens, namely the set of entries for a name, with an empty set as
the default value:
(defn book-entries
[name]
(member name #{}))
As mentioned, we can use a lens to read and set this property. An
example:
(def my-entries (book-entries "David"))
(yank my-entries book-1)
;; => #{[:home "07121xxx"]}
(shove my-entries book-1 #{})
;; => {"Mike" #{[:work "071170709468"] [:home "07071xxx"]}}
This shows that such properties are now first-class:
(book-entries "David") creates a lens for my entries in a phone book!
You can bind this value to a name as here, or pass it to other
functions and process it further - we‘ll come to that below.
By the way: Because member completely removes a map entry that
corresponds to the default value, the new phone book created by the
last expression no longer contains a "David" key.
We now also need to step into the set of entries. For this, lenses of
the following type are helpful:
(defn contains
[v]
(lens #(contains? % v)
#(if %2
(conj %1 v)
(disj %1 v))))
The function contains takes a value and returns a lens that focuses
on the boolean property of whether this value is contained in a set or
not. The yank function of this lens checks whether this value is
contained in a set; the shove function adds or removes a value,
depending on the second argument.
Hence, for our phone book entries, we could define initially:
(defn entries-contains [kind number]
(contains [kind number]))
And we can use it directly on the sets like this:
(def contains-my-work-number
(entries-contains :work "071170709475"))
(yank contains-my-work-number #{[:home "07121xxx"]})
;; => false
(shove contains-my-work-number #{[:home "07121xxx"]} true)
;; => #{[:home "07121xxx"] [:work "071170709475"]}
Now we want to combine the lenses for map entries and those for sets.
In this case, we want to chain them together, or layer them, to
stay with the metaphor. The combined lens should first apply the yank
function of the lens for a map entry when reading, and then apply the
yank function of a lens for the set entry on the resulting set. When
writing, the shove function should correspondingly work the other way
around. If we apply some wishful thinking, we need a combinator
called >> that can do the following:
(def contains-my-work-number-for-me
(>> my-entries contains-my-work-number))
(yank contains-my-work-number-for-me book-1)
;; => false
(shove contains-my-work-number-for-me book-1 true)
;; => {"Mike" #{[:work "071170709468"] [:home "07071xxx"]}
;; "David" #{[:home "07121xxx"] [:work "071170709475"]}
And indeed, it‘s not so difficult to define this extremely useful
combinator:
(defn >>
[l1 l2]
(lens (fn [data] (yank l2 (yank l1 data)))
(fn [data v] (shove l1 data (shove l2 (yank l1 data) v)))))
The yanker first reads the value that lens l1 focuses on in data,
and then reads from it what lens l2 has defined. The shover also
first reads what l1 points to in the existing data, lets l2 make
its changes in it, and finally sets the result back to the appropriate
„location“ as defined by l1.
(The extension to more than two lenses is not difficult either.)
Finally, let‘s come to the two functions on phone books that we set
ourselves as a task at the beginning:
(defn has-entry? [book name kind number] ...)
(defn add-entry [book name kind number] ...)
If we define a lens for the presence of an entry:
(defn book-contains [name kind number]
(>> (book-entries name)
(entries-contains kind number)))
Then the two functions are simply the application of this lens:
(defn has-entry? [book name kind number]
(yank (book-contains name kind number) book))
(defn add-entry [book name kind number]
(shove (book-contains name kind number) book true))
How you could now define a remove-entry is certainly obvious.
Laws
Not everything that conforms to the Lens protocol above should be
considered a lens. The following rules, or laws, make lenses truly
meaningful:
-
You always pull out what you put in:
(yank l (shove l d v)) == v
-
Putting in what you pulled out doesn‘t change anything:
(shove l d (yank l d)) == d
-
Putting in twice is the same as putting in once:
(shove l (shove l d v) v) == (shove l d v)
The lenses from this post satisfy all laws.
Summary
With lenses, modifiable properties of data structures can be defined
very precisely and with minimal redundancy.
It‘s very practical, for example, together with our web client library
reacl, as already mentioned in
an earlier article. But we have also
successfully used lenses in Xtend on a
mutable Java data structure.
Addendum: Lenses for Clojure and ClojureScript are, in slightly
different form, part of our
active-clojure
library.