optics-0.1: Optics as an abstract interface

Safe HaskellNone
LanguageHaskell2010

Optics

Contents

Description

This library makes it possible to define and use Lenses, Traversals, Prisms and other optics, using an abstract interface.

Synopsis

Introduction

Read on for a general introduction to the notion of optics, or if you are familiar with them already, you may wish to jump ahead to the "What is the abstract interface?" section below in Optics.

What are optics?

An optic is a first-class, composable notion of substructure. As a highly abstract concept, the idea can be approached by considering several examples of optics and understanding their common features. What are the possible relationships between some "outer" type S and some "inner" type A?

(For simplicity we will initially ignore the possibility of type-changing update operations, which change A to some other type B and hence change S to some other type T. These are fully supported by the library, at the cost of some extra type parameters.)

Optics.Iso: isomorphisms

First, S and A may be isomorphic, i.e. there exist mutually inverse functions to convert S -> A and A -> S. This is a somewhat trivial notion of substructure: A is just another way to represent "all of S".

An Iso' S A is an isomorphism between S and A, with the conversion functions given by view and review. For example, given

newtype Age = Age Int

there is an isomorphism between the newtype and its representation:

       coerced :: Iso' Age Int
view   coerced :: Age -> Int
review coerced :: Int -> Age

Optics.Lens: generalised fields

If S is a simple product type (i.e. it has a single constructor with one or more fields), A may be a single field of S. More generally, A may be "part of S" in the sense that S is isomorphic to the pair (A,C) for some type C representing the other fields. In this case, there is a projection function S -> A for getting the value of the field, but the update function (setting the value of the field) requires the "rest of S" and so has type A -> S -> S.

A Lens' S A captures the structure of A being a field of S, with the projection function given by view and the update function by set. For example, for the pair type (X,Y) there are lenses for each component:

     _1 :: Lens' (X,Y) X
     _2 :: Lens' (X,Y) Y
view _1 :: (X,Y) -> X
set  _2 :: Y -> (X,Y) -> (X,Y)

(Note that the update function could arguably have the more precise type A -> C -> S, since we do not expect the result of setting a field to depend on the previous value of the field. However, making C explicit turns out to be awkward, so instead we impose laws to require that the result of setting the field depends only on C, and, more generally, that the lens behaves as we would expect.)

Optics.Prism: generalised constructors

If S is a simple sum type (i.e. it has one or more constructors, each with a single field), A may be the type of the field for a single constructor of S. More generally, S may be isomorphic to the disjoint union Either D A for some type D representing the other constructors. In this case, projecting out A from S (pattern-matching on the constructor) may fail, so it has type S -> Maybe A. In the reverse direction we have a function of type A -> S representing the constructor itself.

A Prism' S A captures the structure of A being a constructor of S, with the partial projection function given by preview and the constructor function given by review. For example, for the type Either X Y there is a prism for each constructor:

        _Left  :: Prism' (Either X Y) X
        _Right :: Prism' (Either X Y) Y
preview _Left  :: Either X Y -> Maybe X
review  _Right :: Y -> Either X Y

Optics.Traversal: multiple substructures

Alternatively, S may "contain" the substructure A a variable number of times. In this case, the projection function extracts the (possibly zero or many) elements so has type S -> [A], while the update function may take different values for different elements so has type (A -> A) -> S -> S (though in fact more general formulations are possible).

A Traversal' S A captures the structure of A being contained in S perhaps multiple times, with the list of values given by toListOf and the update function given by over . For example, for the type Maybe X there is a traversal that may return zero or one element:

         traversed :: Traversal' (Maybe X) X
toListOf traversed :: Maybe X -> [X]
over     traversed :: (X -> X) -> Maybe X -> Maybe X

(In fact, traversals of at most one element are known as affine traversals, see Optics.AffineTraversal.)

In general

So far we have seen four different kinds of optic or "notions of substructure", and many more are possible. Observe the important properties they have in common:

  • There are subtyping relationships between different optic kinds. Any isomorphism is trivially a lens and a prism (with no other fields or constructors, respectively). Any lens is a traversal (where the list of elements is always a singleton list), and any prism is also a traversal (where there will be zero or one element depending on whether the constructor matches). This was implicit in the fact that we used that we used the same operators in multiple cases: view gives the projection function of both an isomorphism and a lens, but cannot be applied to a traversal.
  • Optics can be composed. If S is isomorphic to U and U is isomorphic to A then S is isomorphic to A, and similarly for other optic kinds.
  • Composition and subtyping interact: a lens and a prism can be composed, by first thinking of them as traverals using the subtyping relationship. That is, if S has a field U, and U has a constructor A, then S contains zero or one As that we can pick out with a traversal (but in general there is neither a lens from S to A nor a prism).
  • Each optic kind can be described by certain operations it enables. For example lenses support projection and update, while prisms support partial projection and construction.
  • Optics are subject to laws, which are necessary for the operations to make sense.

