Предисловие


Мой переход на F# в качестве излюбленного языка был слегка усеян препятствиями. Примерно через десять лет почти постоянного использования C# у меня пробудилось любопытство, когда я услышал об этом другом #-языке. Моя первая реакция была той, которую с тех пор видел у других C#-разработчиков — отрицание, — C# является хорошим языком, и мне с ним комфортно, так зачем тратить силы на изучение другого? Но любопытство осталось — и, по крайней мере, несколько раз выделил вечер, чтобы прочитать базовый вводный пост и попытаться написать каких-нибудь ката на F#. Это не прижилось, потому что я просто чувствовал себя потерянным и не мог воплотить свой опыт использования C# в ощущение даже отдаленного комфорта с F#. Достаточно легко опустить фигурные скобки, немного замяться, чтобы не забыть let вместо var — но как сделать то, что я хотел?


Тогда я этого не осознавал, но, на мой взгляд, наблюдал потенциальный недостаток в том, как F#-разработчики говорят, описывают и представляют свой язык внешнему миру. Существует обширная база материалов обо всех возможностях и функциональности F#: Algebraic Data Types, Exhaustive Matching, Type Inference и т.д. Есть много статей, посвященных тому, как решать широкий спектр задач с помощью F#. Но, как мне кажется, не хватает чего-то вроде следующего: некоторых указаний о том, как взять то, что вам уже удобно в C#, и перевести их на F#. Так что мне интересно, можем ли мы как-то закрыть этот недостаток.


При этом от читателя требуется немного — поверхностное знакомство с тремя основными моментами синтаксиса F#:


  • let используется как var в C# — для объявления переменной;
  • |> — это оператор пайпа (piping) в F#, который берет результат левой части и передает его в качестве аргумента для правой части;
  • F# использует строчные буквы и апостроф для аннотаций обобщенного типа, поэтому SomeType<T> представлен как SomeType<'a>.

Остальное должно быть понятно из практики и контекста по мере продвижения. Это не должно быть исчерпывающим, замысловатым руководством, но обладать достаточной информацией, чтобы охватить большинство начальных вопросов и поставить людей на правильный путь. Букварь, если хотите.





Мне необходимо


Работать с коллекциями


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


Подобрать тип коллекции


Что-то похожее на Array<T>

Тебе повезло! Массивы в F# такие же как в C#. Однако следует отметить несколько моментов:


  1. Массивы в F# обычно используют нотацию [|element|], потому что [] — это нотация для списков в F#.


  2. Для разделения элементов коллекции в F# используется точка с запятой, а не запятая: [|elementA;elementB|].


  3. Для доступа по индексу в F# требуется префиксная точка перед фигурными скобками:


    let myArray = [|1;2;3|]
    myArray.[1] // 2

  4. F# также предлагает многомерные массивы до 4-х измерений через типы Array2<'a>, Array3<'a> и Array4<'a>.



Что-то похожее на List<T>

По умолчанию в F# тип списка немного отличается от типа List<T> в C#.


Вот что вам нужно знать:


  1. Списки в F# обычно используют нотацию [element] в отличие от массивов.


  2. Списки, как и массивы, разделяют элементы точками с запятой вместо запятых: [elementA;elementB]


  3. Списки в F# реализованы как односвязные списки — это означает, что добавление отдельных элементов выполняется в начале списка с помощью оператора :::


    let myList = [1;2;3]
    4 :: myList // [4;1;2;3]

  4. Если нам необходимо добавить в конец, мы можем использовать оператор @ для объединения двух списков:


    let listA = [1;2]
    let listB = [3;4]
    listA @ listB // [1;2;3;4]


Что-то похожее на Dictionary<TKey,TValue>

По мотивам списка «выглядит похоже, но не нет» — F# предоставляет стандартный Map<'key,'value> тип, который не является родным для C# Dictionary<TKey,TValue>, но реализует обычную группу интерфейсов .NET, таких как IDictionary<TKey,TValue> и IEnumerable<T>


