[Перевод] Букварь по F# для любопытствующих C#-разработчиков
Предисловие
Мой переход на 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
представлен какSomeType<'a>
.
Остальное должно быть понятно из практики и контекста по мере продвижения. Это не должно быть исчерпывающим, замысловатым руководством, но обладать достаточной информацией, чтобы охватить большинство начальных вопросов и поставить людей на правильный путь. Букварь, если хотите.
Мне необходимо
Работать с коллекциями
В F# базовые типы коллекций (в основном) как правило очень похожи на C#, но часто имеют (иногда незначительные) различия в поведении для обеспечения иммутабельности. В большинстве случаев функции, которые работают с этими коллекциями, будут возвращать ссылки и не будут изменять содержимое исходной ссылки.
Подобрать тип коллекции
Что-то похожее на
Array
Тебе повезло! Массивы в 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
По умолчанию в F# тип списка немного отличается от типа List
в 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
По мотивам списка «выглядит похоже, но не нет» — F# предоставляет стандартный Map<'key,'value>
тип, который не является родным для C# Dictionary
, но реализует обычную группу интерфейсов .NET, таких как IDictionary
и IEnumerable
Вот что вам нужно знать:
Словари могут быть созданы из любой коллекции двух элементных кортежей, где первый элемент является ключом, а второй — значением:
[(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
в C#.Из-за того, что система типов F# требует возврата, она использует
Async
вместоTask
в случаях, когда мы не возвращаем фактического значения.F# может генерировать и использовать
Task
с помощью функций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
, но часто заканчивается просто продвижением дальше 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
let getConfigVariable varName =
Private.configFile
|> Map.tryFind varName
// string -> Option
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
— и так в строке 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
) илиOption.ofObj
(для ссылочных типов), чтобы быстро обеспечить безопасность типов для вашего собственного кода.Методы в C#, которые ожидают типы делегатов, такие как
Action
илиFunc
, могут получить лямбда-выражение F# соответствующей сигнатуры, и компилятор будет обрабатывать преобразование. Помните:unit
заменяетvoid
в F# — и его()
значение — поэтомуAction
будет ожидать'T -> unit
, например(fun _ -> printfn "I'm a lambda!")
; и аналогично,Fun
ожидаетunit -> 'T
, например(fun () -> 123)
.В тех случаях, когда C#-библиотека ожидает, что объекты будут декорированы атрибутами, то для этого используется хитрость в виде
<>
, которую F# использует внутри квадратных скобок — так что[Serializable]
C# превратится в[
F#. Аргументы работают одинаково:] [
. И, как и в случае с коллекциями выше, несколько атрибутов разделяются точкой с запятой, а не запятой: например,] [
.]