F#自定义运算符嵌套计算表达式

3
我正在创建一个建模DSL,希望能够创建一个Settings构建器,并使用两个自定义操作:BufferConstraint,它们本身是计算表达式。原因是该领域具有重载的术语,而计算表达式允许您通过使用自定义操作来提供上下文。
我无法弄清楚如何按预期工作。在代码示例底部提供了所需结果的示例。
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
]
2个回答

9

CEs并不像那样工作。 当您编写foo {...}时,该表达式中的foo部分不是函数。 特别是,这意味着您不能这样做:

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

或者是这个:
let f x = foo x
let y = f { ... }

不是这样工作的。这是一种特殊的语法,而不是函数调用。花括号前面的东西必须是一个CE 对象,并且其上定义了所有CE方法。

因此,特别地,这意味着您的SettingsBuilder.Buffer函数不能接受expr,然后将其传递给BufferBuilderBufferBuilder必须紧接在花括号前面。

这意味着SettingsBuilder.Buffer函数应该接受BufferBuilder结果,然后在CE中使用BufferBuilder构建该结果,并且只有在那之后才将其传递给Buffer自定义操作:

    [<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
        })
    }

(请注意,您还需要定义 Zero 来提供表达式的“初始”值)
(还要注意,在 SettingsBuilder 后面加上单位()。 如果没有,SettingsBuilder 就是一个类,但是您需要左大括号左边的东西是一个对象)
我理解您想要像 Buffer "foo" { ... } 这样的漂亮语法,而不是使用额外的BufferBuilder和难看的括号, 但我认为无法做到。一般来说,没有办法让自定义操作像“嵌套”的表达式那样行为。
考虑另一种方法:放弃外部 CE,而是将设置定义为列表,每个元素都使用其相应的 CE 构建。
您需要这些内部 CE 中的每一个生成一个 Setting,以便它们的结果可以成为同一列表的元素。这可以通过修改其 Run 方法以在相关的Setting构造函数中封装结果值来实现:
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
    }
]

我相信我已经修复了拼写错误。 - Matthew Crews

4
上述答案在具体细节上是正确的,但您可以创建一个看起来像您想要的API形状。
抱歉回答有点长,但关于CE和它们的构建器的能力,实际上很少有信息。您还应该查看Bolero的HTML构建器,其中包含一些非常不错的想法。
如果提供适当的_.Yield_.Combine_.Delay实现,您可以使用嵌套计算。此外,由于F# 6.0 [<InlineIfLambda>]属性,您可以为这些类型的表达式生成非常好的、高效的内联代码。
以下代码提供了您尝试使用稍微不同的计算构建器SettingsBuilder实现的API。
...
let mySettings =
    Settings {
        Buffer "b1" {
            Capacity 100.0
        }
        Constraint "c1" {
            Limit 10.0
        }
    }
...
val mySettings: Setting list =
  [Buffer { Name = "b1"
            Capacity = 100.0 }; Constraint { Name = "c1"
                                             Limit = 10.0 }]


让API符合您的要求的关键是不要在SettingsBuilder上为BufferConstraint创建自定义操作,而是创建按名称创建BufferBuilderConstraintBuilder实例的函数,然后包括_.Yield方法,该方法接受这些构建器的_.Run(...)方法的结果。
...
let inline Buffer name = BufferBuilder name
...
let inline Constraint name = ConstraintBuilder name
...
type SettingsBuilder () =
...
    member inline _.Yield (b:Buffer) = Setting.Buffer b
    member inline _.Yield (c:Constraint) = Setting.Constraint c
...

您还需要设置正确的_.Delay_.Combine方法,使用_.Delay而不是_.Zero非常关键。如果使用Zero,则必须在Buffer "b1" { ... }行之前使用yield,例如:yield Buffer "b1" { ... },否则Buffer "b1" { ... }的值将被忽略,因为它们会被编译为F#代码Sequences而不是_.Delay调用。

注意* 如果CE中出现奇怪的东西,请尝试将CE视为引用。 <@ Settings { Buffer "b1" { Capacity 100.0 } } @>。这将生成许多语法树节点,但可以解释为什么某些内容被忽略!

我还向SettingsBuilder添加了一个自定义操作empty,以便您可以指定空的设置结果(即[])

...
let emptySettings =
    Settings {
        empty
    }
...
val emptySettings: Setting list = []

由于我们正在为BufferConstraint设置动态分配构建器,因此我们希望它们成为结构体,以便在使用它们时不需要支付堆分配的代价。当您的构建器将动态创建时,请确保在Builder类型上放置[<Struct>]属性;否则,请使用普通类,因为它将通过静态属性引用访问构建器实例。