Вот что вам нужно знать:


  1. Словари могут быть созданы из любой коллекции двух элементных кортежей, где первый элемент является ключом, а второй — значением:


    [(1,2);(3,4)] |> Map.ofList // [1] = 2, [3] = 4

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


    [(1,2);(1,3)] |> Map.ofList |> Map.find 1 = 3 // true

  3. Верен и обратный процесс: словари можно легко превратить в коллекции кортежей из двух элементов:


    [(1,2);(3,4)] |> Map.ofList |> Map.toList // [(1,2);(3,4)]

  4. Встроенный тип Map в F# не очень хорошо подходит для использования в C#, в случаях интеропа мы можем создать более удобный для C# словарь IDictionary, используя функцию dict с любой коллекцией кортежей из двух элементов. Но учтите, что это по-прежнему неизменяемая структура, и при попытках добавить в нее элементы будет генерироваться исключение.


    [(1,2);(3,4)] |> dict


Подобрать функцию


Одно важное различие между F# и C#, когда дело доходит до работы с коллекциями, заключается в том, что в C# вы, как правило, оперируете над экземпляром коллекции, используя метод этого типа через точку; в то время как F# предпочитает предоставлять семейства функций в модулях, которые принимают экземпляры в качестве аргумента. Итак, C#-вариант myDictionary.Add(someKey,someValue) в F# будет Map.add someKey someValue myMap.


Просто хочу свой LINQ


F# предлагает функции, аналогичные тем, с которыми программисты на C# знакомы по LINQ, но названия часто отличаются, поскольку F# использует систему условных обозначений, которая больше соответствует терминологии, используемой в остальной части мира функционального программирования. Будьте уверены, они в основном ведут себя так, как вы и ожидаете. Дабы не утомлять — LINQ огромен, — я сопоставлю, по моему опыту, наиболее распространенные методы LINQ и их аналоги на F#:


  • .Aggregate() именуется как .fold или .reduce, в зависимости от того, предоставляете ли вы начальное состояние или просто используете первый элемент, соответственно;
  • .Select() именуется как .map;
  • .SelectMany() именуется как .collect;
  • .Where() именуется как .where или .filter (одно и то же, два имени, длинная история)
  • .All() именуется как .forall;
  • .Any() именуется как .exists, если мы подаем предикат, или .isEmpty, если мы просто хотим знать, есть ли в коллекции какие-либо элементы;
  • .Distinct() по-прежнему как .distinct или .distinctBy, если мы подаем функцию проекция;
  • .GroupBy() по-прежнему как .groupBy;
  • .Min() и .Max() по-прежнему остаются как .min и .max с альтернативами .minBy и .maxBy для использования проекции
  • .OrderBy() именуется как .sortBy, и аналогично .OrderByDescending() именуется как .sortbyDescending;
  • .Reverse() именуется как .rev;
  • .First() именуется как .head, если нам нужен первый элемент, или .find, если нам нужен первый элемент, который соответствует предикату. Точно так же вместо .FirstOrDefault() мы используем .tryHead и .tryFind, которые вернут Option, являющимся либо Some matchingValue, либо None, когда он не найден, вместо того, чтобы выбрасывать исключение;
  • .Single() именуется как .exactlyOne, и аналогично .SingleOrDefault() именуется как .tryExactlyOne.

Не уверен, какая функция нужна. У меня есть


Коллекция, а хочу

Отдельное значение или элемент

  • .min, .minBy, .max и .maxBy найдут элемент коллекции относительно других;
  • .sum, .sumBy, .average, .averageBy;
  • .find, .tryFind, .pick и .tryPick позволят найти один конкретный элемент коллекции;
  • .head, .tryHead, .last и .tryLast найдут элементы из начала или конца коллекции;
  • .fold и .reduce позволят применить логику и использовать каждый элемент коллекции для формирования другого значения;
  • .foldBack и .reduceBack делают то же самое, но с конца коллекции.

Равное количество элементов

  • .map позволит преобразовать каждый элемент коллекции;
  • .indexed свернет каждый элемент вашей коллекции в кортеж, первым элементом которого является индексом в коллекции: например, [1] станет [(0,1)];
  • .mapi делает это неявно, учитывая индекс в качестве дополнительного первого аргумента функции маппинга;
  • .sort, .sortDescending, .sortBy и .sortByDescending позволяют изменить порядок вашей коллекции.