The point of the optics library is to capture this common pattern.

What is the abstract interface?

A key principle behind this library is the belief that optics are useful as an abstract concept, and that the purpose of types is to capture abstract concepts and make them useful. The programmer using optics should be able to think in terms of the abstract interface, rather than the details of the implementation, and implementation choices should (as far as possible) not dictate the interface.

Each optic kind is identified by a "tag type" (such as A_Lens), which is an empty data type. The type of the actual optics (such as Lens) is obtained by applying the Optic newtype wrapper to the tag type.

type Lens  s t a b = Optic  A_Lens NoIx s t a b
type Lens' s   a   = Optic' A_Lens NoIx s   a

NoIx as the second parameter to Optic indicates that the optic is not indexed. See the "Indexed optics" section below in Optics for further discussion of indexed optics.

The details of the internal implementation of Optic are hidden behind an abstraction boundary, so that the library can be used without needing to think about the particular implementation choices.

Specification of optics interfaces

Each different kind of optic is documented in a separate module describing its abstract interface, in a standard format with at least formation, introduction, elimination, and well-formedness sections. See "Optic kinds" below in Optics for a list of these modules.

  • The formation sections contain type definitions. For example Optics.Lens defines:

    -- Type synonym for a type-modifying lens.
    type Lens s t a b = Optic A_Lens NoIx s t a b
    
  • The introduction sections describe the canonical way to construct each particular optic. Continuing with a Lens example:

    -- Build a lens from a getter and a setter.
    lens :: (s -> a) -> (s -> b -> t) :: Lens s t a b
    
  • Correspondingly, the elimination sections show how you can destruct the optic into the pieces from which it was constructed.

    -- A Lens is a Setter and a Getter, therefore you can specialise types to obtain
    view :: Lens s t a b -> s -> a
    set  :: Lens s t a b -> b -> s -> t
    
  • The computation rules tie introduction and elimination forms together. These rules are automatically fulfilled by the library (for well-formed optics).

    view (lens f g)   s ≡ f s
    set  (lens f g) a s ≡ g s a
    
  • The well-formedness sections describe the laws that each optic should obey. As far as possible, all optics provided by the library are well-formed, but in some cases this depends on invariants that cannot be expressed in types. Ill-formed optics might behave differently from what the computation rules specify.

    For example, a Lens should obey three laws, known as GetPut, PutGet and PutPut. See the Optics.Lens module for their definitions. The user of the lens introduction form must ensure that these laws are satisfied.

  • Some optic kinds have additional introduction forms, additional elimination forms or combinators sections, which give alternative ways to create and use optics of that kind. In principle these are expressible in terms of the canonical introduction and elimination rules.
  • The subtyping section gives the "tag type" (such as A_Lens), which in particular is accompanied by Is instances that define the subtyping relationship discussed in the following section.

Subtyping

There is a subtyping relationship between optics, implemented using typeclasses. The Is typeclass captures the property that one optic kind can be used as another, and the castOptic function can be used to explicitly cast between optic kinds. Is forms a partial order, represented in the graph below. For example, a lens can be used as a traversal, so there are arrows from Lens to Traversal (via AffineTraversal) and there is an instance of Is A_Lens A_Traversal.

Introduction forms (constructors) return a concrete optic kind, while elimination forms (destructors) are generally polymorphic in the optic kind they accept. This means that it is not normally necessary to explicitly cast between optic kinds, but if needed this is possible using castOptic. For example, we have

view :: Is k A_Getter => Optic' k is s a -> s -> a

so view can be used with isomorphisms or lenses, as these can be converted to a Getter.

Correspondingly, the optic kind module (e.g. Optics.Lens) does not list all ways to construct or use particular the optic kind. For example, since a Lens is also a Traversal, a Fold etc, so you can use traverseOf, preview and many other combinators with lenses.

Subtype hierarchy

This graph gives an overview of the optic kinds and their subtype relationships:

In addition to the optic kinds included in the diagram, there are also indexed variants, including IxAffineTraversal, IxTraversal, IxAffineFold, IxFold and IxSetter. These are explained in more detail in the "Indexed optics" section below in Optics.

