четверг, 18 апреля 2013 г.

Расширение типов в F#

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

F# также поддерживает возможность расширения существующих типов, но принцип работы и логика этого всего дела несколько иная. В F# не существует таких понятий, как методы расширения, свойства расширения и т.п., вместо этого существует общее понятие под названием "расширение типов" (type extension). Причем под этим термином подразумевает два разных явления: intrinsic extensions ("внутренние расширения") и optional extensions ("необязательные расширения").

И хотя синтаксис расширений совершенно одинаковый, семантика у них принципиально разная. Intrinsic extensions – это аналог частичных типов в C#, а optional extensions – аналог методов расширения, но в более расширенном виде, поскольку мы можем добавить в существующий тип не только «экземплярные» методы, но и статические методы, свойства или события.

Intrinsic extensions a.k.a. частичные типы

Intrinsic extensions представляют собой два отдельных объявления одного типа, в результате чего несколько определений "склеиваются" в один CLR тип.

ПРИМЕЧАНИЕ
Странно, что в официальной документации не проводятся параллели между intrinsic extensions и частичными классами и между optional extensions и методами расширения; ведь с их помощью любой C#/VB разработчик сможет понять эти возможности буквально за пару минут.

При этом у "частичных" типов есть один фатальный существенный недостаток: они должны объявляться в одном и том же файле. В официальной документации к этой возможности говорится, что она отлично подходит для одновременной работы над типом двух разработчиков или при работе с автосгенерированном коде. Мне очень интересно, насколько удобно будет использовать эту возможность в таком контексте, когда оба определения находятся в одном и том же файле:)

Есть подозрение, что такое ограничение связано с тем, что одному файлу исходного класса в языке F# соответствует один модуль, который, в свою очередь преобразуется компилятором F# в статический класс. С другой стороны, в F# существует возможность объявлять типы непосредственно в пространстве имен, и почему в этом случае мы не можем объявлять «частичные» классы в разных файлах, я не знаю!

// SampleType.fs
namespace
CustomNamespace
// SampleType объявлен в пространстве имен напрямую, без модуля
type
SampleType() =
   
member public
this.foo() = ()

// SampleTypeEx.fs
namespace
CustomNamespace

type SampleType with
 
   
member public this.boo() = ()

При попытке разнести определение типа по разным файлам вы получите следующую ошибку компиляции: “Namespaces cannot contain extension members except in the same file and namespace where the type is defined. Consider using a module to hold declarations of extension members.”.

ПРИМЕЧАНИЕ
Теперь вы должны понимать, что ждать поддержки дизайнеров для таких средах как Windows Forms или WPF придется довольно долго. Ведь если я все правильно понимаю, то без изменения самого языка, мы можем рассчитывать лишь на возвращение в мир первого .NET-а, когда автосгенерированный код размещался в специализированном регионе в одном файле с остальным кодом.

Но помимо этой особенности, «частичные классы» в F# (a.k.a. intrinsic extensions) обладают рядом ограничений, которые с моей точки зрения выглядят не менее подозрительно.

// "Главное" объявление
type SampleType(a: int) = 
   
let
f1 = 42
   
let
func() = 42
 
    [<DefaultValue>]
   
val mutable
f2: int
   
member private
x.f3 = 42
 

   
static member private
f4 = 42
 
   
member private
this.someMethod() =
        printf
"a: %d, f1: %d, f2: %d, f3: %d, f4: %d, func(): %d"
            a f1 this.f2 this.f3 SampleType.f4 (func()) // Вторая "часть" объявления типа type SampleType with          member private this.anotherMethod() =
 

       
// Следующий код не будет компилироваться!         //printf "a: %d" a         //printf "f1: %d" f1         //printf "func(): %d" (func())

 
       
// Из "частчного" определения у нас есть доступ к членам (members)         // через модификаторы "this" или "SampleType"         printf "f2: %d, f3: %d, f4: %d"             this.f2 this.f3 SampleType.f4

В первом («главном») объявлении класса SampleType мы получаем 3 «значения» и 3 члена: a и f1 становятся закрытыми полями типа int, а func() – закрытой функцией, возвращающей int. Помимо этого мы объявляем еще 3 закрытых члена: f2, f3 и f4, каждый из которых тоже является закрытой переменной. Значения и члены класса в конечном итоге преобразуются компилятором в закрытые поля или закрытые методы и различаются определенными моментами, которые не важны для данного обсуждения.

