Search code examples
f#computation-expression

F# Nested Computation Expression with Custom Operator


I am creating a DSL for modeling, and I would like to be able to create a Settings builder with two Custom Operations: Buffer and Constraint, which themselves are Computation Expressions. The reason for this is that the domain has terms that are heavily overloaded, and Computation Expressions allow you to provide context through the use of Custom Operations.

I cannot figure out how to get this nesting to work as intended. I have provided an example of what my desired outcome is at the bottom of the code example.

type Buffer =
    {
        Name : string
        Capacity : float
    }

type Constraint =
    {
        Name : string
        Limit : float
    }

[<RequireQualifiedAccess>]
type Setting =
    | Buffer of Buffer
    | Constraint of Constraint


type BufferBuilder (name: string) =
    member _.Yield _ : Buffer = { Name = name; Capacity = 0.0 }
    member _.Run x : Buffer = x

    [<CustomOperation("Capacity")>]
    member _.Capacity (b: Buffer, newCapacity) =
        { b with Capacity = newCapacity }

let Buffer = BufferBuilder

type ConstraintBuilder (name: string) =
    member _.Yield _ : Constraint = { Name = name; Limit = 0.0 }
    member _.Run x : Constraint = x

    [<CustomOperation("Limit")>]
    member _.Limit (b: Constraint, newLimit) =
        { b with Limit = newLimit }

let Constraint = ConstraintBuilder

type SettingsBuilder () =

    member _.Yield _ : Setting list = []
    member _.Run x : Setting list = x

    [<CustomOperation("Buffer")>]
    member _.Buffer (settings, name: string, expr) =
        // This does not work
        let newSetting = BufferBuilder name expr
        newSetting :: settings

    [<CustomOperation("Constraint")>]
    member _.Constraint (settings, name: string, expr) =
        // This does not work
        let newSetting = ConstraintBuilder name expr
        newSetting :: settings


// The Computation Expression does not work
let mySettings =
    SettingsBuilder {
        Buffer "b1" {
            Capacity 100.0
        }
        Constraint "c1" {
            Limit 10.0
        }
    }

// Below shows that the desired outcome of `mySettings` would be
let b1 = { Name = "b1"; Capacity = 100.0 }
let c1 = { Name = "c1"; Limit = 10.0 }

let desiredSettings = [
    Setting.Buffer b1
    Setting.Constraint c1
]

Solution

  • CEs don't work like that. When you write foo { ... }, the foo bit in that expression is not a function. In particular, it means that you cannot do this:

    let x = { ... }
    let y = foo x
    

    Or this:

    let f x = foo x
    let y = f { ... }
    

    Doesn't work like that. It's special syntax, not a function call. The thing in front of the curly braces has to be a CE object, with all the CE methods defined on it.

    So in particular, it means that your SettingsBuilder.Buffer function cannot accept expr and then pass it to BufferBuilder. BufferBuilder has to come immediately in front of the curly brace.

    This means that the SettingsBuilder.Buffer function should accept whatever the result of BufferBuilder is, and then, inside the CE, you should build that result using BufferBuilder and only after that pass it to the Buffer custom operation:

        [<CustomOperation("Buffer")>]
        member _.Buffer (settings, b) =
            (Setting.Buffer b) :: settings
    
        [<CustomOperation("Constraint")>]
        member _.Constraint (settings, c) =
            (Setting.Constraint c) :: settings
    
        member _.Zero () = []
    
    ...
    
    let mySettings =
        SettingsBuilder () {
            Buffer (BufferBuilder "b1" {
                Capacity 100.0
            })
            Constraint (ConstraintBuilder "c1" {
                Limit 10.0
            })
        }
    

    (note that you also have to define Zero to provide the "initial" value of your expression)

    (also note the unit () after SettingsBuilder. Without it, SettingsBuilder is a class, but you need the thing to the left of the curly brace to be an object)

    I understand you wanted the nice syntax like Buffer "foo" { ... } instead of the extra BufferBuilder and ugly parentheses in there, but I don't think that can be done. In general, there is no way to have a custom operation behave like a "nested" expression.


    Consider an alternative approach: ditch the outer CE, and instead define the settings as a list, each element built with its corresponding CE.

    You'll need those inner CEs to each produce a Setting so that their results can be elements of the same list. This can be achieved by modifying their Run methods to wrap the resulting value in the relevant Setting constructor:

    type BufferBuilder (name: string) =
        ...
        member _.Run x = Setting.Buffer x
    
    type ConstraintBuilder (name: string) =
        ...
        member _.Run x = Setting.Constraint x
    
    ...
    
    let mySettings = [
        BufferBuilder "b1" {
            Capacity 100.0
        }
        ConstraintBuilder "c1" {
            Limit 10.0
        }
    ]