вторник, 22 октября 2019 г.

Встраиваемые функции и статически разрешаемые параметры типов в F#


Читая документацию по данным вопросам, я ловил себя на мысли, что какие-то важные детали от меня ускользают, причем вовсе даже не потому, что что-то непонятно описано, а потому, что в документации отсутствуют какие-то важные детали. Поэтому решил разобраться в этом более детально.

Куда и как встраиваются функции


В документации читаем: «Встроенные функции — это функции, интегрированные непосредственно в вызывающий код.». Не знаю, может быть это и вполне исчерпывающее определение, но мне непонятно. Поэтому, как говорится, «будем посмотреть» куда они там встраиваются. Смотреть будем при помощи декомпилятора ILSpy.
Создаем проект консольного приложение на F# и вставляем в его главный модуль следующий код
open System


let inline sumandmult x y z = x + y * z

[<EntryPoint>]
let main argv =
    sumandmult 1 2 3 |> printfn "%O"
    sumandmult 1.0 2.0 3.0 |> printfn "%O"
    sumandmult 1I 2I 3I |> printfn "%O"
    0 // return an integer exit code

Далее собираем (или запускаем) проект, после чего декомпилируем полученную программу и смотрим что получилось. ILSpy декомпилирует в C#. Собственно, код самой функции нам не очень интересен, поскольку функции, как следует из определения встраиваются в вызывающий код, поэтому нам больше интересно посмотреть функцию main, то есть ровно то самое место, где функция вызывалась трижды с аргументами разных типов. И видим мы следующее:
        [<EntryPoint>]
        public static int main(string[] argv)
        {
            int num = 1;
            int num2 = 2;
            int num3 = 3;
            int num4 = num + num2 * num3;
            FSharpFunc<int, Unit> fSharpFunc = ExtraTopLevelOperators.PrintFormatLine(new PrintfFormat<FSharpFunc<int, Unit>, TextWriter, Unit, Unit, int>("%O"));
            int func = num4;
            fSharpFunc.Invoke(func);
            double num5 = 1.0;
            double num6 = 2.0;
            double num7 = 3.0;
            double num8 = num5 + num6 * num7;
            FSharpFunc<double, Unit> fSharpFunc2 = ExtraTopLevelOperators.PrintFormatLine(new PrintfFormat<FSharpFunc<double, Unit>, TextWriter, Unit, Unit, double>("%O"));
            double func2 = num8;
            fSharpFunc2.Invoke(func2);
            BigInteger left = NumericLiterals.NumericLiteralI.FromOne<BigInteger>();
            BigInteger left2 = NumericLiterals.NumericLiteralI.FromInt32<BigInteger>(2);
            BigInteger right = NumericLiterals.NumericLiteralI.FromInt32<BigInteger>(3);
            BigInteger bigInteger = left + left2 * right;
            FSharpFunc<BigInteger, Unit> fSharpFunc3 = ExtraTopLevelOperators.PrintFormatLine(new PrintfFormat<FSharpFunc<BigInteger, Unit>, TextWriter, Unit, Unit, BigInteger>("%O"));
            BigInteger func3 = bigInteger;
            fSharpFunc3.Invoke(func3);
            ExtraTopLevelOperators.PrintFormatLine(new PrintfFormat<Unit, TextWriter, Unit, Unit, Unit>("Hello World from F#!"));
            return 0;
        }

Из этого кода сразу становится ясно, что все вызовы функции были фактически заменены ее кодом, в котором подставлены куда надо как аргументы, так и их реальные типы. Также из этого несложно понять, почему такие функции поддерживают только статически разрешаемые джинерики: раз нет вызовов функции, стало быть и разрешить ее параметры типов во время исполнения невозможно.
Также в документации говорится: «Однако чрезмерное использование встроенных функций может привести к тому, что ваш код будет менее устойчив к изменениям в оптимизации компилятора и реализации библиотечных функций.». Честно говоря, о каких изменениях в оптимизации компилятора идет речь, сказать сложно, однако с реализацией библиотечных функций все более-менее ясно. Рассмотрим ситуацию, когда некая библиотека программы содержит такую функцию и предоставляет ее другим библиотекам. Поскольку вызовы такой функции по сути дела заменяются ее кодом, то получается, что код, единожды определенный в одной функции, оказывается разбросанным по разным библиотекам программы. Теперь если вдруг, в этом коде обнаружились проблемы и его изменили, то программу, вроде как, надо обновить. И теперь получается, что замена одной проблемной библиотеки не даст эффекта, поскольку код из нее был ранее импортирован другими библиотеками и они уже используют свои версии этого кода, не обращаясь к первоисточнику. Что делать? Заменять все библиотеки, ссылающиеся на ту, что исправлена? Но это тоже не гарантирует, что будет заменено все, поскольку встроенная функция может вызываться из другой такой же функции другой библиотеки, а та, в свою очередь, вызываться из третьей библиотеки, не имеющей непосредственной ссылки на первую, но фактически опосредованно импортирующей ее код. А тогда что делать? Переустанавливать всю программу?

 

