FSharpPlus divRem - 它是如何工作的?

FSharpPlus divRem - how does it work?

看着 FSharpPlus 我在想如何创建一个要在

中使用的通用函数
let qr0  = divRem 7  3
let qr1  = divRem 7I 3I
let qr2  = divRem 7. 3.

并提出了一个可能的(可行的)解决方案

let inline divRem (D:^T) (d:^T): ^T * ^T = let q = D / d in q,  D - q * d

然后我查看了 FSharpPlus 是如何实现它的,我发现:

open System.Runtime.InteropServices

type Default6 = class end
type Default5 = class inherit Default6 end
type Default4 = class inherit Default5 end
type Default3 = class inherit Default4 end
type Default2 = class inherit Default3 end
type Default1 = class inherit Default2 end

type DivRem =
    inherit Default1
    static member inline DivRem (x:^t when ^t: null and ^t: struct, y:^t, _thisClass:DivRem) = (x, y)
    static member inline DivRem (D:'T, d:'T, [<Optional>]_impl:Default1) = let q = D / d in q,  D - q * d
    static member inline DivRem (D:'T, d:'T, [<Optional>]_impl:DivRem  ) =
        let mutable r = Unchecked.defaultof<'T>
        (^T: (static member DivRem: _ * _ -> _ -> _) (D, d, &r)), r

    static member inline Invoke (D:'T) (d:'T) :'T*'T =
        let inline call_3 (a:^a, b:^b, c:^c) = ((^a or ^b or ^c) : (static member DivRem: _*_*_ -> _) b, c, a)
        let inline call (a:'a, b:'b, c:'c) = call_3 (a, b, c)
        call (Unchecked.defaultof<DivRem>, D, d)    

let inline divRem (D:'T) (d:'T) :'T*'T = DivRem.Invoke D d

我相信这样做是有充分理由的;但是我对为什么这样做不感兴趣,但是:

这是如何运作的?

是否有任何文档可以帮助理解此语法的工作原理,尤其是三个 DivRem 静态方法重载?

编辑

因此,FSharp+ 实现的优势在于,如果 divRem 调用中使用的数字类型实现了 DivRem 静态成员(例如 BigInteger),它将用于代替可能存在的算术运算符。假设 DivRem 比调用默认运算符更有效,这将使 divRem 的效率最佳。然而还有一个问题:

为什么要引入"ambiguity"(o1)?

我们称这三个重载为 o1、o2、o3

如果我们注释掉 o1 并使用类型未实现 DivRem(例如 int 或 float)的数字参数调用 divRem,则由于成员约束,无法使用 o3。编译器可以选择 o2,但它没有,就像它所说的 "you have a perfect signature matching overload o3 (so I will ignore the less than perfect signature in o2) but the member constraint is not fulfilled"。因此,如果我取消注释 o1,我希望它显示 "you have two perfect signature overloads (so I will ignore the less than perfect signature in o2) but both of them have unfulfilled constraints"。相反,它似乎在说 "you have two perfect signature overloads but both of them have unfulfilled constraints, so I will take o2 that, even with a less than perfect signature, can do the job"。即使在第一个实例中,避免 o1 技巧并让编译器说 "your perfect signature overload o3 has an unfulfilled member constraint, so I take o2 which is less than perfect in signature but can do the job" 不是更正确吗?

你的实现很好,其实和第二个重载一样,对应默认实现

F#+是一个F#基础库,类似于F#核心,同样使用fallback机制。 F# 核心使用静态优化并以不安全的方式伪造某些类型约束,但这种技术在 F# 编译器项目之外是不可能的,因此 F#+ 通过对重载方法的特征调用实现相同的效果,而无需伪造静态约束。

因此,您的实现与 F#+ 中的实现之间的唯一区别是 F#+ 将首先(在编译时)查找 [=35= 中定义的 DivRem 静态成员] 正在使用的数字类型,具有标准的 .NET 签名(使用 return 值和引用,而不是元组),这是第三个重载。这个方法可以有一个优化的、特定的实现。我的意思是,假设如果存在这种方法,它在最坏的情况下与默认定义同样最优。

如果此方法不存在,它将回退到默认定义,正如我所说,这是第二次重载。

第一个重载永远不会匹配,它的存在只是为了在重载集中产生必要的歧义。

这项技术目前还没有很好的记录,因为微软的 example in the docs 有点不幸,因为它并没有真正起作用,(可能是因为它没有足够的歧义),但是@rmunn的回答有很详细的解释。

编辑

关于您的问题更新:这不是 F# 编译器的工作方式,至少现在是这样。重载决议后正在解决静态约束,当这些约束不满足时它不会回溯。

添加另一个带有约束的方法会使问题复杂化,迫使编译器在最终重载解析之前进行一些约束求解。

这些天我们some discussions质疑是否应该纠正这种行为,这似乎不是一件小事。

先来看看documentation on overloaded methods,没啥好说的:

Overloaded methods are methods that have identical names in a given type but that have different arguments. In F#, optional arguments are usually used instead of overloaded methods. However, overloaded methods are permitted in the language, provided that the arguments are in tuple form, not curried form.

(强调我的)。要求参数采用元组形式的原因是因为编译器必须能够知道,在调用函数的地方,正在调用哪个重载。例如,如果我们有:

let f (a : int) (b : string) = printf "%d %s" a b
let f (a : int) (b : int) = printf "%d %d" a b

let g = f 5

那么编译器将无法编译 g 函数,因为它不知道 此时代码 [=16] 的哪个版本=] 应该被调用。所以这段代码会产生歧义。

