FSharpPlus divRem - как это работает? - PullRequest
0 голосов
/ 02 июля 2018

Глядя на 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?

EDIT

Таким образом, реализация 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, что не совсем идеально в сигнатуре, но может выполнять работу» даже в первом пример?

Ответы [ 2 ]

0 голосов
/ 02 июля 2018

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

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

(^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), где &r в F # походит на out r в C #. Результатом этого вызова является частное, и он присваивает остаток параметру out, заданному методу. Таким образом, эта перегрузка просто вызывает статический метод DivRem для указанного типа и возвращает кортеж quotient, remainder.

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

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

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

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

0 голосов
/ 02 июля 2018

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

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

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

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

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

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

EDIT

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

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

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

...