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:

  1. You always pull out what you put in:

    (yank l (shove l d v)) == v

  2. Putting in what you pulled out doesn‘t change anything:

    (shove l d (yank l d)) == d

  3. 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.