Composition

Since optics are not functions, they cannot be composed with the (.) operator. Instead there is a separate composition operator (%). The composition operator returns the common supertype of its arguments, or generates a type error if the composition does not make sense.

The optic kind resulting from a composition is the least upper bound (join) of the optic kinds being composed, if it exists. The Join type family computes the least upper bound given two optic kind tags. For example the Join of a Lens and a Prism is an AffineTraversal.

>>> :kind! Join A_Lens A_Prism
Join A_Lens A_Prism :: *
= An_AffineTraversal

The join does not exist for some pairs of optic kinds, which means that they cannot be composed. For example there is no optic kind above both Setter and Fold:

>>> :kind! Join A_Setter A_Fold
Join A_Setter A_Fold :: *
= (TypeError ...)
>>> :t mapped % folded
...
...A_Setter cannot be composed with A_Fold
...

Comparison with lens

The lens package is the best known Haskell library for optics, and established many of the foundations on which the optics package builds (not least in quite a bit of code having been directly ported). It defines optics based on the van Laarhoven representation, where each optic kind is introduced as a transparent type synonym for a complex polymorphic type, for example:

type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t

In contrast, optics tries to preserve an abstraction boundary between the interface of optics and their implementation. Optic kinds are expressed directly in the types, as Optic is an opaque newtype:

type Lens s t a b = Optic A_Lens NoIx s t a b

The choice of representation of Optic is then an implementation detail, not essential for understanding the library. (In fact, optics uses the profunctor representation rather than the van Laarhoven representation; this affects the optic kinds and operations that can be conveniently supported, but not the essence of the design.)

Our design choice to use opaque rather than transparent abstractions leads to various consequences, both positive and negative, which are explored in the following subsections.

Advantages of the opaque design

Since the interface is deliberately chosen rather than to some extent determined by the implementation, we are free to choose a more restricted interface where doing so leads to conceptual simplicity. For example, in lens, the view function can be used with a Fold provided the result type has a Monoid instance, and the multiple targets of the Fold will be combined monoidally. This behaviour can be confusing, so in optics a Fold cannot be silently used as a Getter, and we prefer to have view work on Getters and define a separate foldOf operator for use on Folds. (But the gview function is available for users who may prefer otherwise.)

In general, opaque abstractions lead to better results from type inference (the optic kind is preserved in the inferred type):

>>> :t traversed % to not
traversed % to not
  :: Traversable t => Optic A_Fold '[] (t Bool) (t Bool) Bool Bool

Error messages are domain-specific:

>>> set (to fst)
...
...A_Getter cannot be used as A_Setter
...

Composing incompatible optics yields a sensible error:

>>> sets map % to not
...
...A_Setter cannot be composed with A_Getter
...

Since Optic is a rank-1 type, it is easy to store optics in a datastructure:

>>> :t [folded, backwards_ folded]
[folded, backwards_ folded] :: Foldable f => [Fold (f a) a]

It is possible to define aliases for optics without the monomorphism restriction spoiling the fun:

>>> let { myoptic = _1; p = ('x','y') } in (view myoptic p, set myoptic 'c' p)
('x',('c','y'))

Finally, having an abstract interface gives more freedom of choice in the internal implementation. If there is a compelling reason to switch to an alternative representation, one can in principle do so without changing the interface.

Disadvantages of the opaque design

Since Optic is a newtype, other libraries that wish to define optics must depend upon its definition. In contrast, with a transparent representation, and since the van Laarhoven representations of lenses and traversals depend only on definitions from base, it is possible for libraries to define them without any extra library dependencies (although this does not hold for more advanced optic kinds such as prisms or indexed optics). To address this, the present library is split into a package optics-core, which has a minimal dependency footprint intended for use in libraries, and the "batteries-included" optics package for use in applications.

It is something of an amazing fact that the composition operator for transparent optics is just function composition. Moreover, since Haskell uses (.) for function composition, lens is able to support a pseudo-OOP syntax. In contrast, optics must use a different composition operator (%). Optic does not quite form a Category, thanks to type-changing optics.

Rather than emerging naturally from the definitions, opportunities for polymorphism have to be identified in advance and explicitly introduced using type classes. Similarly, the set of optic kinds and the subtyping relationships between them must be fixed in advance, and cannot be added to in downstream libraries. Thus in a sense the opaque approach is more restrictive than the transparent one. There are cases in lens where the types work out nicely and permit abstraction-breaking-but-convenient shortcuts, such as applying a Traversal as a traverse-like function, whereas optics requires a call to traverseOf.