Статически разрешаемые параметры типов


Снова обратимся к документации и прочитаем: «Статически разрешаемые параметры типа в первую очередь полезны в сочетании с ограничениями элементов, которые являются ограничениями, позволяющими указать, что аргумент типа должен иметь определенный элемент или элементы для использования. Невозможно создать этот тип ограничения с помощью обычного параметра универсального типа.».
Под элементами тут подразумеваются члены (трудности перевода), то есть, говоря по-простому, данный тип джинериков позволяет задать для типа ограничения, требующие наличия у него некоего свойства с заданными именем и типом, метода с заданной сигнатурой или поддержку определенного оператора.
Для понимания того, насколько важно иметь возможность таких ограничений, приведу очень простой пример. Допустим нам нужна простая функция, скажем Add, принимающая два аргумента и возвращающая их сумму. При этом мы хотим, чтобы функция обрабатывала аргументы любых типов, главное, чтобы они поддерживали операцию сложения. Если мы будем реализовывать это на C# или VB.Net, то единственным вменяемым способом это сделать будет отказ от статической типизации.
        static dynamic Add(dynamic x, dynamic y)
        {
            return x + y;
        }
То есть, если в процессе написания кода мы передадим этому методу объекты, к которым нельзя будет применить оператор +, то узнаем мы об этом только во время исполнения, также получим потерю производительности на динамической типизации, и на выходе будет объект неизвестного типа, что также повлечет за собой необходимость выполнения дополнительных телодвижений при использовании результата. Мало того, если вспомнить, что в C# «динамики» появились только в 4-ой версии, то для более ранних версий и этой возможности нет, там только рефлексия спасет, что имеет те же недостатки, только усилия по динамической типизации придется предпринять самостоятельно (в бейсике этой проблемы не было никогда).
Фактически для этих языков данная задача решения не имеет. Это только очень простой пример, на практике же очень часто требуется написать универсальный код, который мог бы обрабатывать объекты разных типов, но не связанных близкородственными связями.
 Проблема заключается в том, что полиморфизм подтипов накладывает на типы избыточные требования. Ведь так, если разобраться, то для чего нужно ограничивать тип параметра функции? В коде функции мы выполняем с аргументами некоторые манипуляции, что порой подразумевает обращение к различным его членам и для того, чтобы это не вызывало ошибок во время исполнения нужно, чтобы все эти члены у объекта присутствовали. Обеспечивает ли полиморфизм подтипов выполнение этого требования? Да, если мы обращаемся к членам некоторого класса или интерфейса, то принадлежность к нему объекта автоматически обеспечивает поддержку им всего, что присуще этому типу. Но, во-первых, интерфейс может содержать десятки членов, а в функции будут использоваться только один-два. Тем не менее, для того, чтобы объект подходил под ограничения реализовать придется все, хотя бы в виде заглушек, правда для того, чтобы все ненужное реализовать в виде заглушек, надо как-то узнать, что именно в коде не используется. Другая проблема в том, что если требуется реализация некоторого интерфейса, то не подойдет ни реализация всех его членов без привязки к интерфейсу, ни, например, реализация другого интерфейса с абсолютно идентичным кодом. А ведь на самом деле не имеет значения, откуда объект получил то или иное свойство или метод – код вполне в состоянии обработать объект, у которого есть в наличии все необходимое.
Таким образом, необходимость в наличии подобных ограничений, как мне кажется, вполне очевидна.

 

Привязка к членам в теле функции


Здесь, как выясняется, не все так гладко как хотелось бы. Следующий код не скомпилируется
let inline printName< ^a when ^a: (member Name: string)> (x: ^a) =
    x.Name |> printfn "%s"
И хотя, казалось бы, мы заявили, что тип параметра x должен содержать свойство Name, тем не менее компилятор пишет, что невозможно сделать такое с неизвестным типом. В документации по этому поводу ничего внятного я не нашел, так что пришлось разбираться.
На странице документации Статически разрешаемые параметры типов есть пример кода, в котором мы находим следующее

    // default implementation of replace
    static member inline replace< ^a, ^b, ^c, ^d, ^e when ^a :> CFunctor and (^a or ^d):
(static member fmap: (^b -> ^c) * ^d -> ^e) > (a, f) =
        ((^a or ^d) : (static member fmap : (^b -> ^c) * ^d -> ^e) (konst a, f))

    // call overridden replace if present
    static member inline replace< ^a, ^b, ^c when ^b: (static member replace: ^a * ^b -> ^c)>(a: ^a, f: ^b) =
        (^b : (static member replace: ^a * ^b -> ^c) (a, f))

