Предисловие
Мой переход на 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>.
Остальное должно быть понятно из практики и контекста по мере продвижения. Это не должно быть исчерпывающим, замысловатым руководством, но обладать достаточной информацией, чтобы охватить большинство начальных вопросов и поставить людей на правильный путь. Буква��ь, если хотите.
- Мне необходимо
- Работать с коллекциями
- Работать асинхронно
- Сообщать об ошибке или контролировать выполнение программы
- Использовать C#-библиотеки в F#
- Работать с коллекциями
Мне необходимо
Работать с коллекциями
В F# базовые типы коллекций (в основном) как правило очень похожи на C#, но часто имеют (иногда незначительные) различия в поведении для обеспечения иммутабельности. В большинстве случаев функции, которые работают с этими коллекциями, будут возвращать ссылки и не будут изменять содержимое исходной ссылки.
Подобрать тип коллекции
Что-то похожее на Array<T>
Тебе повезло! Массивы в F# такие же как в C#. Однако следует отметить несколько моментов:
Массивы в F# обычно используют нотацию
[|element|], потому что[]— это нотация для списков в F#.
Для разделения элементов коллекции в F# используется точка с запятой, а не запятая:
[|elementA;elementB|].
Для доступа по индексу в F# требуется префиксная точка перед фигурными скобками:
let myArray = [|1;2;3|] myArray.[1] // 2
F# также предлагает многомерные массивы до 4-х измерений через типы
Array2<'a>,Array3<'a>иArray4<'a>.
Что-то похожее на List<T>
По умолчанию в F# тип списка немного отличается от типа List<T> в C#.
Вот что вам нужно знать:
Списки в F# обычно используют нотацию
[element]в отличие от массивов.
Списки, как и массивы, разделяют элементы точками с запятой вместо запятых:
[elementA;elementB]
Списки в F# реализованы как односвязные списки — это означает, что добавление отдельных элементов выполняется в начале списка с помощью оператора
:::
let myList = [1;2;3] 4 :: myList // [4;1;2;3]
Если нам необходимо добавить в конец, мы можем использовать оператор
@для объединения двух списков:
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,2);(3,4)] |> Map.ofList // [1] = 2, [3] = 4
Если создаем из последовательности, где есть дубликаты, то последний элемент для данного ключа является значением:
[(1,2);(1,3)] |> Map.ofList |> Map.find 1 = 3 // true
Верен и обратный процесс: словари можно легко превратить в коллекции кортежей из двух элементов:
[(1,2);(3,4)] |> Map.ofList |> Map.toList // [(1,2);(3,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#-разработчиков:
F# имеет отдельный тип
Async<'t>, похожий наTask<T>в C#.
Из-за того, что система типов F# требует возврата, она использует
Async<unit>вместоTaskв случаях, когда мы не возвращаем фактического значения.
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
}Имейте в виду, что
let!в Async-блоках работают только при вызове Async-образующих функций — аналогично тому, как в C#awaitможно использовать только для методов, возвращающихTask.
Отличие, однако, заключается в том, что поскольку 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Так что тут происходит?
- Мы пытаемся взять переменную из нашей конфигурации. Может быть, она существует, а может и нет, но это имеет значение только для этой единственной функции.
- Затем мы перенаправляем в
Option.bind— который неявно обрабатывает логику безопасности для нас: если предыдущий шаг имеет значениеSome— используйте его в качестве аргумента этой функции, — в противном случае оставьте его какNoneи двигайтесь дальше. Option.mapделает то же самое — если есть значениеSome, используйте его с этой функцией, в противном случае просто двигайтесь дальше.
Прозорливый наблюдатель заметит, что на шаге 3 нет непосредственной разницы между bind и map — они оба автоматически обрабатывают одно и то же, верно? Но обратите внимание на разные сигнатуры между readFile и countLines — bind имеет дополнительный шаг, который производит 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 и map (и mapError, если вам это нужно) — но вместо 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.Json—FSharp.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>].