More specific differences

The sections above set out the major conceptual differences from the lens package, and their advantages and disadvantages. Some more specific design differences, which may be useful for comparison or porting code between the libraries. This list is no doubt incomplete.

  • The composition operator is (%) rather than (.) and is defined as infixl 9 instead of infixr 9.
  • Fewer operators are provided, and none of operators are exported from the main Optics module. Import Optics.Operators or Optics.Operators.State if you want them.
  • The view function and corresponding (^.) operator work only for Getters and have a more restricted type. The equivalent for Folds is foldOf, and you can use preview for AffineFolds. Alternatively you can use gview which is more compatible with view from lens, but it uses a type class to choose between view, preview and foldOf.
  • Indexed optics are rather different, as described in the "Indexed optics" section below in Optics. All ordinary optics are "index-preserving", so there is no separate notion of an index-preserving optic.
  • Each provides indexed traversals.
  • firstOf from lens is replaced by headOf.
  • concatOf from lens is omitted in favour of the more general foldOf.
  • set' is a strict version of set, not set for type-preserving optics.
  • Numbered lenses for accessing fields of tuples positionally are provided only up to _9, rather than _19.
  • There are four variants of backwards for (indexed) Traversals and Folds: backwards, backwards_, ibackwards and ibackwards_.
  • There is no Traversal1 and Fold1.
  • There are affine variants of (indexed) traversals and folds (AffineTraversal, AffineFold, IxAffineTraversal and IxAffineFold). An affine optic targets at most one value. Composing a Lens with a Prism produces an AffineTraversal, so for example matching (_1 % _Left) is well-typed.
  • Functions ifiltered and indices are defined as optic combinators due to restrictions of internal representation.
  • We can't use traverse as an optic directly. Instead there is a Traversal called traversed. Similarly traverseOf must be used to apply a Traversal, rather than simply using it as a function.
  • The re combinator produces a different optic kind depending on the kind of the input Iso, for example Review reverses to Getter while a reversed Iso is still an Iso. Thus there is no separate from combinator for reversing Isos.

Other resources

Using the library

To get started, you can just

import Optics

and if you prefer to use operators

import Optics.Operators
import Optics.Operators.State

Optic kinds

module Optics.Iso

Optic operators

Optics utilities

At

An AffineTraversal to traverse a key in a map or an element of a sequence:

>>> preview (ix 1) ['a','b','c']
Just 'b'

a Lens to get, set or delete a key in a map:

>>> set (at 0) (Just 'b') (Map.fromList [(0, 'a')])
fromList [(0,'b')]

and a Lens to insert or remove an element of a set:

>>> IntSet.fromList [1,2,3,4] & contains 3 .~ False
fromList [1,2,4]

module Optics.At

Cons

Prisms to match on the left or right side of a list, vector or other sequential structure:

>>> preview _Cons "abc"
Just ('a',"bc")
>>> preview _Snoc "abc"
Just ("ab",'c')

Each

An IxTraversal for each element of a (potentially monomorphic) container.

>>> over each (*10) (1,2,3)
(10,20,30)

Empty

A Prism for a container type that may be empty.

>>> isn't _Empty [1,2,3]
True

Re

Some optics can be reversed with re. This is mainly useful to invert Isos:

>>> let _Identity = iso runIdentity Identity
>>> view (_1 % re _Identity) ('x', "yz")
Identity 'x'

Yet we can use a Lens as a Review too:

>>> review (re _1) ('x', "yz")
'x'

In the following diagram, red arrows illustrate how re transforms optics. The ReversedLens and ReversedPrism optic kinds are backwards versions of Lens and Prism respectively, and are present so that re . re does not change the optic kind.

module Optics.Re

ReadOnly

Setter utilities for working in MonadState.

View

A generalized view function gview, which returns a single result (like view) if the optic is a Getter, a Maybe result (like preview) if the optic is an AffineFold, or a monoidal summary of results (like foldOf) if the optic is a Fold. In addition, it works for any MonadReader, not just (->).

>>> gview _1 ('x','y')
'x'
>>> gview _Left (Left 'x')
Just 'x'
>>> gview folded ["a", "b"]
"ab"
>>> runReaderT (gview _1) ('x','y') :: IO Char
'x'

This module is experimental. Using the more type-restricted variants is encouraged where possible.

Zoom

A class to zoom in, changing the State supplied by many different monad transformers, potentially quite deep in a monad transformer stack.