Интересной особенностью является то, что из «главного» объявления у нас есть доступ ко всем закрытым частям класса, а из «частичного» объявления – только к «членам», но не «значениям»!

По словам Дона Сайма, такое поведение является «фичей», а не «багом»; let binding в типах является достаточно интересным зверем: если объявленное таким образом значение используется в позднее, то оно преобразуется в закрытое поле. В противном случае, оно преобразуется в локальную переменную конструктора. И чтобы упростить анализ того, чем является let binding его область видимости ограничена «лексической областью видимостью» (lexical scope), т.е. «главным определением типа».

Тем не менее, я не вижу особого смысла в текущей реализации intrinsic extensions, поскольку она не применима для дизайнеров, а совместная работа над одним типом двух разработчиков звучит для меня, как ересь, особенно в мире ФП, в котором типы просто не могут быть достаточно большими по определению.

Optional extensions a.k.a. extension methods

С другой стороны, не все так плохо и в отличие от языка C#, F# предоставляет более широкие возможности по расширению существующих типов. Optional extensions позволяют расширять существующие типы методами расширения (статическими и экземплярными), а также свойствами и событиями.

ПРИМЕЧАНИЕ
Еще раз напомню, что синтаксически, разницы между intrinsic extensions («частичными типами») и optional extensions («методами расширения») нет никакой. Разницу определяет компилятор, в зависимости от того, располагается расширение в том же файле или нет.

open System
open System.Globalization
 
type Int32 with


   
// Instance Method Extension     member public
this.ToHex() =
         this.ToString(
"X"
)
 
   
// Property Extension     member public this.Hex with
get() = this.ToHex()
 
   
// Static Property Extension     static member public Infinite with
get() = -1
 

   
// Static Method Extension     static member public
FromHex(s: string) =
         Int32.Parse(s.Replace(
"0x", ""
), NumberStyles.HexNumber)


  
printfn
"%s" ((32).ToHex()) // 20 printfn "%s" ((32).Hex) // 20 printfn "%d" (Int32.FromHex "0xBeBe") // 48830 printfn "%d" (Int32.Infinite) // -1

Именно за счет этой возможности F# расширяет некоторые стандартные типы BCL, такие как Array дополнительными фишками из мира функционального программирования:

Как правильно написал mstyura, F# использует не методы расширения класса Array; вместо этого используется модуль Array с набором функций. Но в принципе, расширение существующих типов с помощью методов расширения тоже возможно.

open System
// Аналог: int[] a = new [] {1, 2, 3}
let a = [|1; 2; 3|]
 
// Вызываем "стандартный метод"
let ra = Array.AsReadOnly a
 
// Вызываем "статический метод расширения"
let fa = Array.filter (fun v -> v > 2) a
printfn "Greater than 2: %A" fa // [|3|]

С помощью этой же возможности можно легко добавить ФП-дружественные методы в существующие типы; добавить фабричные методы (аналогичные методу fromHex) или дополнительные свойства.

Ложка дёгтя. Расширение перечислений

Для меня одним из «расширяемых» типов в языке C# всегда были перечисления (enums): добавить простые расчеты, метод получения строкового представления или еще что-нибудь похожее.

Поскольку F# позволяет расширять тип статическими методами или статическими свойствами, то была мысль расширить таким образом некоторые перечисления, в частности BindingFlags:

open System.Reflection
 
type BindingFlags with
    // "Свойство" для получения всех экземплярных членов перечисления     static member public AllInstanceMembers with get() =
         BindingFlags.Public ||| BindingFlags.NonPublic ||| BindingFlags.Instance

Чтобы потом использовать его следующим образом:

let instanceFields = typeof<DateTime>.GetFields(BindingFlags.AllInstance)

Но, к сожалению, при попытке сделать это, мы получим ошибку: “Enumerations cannot have members”.

И опять, такое поведение кажется немного нелогичным. Для меня расширение своих (или существующих) перечислений с помощью методов расширения всегда было полезной возможностью в языке C#. С одной стороны, благодаря размеченным объединениям эта проблема отсутствует для своих собственных перечислений в F#. Но, с другой стороны, бывает же полезно расширить существующие перечисления для упрощения работы с существующими библиотеками.