现在,查看 DivRem class 中的三个重载静态方法,它们具有三种不同的类型签名:

static member inline DivRem (x:^t when ^t: null and ^t: struct, y:^t, _thisClass:DivRem)
static member inline DivRem (D:'T, d:'T, [<Optional>]_impl:Default1)
static member inline DivRem (D:'T, d:'T, [<Optional>]_impl:DivRem  )

此时,您可能会问自己编译器将如何在这些静态重载之间进行选择:如果省略第三个参数,则第二个和第三个似乎无法区分,如果给出第三个参数但是DivRem 的实例,那么它与第一个重载看起来不明确。此时,将该代码粘贴到 F# Interactive 会话中会有所帮助,因为 F# Interactive 将生成更具体的类型签名,可以更好地解释它。这是我将该代码粘贴到 F# Interactive 时得到的结果:

type DivRem =
  class
    inherit Default1
    static member
      DivRem : x: ^t * y: ^t * _thisClass:DivRem -> ^t *  ^t
                 when ^t : null and ^t : struct
    static member
      DivRem : D: ^T * d: ^T * _impl:Default1 -> ^a *  ^c
                 when ^T : (static member ( / ) : ^T * ^T -> ^a) and
                      ( ^T or  ^b) : (static member ( - ) : ^T * ^b -> ^c) and
                      ( ^a or  ^T) : (static member ( * ) : ^a * ^T -> ^b)
    static member
      DivRem : D: ^T * d: ^T * _impl:DivRem -> 'a * ^T
                 when ^T : (static member DivRem : ^T * ^T * byref< ^T> -> 'a)
    static member
      Invoke : D: ^T -> d: ^T -> ^T *  ^T
                 when (DivRem or ^T) : (static member DivRem : ^T * ^T * DivRem -> ^T * ^T)
  end

这里的第一个DivRem实现是最容易理解的;它的类型签名与 FSharpPlus 源代码中定义的相同。查看documentation on constraintsnullstruct约束是相反的:null约束意味着"the provided type must support the null literal"(不包括值类型),而struct约束的意思是"the provided type must be a .NET value type"。所以实际上永远无法选择第一个重载;正如古斯塔沃在他的出色回答中指出的那样,它的存在只是为了让编译器能够处理这个 class。 (尝试省略第一个重载并调用 divRem 5m 3m:你会发现它无法编译并出现错误:

The type 'decimal' does not support the operator 'DivRem'

所以第一个重载的存在只是为了欺骗 F# 编译器做正确的事情。然后我们将忽略它并继续进行第二个和第三个重载。

现在,第二个和第三个重载的区别在于第三个参数的类型。第二个重载的参数是基础 class (Default1),第三个重载的参数是派生的 class (DivRem)。这些方法将 总是 DivRem 实例作为第三个参数调用,那么为什么要选择第二个方法呢?答案在于第三种方法自动生成的类型签名:

static member
  DivRem : D: ^T * d: ^T * _impl:DivRem -> 'a * ^T
             when ^T : (static member DivRem : ^T * ^T * byref< ^T> -> 'a)

此处的 static member DivRem 参数约束由以下行生成:

(^T: (static member DivRem: _ * _ -> _ -> _) (D, d, &r)), r

发生这种情况是因为 F# 编译器如何处理对带有 out 参数的函数的调用。在 C# 中,此处要查找的 DivRem 静态方法是一个带有参数 (a, b, out c) 的方法。 F# 编译器将该签名转换为签名 (a, b) -> c。因此,此类型约束会查找 BigInteger.DivRem 之类的静态方法,并使用参数 (D, d, &r) 调用它,其中 F# 中的 &r 类似于 C# 中的 out r 。该调用的结果是商,并将余数分配给提供给该方法的 out 参数。所以这个重载只是在提供的类型上调用 DivRem 静态方法,并且 returns 一个 quotient, remainder.

的元组

最后,如果提供的类型没有 DivRem 静态方法,则第二个重载(签名中带有 Default1 的重载)将最终被调用。该函数在提供的类型上查找重载的 *-/ 运算符,并使用它们计算商和余数。

换句话说,正如 Gustavo 的简短回答所解释的那样,此处的 DivRem class 将遵循以下逻辑(在编译器中):

  • 如果正在使用的类型有一个静态 DivRem 方法,请调用它,因为假定它可能针对该类型进行了优化。
  • 否则,计算商qD / d,然后计算余数为D - q * d

就是这样:剩下的复杂性只是强制 F# 编译器做正确的事情,并最终得到一个尽可能高效的漂亮 divRem 函数。