>>> flip execState ('a','b') $ zoom _1 $ equality .= 'c'
('c','b')

Indexed optics

The optics library also provides indexed optics, which provide an additional index value in mappings:

over  :: Setter     s t a b -> (a -> b)      -> s -> t
iover :: IxSetter i s t a b -> (i -> a -> b) -> s -> t

Note that there aren't any laws about indices. Especially in compositions the same index may occur multiple times.

The machinery builds on indexed variants of Functor, Foldable, and Traversable classes: FunctorWithIndex, FoldableWithIndex and TraversableWithIndex respectively. There are instances for types in the boot libraries.

class (FoldableWithIndex i t, Traversable t)
  => TraversableWithIndex i t | t -> i where
    itraverse :: Applicative f => (i -> a -> f b) -> t a -> f (t b)

Indexed optics can be used as regular ones, i.e. indexed optics gracefully downgrade to regular ones.

>>> toListOf ifolded "foo"
"foo"
>>> itoListOf ifolded "foo"
[(0,'f'),(1,'o'),(2,'o')]

But there is also a combinator noIx to explicitly erase indices:

>>> :t (ifolded % simple)
(ifolded % simple)
  :: FoldableWithIndex i f => Optic A_Fold '[i] (f b) (f b) b b
>>> :t noIx (ifolded % simple)
noIx (ifolded % simple)
  :: FoldableWithIndex i f => Optic A_Fold NoIx (f b) (f b) b b
λ> :t noIx (ifolded % ifolded)
noIx (ifolded % ifolded)
  :: (FoldableWithIndex i1 f1, FoldableWithIndex i2 f2) =>
     Optic A_Fold NoIx (f1 (f2 b)) (f1 (f2 b)) b b

As the example above illustrates, regular and indexed optics have the same tag in the first parameter of Optic, in this case A_Fold. Regular optics simply don't have any indices. The provided type aliases IxLens, IxGetter, IxAffineTraversal, IxAffineFold, IxTraversal, IxFold and IxSetter are variants with a single index. In general, the second parameter of the Optic newtype is a type-level list of indices, which will typically be NoIx (the empty index list) or (WithIx i) (a singleton list).

When two optics are composed with (%), the index lists are concatenated. Thus composing an unindexed optic with an indexed optic preserves the indices, or composing two indexed optics retains both indices:

λ> :t (ifolded % ifolded)
(ifolded % ifolded)
  :: (FoldableWithIndex i1 f1, FoldableWithIndex i2 f2) =>
     Optic A_Fold '[i1, i2] (f1 (f2 b)) (f1 (f2 b)) b b

In order to use such an optic, it is necessary to flatten the indices into a single index using icompose or a similar function:

λ> :t icompose (,) (ifolded % ifolded)
icompose (,) (ifolded % ifolded)
  :: (FoldableWithIndex i1 f1, FoldableWithIndex i2 f2) =>
     Optic A_Fold (WithIx (i1, i2)) (f1 (f2 b)) (f1 (f2 b)) b b

For example:

>>> itoListOf (icompose (,) (ifolded % ifolded)) [['a','b'], ['c', 'd']]
[((0,0),'a'),((0,1),'b'),((1,0),'c'),((1,1),'d')]

Alternatively, you can use one of the (<%) or (%>) operators to compose indexed optics and pick the index to retain, or the (<%>) operator to retain a pair of indices:

>>> itoListOf (ifolded <% ifolded) [['a','b'], ['c', 'd']]
[(0,'a'),(0,'b'),(1,'c'),(1,'d')]
>>> itoListOf (ifolded %> ifolded) [['a','b'], ['c', 'd']]
[(0,'a'),(1,'b'),(0,'c'),(1,'d')]
>>> itoListOf (ifolded <%> ifolded) [['a','b'], ['c', 'd']]
[((0,0),'a'),((0,1),'b'),((1,0),'c'),((1,1),'d')]

In the diagram below, the optics hierachy is amended with these (singly) indexed variants (in blue). Orange arrows mean "can be used as one, assuming it's composed with any optic below the orange arrow first". For example. _1 is not an indexed fold, but itraversed % _1 is, because it's an indexed traversal, so it's also an indexed fold.

>>> let fst' = _1 :: Lens (a, c) (b, c) a b
>>> :t fst' % itraversed
fst' % itraversed
  :: TraversableWithIndex i f =>
     Optic A_Traversal '[i] (f a, c) (f b, c) a b

Generation of optics

...with Template Haskell

module Optics.TH

...with OverloadedLabels

Optics for concrete base types