F# type functions
Всем привет! Сегодня, по совету Владимира Матвеева (не могу не упомянуть новый и старый блоги), попытаюсь осветить такую интересную и редкую тему, как F# type functions.
Я думаю многие разработчики, изучающие F# и имеющие опыт разработки на C#, наверняка задавались вопросом: как выразить на F# некоторые встроенные в язык конструкции C#, такие как typeof(T)
, default(T)
и, возможно, гораздо более редко встречающийся оператор sizeof(T)
?
Давайте задумается, а что у этих операторов C# есть общего? Все они являются выражениями, параметризируются статически известным типом, не имеют видимых сторонних эффектов и значение результата их вычисления зависит только от явно задаваемого типа-параметра. В принципе, можно было бы заменить все эти операторы обычными generic-методами следующей сигнатуры: Type TypeOf<T>()
. Однако раз данная функция чиста и не получает аргументов, то удобнее думать о ней вообще как о значении, параметризованном типом.
Вместо встроенных в язык и грамматику конструкций, F# предлагает в стандартной библиотеке несколько таких значений, обладающих функционалом соответствующих операторов C#:
typeof(List<T>) => typeof<List<T>>
typeof(Action<,,>) => typedefof<Action<_,_,_>>
default(decimal) => Unchecked.defaultof<decimal>
sizeof(int) => sizeof<int>
Стоит отметить, что в C# введён специальный синтаксис для получения generic type definition (открытого generic-типа) – надо вовсе не указывать все типы-параметры, например: Action<>
или Dictionary<,>
. Однако в F# явные типы-параметры надо указывать всегда (отдельного синтаксиса не предусмотрено), поэтому для получения generic type definition следует пользоваться отдельным значением typedefof<T>
, в качестве T
указав generic-тип с любыми возможными конкретными типами-параметрами. Иногда можно применить небольшой приём для улучшения читаемости кода: указать в качестве типов-параметров _
– тогда вывод типов автоматически выведет соответствующие типы-параметры в тип obj
(как в примере выше).
Так вот, подобные let
-привязки значений (то есть не имеющие аргументов), параметризированных типами, в F# называются «type functions». В стандартной библиотеке F# есть ещё несколько примеров type functions, которые Вы наверняка уже замечали:
List.empty
Array.empty
Seq.empty
Map.empty
Можно снова заметить, что данные значения легко представить как чистые generic-функции без аргументов, результат которых зависит только от типа-параметра. Однако следует заметить, что указывать тип-параметр явно для данных значений оказывается совершенно не обязательно (что вовсе невозможно в случае typeof<T>
и других рассмотренных выше type functions), тип-параметр может быть неявно определён алгоритмом вывода типов F# по дальнейшему использованию. Получается, что эти type functions представляют собой некие уникальные значения, не зависящее выведенного типа-параметра. По сути, пустой список типа string
логически ничем не отличается от пустого списка типа int
, так как тип-параметр логически не используется, почему бы в данном случае не иметь удобное полиморфное значение «пустой список», не зависящее от типа-параметра?
Следует добавить, что []
и [||]
являются всего лишь синтаксическим сахаром для List.empty
и Array.empty
соответственно, то есть так же являются type functions.
Возникают вопросы: как определяются свои type functions? Почему для некоторых type functions тип-параметр требуется указывать явно, а для других – нет?
Предупреждаю сразу, что определение собственных type function в F# – очень редкое занятие в программировании на F#. Определить type function очень легко – надо всего лишь добавить явное перечисление типов-параметров для let-привязки значения:
// Список иерархии классов для типа 'T
let typeHierarchy<'T> =
let rec loop ts (t: System.Type) =
if t = null then ts
else loop (t::ts) t.BaseType
loop [] typeof<'T>
Использование:
typeHierarchy<System.IO.FileStream>
|> List.map (fun t -> t.Name);;
val it : string list =
["Object"; "MarshalByRefObject"; "Stream"; "FileStream"]
Однако следует обратить внимание на то, что явное перечисление типов-аргументов для let-привязок внутри выражений, определений типов и computation expressions запрещено, то есть type functions в F# можно определить только на уровне модуля.
В случае type function подобных typeof<T>
, тип возвращаемого значения никак не полагается на тип-параметр type function (typeof<T>
является значением обычного типа System.Type
), а следовательно, имеет смысл потребовать от пользователя явное указание типа-параметра, иначе вывод типов всегда будет предполагать тип obj
. Это можно осуществить, отметив type function специальным атрибутом [<RequiresExplicitTypeArguments>]
из стандартной библиотеки F#. Этот атрибут работает для любых let-привязок и методов, отключая вывод типов F#, что помогает избавиться от вывода типа obj
для типов-параметров, которые не могут быть корректно выведены из аргументов и должны указываться по месту вызова явно, например:
type Foo =
member this.ServicesOfType<'T>(name: string) =
...
Foo.ServicesOfType() // компилируется, но 'T = obj!
Однако в случае других type functions, таких как Seq.empty
и других, тип-параметр может быть успешно выведен по дальнейшему использованию, так как тип возвращаемого значения полагается на тип-параметр (Seq.empty<’a>
представляет собой значение типа seq<’a>
). Отлично, давайте определим тривиальную type function и попробуем её использовать следующим образом:
let myEmptyList<'a> = List.empty<'a>
let func() =
let empty = myEmptyList
(1 :: empty, "a" :: empty) // error
Получаем ошибку следующего содержания:
Type mismatch. Expecting a string list but given a int list. The type ‘string’ does not match the type ‘int’.
То есть значение empty невозможно использовать как полиморфное, как список различного типа в нескольких выражениях. Дело в том, что type значение function по умолчанию не подвергается автоматическому обобщению (automatic generalization) F#, как функции, например:
let func() =
let empty() = myEmptyList
(1 :: empty(), "a" :: empty()) // fine
Как раз для решения данной проблемы применяется атрибут [<GeneralizableValue>]
. После аннотации type function данным атрибутом, значение начинает рассматриваться как полиморфное и принимать участие в автоматическом обобщении:
[<GeneralizableValue>]
let myEmptyList<'a> = List.empty<'a>
let func() =
let empty = myEmptyList
(1 :: empty, "a" :: empty) // fine
Обычно type function следует обязательно отмечать либо атрибутом [<GeneralizableValue>]
, либо [<RequiresExplicitTypeArguments>]
, в зависимости от сценария использования.
Напоследок следует сказать, что для компилятора все обращения к type function превращаются в обычный вызов generic-функции, то есть значение type всегда вычисляется заново, что может приводить к забавным эффектам:
let zeroRef<'a> : int ref = ref 0
zeroRef := 1
printfn "%A" zeroRef // {contents = 0;}
Рекомендую определять свои type functions только убедившись, что вычисление не имеет видимых сторонних эффектов и является хорошим претендентом на то, чтобы рассматривать его как значение, параметризованное типом. Ну и понимая, что оно вам действительно надо, конечно.