Возможно меньшее количество элементов

  • .filter вернет коллекцию, содержащую только элементы, соответствующие указанному предикату;
  • .choose похож на .filter, но заодно позволяет маппить элементы;
  • .skip вернет оставшиеся элементы после игнорирования первых n;
  • .take и .truncate возвращают первые n-элементов, выбрасывая или нет исключение, соответственно;
  • .distinct и .independentBy позволят удалить дубликаты из коллекции.

Возможно большее количество элементов

  • .collect применит функцию создания коллекции к каждому элементу вашей коллекции и объединит все результаты воедино.

Чтобы изменить форму коллекции

  • .windowed вернет новую коллекцию всех групп размером n из исходной коллекции: например, [1; 2; 3] станет [[1; 2]; [2; 3]], когда n = 2;
  • .groupBy вернет новую коллекцию кортежей, где первый элемент является ассоциативным ключом, а второй — набором начальных элементов, которые соответствуют ассоциации: например, [1; 2; 3], преобразованной (fun i -> i % 2), приведет к [(0, [2]); (1, [1; 3])];
  • .chunkBySize вернет новую коллекцию, содержащую до n коллекций оригинала: например, [1; 2; 3] станет [[1; 2]; [3]], когда n = 2;
  • .splitInto вернет новую коллекцию, содержащую n коллекций одинакового размера из исходного: например, [1; 2; 3] станет [[1]; [2]; [3]], когда n = 3.

Чтобы пройти по коллекции без ее изменения

  • .iter и .iteri берут и применяют функцию к каждому элементу вашей коллекции, но не возвращают никакого значения.

Отдельное значение и хочу

Чтобы было частью коллекции

  • .singleton можно использовать для создания коллекции из одного элемента из значения;
  • .init примет размер и функцию инициализатора и создаст новую коллекцию этого размера.

Несколько коллекций и хотите

Скомбинировать их

  • .append принимает две коллекции и создает новую единую коллекцию, содержащую все элементы обеих;
  • .concat делает то же самое, но для коллекции коллекций;
  • .map2 и .fold2 действуют как выше указанные .map и .fold, но будут предоставлять элементы из одного индекса в двух исходных коллекциях для функции маппинга / свертки;
  • .allPairs принимает две коллекции и образует все перестановки по 2 элемента между ними;
  • .zip и .zip3 берут 2 (или 3) коллекции и создают одну коллекцию, состоящую из кортежей элементов из одного индекса в источниках.

Работать асинхронно


Модель асинхронности в F# похожа на модель в C#, но имеет несколько важных отличий, которые иногда застают врасплох C#-разработчиков:


  1. F# имеет отдельный тип Async<'t>, похожий на Task<T> в C#.


  2. Из-за того, что система типов F# требует возврата, она использует Async<unit> вместо Task в случаях, когда мы не возвращаем фактического значения.


  3. F# может генерировать и использовать Task<T> с помощью функций Async.StartAsTask и Async.AwaitTask из базовой библиотеки.



У F# есть еще одно очень заметное отличие от C# в отношении асинхронного кода: C# "включает" ключевое слово await внутри метода, применяя ключевое слово async к сигнатуре этого метода; F# использует языковую функцию, называемую computation expression, в результате чего асинхронность становится частью тела функции. Это также имеет некоторые последствия на то, как вы пишете код внутри этого тела функции:


let timesTwo i = i * 2 // У нас есть определение нашей базовой функции

// И теперь мы можем сделать это асинхронным

let timesTwoAsync i = async { // Обратите внимание, что при работе с computation expression мы начинаем с нашего ключевого слова, а затем с самой функции внутри фигурных скобок
   return i * 2 // Мы также используем ключевое слово `return` для завершения выражения
}