UPDATE: Решение текущей задачи с помощью модуля BindingFlags (по мотивам комментария mstyura).

Изначально мне показалось очень странным, что F# не очень строг к уникальности имен типов. Это выливается в некоторые неоднозначности с размеченными объединениями и записями, но, с другой стороны, может позволить решить проблему “расширения перечислений”. Вместо расширения существующего перечисления BindingFlags, мы можем просто объявить модуль с таким же именем:

namespace System.Reflection
 
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
[<RequireQualifiedAccess>]
module BindingFlags =
   
     
let
AllInstanceMembers =
         BindingFlags.Public |||
         BindingFlags.NonPublic |||
         BindingFlags.Instance
// Теперь используем BindingFlags.AllInstanceMembers let instanceFields = typeof<DateTime>.GetFields(BindingFlags.AllInstanceMembers)
Взаимодействие с языком C#

Методы расширения в C# и расширения типов (optional type extension) в F# являются лишь синтаксическим сахаром, о котором знают компиляторы этих языков. Так, например, метод расширения в C# - это всего лишь статический метод, помеченный атрибутом System.Runtime.CompilerServices.Extension:

[Extension]
public static class StringEx
{
    [
Extension
]
   
public static bool IsNullOrEmpty(string
s)
    {
       
return string.IsNullOrEmpty(s);
    }
}

После чего при вызове метода "".IsNullOrEmpty() компилятор будет искать не только экземплярные методы класса String, но и все методы с атрибутом ExtensionAttribute, принимающие String в качестве первого аргумента. Напрямую использовать ExtensionAttribute в языке C# мы не можем и компилятор попросит нас использовать методы расширения, но мы можем воспользоваться этими атрибутами из других языков, чтобы использовать созданные методы в качестве методов расширения из языка C#.

В этом плане, расширения типов в F# построены аналогичным образом, но вместо ExtensionAttribute используется CompilationArgumentCountsAttribute. Поскольку эти два подхода не совместимы, то для использования методов расширения, написанных на F# в других языках (как C#), придется выполнить кое-какие операции вручную:

open System
open System.Runtime.CompilerServices
 
[<Extension>]
module StringEx =
 

   
// Используем тот же синтаксис, что и для расширения типов F#     type String with
        // Опять же, тот же синтаксис, но явно указываем имя метода         [<CompiledName("IsNullOrEmpty")>]
        [<Extension>]
       
member public x.IsNullOrEmpty() =
           String.IsNullOrEmpty(x)

В этом случае, мы сможем использовать метод IsNullOrEmpty не только из F#, но и из C# или VB! К сожалению, этот трюк не поможет использовать свойства расширения, написанные на F# в языке C#!

Заключение

На мой неподготовленный взгляд, расширение типов в F# мне показались не слишком продуманным. Частичные типы сейчас вообще кажутся бесполезными, поскольку мы не можем их использовать в разных файлах. Расширения существующих типов (optional extensions) порадовали возможностью расширять типы статическими методами или свойствами. Но не очень порадовало, что этой возможностью нельзя пользоваться с перечислениями, и то, что придется попотеть, чтобы эти расширения можно было использовать в других языках.

4 комментария:

  1. По поводу одного файла и 2 определений в нем: м.б. это облегчило или элиминировало бы merge-конфликты, если над типом трудятся 2 программера :D

    ОтветитьУдалить
  2. В этом случае, мы сможем использовать метод IsNullOrEmpty не только из F#, но и из C# или VB! К сожалению, этот трюк не поможет использовать свойства расширения, написанные на F# в языке C#!

    Я чего-то не понял? Или ты написал прямо противоположное

    ОтветитьУдалить
  3. @Андрей: :D хз, честно говоря:))

    @eugene: Чтобы использовать методы расширения, написанные на F# в C# или VB нужно добавить руками атрибуты.
    Но если на свойства расширения, написанные на F# навесить эти же атрибуты, то это все равно не позволит использовать их из C# или VB.

    Вроде так и написал.

    ОтветитьУдалить
  4. Понял в чем прикол. Свойства и методы.

    ОтветитьУдалить