Constructing lenses using quotations

Earlier this month I wrote a post describing lenses in F#. Lenses are a nice tool for reducing the amount of boilerplate code required when working with record types, most notably when performing updates. However, the lenses themselves contain a fair amount of boilerplate, with most taking the form:

static member Lens_ = 
    (fun source -> source.View),
    (fun view source -> { source with View = view; })

I was intrigued, then, when I saw Dominic Graefen's post about dynamic lenses using quotations and reflection. His post outlines a way to create a type of lens that requires essentially no boilerplate code. Dominic's approach provides direct update access to record properties without the need for Lens<'TSource, 'TView> lens properties:

let original = { View = "Original Value"; }
let updated = Lens.With <@ original.View @> "New Value"

In this post I will describe how the same approach of using quotations and reflection can be used to create lens properties.

Motivation

There are two motivations for wanting lens properties on our record types.

Firstly they are potentially useful - they are reusable (i.e. a traditional lens can be passed around and applied to any instance of the source record type, whereas the dynamic versions are tied to a specific instance), composable and represent a standard pattern which can be used to interface with the code of other developers and third party libraries.

Secondly constructing the lens when the type is initialised, rather than on-demand may help improve performance. As Dominic says in his post both quotations and reflection can be detrimental to performance.

Ideally we would like a way to create lens properties with less boilerplate, e.g.:

static member Lens_ = Lens.make <@ fun source -> source.View @>

The Lens.make function accepts an expression which points to the view and returns a lens. It has the signature Expr<'TSource -> 'TView> -> Lens<'TSource, 'TView>.

Implementation

If we assume that the Lens module used in my previous post is the starting point then it's actually quite straight forward to take Dominic's approach and tweak it to construct lenses of type Lens<'TSource, 'TView>. Using a simple lambda expression to point to the property of interest also helps simplify the code that parses the quotation.

Here is a basic implementation of Lens.make with supporting functions:

[<RequireQualifiedAccess>]
module Lens = 

    [<AutoOpen>]
    module private Helpers = 

        let getProperty (expr : Expr) = 
            match expr with
            | Lambda (_, PropertyGet (_, property, _)) -> Some property
            | _ -> None

        let getUpdatedRecord<'TSource> (original : 'TSource) (property : PropertyInfo) value = 

            let recordType = typeof<'TSource>
            let properties = FSharpType.GetRecordFields recordType

            let values = 
                properties
                |> Array.map (fun property' -> 
                        if (property' = property) then
                            value
                        else
                            property'.GetValue original
                    )

            let updated = FSharpValue.MakeRecord (recordType, values)

            updated :?> 'TSource

        let withProperty expr f = 
            match (getProperty expr) with
            | Some property -> f property
            | _ -> failwith "Expression must be a lambda of form: fun source -> source.View"

        let createGet<'TSource, 'TView> (expr : Expr<'TSource -> 'TView>) = 
            withProperty expr (fun property -> 
                fun (source : 'TSource) -> property.GetValue (source) :?> 'TView
            )

        let createPut<'TSource, 'TView> (expr : Expr<'TSource -> 'TView>) = 
            withProperty expr (fun property ->
                fun (view : 'TView) (source : 'TSource) ->  getUpdatedRecord source property view
            )

    let make expr = 
        (createGet expr),
        (createPut expr)

Lenses created using Lens.make will use reflection when called but the quotation parsing is only done once, when the type is initialised. Performance-wise this approach speeds things up slightly. Using a similar approach to the author of the Reflenses project that Dominic links to in his post we can run some basic tests:

open System
open System.Diagnostics

//Records
type Person = {
    Name : String;
    Age : Int32;
}
with    
    static member Age_ = Lens.make <@ fun person -> person.Age @>

type Employee = {
    Payroll : String;
    Person : Person;
}
with    
    static member Person_ = Lens.make <@ fun employee -> employee.Person @>

//Test timer
let inline time f iterations = 

    let stopwatch = Stopwatch.StartNew ()

    for _ in 1 .. iterations do
        f () |> ignore

    stopwatch.Stop ()

    printfn "%A" stopwatch.ElapsedMilliseconds

//Test data
let employee = { Payroll = "XXXX"; Person = { Name =  "Bob"; Age = 30; }; }

//Tests of lenses using Lens.make
let lens = (Employee.Person_ >=> Person.Age_)

time (fun () -> Lens.put lens 25 employee) 1000                                 //90 ms
time (fun () -> Lens.put (Employee.Person_ >=> Person.Age_) 25 employee) 1000   //152 ms

//Tests of lenses using Lens.With
let expr = <@ employee.Person.Age @>

time (fun () -> Lens.With expr 25) 1000                                         //316 ms
time (fun () -> Lens.With <@ employee.Person.Age @> 25) 1000                    //355 ms

Both approachs can obviously be optimised further (e.g. via memoization as demonstrated here by Reflenses), but the lenses constructed by Lens.make seem to perform significantly better than dynamic lenses. As both use essentially the same code to create updated records the difference is likely entirely due to quotation parsing done by the dynamic lenses.

Conclusion

In this post I have shown how to use quotations and reflection to reduce the boilerplate code required when defining lenses for record types.

Ultimately I think that a Lens module with support for both traditional Lens<'TSource, 'TView> lenses and dynamic lenses provides the most flexibility for developers, with both approaches having pros and cons depending on the scenario.

For example, in order to update a property nested deep within a record type that is only updated once it would be preferable, in terms of readability and effort required, to simply use a dynamic lens to perform the udpate. However, when working with more frequently used properties having ready made lenses which can be reused and combined might make more sense.

Comments