A lens is a first-class getter/setter for a part A of a whole S. It lets
you read, replace, and transform a nested field without hand-writing the
rebuild-the-whole boilerplate at every use site. Turmeric ships lenses in
stdlib/lens.tur.
(load "stdlib/lens.tur")
(defstruct Point :copy :heap [x : int y : int])
(defn point-x [] : (Lens Point int)
(lens
(fn [p : Point] : int (.x p))
(fn [nx : int p : Point] : Point (make-struct Point :x nx :y (.y p)))))
(defn main [] : int
(let [px (point-x)
p (make-struct Point :x 3 :y 9)]
(println (view px p)) ; 3
(println (.x (set px 42 p))) ; 42
(println (.x (over px (fn [v : int] : int (* v 10)) p)))) ; 30
0)
| Combinator | Type | Meaning |
|---|---|---|
lens |
(fn [S] A) -> (fn [A S] S) -> (Lens S A) |
build a lens from a getter and a setter |
view |
(Lens S A) -> S -> A |
read the focused A |
set |
(Lens S A) -> A -> S -> S |
replace the focused A |
over |
(Lens S A) -> (fn [A] A) -> S -> S |
map a function over the focused A |
view l s is ((.lget l) s); set l a s is ((.lput l) a s); over l f s
is set l (f (view l s)) s.
A Lens is :copy, so a single lens value can be view/set/over'd
repeatedly. The whole S a lens focuses is usually a :copy struct too, so it
can be reused across calls; if the setter rebuilds the record (the common case),
make it :copy :heap so it is pointer-carried and cheap to thread. A move-only
S can still be used, but only linearly (each whole consumed once).
stdlib/lens.tur uses the profunctor-by-record encoding. A Lens S A is a
plain record holding two functions -- a getter (fn [S] A) and a setter
(fn [A S] S):
(defstruct Lens :copy [S A] (lget (fn [S] A)) (lput (fn [A S] S)))
view, set, and over just project those two fields and call them. There is
no functor, no forall, and no runtime dictionary involved. This is the form
you get from (load "stdlib/lens.tur") and the form every example above uses.
The stdlib does not use the van Laarhoven encoding (below). If you only want to read and update nested fields, you never need to think about van Laarhoven at all -- the record form is the whole story.
The classic Haskell optic encodes a lens as a single polymorphic function:
type Lens s a = forall f. Functor f => (a -> f a) -> (s -> f s)
Turmeric can express this form -- the kind, constraint, and higher-rank
machinery it needs, plus the runtime typeclass-dictionary passing it relies on,
are all part of the language. A van Laarhoven lens carries the caller's
Functor dictionary through the poly carrier at runtime, so its body dispatches
fmap on whichever instance the caller picks (Const for view, Identity
for set/over).
It is not what stdlib/lens.tur ships, and you write it by hand when you
want it. The one thing it buys you over the record form is composition (below).
The record form gives up composing optics with ordinary function composition. A
generic lens-compose would need its setter to read the whole s (to view the
intermediate part) and write it back -- using s twice -- which the linearity
checker rejects for an abstract move-only S. Compose by hand at concrete,
copyable whole types:
;; Line -> start:Point -> x:int
(defn line-start-x [] : (Lens Line int)
(lens
(fn [l : Line] : int (.x (.start l)))
(fn [nx : int l : Line] : Line
(make-struct Line
:start (make-struct Point :x nx :y (.y (.start l)))
:end (.end l)))))
where the whole types (Line, Point) are :copy so l/s can be used more
than once.
The van Laarhoven form composes freely (l1 . l2 is just function composition);
reach for it when you need optic composition and don't want to hand-write the
combined getter/setter.
This section only matters if you write van Laarhoven lenses. The record form does not thread a functor and can ignore it.
A van Laarhoven lens threads (f a) through a one-int64 poly carrier. Two shapes
of functor work through that carrier:
:heap type whose (f a)
fits in one int64 word (e.g. (defopaque Const [r a] :int),
(defopaque Identity [a] :int)). These ride the carrier directly at no
runtime cost beyond the dict dispatch.:copy struct or flat-product ADT whose
(f a) is wider than one word. Codegen boxes the aggregate into the carrier at
each lens crossing and unboxes it on the other side. view/set/over,
generic focus inference, and composition all thread through unchanged; the box
pays one heap alloc + copy + free per crossing.Where the compiler can prove a lens call site statically and uniquely
resolves to a simple lens (one with a single fmap dispatch at its body tail),
it redirects the call to a monomorphized by-value body with no heap box on
either the (f S) or (f A) result. A consumer lens param that resolves to
several distinct simple lenses gets one box-free clone per lens. Composed
lenses (a body that tails into another lens, e.g.
line-a-x = line-a . point-x) also thread (f a) by value end to end --
no carrier box at any composition crossing (see
van-laarhoven-composed-byvalue-plan).
A few shapes still ride the boxed carrier bridge as a correctness backstop:
fmap dispatch, one missing its adapter, or nesting past
the lowering depth cap.stdlib/lens.tur -- the module (record encoding)forall mechanism lenses use