let timesFour i = async {
    let! doubleOnce = timesTwoAsync i // Обратите внимание  на `!` в нашем `let!` — это похоже на `await` в C# — правосторонняя функция должна возвращать `Async<'a>`
    // После того, как мы связали результат асинхронной функции с помощью `let!` — мы можем использовать его потом как обычно
    let doubleTwice = timesTwo doubleOnce // В случае неасинхронных функций мы можем написать наш код как обычно

    return doubleTwice
}

  1. Имейте в виду, что let! в Async-блоках работают только при вызове Async-образующих функций — аналогично тому, как в C# await можно использовать только для методов, возвращающих Task.


  2. Отличие, однако, заключается в том, что поскольку F# обрабатывает асинхронность исключительно в теле функций, нет никаких требований в том, какие функции можно связывать с let! — все, что возвращает Async<'a>, допустимо. Это разнится с требованиям C# о том, что вы можете применять await только к методам, помеченным как async.



Сообщать об ошибке или контролировать выполнение программы


Во-первых, определение: когда мы говорим об ошибках и выполнении программы, я не имею в виду исключения — в F# они есть и вполне схожим образом работают как в C#. Я имею в виду предсказуемые и потенциально исправимые ошибки; потому что эта та область, в которой F# с первого взгляда может показаться похож на C#, но очень быстро становится очевидно, насколько они различаются. В частности, это проявляется в использовании значения null как распространенного сигнала об ошибки в C#. Это не редкий паттерн в C#, который выглядит примерно так:


public Foo DoSomething(Bar bar)
{
    if (bar.IsInvalid)
    {
        return null;
    }

    return new Foo(bar.Value);
}

И затем, вызывающий DoSomething может проверить возвращаемое значение на null и либо обработать, либо передать его дальше. По моему опыту, одна из областей, где это часто возникает — это функция LINQ FirstOrDefault(), которая используется, чтобы избежать исключения в случае пустого IEnumerable<T>, но часто заканчивается просто продвижением дальше null.


Изначально кажется, что F# пытается осуществить это с помощью своего типа Option<'a> — и часто возникает вопрос: не является ли None просто ярлыком для null, за исключением того, что теперь труднее получить значение обернутое в Some? Потому что для этого потребуется pattern matching или проверка .HasValue для опции — и действительно ли это лучше? Это не так, и именно поэтому F# посредством функционального программирования предлагает более чистое решение: разрабатывать основную часть кодовой базы, не беспокоясь о проверке на существующие ошибки, а вместо этого беспокоясь только об оповещении потенциально новых, специфичных для данной функции. Мы можем сделать это, написав большинство наших функций так, как будто входные данные уже были проверены для нас, и затем, с помощью функций map или bind, связать наши безответственные функции вместе. Давайте посмотрим на них в контексте Option:


  • map требуется два аргумента: функция 'a -> 'b и Option<'a>, из которых она будет генерировать Option<'b>;
  • bind также требует два аргумента: функция 'a -> Option<'b> и Option<'a>, из которых она будет генерировать Option<'a>.

Давайте посмотрим, что они могут для нас сделать:


// string -> Option<string>
let getConfigVariable varName =
    Private.configFile
    |> Map.tryFind varName

// string -> Option<string[]>
let readFile filename =
    if File.Exists(filename)
        then Some File.ReadLines(filename)
        else None

// string[] -> int
let countLines textRows = Seq.length file

getConfigVariable "storageFile"                 // 1
|> Option.bind readFile                         // 2
|> Option.map countLines                        // 3

Так что тут происходит?


  1. Мы пытаемся взять переменную из нашей конфигурации. Может быть, она существует, а может и нет, но это имеет значение только для этой единственной функции.
  2. Затем мы перенаправляем в Option.bind — который неявно обрабатывает логику безопасности для нас: если предыдущий шаг имеет значение Some — используйте его в качестве аргумента этой функции, — в противном случае оставьте его как None и двигайтесь дальше.
  3. Option.map делает то же самое — если есть значение Some, используйте его с этой функцией, в противном случае просто двигайтесь дальше.

Прозорливый наблюдатель заметит, что на шаге 3 нет непосредственной разницы между bind и map — они оба автоматически обрабатывают одно и то же, верно? Но обратите внимание на разные сигнатуры между readFile и countLinesbind имеет дополнительный шаг, который производит flatten (прим. перев.: разворачивает вложенную структуру, Option.flatten) над параметром Option, который выводит его функция. Рассмотрим альтернативу: если бы мы использовали map, то в конце строки 2 у нас было бы Option<Option<string[]>> — и так в строке 3 нам потребуется Option.map (Option. map countLines)!


