package lun

  1. Overview
  2. Docs

Optics in OCaml: Lun(ette).

The aim of this library is to provide simple access to user-defined types. The domain of optics for functional languages (and its documentation) can be very vast. In this respect, this library is not intended to (re)implement all this documentation with increasingly complicated types, but rather to offer a fairly "straightforward" user experience (symptomatic of OCaml developers) to solve fairly simple access and mutation problems on records and ADTs.

Introductions.

This module offers two views: lenses and prisms. The first is used for records, the second for ADTs. These views offer values of a certain type t which can subsequently be composed together. These values describe your type and they can be self-generated by a ppx.

From these values, we can use the get function or the set function (or setf). In this way, accessors can be obtained from a description of the user's types.

Lenses & Records.

Let's take the example of a "nested" record.

type t = { c : string }
type s = { a : int; b : t }

It may be difficult to update the c field from a value of type s. Indeed, we might normally write something like this:

let update_c s v =
  { s with b= { s.t with c = v } }

This style becomes more unwiedly as the level of nesting increases. Lun(ette) solves this problem by allowing you to write this instead:

let b () = Lun.lense (fun { b; _ } -> b) (fun s b -> { s with b })
let c () = Lun.lense (fun { c } -> c) (fun t c -> { t with c })
let update_c s v = Lun.(set (b >> c) v s)

The values b and c can be reused and combined with other values such as:

type r = { d : s; e : float }

let d () = Lun.lense (fun { d; _ } -> d) (fun r d -> { r with d })
let update_c' r v = Lun.(set (d >> c) v s) 

The purpose here is mainly to use values in order to get (get) or modify (set) fields in a record which can then be composed. For nested and more complex records, such a library can really facilitate their handling.

As you can see, the creation of a lense is quite simple and can be automated. That is why Lun(ette) also offers a ppx that automatically generates these values.

Mutable fields.

The optics presented here as well as those generated by our ppx do not mutate, even on fields annotated as mutable. The use of `set`/`setf` remains "referentially transparent":

type t = { mutable v : int }
let v () = Lun.lense (fun { v } -> v) (fun _ v -> { v })

let v0 = { v=0 }
let v1 = Lun.setf v ~f:succ v0

let () =
  assert (v0.v = 0);
  assert (v1.v = 1);
  v1.v <- 2;
  assert (v0.v = 0);
  assert (v1.v = 2)

Prisms and ADTs.

Previously, we have seen the lenses that characterise the "has-a" relationship. Our lense c characterises that our type s has the field c.

The prism characterises another relationship: the "is-a" relationship.

In this sense, the get operation allows us to know if a value corresponds to our focus (and potentially returns its associated value) or not. Let's take an example:

type t =
  | A of int
  | B
  | C of string

let a () = Lun.prism (fun v -> A v) @@ function
  | A v -> Result.ok v
  | x -> Result.error x

let is_a : t -> int option = Lun.get_opt a
assert (is_a (A 42) = Some 42)
assert (is_a B = None)

It should always be kept in mind that these values are composable, thus:

type t = { name : string option }

let name () = Lun.lense (fun { name } -> name) (fun _ name -> { name })
let update_name : string -> t -> t = Lun.(set (a >> some))
assert (update_name "Romain" { name= None } = { name= None })
assert (update_name "Romain" { name= Some "Patrick" } = { name= Some "Romain" })

Tuples and ADTs.

As you may know, parentheses have a syntactic meaning with respect to constructors. Foo of (int * int) != Foo of int * int. This difference is not only syntactic, but also has an impact on the representation of our Foo constructor. However, this difference may have an impact on the very definition of our prisms:

type t =
  | Foo of int * int
  | Bar of (int * int)

let foo () = Lun.prism (fun (a, b) -> Foo (a, b)) @@ function
  | Foo (a, b) -> Ok (a, b)
  | x -> Error x

let bar () = Lun.prism (fun v -> Bar v) @@ function
  | Bar v -> Ok v
  | x -> Error x

The ppx_lun.

Lun(ette) offers a ppx that can generate the values seen above. The ppx manages records, ADTs (with or without a tuple/record). It does not, however, handle GADTs.

Its use is relatively simple, it consists in annotating a type with [@@deriving lun].

type t = ..
[@@deriving lun]

The ppx will then generate values whose name is the name of the type suffixed with the name of the field (in the case of a record) or the constructor (in the case of an ADT).

type a = A
val a_A : ...

type b = { b : a }
val b_b : ...

For more details on the generation, the distribution offers a lun executable that allows to obtain the ppx result:

$ opam install ppx_lun
$ cat >main.ml <<EOF
type t = { v : int } [@@deriving lun]
EOF
$ lun main.ml
type t = {
  v: int }[@@deriving lun]
include
  struct
    let _ = fun (_ : t) -> ()
    let t_v () =
      Lun.lense (fun vObwn1M -> vObwn1M.v) (fun _ -> fun v -> { v })
    let _ = t_v
  end[@@ocaml.doc "@inline"][@@merlin.hide ]

Limitations.

The value restriction.