另外,_.Yield (u:unit)_.Delay(a:unit->unit)_.For(s:unit,f:unit->unit)方法共同使得empty操作只能单独存在于Settings CE中。

你可以在这里找到代码的要点Sharplab.IO Gist
type Buffer =
    {
        Name : string
        Capacity : float
    }

type Constraint =
    {
        Name : string
        Limit : float
    }

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


// we make this type a Struct so that we can allocate on the stack for very low cost
type [<Struct; NoComparison; NoEquality>] BufferBuilder (name:string) =
    member this.Yield _ : Buffer = { Name = name; Capacity = 0.0 }
    member inline _.Run x : Buffer = x

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

let inline Buffer name = BufferBuilder name

type [<Struct; NoComparison; NoEquality>] ConstraintBuilder (name:string) =
    member this.Yield _ : Constraint = { Name = name; Limit = 0.0 }
    member inline _.Run x : Constraint = x

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

let inline Constraint name = ConstraintBuilder name

type SettingsBuilder () =
    
    member inline _.Yield (u:unit) = () // used to indicate we are at the front of the computation expression with nothing defined yet
    member inline _.Yield (b:Buffer) = Setting.Buffer b
    member inline _.Yield (c:Constraint) = Setting.Constraint c
    // we use Delay and InlineIfLambda so that the aggressive F# compiler inlining will remove all the "function" calls
    // this produces straight line code after compilation
    member inline _.Delay([<InlineIfLambda>] a:unit -> Setting list) = a() // normal delay method for
    
    member inline _.Delay([<InlineIfLambda>] a:unit -> Setting) = [a()] // used to convert the last setting in the computation
                                                                        // expression into Setting list
                                                                        
    member inline _.Delay([<InlineIfLambda>] a:unit -> unit) = []       // used to allow empty to be used by itself
    
    member inline _.Combine(x1 : Setting, x2 : Setting list) =          // this is working backwards from the end of the computation
                                                                        // to the front
        x1 :: x2
    member inline _.For(s:unit, [<InlineIfLambda>] f:unit -> unit) =    // this makes empty only allowed in an empty Settings expression
        f()
    [<CustomOperation("empty")>]
    member inline _.Empty(s:unit) = () // this only can be called if the computation expression prior to here is the value unit which
                                       // can only happen if _.Yield (u:unit) = () was called prior to this and by returning unit we force
                                       // the For and Delay operations to have to use unit->unit which mean we can restrict this operation to
                                       // be the only operation allowed
    member inline _.Run(s:Setting) = [s] // allow a single Setting to be returned as a list
    member inline _.Run (x : Setting list) = x

let Settings = SettingsBuilder()

// This Computation Expression does work, in with the same API shape you would like to have
let mySettings =
    Settings {
        Buffer "b1" {
            Capacity 100.0
        }
        Constraint "c1" {
            Limit 10.0
        }
    }
(* THIS IS THE RESULTING DISASSEMBLED IL THAT mySettings becomes IN C#:
            Settings@67 = new @_.SettingsBuilder();
            builder@72 = new @_.BufferBuilder("b1");
            builder@72-1 = @_.builder@72;
            b@25 = new @_.Buffer(builder@72-1.name, 0.0);
            @_.Setting head = @_.Setting.NewBuffer(new @_.Buffer(@_.b@25.Name@, 100.0));
            builder@75-2 = new @_.ConstraintBuilder("c1");
            builder@75-3 = @_.builder@75-2;
            b@35-1 = new @_.Constraint(builder@75-3.name, 0.0);
            mySettings@70 = FSharpList<@_.Setting>.Cons(head, FSharpList<@_.Setting>.Cons(@_.Setting.NewConstraint(new @_.Constraint(@_.b@35-1.Name@, 10.0)), FSharpList<@_.Setting>.Empty));
*)   
let emptySettings =
    Settings {
        empty
    }
(* THIS IS THE RESULTING DISASSEMBLED IL THAT emptySettings becomes IN C#:
            emptySettings@90 = FSharpList<@_.Setting>.Empty;
*)

// 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
]

(* THIS IS THE RESULTING DISASSEMBLED IL THAT desiredSettings becomes IN C#:
            b1@99 = new @_.Buffer("b1", 100.0);
            c1@100 = new @_.Constraint("c1", 10.0);
            desiredSettings@102 = FSharpList<@_.Setting>.Cons(@_.Setting.NewBuffer(@_.b1), FSharpList<@_.Setting>.Cons(@_.Setting.NewConstraint(@_.c1), FSharpList<@_.Setting>.Empty));
*)

mySettings = desiredSettings




1
此外,如果您将BufferBuilder和ConstraintBuilder声明为Struct类型,则分配基本上是免费的,因为它们不再位于堆上。 - Patrick Simpson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接