let inline replace_instance< ^a, ^b, ^c, ^d when (^a or ^c): (static member replace: ^b * ^c -> ^d)> (a: ^b, f: ^c) =
    ((^a or ^c): (static member replace: ^b * ^c -> ^d) (a, f))


Здесь в телах функций и методов мы видим странные конструкции, описаний которых в документации я не нашел, но понять, что они означают – совсем несложно.  Выражение в скобках состоит из двух частей: в первой синтаксис совпадает с синтаксисом ограничений джинериков на наличие у объекта члена (в данном случае статического метода), во второй – кортеж объектов, как несложно догадаться, это аргументы, передаваемые методу.  В частности
(^b : (static member replace: ^a * ^b -> ^c) (a, f))
Это означает, что у типа ^b находим статический метод replace c заданной сигнатурой и передаем ему аргументы a и f. Данное выражение будет возвращать ровно то, что возвратит этот метод. Также тут есть варианты, когда нужный метод ищется у одного из двух типов, но все это, как уже было сказано, совпадает с синтаксисом ограничений и не нуждается в дополнительных разъяснениях. Здесь же мы разберемся в тех вопросах, которые из данных примеров не вполне ясны.
Для начала было бы неплохо выяснить, как обращаться к методам экземпляра, поскольку все три примера из документации обращаются к статическим методам. В сети довольно сложно найти по этому поводу внятную информацию, но мне удалось найти пример, из которого я выяснил, что для методов экземпляра в таком выражении ссылку на экземпляр надо передавать во втором кортеже на первой позиции и только потом аргументы. То есть, если мы хотим у строки mystring вызвать метод Substring с одним параметром, то выглядеть это должно следующим образом:
let inline endOfStr mystring index = (^a: (member Substring: int -> ^a) (mystring, index))
С получением значения свойства тоже все довольно просто. Например, нам нужно вывести на консоль значение свойства Length: int у любого объекта, у которого есть такое свойство.
let inline printLength obj = (^a: (member Length: int) (obj)) |> printfn "%i"
Далее можно выводить, скажем, длину массивов вот так. Но проблема в том, что с присвоением значения свойству все не так просто. Если мы попробуем сделать как-то так
(^a: (member MyProp: int) (obj)) <- 5
То ничего не получится, поскольку данная конструкция фактически является вызовом метода, а присвоение значения вызванному методу не имеет смысла.
Решение этой проблемы состоит в прямом обращении к сеттеру свойства. Как известно, акцессоры свойства создают обычные методы с именами типа get_ИмяСвойства и set_ИмяСвойства. Таким образом, если у нас есть класс A со свойством Prop, вот такой
type A() =
    member val Prop = 0 with get, set