Но возникает вопрос, как мне на самом деле получить значение, если оно является выводом этого Option? И это справедливый вопрос. И ответ — избегать этого как можно дольше. Поскольку, чем позже вы откладываете попытку развернуть Option, тем меньше кода вам нужно написать, который хоть как-то предполагает, что ошибка возможна. И в тот момент, когда вам, наконец, определенно необходимо получить значение, у вас есть два варианта:


  • Option.defaultValue принимает 'a и Option<'a> — если Option имеет значение, он возвращает его, в противном случае он возвращает значение 'a, которое вы ему дали.
  • Option.defaultWith — то же самое, но вместо значения для генерации значения требуется функция unit -> 'a.

Так уж совпало, что та же самая логика применима к встроенному в F# типу Result<'a,'b>, который также предлагает bind и mapmapError, если вам это нужно) — но вместо None у вас есть вариант Error, который вы можете использовать для хранения информации о том, что пошло не так — будь то string или пользовательский тип ошибки по вашему выбору.


Использовать C#-библиотеки в F


Одно из восхитительных преимуществ F# — и, вероятно, почему C#-разработчик сначала смотрит на него, а не на что-то вроде Haskell, — это то, что он является частью большой экосистемы .NET и поддерживает взаимодействие со всеми C#-библиотеками, с которыми разработчик уже знаком. Код на C# может (в основном) использоваться в F#, но иногда возникают некоторые затруднения, но обычно с легкими обходными путями:


  • При вызове C#-методов компилятор F# рассматривает метод как кортеж с одним аргументом. Из-за этого частичное применение строго невозможно, и пайпинг может быть затруднен из-за перегрузки:


    "1" |> Int32.Parse                          // Подобно Int32.Parse("1")
    ("1", NumberStyles.Integer) |> Int32.Parse  // Подобно Int32.Parse("1", NumberStyles.Integer)
    NumberStyles.Integer |> Int32.Parse "1"     // Не компилируется, потому что ожидает кортежный аргумент, а не два отдельных аргумента.

  • C#-Библиотеки — особенно те, которые включают сериализацию или рефлексию, — часто не приспособлены для понимания встроенных типов F#. Наиболее распространенным случаем здесь являются библиотеки JSON, которые могут затрудняются над сериализацией и/или десериализацией Unions и Records — в таких случаях настоятельно рекомендуется проверить на существование библиотеки расширений, которая предоставляет специфичную функциональность F#. Например, Newtonsoft.Json имеет пакет Newtonsoft.Json.FSharp, System.Text.JsonFSharp.SystemTextJson. С другой стороны, в этих случаях может быть также хорошо проверить нативные библиотеки на F# подобно Thoth или Chiron.


  • Благодаря возможности C# создавать null для любого ссылочного типа, и отсутствию (на момент написания) (прим. перев.: fsharp/fslang-suggestions#577) встроенного интеропа для обозначения nullable reference type в C#, полезно попытаться изолировать код C# на внешнем уровне вашей логики и использовать утилиты, такие как Option.ofNullable (для Nullable<T>) или Option.ofObj (для ссылочных типов), чтобы быстро обеспечить безопасность типов для вашего собственного кода.


  • Методы в C#, которые ожидают типы делегатов, такие как Action<T> или Func<T>, могут получить лямбда-выражение F# соответствующей сигнатуры, и компилятор будет обрабатывать преобразование. Помните: unit заменяет void в F# — и его () значение — поэтому Action<T> будет ожидать 'T -> unit, например (fun _ -> printfn "I'm a lambda!"); и аналогично, Fun <T> ожидает unit -> 'T, например (fun () -> 123).


  • В тех случаях, когда C#-библиотека ожидает, что объекты будут декорированы атрибутами, то для этого используется хитрость в виде <>, которую F# использует внутри квадратных скобок — так что [Serializable] C# превратится в [<Serializable>] F#. Аргументы работают одинаково: [<DllImport('user32.dll', CharSet = CharSet.Auto)>]. И, как и в случае с коллекциями выше, несколько атрибутов разделяются точкой с запятой, а не запятой: например, [<AttributeOne; AttributeTwo>].