The first limitation is the OCaml value restriction which forces us to eta-expand our value with fun () -> .... In this case, this library is focused on using t which is the eta-expansion of s. So you should not manipulate s in our program.

For more details on this subject, we advise you to read: Polymorphism and its limitations.

However, thanks to this eta-expansion, we are able to manipulate parametric types like 'a option or 'a list:

let hd () = Lun.prism (fun v -> [ v ]) @@ function
  | v :: _ -> Ok v
  | v -> Error v

Performances.

Of course, accessing a field via Mon(ette) will be slower than its handwritten counterpart (this is the price of composability).

A Lun(ette) access can be 15 to 20 times slower than a handwritten access. However, as you can imagine, the goal of Lun(ette) is not performance but ease of handling and composing optics.

Generalized functions.

One of the most seen aspects of optics is the composability and reusability of optics with generalized functions (such as map or fold). This reuse can look very good (especially in Haskell) but it is a departure from a certain vision of what is usually done in OCaml.

Of course, this remains a matter of opinion, but if the expressiveness of Lun(ette) can be criticised (compared to what may already exist in terms of "accessors" in OCaml), the real reason remains in our ambition to offer a fairly straightforward library to use (without requiring a CS degree).

type (-'s, +'t, +'a, -'b) s

The original optic representation similar to the standard Van Laarhoven encoding.

type (-'s, +'t, +'a, -'b) t = unit -> ('s, 't, 'a, 'b) s

Type of optics where the type parameters are as follows:

  • 's the source type
  • 't the output type
  • 'a the focus of the lense
  • 'b is the type such as 'b/'a's = 't, i.e. the result type of the focused transformation that produces the necessary output 't.

NOTE: the unit type is due to the value restriction. We must eta-expand values to let the compiler to generalize correctly polymorphic values.

val lense : ('s -> 'a) -> ('s -> 'b -> 't) -> ('s, 't, 'a, 'b) s

lense prj inj makes a new optic which projects 'a from 's and injects 'b into 't from 's. For instance:

type t = { v : int }

let optic () = Lun.lense (fun { v } -> v) (fun _ v -> { v })
val prism : ('b -> 't) -> ('s -> ('a, 't) result) -> ('s, 't, 'a, 'b) s

prism inj prj makes a new optic which injects a value 'b as a 't and tries to project 'a from 's. For instance:

type t = A of int | B of string

let optic () = Lun.prism (fun n -> A n) @@ function
  | A n -> Ok n
  | v -> Error v
exception Undefined

An exception raised by get when it's not possible to project a value with an optic.

val get : ('s, 't, 'a, 'b) t -> 's -> 'a

get optic s tries to project 'a from 's. If the given optic is a lense, this function never fails. Otherwise, if the given optic is a prism, it can raise an exception Undefined. For instance:

type t = A | B
let a () = prism (fun () -> A) @@ function
  | A -> Ok ()
  | x -> Error x

let () = match Lun.get a B with
  | () -> print_endline "Unexpected A value"
  | exception Lun.Undefined -> print_endline "The given value is not A"
val get_opt : ('s, 't, 'a, 'b) t -> 's -> 'a option

get_opt optic v tries to project 'a from 's. If it's not possible (see get for more details), it returns None. Otherwise, it returns the value 'a with Some.

val set : ('s, 't, 'a, 'b) t -> 'b -> 's -> 't

set optic v t tries to inject from the given optic the value v : 'b into the given value t : 's and return it as a 't value. For instance, below, set_v and set_v' are equivalent:

type t = { v : int }
let optic = Lun.lense (fun { v } -> v) (fun _ v -> { v })

let set_v v t = Lun.set optic v t
let set_v' v _ = { v }

In the case of a prism, t is only changed if the constructor is the one described by the given optic. For instance:

type t = A of int | B of string
let a () prism (fun v -> A v) @@ function
  | A v -> Ok v
  | x -> Error x

assert (set a (A 42) (B "Hello World") = B "Hello World")
val setf : ('s, 't, 'a, 'b) t -> f:('a -> 'b) -> 's -> 't

setf optic ~f s tries to update the value 'a to 'b from 's and returns 't. For instance:

type t = A of int | B of string
let a () = prism (fun v -> A v) @@ function
  | A n -> Ok n
  | v -> Error v

let succ = Lun.setf a ~f:succ
assert (succ (A 0) = A 1) 
val (>>) : ('a, 'b, 'c, 'd) t -> ('c, 'd, 'e, 'f) t -> ('a, 'b, 'e, 'f) t

a >> b composes the given optic a with b such as b will inject what a projects and vice-versa. For instance:

{ type t = A of int option | B of string let a = Lun.prism (fun n -> A n) @@ function | A n -> Ok n | v -> Error v let succ = Lun.(setf (a >> some) ~f:succ) assert (succ (A (Some 0)) = A (Some 1)) }

Common lenses and prisms.

val fst : ('a * 'x, 'b * 'x, 'a, 'b) t
val snd : ('x * 'a, 'x * 'b, 'a, 'b) t
val some : ('a option, 'b option, 'a, 'b) t
OCaml

Innovation. Community. Security.