不使用for..in..do的扩展计算表达式

24

我所说的扩展计算表达式是指通过CustomOperation属性定义自定义关键字的计算表达式。

在阅读有关扩展计算表达式的内容时,我发现@kvb编写了非常酷的IL DSL:

let il = ILBuilder()

// will return 42 when called
// val fortyTwoFn : (unit -> int)
let fortyTwoFn = 
    il {
        ldc_i4 6
        ldc_i4_0
        ldc_i4 7
        add
        mul
        ret
    }

没有使用for..in..do结构,我想知道操作如何组合。 我的直觉是从x.Zero成员开始,但我没有找到任何参考来验证。

如果上面的示例过于技术性,这里有一个类似的DSL,在其中列出幻灯片的组件,而不使用for..in..do

page {
      title "Happy New Year F# community"
      item "May F# continue to shine as it did in 2012"
      code @"…"
      button (…)
} |> SlideShow.show

我有几个相关的问题:

  • 如何定义或使用扩展计算表达式而不使用For成员(即提供一个小的完整示例)? 如果它们不再是单子,我不会太担心,因为我对它们在开发DSL方面很感兴趣。
  • 我们可以使用带有let!return!的扩展计算表达式吗?如果可以,有没有不这样做的原因? 我之所以问这些问题,是因为我没有遇到过使用let!return!的示例。
2个回答

14
我很高兴你喜欢那个IL的例子。最好理解表达式如何被转化为简化形式的方法可能是查看规范文档(尽管有点复杂...)。
在那里,我们可以看到像下面这样的东西:
C {
    op1
    op2
}

gets desugared as follows:

T([<CustomOperator>]op1; [<CustomOperator>]op2, [], fun v -> v, true) ⇒
CL([<CustomOperator>]op1; [<CustomOperator>]op2, [], C.Yield(), false) ⇒
CL([<CustomOperator>]op2, [], 〚 [<CustomOperator>]op1, C.Yield() |][], false) ⇒
CL([<CustomOperator>]op2, [], C.Op1(C.Yield()), false) ⇒
〚 [<CustomOperator>]op2, C.Op1(C.Yield()) 〛[]C.Op2(C.Op1(C.Yield()))

关于为什么使用Yield()而不是Zero,原因在于如果作用域中有变量(例如,使用了一些lets或者在for循环中等),则会得到Yield(v1,v2,...),但是Zero显然无法这样使用。请注意,这意味着在Tomas的lr示例中添加一个多余的let x = 1将无法编译通过,因为Yield将被调用并带有类型为int而不是unit的参数。
还有另一个技巧可以帮助理解计算表达式的编译形式,在F# 3中滥用自动引用支持计算表达式即可。只需定义一个不起作用的Quote成员,使Run返回其参数即可。
member __.Quote() = ()
member __.Run(q) = q

现在您的计算表达式将被评估为其解糖化形式的引用。在调试时,这非常方便。

1
其实我一直在等你的答复。你有你的IL DSL文档或者发布在哪里吗?这将是一个非常好的例子来获得理解和启示。Quote成员的技巧也会很有帮助。谢谢。 - pad
1
@pad - 不,它目前没有在任何地方发布 - 它只是一个非常简单的概念验证,有很多限制。我会尽力整理一下,并在不久的将来以某种形式发布出来。 - kvb
@pad - 它现在已发布于 https://github.com/kbattocchi/ILBuilder。很抱歉耽误了。 - kvb
你能否详细说明一下这个“引用”技巧。我相信它很简单,但我找不到任何文档,也不清楚如何使用它。 - Jason Kleban
1
在你的计算构建器中,像我的答案一样添加 QuoteRun 成员。这样,每当你评估你的计算表达式(例如 myBuilder { myOp1; myOp2 }),结果将是一个包含调用集的 Expr<_>,如果你没有添加 Quote 方法,编译器就会进行这些调用。希望这能有所帮助。 - kvb

9

我必须承认,当使用查询表达式功能(如CustomOperation属性)时,我并不完全理解计算表达式的工作原理。但以下是我一些实验中的观察,可能有所帮助...

首先,我认为不能自由地组合标准的计算表达式特性(例如return!等)和自定义操作。某些组合显然是允许的,但并不是所有的都可以。例如,如果我定义了自定义操作leftreturn!,那么我只能在return!之前使用自定义操作。

// Does not compile              // Compiles and works
moves { return! lr               moves { left 
        left }                           return! lr }

关于仅使用自定义操作的计算,最常见的自定义操作(orderByreverse等)通常具有类型M<'T> -> M<'T>,其中M<'T>是表示我们正在构建的东西(例如列表)的某种(可能是通用的)类型。
例如,如果我们想要构建一个表示左/右移动序列的值,我们可以使用以下Commands类型:
type Command = Left | Right 
type Commands = Commands of Command list

定制的操作如leftright可以将Commands转换为Commands并将新步骤附加到列表末尾。类似这样:

type MovesBuilder() =
  [<CustomOperation("left")>]
  member x.Left(Commands c) = Commands(c @ [Left])
  [<CustomOperation("right")>]
  member x.Right(Commands c) = Commands(c @ [Right])

请注意,这与yield不同,后者仅返回单个操作或命令,如果使用自定义操作,yield需要Combine来组合多个单独的步骤。但是如果您使用自定义操作,则永远不需要组合任何内容,因为自定义操作逐渐构建整个Commands值。它只需要一些初始的Commands值,该值在开始时被使用...
现在,我希望在那里看到Zero,但实际上它调用了一个以单位作为参数的Yield,因此您需要:
member x.Yield( () ) = 
  Commands[]

我不确定为什么会这样,但是Zero经常被定义为Yield(),所以也许目标是使用默认定义(但是正如我所说,我也希望在这里使用Zero...)
我认为将自定义操作与计算表达式结合起来是有意义的。虽然我对如何使用标准的计算表达式有很强的看法,但我确实没有关于使用自定义操作的计算的好的直觉 - 我认为社区仍需要解决这个问题 :-)。但例如,您可以像这样扩展上述计算:
member x.Bind(Commands c1, f) = 
  let (Commands c2) = f () in Commands(c1 @ c2)
member x.For(c, f) = x.Bind(c, f)
member x.Return(a) = x.Yield(a)

在某些情况下,翻译将开始需要使用 ForReturn,但在这里它们可以像 BindYield 一样定义 - 我并不完全理解何时使用哪种替代方案。

然后你可以写出类似以下的内容:

let moves = MovesBuilder()

let lr = 
  moves { left
          right }    
let res =
  moves { left
          do! lr
          left 
          do! lr }

1
谢谢Tomas,你的回答解决了我一些疑问。尽管我很喜欢这个功能,但它仍然是神秘的。缺乏文档(甚至是语义)是主要问题。 - pad

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