FSHarpPlus divRem - как это работает?

Глядя на 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 оптимальным по эффективности. Однако остается вопрос:

зачем нам вводить "двусмысленность" (o1)?

Позвольте назвать три перегрузки o1, o2, o3

Если мы прокомментируем o1 и вызываем divRem с числовым параметром, тип которого не реализует DivRem (например, int или float), то o3 не может использоваться из-за ограничения члена. Компилятор мог выбрать o2, но это не так, как сказано: "У вас есть идеальная сигнатура, перегружающая o3 (поэтому я буду игнорировать менее совершенную подпись в o2), но ограничение члена не выполняется". Поэтому, если я раскомментирую o1, я бы ожидал, что он скажет: "у вас есть две совершенные сигнатурные перегрузки (поэтому я проигнорирую менее совершенную подпись в o2), но обе они имеют невыполненные ограничения". Вместо этого, похоже, "у вас есть две совершенные сигнатурные перегрузки, но у обоих из них есть невыполненные ограничения, поэтому я возьму o2, который даже с менее совершенной подписью сможет выполнить эту работу". Не было бы правильнее избегать трюка o1 и позволить компилятору сказать: "Ваша совершенная перегрузка подписи o3 имеет невыполненное ограничение члена, поэтому я беру o2, который менее совершенен подписи, но может выполнять работу" даже в первом пример?

Ответы

Ответ 1

Сначала давайте посмотрим на документацию по перегруженным методам, о которой не так много сказать:

Перегруженные методы - это методы, имеющие одинаковые имена в заданном типе, но имеющие разные аргументы. В F # необязательные аргументы обычно используются вместо перегруженных методов. Однако перегруженные методы разрешены на языке, при условии, что аргументы находятся в форме кортежа, а не в карри.

(Акцент мой). Причина того, что аргументы должны быть в форме кортежа, заключается в том, что компилятор должен знать, в момент, когда вызывается функция, какая перегрузка вызывается. Например, если бы у нас было:

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 так как на этом этапе кода не будет знать, какую версию f следует вызывать. Таким образом, этот код будет неоднозначным.

Теперь, глядя на эти три перегруженных статических метода в классе DivRem, они имеют три разных типа подписей:

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. Рассматривая документацию по ограничениям, ограничения null и struct противоположны: null ограничение означает "предоставленный тип должен поддерживать нулевой литерал" (который исключает типы значений), а ограничение struct означает "предоставленный тип должен быть.NET. тип ценности". Поэтому первая перегрузка никогда не может быть выбрана; как указывает Густаво в своем превосходном ответе, он существует только для того, чтобы компилятор смог справиться с этим классом. (Попробуйте исключить эту первую перегрузку и вызвать divRem 5m 3m: вы обнаружите, что она не скомпилируется с ошибкой:

Тип "десятичный" не поддерживает оператор "DivRem"

Таким образом, первая перегрузка существует только для того, чтобы обмануть компилятор F # в правильном направлении. Затем мы проигнорируем его и перейдем к второй и третьей перегрузкам.

Теперь вторая и третья перегрузки различаются по типу третьего параметра. Вторая перегрузка имеет параметр, являющийся базовым классом (по Default1), а третья перегрузка имеет параметр, являющийся производным классом (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 параметра static member DivRem здесь генерировалось по строке:

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

Это происходит из-за того, как компилятор F # обрабатывает вызовы функций out параметров. В С# статический метод DivRem, который здесь просматривается, является параметром (a, b, out c). Компилятор F # преобразует эту подпись в подпись (a, b) → c. Таким образом, это ограничение типа ищет статический метод, например BigInteger.DivRem и вызывает его с параметрами (D, d, &r) где &r в F # похоже out r в С#. Результатом этого вызова является частное, и он присваивает остаток параметру out предоставленному методу. Таким образом, эта перегрузка просто вызывает статический метод DivRem для предоставленного типа и возвращает кортеж quotient, remainder.

Наконец, если предоставленный тип не имеет статического метода DivRem, то вторая перегрузка (та, которая имеет Default1 по Default1 в своей подписи), является той, которая заканчивается Default1. Он ищет перегруженные операторы *, - и / на предоставленных типах и использует их для вычисления коэффициента и остатка.

Другими словами, как объясняет Густаво, более короткий ответ объясняет, что класс DivRem будет следовать следующей логике (в компиляторе):

  • Если для используемых типов существует статический метод DivRem, вызовите его, поскольку он предположил, что он может быть оптимизирован для этого типа.
  • В противном случае вычислите фактор q как D/d, затем вычислите остаток как D - q * d.

То, что это: остальная сложность заключается в том, чтобы заставить компилятор F # поступать правильно, и в итоге divRem функция divRem, максимально эффективная.

Ответ 2

Ваша реализация просто прекрасна, она на самом деле такая же, как и вторая перегрузка, которая соответствует реализации по умолчанию.

F # + - базовая библиотека F #, аналогичная ядру F #, а также использует резервный механизм. Ядро F # использует статические оптимизации и подделывает некоторые ограничения типа небезопасным способом, но этот метод невозможен вне проекта компиляции F #, поэтому F # + достигает такого же эффекта с вызовом на основе перегруженного метода, без необходимости подделывать статические ограничения.

Таким образом, единственная разница между вашей реализацией и DivRem F # + заключается в том, что F # + сначала будет искать (во время компиляции) статический член DivRem определенный в классе используемого числового типа, со стандартным.NET подпись (используя возвращаемое значение и ссылку вместо кортежа), которая является третьей перегрузкой. Этот метод может иметь оптимизированную конкретную реализацию. Я имею в виду, предполагается, что если этот метод существует, он будет в худшем случае оптимальным, чем определение по умолчанию.

Если этот метод не существует, он будет отвисеть от определения по умолчанию, которое, как я уже сказал, является второй перегрузкой.

Первая перегрузка никогда не будет соответствовать, и там только для создания необходимой двусмысленности в наборе перегрузки.

Этот метод пока не совсем задокументирован, так как пример в документах от Microsoft немного неудачен, так как он действительно не работает (возможно, потому, что он не имеет достаточной двусмысленности), но ответ @rmunn имеет очень детальное объяснение.

РЕДАКТИРОВАТЬ

Что касается вашего обновления вопроса: это не то, как работает компилятор F #, по крайней мере прямо сейчас. Статические ограничения решаются после разрешения перегрузки, и они не возвращаются, когда эти ограничения не выполняются.

Добавление другого метода с ограничениями усложняет проблему таким образом, чтобы заставить компилятор выполнять некоторое ограничение до окончательного разрешения перегрузки.

В эти дни у нас есть некоторые дискуссии, которые спрашивают, нужно ли исправлять это поведение, что, похоже, не является тривиальной задачей.