On this page:
1.1 Lenses (Guide)
1.2 Traversals (Guide)
1.3 Isomorphisms (Guide)
1.4 Conclusion
8.15.0.2

1 Optics Guide🔗ℹ

This page serves as a guide for those who aren’t familiar with optics.

Optics are useful for performing accesses and immutable updates deeply within a structure. They are very powerful, but they are also very abstract and have a bit of a learning curve.

The big idea is that optics allow you to separate the "where" from the "what" when you are operating on parts of a structure. An optic describes "where", and something like a procedure may describe "what" you want to do. Additionally, optics provide a rich language for specifying "where", and are re-usable.

1.1 Lenses (Guide)🔗ℹ

The most intuitive type of optic is a lens. A lens is a first class getter and setter for a focus within a target.

For example,
> (struct posn [x y] #:transparent)
> (define posn-x-lens (struct-lens posn x))

posn-x-lens is a lens that targets a posn and focuses on its x field. We can use this lens to get a posn’s x value or to make a copy of a posn with a new x value:

> (define posn-1-2 (posn 1 2))
> (lens-get posn-x-lens posn-1-2)

1

> (lens-set posn-x-lens posn-1-2 10)

(posn 10 2)

> posn-1-2

(posn 1 2)

Using lens-set does not mutate the target, it returns a modified copy of the target.

In a language like Java, JavaScript, or Python, we’d write myposn.x to access the field and we’d write myposn.x = 10 to set the field (using mutation, unlike lenses). In a sense, posn-x-lens is the .x itself.

Here are other examples of lenses:

> (lens-set car-lens (cons 1 2) #t)

'(#t . 2)

> (lens-set cdr-lens (cons 1 2) #t)

'(1 . #t)

These lenses focus on the car and cdr of a pair, respectively.

You can create your own lenses with make-lens.

Examples:
> (define my-car-lens
    (make-lens car ; getter
               (lambda (pair new-car) ; setter
                 (match pair [(cons old-car old-cdr)
                              (cons new-car old-cdr)]))))
> (lens-set my-car-lens (cons 1 2) #t)

'(#t . 2)

You can also compose lenses to focus on values deep within a target.

Examples:
> (struct rect [top-left width height] #:transparent)
> (define rect1 (rect (posn 10 20) 16 9))
> (define rect-top-left-lens (struct-lens rect top-left))
> (define rect-x-lens (lens-compose rect-top-left-lens posn-x-lens))
> (lens-get rect-x-lens rect1)

10

> (lens-set rect-x-lens rect1 35)

(rect (posn 35 20) 16 9)

rect-top-left-lens targets a rect and focuses on its top-left posn. posn-x-lens targets a posn and focuses on its x-coordinate. Since rect-top-left-lens focuses on the same type of value as the target of posn-x-lens, we can compose them to create rect-x-lens, which targets a rect and focuses on the x-coordinate of its top-left posn.

> (struct tree [val children] #:transparent)
> (define tree1 (tree 1 (list (tree 2 (list (tree 3 '())))
                              (tree 4 '()))))
> (define tree-children-lens (struct-lens tree children))
> (define tree-value-lens (struct-lens tree val))
> (define tree-first-value-lens (lens-compose tree-children-lens car-lens tree-value-lens))
> (lens-get tree-first-value-lens tree1)

2

> (lens-set tree-first-value-lens tree1 20)

(tree 1 (list (tree 20 (list (tree 3 '()))) (tree 4 '())))

tree-children-lens targets a tree and focuses on its list of children. car-lens targets a pair (or a list) and focuses on its car (its first element), which is a tree. tree-value-lens targets a tree and focuses on its val. Similar to above, we can compose these three lenses to get tree-first-value-lens which targets a tree and focuses on the val of its first immediate child.

When we compose lenses, the focus of the first lens is used as the target of the second. Using lens composition, we can perform very deep accesses and modifications.

Where posn-x-lens is like .x for a posn, rect-top-left-lens is like .top_left.x.

1.2 Traversals (Guide)🔗ℹ

Traversals are like lenses, except they can have zero or multiple foci. Traversals are useful for focusing on all the elements of a collection. A traversal is like a first class map and foldl.

Examples:
> (traversal-map list-traversal '(1 2 3) add1)

'(2 3 4)

> (traversal-map rose-traversal '((1 2) (3 ((4)))) add1)

'((2 3) (4 ((5))))

> (traversal-foldl rose-traversal '((1 2) (3 ((4)))) + 0)

10

> (traversal->list rose-traversal '((1 2) (3 ((4)))))

'(1 2 3 4)

All lenses are traversals, but not all traversals are lenses. Lenses are just traversals that happen to have exactly one focus.

Examples:

Like lenses, traversals can be composed. Each focus of the first traversal becomes the target for the second traversal. The composition focuses on all the inner foci of all the outer foci. Don’t think about that too much though, just look at an example and it’ll make more sense.

Examples:
> (define lov-traversal (traversal-compose list-traversal vector-traversal))
> (traversal-map lov-traversal '(#(1 2 3) #(4) #()) add1)

'(#(2 3 4) #(5) #())

> (traversal->list lov-traversal '(#(1 2 3) #(4) #()))

'(1 2 3 4)

Here, we have composed list-traversal and vector-traversal. This traversal targets a list of vectors and focuses on each element of each vector. traversal->list collects all the foci into a list, so it’s useful for seeing what an optic focuses on.

Where things really get interesting is when we compose traversals with lenses. This works because lenses are traversals.

Examples:
> (define lop-x-traversal (traversal-compose list-traversal posn-x-lens))
> (traversal? lop-x-traversal)

#t

> (traversal-map lop-x-traversal (list (posn 10 20) (posn 30 40)) sqr)

(list (posn 100 20) (posn 900 40))

> (define tree-child-value-traversal (traversal-compose tree-children-lens list-traversal tree-value-lens))
> (traversal? tree-child-value-traversal)

#t

> tree1

(tree 1 (list (tree 2 (list (tree 3 '()))) (tree 4 '())))

> (traversal-map tree-child-value-traversal tree1 number->string)

(tree 1 (list (tree "2" (list (tree 3 '()))) (tree "4" '())))

lop-x-traversal targets a list of posns and focuses on each posns’ x-value.

tree-child-value-traversal targets a tree and focuses on each immediate child’s value. It does not focus on "grandchildren".

In the context of composing optics, traversals are good for "mapping" an optic over a collection. In the above example, tree-children-lens targets a tree and focuses on the list of immediate children in a tree. It focuses on the list itself, not its elements. That is a subtle but important distinction. To focus on each child, we compose it with list-traversal. We also add tree-value-lens to the composition to focus on each child’s value. In a sense, tree-value-lens gets "mapped" over the list that tree-children-lens focuses on.

When we compose a traversal with a lens, we get a traversal. Since lenses are traversals that happen to have a single focus, they work just fine with traversal composition. Compositions of simple lenses and traversals are sufficient to specify the "where" of most computations. But as we’ll see, there are a few more tricks that allow us to express certain, complex "where"s more simply.

1.3 Isomorphisms (Guide)🔗ℹ

An isomorphism is a lens where the focus is "equivalent" to the target. It is used when two types or representations of data can be converted back and forth between each other. Isomorphisms are useful for treating data from one representation as another, like an adapter.

Example:

Here, we are using the isomorphism between symbols and strings to treat a symbol as a string. We are using a string-to-string function, but we are inputting and outputting a symbol. The isomorphism, symbol<->string just automates this back-and-forth conversion.

In math, an isomorphism is a one-to-one mapping between two sets. In code, we represent this as a pair of functions which are inverses of each other.

Examples:
> (define my-symbol<->string (make-iso symbol->string string->symbol))
> my-symbol<->string

#<make-iso>

All isomorphisms are lenses (and thus, are traversals too), but not all lenses are isomorphisms.

Examples:
> (lens? symbol<->string)

#t

> (lens-get symbol<->string 'chocolate)

"chocolate"

> (lens-set symbol<->string 'cookies-and-cream "oreo")

'oreo

Think about that usage of lens-set. The 'cookies-and-cream was ignored completely. In fact, for an isomorphism, there is no need to supply the target! This is because the new focus completely determines the value of the new target. As such, the library provides iso-forward and iso-backward, which correspond to lens-get and lens-set respectively.

Examples:
> (iso-forward symbol<->string 'chocolate)

"chocolate"

> (iso-backward symbol<->string "oreo")

'oreo

Like traversals, isomorphisms become very useful when combined with other optics. For example, let’s consider the following data structure representing a rectangular bounding box:

> (struct bounds [top-left bottom-right] #:transparent)

A bounds represents a rectangular area of space. Recall the rectangle example above. These two data types are isomorphic:

> (define bounds<->rect
    (make-iso
     (lambda (b)
       (match b
         [(bounds (and top-left (posn x0 y0)) (posn x1 y1))
          (rect top-left (- x1 x0) (- y1 y0))]))
     (lambda (r)
       (match r
         [(rect (and top-left (posn x y)) width height)
          (bounds top-left (posn (+ x width) (+ y height)))]))))
> rect1

(rect (posn 10 20) 16 9)

> (iso-backward bounds<->rect rect1)

(bounds (posn 10 20) (posn 26 29))

> (iso-forward bounds<->rect (iso-backward bounds<->rect rect1))

(rect (posn 10 20) 16 9)

We can use this isomorphism to treat a bounds as a rect and vice versa.

> (define rect-width-lens (struct-lens rect width))
> (define bound-width-lens (lens-compose bounds<->rect rect-width-lens))
> (define (widen-bounds bnd dw)
    (lens-modify bound-width-lens
                 bnd
                 (lambda (w) (+ w dw))))
> (widen-bounds (bounds (posn 10 10) (posn 20 20)) 5)

(bounds (posn 10 10) (posn 25 20))

Here, we take advantage of the fact that all isomorphisms are lenses and compose our isomorphism with the rect-width-lens. This creates a lens that focuses on the width of a bounds, even though a bounds doesn’t actually have a width field! Using this lens, we can treat a bounds as if it has a width and modify its width.

We can also treat a rect as a bounds by reversing the isomorphism.

> (define rect-bottom-right-lens (lens-compose (iso-reverse bounds<->rect) (struct-lens bounds bottom-right)))
> (lens-set rect-bottom-right-lens rect1 (posn 30 30))

(rect (posn 10 20) 20 10)

Here, we create a lens that focuses on the bottom right position of a rect even though it doesn’t have a bottom right as a field. The way we defined our isomorphism is useful for treating a bounds as a rect, but here, we want to to the reverse. Luckily, since isomorphisms are bi-directional, we can use iso-reverse to treat a rect as a bounds in an optic composition.

An isomorphism is like an adapter. If you have two equivalent representations, and need to perform operations on one that are easier on the other, isomorphisms help you avoid explicitly converting back and forth. And when combined with optic composition, isomorphisms can allow you to abstract these operations further. More importantly, isomorphisms allow you to specify a "where" in one data representation in terms of a "where" in some other, equivalent representation.

Now let’s put it all together. Let’s create a function that increments the height of all boundss in a list of bounds:

> (define (increment-bounds-heights lob)
    (traversal-map
     (traversal-compose list-traversal bounds<->rect (struct-lens rect height))
     lob
     add1))
> (increment-bounds-heights (list (bounds (posn 0 0) (posn 10 10)) (bounds (posn 5 5) (posn 15 20))))

(list (bounds (posn 0 0) (posn 10 11)) (bounds (posn 5 5) (posn 15 21)))

We use list-traversal for its mapping behavior, we treat all the elements as rects, and we focus on their heights. Then we just apply add1 to them.

1.4 Conclusion🔗ℹ

Optics allow you to separate the "where" from the "what". You can create combinations of optics to specify exactly where you want a modification or access, and then separately, you can specify what you want to do by providing a procedure.

Traversals are useful for focusing on multiple parts of a structure, isomorphisms are useful for conversions/adapters between equivalent representations of data, and lenses are useful for focusing on one particular part of a structure.

There are other types of optics, like prisms, and other ways of combining optics, like optic-compose and update. Read the reference to find out more.