То мы можем написать вот такую функцию, с помощью которой будем присваивать значение свойству Prop
let inline setProp obj newVal = ( ^a: (member set_Prop: ^b -> unit) (obj, newVal))
И далее так
let a = A()
setProp a 24
a.Prop |> printfn "%i"
Этот код выведет 24, что означает, что значение свойству было присвоено. Кроме того, получить значение свойства мы можем двумя способами
( ^a: (member Prop: ^b) (obj)
( ^a: (member get_Prop: unit -> ^b) (obj))
То есть можно обратиться как к свойству, так и к его геттеру.
Далее возникает вопрос, как работать с полями. Ну с получением значения вроде все ясно, там так же, как и со свойствами, но вот как насчет присвоения нового значения, ведь у полей нет акцессоров и по идее, обращение к ним не должно возыметь эффекта. Но на самом деле тут все предусмотрено и с полями можно работать точно так же, как и со свойствами, обращаясь к их как бы существующим акцессорам (ну по крайней мере с сеттером все работает, с геттером не проверял).
С событиями дело обстоит также, как и со свойствами. Каждое событие порождает пару методов, с помощью которых можно подписаться на событие и отписаться от него, методы имеют имена add_ИмяСобытия и remove_ИмяСобытия. Принимают они EventHandler того типа, которому принадлежит само событие. Для примера можно создать проект, добавить ссылки на System.Windows.Forms.dll и System.Drawing.dll и в главном модуле разместить следующий код
open System
open System.Windows.Forms


let inline addClick ctrl =
    let eh = EventHandler(fun o e -> MessageBox.Show("Object clicked") |> ignore)
    (^a:(member add_Click: EventHandler -> unit) (ctrl, eh))

let showForm ()=
    use f = new Form()
    use btn = new Button()
    btn.Text <- "Click me"
    f.Controls.Add(btn)
    addClick btn
    f.ShowDialog() |> ignore


[<EntryPoint>]
let main argv =
    printfn "%A" argv
    showForm ()
    0 // return an integer exit code

Здесь у нас функция addClick добавляет к объекту ctrl неизвестного типа обработчик события Click типа System.EventHandler который в окне сообщений выводит текст “Objekt clicked”. А функция ShowForm создает форму, добавляет на нее кнопку, устанвливает текст кнопки и передает ее функции addClick, после чего отображает форму. При запуске приложения можно убедиться, что форма появляется и при клике по кнопке появляется окошко сообщений с указанным текстом.
Думаю, об отписке от события подробно писать нет смысла, но есть еще пара вопросов, которые следовало бы рассмотреть.
Как ни странно, данная конструкция немного расширяет возможности ограничений типа. В частности, в декларации параметров типа нельзя потребовать от типа наличия у него параметризированного конструктора, но с помощью данной привязки это ограничение можно обойти, хотя и не без проблем.
Во всех предыдущих примерах мы не указывали параметры типа в декларациях функций, вместо этого параметры типов и их ограничения выводила система выведения типов, опираясь на привязки, использованные в теле функции. Выясняется, что вызвать параметрический конструктор с помощью такой привязки тоже возможно, что автоматически создаст такое ограничение для параметра типа, что невозможно, если явно прописывать такое ограничение в декларации.
Создадим класс B со следующим кодом.
type B<'a>(i) =
    member this.I:'a = i
    override this.ToString() = sprintf "B: {I: %A}" this.I
Класс имеет конструктор с одним параметром, ридонли свойство I, возвращающее то, что получил конструктор (тип параметра конструктора и свойства определен параметром типа 'a), также в нем переопределен метод ToString для лучшего отображения на консоли.
Можно теперь написать что-то типа такого
let inline getXList x il = x::[for i in il -> (^a:(new: ^b -> ^a) (i))]
Здесь мы получаем объект x неизвестного типа и коллекцию элементов другого типа. На базе всего этого создаем коллекцию объектов того же типа, что и x, причем создаем их, вызвав параметрический конструктор этого типа и передавая ему элементы коллекции il как аргументы. И в начало списка пришпандериваем сам объект x. Это прекрасно работает в частности с классом B и, скажем, списком целых чисел, дробных чисел или строк
    [3;5;8;12] |> getXList (B(4)) |> printfn "%A"
    [3.0;5.0;8.0;12.0] |> getXList (B(4.0)) |> printfn "%A"
    "est" |> getXList (B('T')) |> printfn "%A"
Результат будет таким
[B: {I: 4}; B: {I: 3}; B: {I: 5}; B: {I: 8}; B: {I: 12}]
[B: {I: 4.0}; B: {I: 3.0}; B: {I: 5.0}; B: {I: 8.0}; B: {I: 12.0}]
[B: {I: 'T'}; B: {I: 'e'}; B: {I: 's'}; B: {I: 't'}]
Но есть небольшая проблема. В данном примере мы явно передаем экземпляр целевого типа в функцию, а поскольку код функции таков, что по этому экземпляру можно легко понять, что требуется вызывать конструктор этого же типа, то проблемы нет. Другой вопрос, если мы хотим, чтобы функция создавала экземпляр и при этом не требовала другой экземпляр. Например, такая
let inline createB i = (^a:(new: ^b -> ^a) (i))
Как в данном случае сообщить функции при вызове, что нужно создавать именно экземпляр класса B? В обычных условиях можно при вызове явно задать параметры типа. Но под обычными условиями я понимаю те, при которых параметры типа объявлены явно. Но в данном случае мы явно ничего не объявляли, а если бы мы объявили явно параметры типа, то и ограничения для них пришлось бы писать явно, поскольку в этом случае система выведения типа не работала бы. Но указать явно такое ограничение мы не можем, поскольку это не предусмотрено.
Фактически, если мы сделаем вот так
createB<_, B<_>> 5 |> printfn "%A"
То компилятор будет ругаться, и сообщит нам о том, что раз параметры не заданы явно в декларации, то и при вызове их явно задавать нельзя. Тем не менее этот код и скомпилируется, и выполнится правильно.  Естественно, в ситуации, где выводится много параметров с разными ограничениями, вызов таких функций может оказаться делом совсем непростым, поскольку надо точно расположить аргументы типа, опираясь на то, как их расположила система выведения типов. Тем не менее, несмотря на все сложности, данный подход работает и если уж «кровь из носу» надо, то можно его использовать, хотя бы ограниченно.
Ну и, конечно же, индексаторы. К сожалению, получить к ним доступ мне не удалось, поскольку при попытке достучаться до get_Item, компилятор мне сообщил, что это не поддерживается. Способа обхода этого ограничения я не нашел.



 Во вложении проект с примерами из статьи.