Введение в F#
Евгений Лазин, Максим Моисеев, Давид Сорокин |
Аннотация: Данная статья призвана познакомить читателя с новым, приобретающим популярность языком программирования F#, который является достойным наследником традиций семейства языков ML. F# поддерживается компанией Microsoft как один из основных .NET языков в их флагманском продукте для разработчиков — Visual Studio 2010. Статья описывает основные возможности F# и ориентирована в основном на .NET программистов, которые хотели бы выйти за рамки уютного C#, погрузиться в функциональную парадигму и изучить новый язык. Кроме того, описание расширенных возможностей языка может показаться интересным для практикующих функциональных программистов.
This article is an introduction into the emerging new programming language F#, derived from ML. F# is supported by the Microsoft in its flagship development environment Visual Studio 2010. Article describes the main features of F#. The target audience of this article is .NET programmers looking for something new outside of the cosy C# world, ready to dive into functional paradigm and learn new languages. Description of the advanced language features would be of interest to seasoned functional programmers.
Обсуждение статьи ведётся в LiveJournal.
1 Введение
F# — это мультипарадигменный язык программирования, разработанный в подразделении Microsoft Research и предназначенный для исполнения на платформе Microsoft .NET. Он сочетает в себе выразительность функциональных языков, таких как OCaml и Haskell с возможностями и объектной моделью .NET.
История F# началась в 2002 году, когда команда разработчиков из Microsoft Research под руководством Don Syme решила, что языки семейства ML вполне подходят для реализации функциональной парадигмы на платформе .NET. Идея разработки нового языка появилась во время работы над Generic’ами — реализациейобобщённого программирования для Common Language Runtime. Известно, что одно время в качестве прототипа нового языка рассматривался Haskell, но из-за функциональной чистоты и более сложной системы типов потенциальный Haskell.NET не мог бы предоставить разработчикам простого механизма работы с библиотекой классов .NET Framework, а значит, не давал бы каких-то дополнительных преимуществ. Как бы то ни было, за основу был взят OCaml, язык из семейства ML, который не является чисто функциональным и предоставляет возможности для императивного и объектно-ориентированного программирования. Однако заметьте, что Haskell, хоть и не стал непосредственно родителем нового языка, тем не менее, оказал на него некоторое влияние1. Например, концепция вычислительных выражений (computation expressions или workflows), играющих важную роль для асинхронного программирования и реализации DSL на F#, позаимствована из монад Haskell.
Следующим шагом в развитии нового языка стало появление в 2005 году его первой версии. С тех пор вокруг F# стало формироваться сообщество. За счёт поддержки функциональной парадигмы язык оказался востребован в научной сфере и финансовых организациях. Во многом благодаря этому Microsoft решила перевести F# из статуса исследовательских проектов в статус поддерживаемых продуктов и поставить его в один ряд с основными языками платформы .NET. И это несмотря на то, что в последнее время всё большую активность проявляют динамические языки, поддержка которых также присутствует в .NET Framework. 12 апреля 2010 года свет увидела новая версия флагманского продукта для разработчиков — Microsoft Visual Studio 2010, которая поддерживает разработку на F# прямо из коробки.
2 Начало работы
Прежде чем начать знакомство непосредственно с языком, стоит вкратце рассказать об инструментах, которые могут понадобиться читателю для ознакомления с материалом статьи.
Исполняемый файл fsi.exe, входящий в комплект поставки F#, представляет собой интерактивную консоль, эдакий REPL2, в которой можно быстро проверить работоспособность отдельных фрагментов кода на F#.
В состав современных инсталляционных пакетов F# входят также модули интеграции в Visual Studio 2008 и свободно распространяемую Visual Studio 2008 Shell, которые позволяют компилировать участки кода прямо из редактора. Это средство видится авторам наиболее удобным для первоначального знакомства с языком, так как открыв текст программы во встроенном редакторе кода, можно отправлять выделенные участки на исполнение простым нажатием комбинации клавиш Alt+Enter.
Исполняемый файл fsc.exe — непосредственно компилятор исходного кода F#, который читатель может использовать совместно со своим любимым текстовым редактором3.
Утилиты fsc.exe и fsi.exe отлично работают и под Mono, открытой реализацией .NET Framework.
Файлы, содержащие код на F#, обычно имеют следующие расширения:
- .fs — обычный файл с кодом, который может быть скомпилирован;
- .fsi — файл описания публичного интерфейса модуля. Обычно генерируется компилятором на основе кода, а затем редактируется вручную;
- .fsx — исполняемый скрипт. Может быть запущен прямо из Windows Explorer при помощи соответствующего пункта всплывающего меню или передан на исполнение в интерактивную консоль fsi.exe.
В некоторых источниках можно встретить в начале F# кода директиву #light on. Эта директива отключает режим совместимости синтаксиса с OCaml, делая отступы в коде значимыми (как, например в Python или Haskell). В последних версиях облегчённый режим включен по умолчанию, поэтому необходимости в указании директивы #light больше нет.
3 Основные возможности языка
Любая вводная статья об F# начинается с рассказа о том, как удачно этот язык совмещает в себе качества, присущие разным парадигмам программирования, не будем делать исключения и мы. Итак, как было сказано в определении — F# является мультипарадигменным языком, а это значит, что на нём можно реализовывать функции высших порядков, внутри которых исполнять императивный код и обёртывать всё это в классы для использования клиентами, написанными на других языках на платформе .NET.
3.0.1 F# функциональный
Начнём с парадигмы, которая наиболее близка (или, по крайней мере, наиболее интересна) читателям этого журнала — функциональной. Ни для кого не станет сюрпризом, что F#, будучи наследником славных традиций семейства языков ML, предоставляет полный набор инструментов функционального программирования: здесь есть алгебраические типы данных и функции высшего порядка, средства для композиции функций и неизменяемые структуры данных, а также частичное применение на пару с каррированием. Со слов экспертов в OCaml, в F# не хватает функторов. В силу своего практически полного незнания OCaml, авторам остаётся лишь констатировать факт, что функторов тут действительно нет. Но кто знает, может и появятся.
Все функциональные возможности F# реализованы в конечном итоге поверх общей системы типов .NET Framework. Однако этот факт не обеспечивает удобства использования таких конструкций из других языков платформы. При разработке собственных библиотек на F# следует предусмотреть создание объектно-ориентированных обёрток, которые будет проще использовать из C# или Visual Basic .NET.
3.0.2 F# императивный
В случаях, когда богатых функциональных возможностей не хватает, F# предоставляет разработчику возможность использовать в коде прелести изменяемого состояния. Это как непосредственно изменяемые переменные, поддержка полей и свойств объектов стандартной библиотеки, так и явные циклы, а также изменяемые коллекции и типы данных.
3.0.3 F# объектно-ориентированный
Объектно-ориентированные возможности F#, как и многое другое в этом языке, обусловлены необходимостью предоставить разработчикам возможность использовать стандартную библиотеку классов .NET Framework. С поставленной задачей язык вполне справляется: можно как использовать библиотеки классов, реализованные на других .NET языках, так и разрабатывать свои собственные. Следует отметить, однако, что некоторые возможности ОО языков реализованы не самым привычным образом.
3.0.4 И не только…
Подобное смешение парадигм, с одной стороны, может привести к плачевным результатам, а с другой — предоставляет больше гибкости и позволяет писать более простой код. Так, например, внутри стандартной библиотеки F# некоторые функции написаны в императивном стиле в целях оптимизации скорости исполнения.
Томаш Петричек в своём блоге [8] упоминает также о «языко-ориентированном» программировании. Мы тоже коснёмся этой темы ниже, а пока отметим лишь, что F# отлично подходит как для написания встроенных DSL, то есть имитации языка предметной области средствами основного языка, так и для преобразования F# кода в конструкции, исполняемые другими средствами, например в SQL-выражения или в последовательности инструкций GPU. Кроме того, в комплект поставки F# входят утилиты FsYacc и FsLex, являющиеся аналогами таких же утилит для OCaml, и позволяющие генерировать синтаксические и лексические анализаторы, а значит на F# вполне можно разработать свой собственный язык программирования.
3.1 Система типов
Каждая переменная, выражение или функция в F# имеет тип. Можно считать, что тип — это некий контракт, поддерживаемый всеми объектами данного типа. К примеру, тип переменной однозначно определяет диапазон значений этой переменной; тип функции говорит о том, какие параметры она принимает и значение какого типа она возвращает; тип объекта определяет то, как этот объект может быть использован.
F# — статически типизированный язык. Это означает, что тип каждого выражения известен на этапе компиляции, и позволяет отслеживать ошибки, связанные с неправильным использованием объектов определенного типа, до запуска программы. Помимо этого, F# — язык программирования со строгой типизацией, а значит, в выражениях отсутствует неявное приведение типов. Попытка использовать число с плавающей точкой там, где компилятор ожидает увидеть целое, приведет к ошибке компиляции.
3.1.1 Типы значений
Как и любой другой .NET-язык, F# поддерживает множество примитивных типов .NET, таких как System
.
Byte
, System
.
Int32
и System
.
Char
. Как и в C#, для удобства разработчиков компилятор языка также поддерживает псевдонимы для этих типов: byte
, int
и char
соответственно. Чтобы различать числовые литералы разных типов, используются суффиксы:
-
uy — для значений типа
System
.
Byte
; -
s — для
System
.
Int16
; -
u — для
System
.
UInt32
; - и др., с полным списком можно ознакомиться на странице документации.
Для преобразования значений из одного типа в другой нужно использовать одноимённые встроенные функции byte
, sbyte
, int16
, int
, uint
и т. п. Ещё раз отметим, что эти функции следует вызывать явно.
Помимо прочего существует единственное значение типа unit
, обозначаемое ()
. Оно обозначает отсутствие результата исполнения функции, аналогично void
в C# или C++.
3.1.2 Типы функций
Функции в F# также имеют тип, обозначение которого выглядит, например, так:
Такая запись означает функцию, принимающую на входе два параметра типа string
, и возвращающую string
в качестве результата.
Типы шаблонных функций записываются аналогично:
Заметьте, что вместо указания конкретных типов значений используются просто метки. Данное описание типа аналогично (но не идентично) следующему делегату из C#:
Это любая функция двух аргументов, которая возвращает некоторое значение. Типы A
, B
и C
могут быть разными, но могут и совпадать.
3.1.3 Присвоение значений
Сопоставление имени и значения производится при помощи оператора связывания let
:
В данном контексте value
— значение, а не переменная. При этом по умолчанию, все значения неизменяемые. Настоящую переменную можно создать, указав перед её именем ключевое слово mutable
:
Принципиальной разницы между значениями и функциями нет. Функции — это те же значения, а следовательно их точно так же можно использовать в выражениях, передавать в качестве параметров в другие функции и возвращать в качестве результата функции. При вызове функций параметры записываются последовательно и разделяются пробелами, при этом список параметров не надо заключать в круглые скобки, как это делается в Java или C#.
Для удобства последнее выражение в теле функции автоматически возвращается в качестве результата функции.
3.1.4 Подробнее о функциях
В функциональном программировании есть два близких понятия: каррирование и частичное применение. Для того, чтобы объяснить каррирование, рассмотрим следующие два определения на C#:
Обе функции делают одно и то же: вычисляют сумму двух целых чисел, но при этом вторая из них является функцией от одного аргумента — первого слагаемого — и возвращает другую функцию от одного аргумента, внутри которой происходит вычисление результата. Вызовы этих функций будут выглядеть следующим образом:int
res
=
ClassicAdd
(40, 2)
и int
res
=
CurriedAdd
(40)(2)
. CurriedAdd
можно переписать с использованием лямбда выражений следующим образом:
Функции, подобные CurriedAdd
из нашего примера, которые берут свои аргументы по одному, каждый раз формируя функцию меньшего числа аргументов, называются каррированными, а преобразование обычных функций в такой вид — каррированием или каррингом. Функции в F# каррированы по умолчанию, а чтобы сделать функцию как в C#, следует объединить параметры в кортеж. Именно так F# и трактует функции стандартной библиотеки .NET Framework.
Соответствующие вызовы будут выглядеть так: let
res
=
curriedAdd
40 2
и let
res
=
classicAdd
(40, 2)
. Можно заметить, что вызов некаррированной версии функции совпадает по синтаксису с таким же вызовом в C#.
Вернёмся к определению каррированной функции CurriedAdd
. Что произойдёт, если вызвать её, передав только один аргумент: var
add40
=
CurriedAdd
(40)
? В случае с C# ответ вполне очевиден — в теле функции мы явно создаём анонимный делегат, который и станет результатом вызова с одним параметром. То есть, add40
будет функцией одного аргумента, которая будет уметь прибавлять 40 к любому числу, переданному ей в качестве аргумента.
В F# всё точно так же, за тем лишь исключением, что это не так явно следует из синтаксиса. Если написать let
add40
=
curriedAdd
40
, то add40
точно так же будет функцией одного аргумента. Подобная техника как раз и называется частичным применением.
Разница между этими двумя понятиями довольно тонка. Каррирование — это возможность функции принимать аргументы по одному, каждый раз возвращая новую функцию от меньшего числа аргументов. Частичное применение — это техника, позволяющая зафиксировать значение одного или нескольких первых параметров каррированной функции4.
Ещё одной важной особенностью функционального программирования, которая поддерживается F#, является композиция функций. В стандартной библиотеке F# определён целый ряд операторов, облегчающих конструирование более сложных функций из простых. Их можно условно разделить на два вида:
-
Конвейерные операторы — передают значение, вычисленное одной функцией, на вход второй. Пожалуй, наиболее часто употребляемым оператором из этой группы можно назвать
|>
, определение которого выглядит так:let (|>) x f = f xКазалось бы, ничего сверхъестественного — лишь простая перестановка местами функции и её аргумента. Но это может быть очень удобно в случае, когда необходимо последовательно совершить несколько преобразований над одним исходным значением, например, списком:let list = [1..10] list |> List.map (fun i -> i*i) |> List.iter (fun sq -> printfn "Square: %d" sq)В данном примере мы сначала сформировали из исходного списка новый, путём возведения всех его элементов в квадрат с помощью библиотечной функцииList
.
map
, а затем значения из нового списка вывели на экран одно за другим с помощью функцииList
.
iter
. Возможность комбинировать функции таким образом является функциональным аналогом подхода, известного в объектно-ориентированных языках как «method chaining», который последнее время употребляется рядом с выражением «гибкий интерфейс». Вот пример подобного подхода из стандартной библиотеки .NET:var sb = new StringBuilder() .Append("Hello ") .Append("World!") .AppendLine();В эту же группу операторов входят аналоги оператора
|>
, позволяющие работать с функциями более одного аргумента:||>
и|||>
, а также их «обратные» варианты:<|
,<||
и<|||
. -
Операторы композиции — в отличие от конвейерных, не передают значения, а просто формируют новые функции5. Объявление одного такого оператора выглядит следующим образом:
let (>>) f g x = g (f x)Если передать этому оператору все три аргумента сразу, не произойдёт ничего интересного. Но если вспомнить про частичное применение и опустить аргумент
x
, то результатом станет новая функция. Для примера реализуем аналог оператора композиции на C#.Func<A, C> Compose<A, B, C>( Func<A, B> f, Func<B, C> g){ return (x) => g( f(x) ); }Вполне объяснимо наличие в стандартной библиотеке F# также и «обратного» оператора композиции<<
.
3.1.5 Вывод типов
В отличие от большинства других языков программирования6, F# не требует явно указывать типы всех значений. Механизм вывода типов позволяет определить недостающие типы значений, глядя на их использование. При этом некоторые значения должны иметь заранее известный тип. В роли таких значений, к примеру, могут выступать числовые литералы, так как их тип однозначно определяется суффиксом. Рассмотрим простой пример:
Функция add
возвращает сумму своих параметров и имеет тип int
->
int
->
int
. Если не смотреть на сигнатуру функции, то можно подумать, что она складывает значения любых типов, но это не так. Попытка вызвать её для аргументов типа float
или decimal
приведёт к ошибке компиляции. Причина такого поведения кроется в механизме вывода типов. Поскольку оператор +
может работать с разными типами данных, а никакой дополнительной информации о том, как эта функция будет использоваться, нет, компилятор по умолчанию приводит её к типу int
->
int
->
int
. Это можно проиллюстрировать на примере следующей сессии FSI:
В данном примере функция add
имеет тип float
->
float
->
float
, так как компилятор может вывести её тип, глядя на её использование. F# также позволяет указывать аннотации для явного указания типов аргументов или возвращаемого значения функции.
В данном примере типы аргументов функции заданы явно с помощью аннотаций. Указывать типы всех аргументов не обязательно: достаточно указать тип любого из них или тип возвращаемого значения, как в следующем примере:
3.1.6 Единицы измерения (units of measure)
Как правило, программы работают не просто с переменными типа int
или float
. Например, метод System
.
String
.
Length
возвращает не просто значение типа int
, а количество символов в строке, и складывать его с другим значением того же типа int
, содержащим количество секунд, прошедших с 1-го января 1970-го года, бессмысленно. Язык программирования F# позволяет программисту связать с любым значением числового типа произвольную единицу измерения. В дальнейшем, во время компиляции, будет выполняться проверка корректности приложения, на основе анализа размерностей.
Для создания единицы измерения нужно перед объявлением типа указать атрибут Measure
7.
После этого типы s
и m
можно использовать в качестве единиц измерения, для чего их следует указать в угловых скобках после соответствующего значения или типа:
Значения a
и b
имеют одинаковые типы, но разные размерности, что означает, что их нельзя вычитать, складывать и сравнивать. Значения разных размерностей, однако, можно умножать и делить, результат операции в таком случае будет иметь составную размерность.
Попытка сложить метры с секундами приводит к ошибке «error FS0001: The unit of measure ’s’ does not match the unit of measure ’m’».
Значение v
имеет составную размерность m
/
s
. В предыдущем примере, единицы измерения m
и s
являются независимыми, то есть не выражаются друг через друга, однако F# позволяет определять зависимости между разными единицами измерения:
или просто:
Значения, имеющие размерность Hz
, можно будет использовать в качестве значений с размерностью 1/
s
.
3.1.7 Обобщённые единицы измерения
Обычные функции, работающие с числовыми значениями разных типов, не будут работать со значениями, имеющими размерность. Это может быть неудобно, так как требует использовать функции приведения типа.
Чтобы избежать ненужных преобразований, можно не указывать единицу измерения явно. Для этого вместо имени размерности следует использовать символ подчёркивания, в результате чего механизм вывода типов определит используемую размерность автоматически, иначе она будет обобщённой.
В данном примере, функция sqr
принимает параметр типа int
, имеющий любую размерность, либо не имеющий её вовсе.
Следует также отметить, что единицы измерения не вписываются в рамки общей системы типов .NET Framework, поэтому информация о них сохраняется в мета-данных и может быть использована только из другой F#-сборки.
3.1.8 Некоторые встроенные типы
Стандартная библиотека F# предоставляет программисту ряд типов данных, предназначенных в первую очередь для создания программ в функциональном стиле.
Кортеж — упорядоченный набор элементов, экземпляр класса Tuple
. Элементы кортежа могут иметь разные типы. Для создания нового экземпляра кортежа8 нужно перечислить значения, входящие в него, через запятую:
В данном примере string
*
int
*
float
— это тип кортежа. Существуют две встроенные функции для работы с кортежами — fst
и snd
, возвращающие первый и второй элемент кортежа соответственно. Эти функции определены только для кортежей, состоящих из двух элементов. Для извлечения элементов из кортежа можно использовать оператор связывания. Для этого, в левой части, через запятую, должны быть перечислены идентификаторы, соответствующие элементам кортежа.
Список может содержать множество элементов одного типа. Для создания нового списка его элементы должны быть перечислены в квадратных скобках через точку с запятой.
Чтобы создать новый список, вовсе не обязательно перечислять все его элементы. Существуют другие возможности: создание списка на основе диапазона значений, а также генераторы списков9. Для создания списка, содержащего диапазон значений, нужно задать его верхнюю и нижнюю границу:
Кроме того можно указать шаг приращения значений, который может быть отрицательным:
В более сложных случаях можно использовать генераторы списков. Генератор списка — это фрагмент кода, заключённый в квадратные скобки, используемый для создания всех элементов списка. Элементы списка добавляются с помощью ключевого слова yield
. С помощью такого выражения, например, можно получить список чётных чисел:
Внутри генераторов списков10 можно использовать практически любые выражения F#: yield
, for
, if
, try
и т.д.
3.1.9 Операции над списками
Списки в F# не являются экземплярами типа System
.
Collections
.
Generic
.
List
<
T
>
, и в отличие от последних — неизменяемы. Добавить или удалить элемент из списка нельзя, вместо этого можно создать новый список на основе существующего. Вообще, любые операции над списками в F# приводят к созданию нового неизменяемого списка11. Для добавления элемента в начало списка служит оператор ‘::
’, который является псевдонимом функции Cons
из модуля List
:
Модуль List
содержит множество функций, работающих со списками. Два списка можно объединить с помощью оператора конкатенации ‘@
’:
При обработке неизменяемых списков довольно часто требуется получить из исходного списка его первый элемент и список всех остальных элементов. Для этого служат функции List
.
head
и List
.
tail
, соответственно12.
Модуль List
также содержит несколько важных функций высшего порядка, хорошо известных программистам, знакомым с другими функциональными языками программирования. Эти функции позволяют отказаться от использования циклов и явной рекурсии при обработке списков; кроме того, они упрощают формальное доказательство корректности алгоритма.
List
.
map
создает новый список, применяя пользовательскую функцию ко всем элементам исходного списка. Её тип: ('
a
-> '
b
) -> '
a
list
-> '
b
list
. То есть, она принимает на вход пользовательскую функцию преобразования и исходный список, и возвращает новый список, с элементами другого типа.
В данном примере пользовательская функция возвращает квадрат своего аргумента13. В C# аналогичного эффекта можно добиться c помощью Enumerable
.
Select
и дальнейшего преобразования результата к списку:
List
.
reduce
выполняет операцию свёртки, её тип: ('
a
-> '
a
-> '
a
) -> '
a
list
-> '
a
. Пользовательская функция принимает на вход два аргумента: аккумулятор и следующий элемент списка и должна вернуть новое значение аккумулятора.
В данном примере с помощью функции List
.
reduce
, вычисляется сумма всех элементов списка14. При первом вызове функция, переданная пользователем, получает голову списка в качестве аккумулятора.
Функция List
.
fold
в целом аналогична функции List
.
reduce
, за исключением того, что тип элементов списка и тип аккумулятора могут быть разными. Описанная выше функция List
.
reduce
является частным случаемList
.
fold
. Эта функция имеет тип ('
a
-> '
b
-> '
a
) -> '
a
-> '
b
list
-> '
a
. В отличие от List
.
reduce
, она содержит один дополнительный аргумент, который выступает в роли начального значения аккумулятора. Это можно проиллюстрировать на примере выражения, преобразующего список чисел в строку:
Поскольку тип результата отличается от типа элементов списка, использовать функцию List
.
reduce
в данном случае нельзя. Итак, первый аргумент List
.
fold
— функция с двумя аргументами: строкой и числом. Она прибавляет к строке-аккумулятору запятую, пробел и строковое представление числа — элемента списка. Второй аргумент функции List
.
fold
— начальное значение аккумулятора, равное строковому представлению первого элемента списка. Третий и последний аргумент List
.
fold
— обрабатываемый список. Так как строковое представление первого элемента списка уже содержится в аккумуляторе, туда передаётся только хвост списка.
Оба варианта функций свёртки списков реализованы в C# с помощью перегруженного метода Enumerable
.
Aggregate
, принимающего 2 и 3 аргумента.
3.2 Последовательности, ленивость
Обычно выражения в F# вычисляются «энергично». Это означает, что значение выражения будет вычислено независимо от того, используется оно где-либо или нет. Например, если определить список с помощью генератора, то список будет создан в памяти целиком, независимо от дальнейшего его использования.
В противоположность жадному подходу существует стратегия ленивых вычислений, которая позволяет вычислять значение выражения только тогда, когда оно становится необходимо. Преимуществами такого подхода являются:
- производительность, поскольку неиспользуемые значения просто не вычисляются;
- возможность работать с бесконечными или очень большими последовательностями, так как они никогда не загружаются в память полностью;
- декларативность кода. Использование ленивых вычислений избавляет программиста от необходимости следить за порядком вычислений, что делает код проще.
Основной недостаток ленивых вычислений — плохая предсказуемость. В отличие от энергичных вычислений, где очень просто определить пространственную и временную сложность алгоритма, с ленивыми вычислениями всё может быть куда менее очевидно. F# позволяет программисту самостоятельно решать, что именно должно вычисляться лениво, а что нет. Это в значительной степени устраняет проблему предсказуемости, так как ленивые вычисления применяются только там, где это действительно необходимо, позволяя сочетать лучшее из обоих миров.
3.2.1 Тип данных Lazy
С помощью значения данного типа можно представить отложенное вычисление.
При первом обращении к свойству Value
происходит вычисление значения. При последующих — используется ранее вычисленное значение. Аналогично использовать ключевое слово lazy
:
3.2.2 Последовательности
Последовательность — всего лишь синоним для .NET-интерфейса IEnumerable
<
T
>
, поэтому последовательности легко могут быть использованы из сборок, написанных на других .NET-языках.
Последовательности создаются похожим со списками образом. Можно задать последовательность в виде диапазона:
Попытка создать список такого же размера приведёт к ошибке.
Последовательность может быть создана при помощи генератора последовательности (sequence comprehension), аналогично генераторам списка. В отличии от генератора списка, он не создает все элементы последовательности сразу. Выполнение кода прерывается после того, как очередной элемент последовательности был создан с помощью ключевого слова yield
. Дальнейшее выполнение кода будет продолжено тогда, когда потребуется следующий элемент. Код генератора последовательности записывается в фигурных скобках, расположенных после идентификатора seq
:
Генераторы последовательностей F# аналогичны итераторам, которые появились в C# версии 2. Так, например, приведённый выше код аналогичен следующему:
Однако возможности генераторов последовательностей несколько шире. Например, они могут быть вложенными или рекурсивными. Чтобы включить в одну последовательность элементы другой, используется ключевое слово yield
!
(читается «Yield bang»):
В данном примере функция collatz
возвращает все числа, входящие в последовательность Коллатца, начиная с n
. Во-первых, функция рекурсивна (поэтому объявлена с помощью ключевого слова rec
). Во-вторых, функция возвращает последовательность, так как её тело состоит из одного sequence expression. При этом последовательности, возвращаемые рекурсивными вызовами функции collatz
, объединяются с помощью оператора yield
!
в одну плоскую последовательность.
Модуль Seq
содержит функции для работы с последовательностями. В нём есть как аналоги функций length
, filter
, fold
, map
модуля List
, так и специфические для последовательностей функции, такие как take
и unfold
.
3.3 Сопоставление с образцом (Pattern matching)
Сопоставление с образцом — основной способ работы со структурами данных в F#.
Эта языковая конструкция состоит из ключевого слова match
, анализируемого выражения, ключевого слова with
и набора правил. Каждое правило — это пара образец–результат. Всё выражение сопоставления с образцом принимает значение того правила, образец которого соответствует анализируемому выражению. Все правила сопоставления с образцом должны возвращать значения одного и того же типа. В простейшем случае в качестве образцов могут выступать константы:
В правилах сопоставления с образцом можно использовать символ подчеркивания («wildcard»), если конкретное значение неважно.
Если набор правил сопоставления не покрывает все возможные значения образца, компилятор F# выдаёт предупреждение. На этапе исполнения, если ни одно правило не будет соответствовать образцу, будет сгенерировано исключение MatchFailureException
.
Помимо констант в правилах сопоставления с образцом можно использовать имена значений. Это нужно, чтобы извлечь данные из образца и связать их с именем.
Конечно, данный пример выглядит надуманным, так как в выражении можно использовать непосредственно значение-образец, а вместо именованного значения — wildcard. Однако, это может быть очень удобно в тех случаях, когда мы имеем дело с более сложными данными, например с кортежами или списками. Тогда с помощью правила сопоставления с образцом можно разобрать сложную структуру данных на составные части.
Разные правила могут быть объединены между собой.
В этом примере первое правило сработает в том случае, если один из аргументов равен true
.
В некоторых случаях, простого сопоставления бывает мало, и требуется использовать в правилах более сложную логику. Тогда можно указать дополнительные ограничения после ключевого слова when
:
3.3.1 Активные шаблоны (active patterns)
Сопоставление с образцом — намного более выразительный механизм, чем обычные условные выражения, однако их не всегда удобно использовать. К примеру, в правилах сопоставления с образцом нельзя вызывать функции и приходится пользоваться ограничениями when
. Рассмотрим следующий код:
Сопоставление с образцом здесь не даёт каких-либо преимуществ по сравнению с обычным сравнением.
Для восполнения этого и подобных пробелов, служат активные шаблоны — особые функции, которые могут быть использованы в правилах сопоставления с образцом.
Одно-вариантные активные шаблоны15 позволяют выполнить простое преобразование данных, к примеру, из одного типа в другой. Это может быть полезно тогда, когда данные исходного типа не могут быть использованы в правилах сопоставления с образцом, либо когда нам нужно использовать более сложную логику при выполнении сравнения. Активный шаблон этого типа — простая функция одного аргумента, имя которой при объявлении заключено в прямые, а затем в круглые скобки16.
Предыдущий пример можно переписать следующим образом:
Здесь FileExtension
— активный шаблон, который просто возвращает расширения файла. За именем шаблона следует его результат. Если результат представлен константой, то результат сравнивается с ней и при совпадении, правило считается выполненным. Если результат — именованное значение, правило считается выполненным, а результат выполнения функции-активного шаблона можно получить по имени. Например:
Значение ext
будет содержать расширение файла или пустую строку, если файл не имеет расширения.
Частичные активные шаблоны (partial-case active patterns) используются в тех случаях, когда преобразование не всегда возможно. Функция-шаблон должна возвращать значение типа option
<
T
>
, равное None
, если преобразование невозможно17.
В данном примере определяется тип ValueType
, значения которого представляют собой результаты разбора строки18. Функция makeParsePattern
используется для создания активных шаблонов. Далее определяются шаблоны: IsInteger
, IsFloat
и IsBoolean
. Эти шаблоны являются функциями, которые принимают один аргумент строкового типа и возвращают опциональное значение. Частичный активный шаблон задаётся с помощью конструкции (|
«имя»|
_
|)
. Как и в случае single-case шаблона, на вход функции передается значение-образец. Если функция вернула None
, считается, что значение не удовлетворяет правилу.
Активные шаблоны могут быть параметризованными. При этом в match
-выражении указывается сначала имя шаблона, затем параметры шаблона, а потом его результат. В качестве иллюстрации можно переписать предыдущий пример следующим образом:
Результат шаблона используется здесь для захвата имени файла без расширения, а параметр — для определения типа.
В предыдущих примерах мы классифицировали входные значения, используя активные шаблоны и размеченные объединения. Предполагается, что в дальнейшем значение, которое возвращает функция parseStr
или fileType
, будет в свою очередь разобрано с помощью сопоставления с образцом19. Можно предположить, что это будет происходить примерно так:
Многовариантные активные шаблоны (multi-case active patterns) позволяют упростить классификацию данных при использовании размеченных объединений. Например:
Нам больше не нужна функция parseStr
, которая занималась разбором строки и возвращала элемент размеченного объединения.
3.4 Пользовательские типы данных
F# поддерживает различные пользовательские типы данных, такие как записи, размеченные объединения и классы. Помимо этого, в полной мере поддерживается параметрический полиморфизм: пользовательские типы могут быть обобщенными, так же как и функции.
В следующем примере создаётся тип-запись с двумя полями.
Записи F# аналогичны структурам из C# или C++ и могут использоваться для группировки нескольких логически связанных значений в одну сущность, оставляя при этом, в отличие от кортежей, возможность доступа к отдельным элементам по имени. Доступ к полям производится при помощи знакомого синтаксиса: через точку.
Заметьте, мы не указывали тип значения mary
явно: компилятор смог вывести его самостоятельно на основе того, какие поля использовались при создании экземпляра. Механизм вывода типов работает с пользовательскими типами данных, в большинстве случаев избавляя от необходимости указывать типы явно.
Размеченное объединение — это алгебраический тип данных20, такой же как data
в Haskell21. Определение такого типа состоит из названия и списка конструкторов с параметрами. Понятие конструктора в данном случае не совсем совпадает с принятым в объектно-ориентированном программировании толкованием, тем не менее, это тоже функция, вызов которой приводит к появлению нового экземпляра. Отличие от конструкторов классов состоит в том, что конструктор размеченного объединения может использоваться в шаблонах механизма сопоставления с образцом для «разборки» объекта на составные части.
С объектно-ориентированной точки зрения такой тип выглядит как небольшая иерархия классов, где MyDateTime
выступает в роли базового класса, а FromTicks
и FromString
— классы-наследники. Таким образом, несмотря на то, что размеченное объединение является специфической для F# структурой данных, её можно использовать в публичных API, которые будут вызываться из других .NET языков, хотя такое использование будет далеко не самым удобным.
.NET-перечисления, которые в C# объявляются при помощи ключевого слова enum
, в F# также объявляются как размеченные объединения. Правда, такое объединение не может иметь параметризованных конструкторов. Кроме того, каждому конструктору должно соответствовать числовое значение.
Аналогичный тип можно объявить на C# следующим образом:
3.4.1 Объекты и классы
F# поддерживает два способа объявления классов: явный и неявный. Первый подходит в тех случаях, когда программисту требуется контроль над тем, как создается объект класса и какие поля он содержит. Второй позволяет переложить большую часть работы на компилятор.
В случае явного объявления класса программист должен объявить поля класса и хотя бы один конструктор:
С помощью ключевого слова val
объявляются члены-данные класса, а с помощью ключевого слова new
создаётся конструктор класса. Члены-данные класса обязательно должны инициализироваться в конструкторе, а точнее — внутри выражения-конструктора (constructor expression), которое записывается в фигурных скобках после символа равенства. Если какое-либо поле не будет инициализировано, компилятор выдаст ошибку. Внутри выражения-конструктора можно только инициализировать поля класса, причем каждое поле — только один раз: попытка сделать что-нибудь ещё опять же приведет к ошибке компиляции.
На первый взгляд это ограничение кажется неразумным, так как иногда всё же требуется выполнить какие-либо действия во время создания экземпляра класса. Например выполнить валидацию параметров конструктора или записать что-нибудь в лог. На самом деле всё не так печально: в конструкторе можно не только инициализировать члены-данные, но и добавить произвольный код, который будет выполняться до инициализации. Он записывается перед constructor expression. Также можно написать код, который будет выполняться после инициализации полей, для чего следует использовать блок then
после соответствующего выражения-конструктора.
В данном примере перед инициализацией полей класса происходит проверка переданных параметров, а после неё следует вывод сообщения об успешном создании экземпляра. Обратите внимание, что код конструктора не может обращаться к членам-данным объекта иначе, чем через выражение-конструктор.
Все члены-данные по умолчанию неизменяемы. Так же, как и в случае обычных значений, это поведение можно изменить с помощью ключевого слова mutable
.
Во многих случаях классы можно объявлять намного проще. Для этого нужно сразу после имени класса в круглых скобках перечислить параметры его основного конструктора. Эти параметры будут доступны для использования в методах класса, при этом объявлять их явно с помощью ключевого слова val
не нужно.
В этом случае конструктор класса и его члены-данные создаются неявно. Конструктор класса принимает три параметра, но сам класс будет иметь два поля: author
и title
. Поле publishDate
создано не будет, так как нигде не используется. Попытка использовать val
в классе, объявленном с использованием неявного синтаксиса приведёт к ошибке компиляции.
Класс, объявленный неявно, может иметь более одного конструктора, которые также объявляются с помощью ключевого слова new
, за которым следует список параметров и код конструктора. Но, в отличие от явного объявления класса, в этом случае мы не можем использовать constructor expression. Вместо этого конструктор обязательно должен вызывать основной конструктор, созданный неявно, и передавать в него соответствующие параметры.
В этом примере определён дополнительный конструктор, который получает на вход строку, разбирает её и вызывает основной конструктор.
Внутри неявного определения класса можно объявлять переменные, используя оператор связывания let
, а также выполнять произвольный код с помощью ключевого слова do
.
Здесь добавлены проверки для параметров основного конструктора, которые будут выполняться во время создания объекта независимо от того, с помощью какого конструктора объект создаётся. Помимо этого, с помощью оператора связывания здесь объявлена переменная класса publishDate
и метод класса SetPublishDate
, с помощью которого можно изменять значение этой переменной.
Классы, как и записи или размеченные объединения, могут быть обобщёнными. Для этого нужно после имени класса в фигурных скобках перечислить обобщённые параметры класса:
Создать экземпляр класса можно следующим образом:
3.4.2 Свойства и методы
В предыдущих примерах методы уже использовались, теперь настало время поговорить о них подробнее. Объявление метода или свойства начинается с ключевого слова member
, за которым следует имя self
-идентификатора. Это имя используется для обращения к членам класса внутри метода или свойства. В случае, если метод или свойство статические, self
-идентификатор указывать не нужно. В отличие от C#, здесь нет неявной переменной this
, которая передаётся в каждый нестатический метод класса и предоставляет доступ к объекту, для которого вызван этот метод. Вместо этого, разработчик волен сам выбирать удобное имя, под которым будет известен текущий объект.
Здесь класс Point
имеет два свойства и один метод. Свойства X
и Y
доступны для чтения и для записи. Если свойство не предполагает возможности чтения или установки нового значения, соответствующую часть объявления можно опустить.
Видимость методов, свойств, полей и классов можно изменять с помощью ключевых слов public
, private
и internal
. В отличие от других .NET языков, F# не поддерживает модификатор protected
, однако правильно работает с защищёнными методами и свойствами классов, созданных с использованием других языков программирования.
Ключевые слова sealed
или abstract
, которыми можно помечать классы в C#, не поддерживаются в F# на уровне языка. Тем не менее, в стандартной библиотеке есть соответствующие атрибуты: Sealed
и AbstractClass
, которые обрабатываются компилятором и приводят к ожидаемому результату.
В F# полностью поддерживается перегрузка методов. Можно определить несколько методов, имеющих одинаковое имя, но отличающихся типом и количеством параметров.
3.4.3 Наследование
Как и любой объектно-ориентированный язык, F# позволяет создать новый класс, наследующий свойства уже существующего.
При явном описании класса-наследника необходимо после ключевого слова inherit
указать имя базового класса, и вызвать его конструктор в constructor expression с помощью того же ключевого слова.
При неявном объявлении класса аргументы для вызова конструктора указываются сразу же после объявления базового класса.
Класс-наследник может уточнять либо переопределять поведение базового класса переопределяя его методы. В отличие от C#, все члены класса, которые могут быть переопределены, являются абстрактными и объявляются при помощи ключевого слова abstract
. Но, опять же в отличие от C#, абстрактный метод может иметь реализацию по умолчанию, которая задаётся с помощью ключевого слова default
.
В данном примере мы создали класс Shape
, который является базовым для всех геометрических фигур. Он имеет одно абстрактное свойство и абстрактный метод, которые имеют реализацию по умолчанию и переопределяются в наследнике. Странное объявление типа Vertex
в начале примера просто создаёт псевдоним для типа Point
<
float
>
.
Метод Item
является методом-индексатором, который позволяет обращаться к объекту с помощью оператора ‘.[]
’ (обратите внимание на точку перед квадратными скобками, она обязательна). Аналогичные конструкции в C# объявляются при помощи специального синтаксиса индексаторов this
[]
.
Интерфейсы — важная часть платформы .NET. Они позволяют описать контракт, который обязуется выполнить класс, реализуя методы данного интерфейса. Если обычное наследование реализует отношение «является частным случаем» и применяется для расширения базового класса, то наследование от интерфейса реализует отношение «поддерживает» и применяется для того, что бы определить возможные точки соприкосновения разных подсистем, которые могут ничего не знать друг о друге, кроме того, что одна из них поддерживает нужный интерфейс.
Интерфейс в F# — это просто чистый абстрактный класс.
Реализация интерфейса отличается от наследования класса, что впрочем логично. Нелогичным может показаться то, что нельзя вызывать перегруженные методы интерфейса — это приведет к ошибке компиляции. Чтобы вызвать метод интерфейса, нужно выполнить приведение типа с помощью оператора ‘:>
’.
Это требование позволяет избавиться от неоднозначности при вызове метода интерфейса. Можно расширить предыдущий пример в качестве иллюстрации:
Мы определили три метода Execute
, один из них — метод класса, два других — методы интерфейсов, реализуемых классом. В F# это не приводит к ошибкам, так как программист всегда указывает явно и интерфейс, метод которого реализуется, и интерфейс, метод которого он пытается вызвать.
3.4.4 Объектные выражения
В любом более или менее серьёзном приложении встречаются вспомогательные классы, объекты которых используются только в одном месте, например в качестве предиката сравнения для алгоритма сортировки. Такой класс, как правило, реализует какой-либо интерфейс и служит только одной цели. При этом класс объекта нигде в программе не используется, используются только объекты этого класса. В качестве примера можно привести метод Sort
контейнера List
. Этот метод принимает в качестве одного из аргументов объект, реализующий интерфейс IComparer
, который в дальнейшем используется для сортировки элементов контейнера.
F# позволяет создавать подобные объекты по месту использования с помощью так называемых объектных выражений или object expressions. Объектное выражение — это выражение, результатом которого является объект анонимного класса. Записывается оно следующим образом:
После выполнения метода Sort
элементы контейнера будут упорядочены по дате публикации.
Помимо этого, с помощью объектных выражений можно создавать объекты-наследники какого-либо класса. В этом случае, после имени класса в круглых скобках нужно перечислить параметры конструктора:
С помощью объектных выражений нельзя добавлять новые методы или свойства.
3.4.5 Методы расширения
Методы расширения — это механизм, аналогичный методам расширения из C#. Он позволяет дополнить любой класс новыми методами и свойствами. Это может быть полезно например в тех случаях, когда исходный код класса недоступен, а наследование неудобно по каким-либо причинам. Следующий код демонстрирует пример создания и использования метода расширения:
К сожалению, метод расширения, созданный подобным образом можно будет использовать только из другого F#-кода. Чтобы создать метод расширения, доступный другим языкам .NET, необходимо, как и во многих других случаях, воспользоваться магическими атрибутами:
4 Расширенные возможности
В предыдущих разделах мы рассмотрели основные возможности языка программирования F#. Конечно, одного факта того, что он привносит некоторые новые функциональные практики в мир .NET, было бы мало для поддержки его как полноправного члена семейства языков Visual Studio. Как было видно из примеров, многие аспекты языка не уникальны и могут быть с той или иной степенью изящности выражены на C# и, возможно, Visual Basic. В следующих разделах будут описаны те возможности языка, которые сложно отнести к базовым. Именно они делают F# полезным с практической точки зрения и могут показаться знакомыми и/или интересными разработчикам на Haskell и Erlang.
4.1 Вычислительные выражения
Среди нововведений F# можно особо выделить так называемые вычислительные выражения (computation expressions или workflows). Они являются обобщением генераторов последовательности и, в частности, позволяют встраивать в F# такие вычислительные структуры, как монады и моноиды. Также они могут быть применены для асинхронного программирования и создания DSL.
Вычислительное выражение имеет форму блока, содержащего некоторый код на F# в фигурных скобках. Этому блоку должен предшествовать специальный объект, который называется еще построителем (builder). Общая форма следующая: builder {
comp-expr }
.
Построитель определяет способ интерпретации того кода, который указан в фигурных скобках. Сам код вычисления внешне почти не отличается от обычного кода на F#, кроме того, что в нём нельзя определять новые типы, а также нельзя использовать изменяемые значения. Вместо таких значений можно использовать ссылки, но делать это следует с большой осторожностью, поскольку вычислительные выражения обычно задают некие отложенные вычисления, а последние не очень любят побочные эффекты.
В зависимости от построителя внутри вычислительного кода можно также использовать особые конструкции let
!
, use
!
, return
, return
!
, yield
и yield
!
. Если читатель знаком с языком программирования Haskell, то можно заметить, что let
!
соответствует стрелке из нотации do, а return
имеет тот же смысл, что и в Haskell.
По своей сути вычислительное выражение является синтаксическим сахаром вокруг указанного построителя. Компилятор F# проходится по коду вычисления и заменяет языковые конструкции вызовами соответствующих методов построителя b согласно следующей таблице, где кавычки обозначают оператор преобразования:
Конструкция | Форма преобразования |
let! pat = expr in cexpr |
b.Bind(expr,(fun pat → «cexpr»)) |
let pat = expr in cexpr | let pat = expr in «cexpr» |
use pat = expr in cexpr | b.Using(expr,(fun pat → «cexpr»)) |
use! pat = expr in cexpr | b.Bind(expr,(fun x → |
b.Using(x,fun pat → «cexpr»))) | |
do! expr in cexpr | b.Bind(expr,(fun () → «cexpr»)) |
do expr in cexpr | expr; «cexpr» |
for pat in expr do cexpr | b.For(expr,(fun pat → «cexpr»)) |
while expr do cexpr | b.While((fun () → expr), |
b.Delay(fun () → «cexpr»)) | |
if expr then cexpr1 else cexpr2 | if expr then «cexpr1» else «cexpr2» |
if expr then cexpr | if expr then «cexpr» else b.Zero() |
match expr with pat → cexpr | match expr with pat → «cexpr» |
cexpr1 | v.Combine(«cexpr1», |
cexpr2 | b.Delay(fun () → «cexpr2»)) |
return expr | b.Return(expr) |
return! expr | b.ReturnFrom(expr) |
yield expr | b.Yield(expr) |
yield! expr | b.YieldFrom(expr) |
Также есть преобразование для конструкции try
, но оно более длинное. Таким образом, все основные конструкции F# оказываются охвачены.
Основная идея заключается в том, что когда компилятор обрабатывает очередную конструкцию вычислительного выражения, то он пытается вызвать соответствующий метод построителя. Построитель не обязан реализовывать все указанные методы. Если нужного метода нет, то будет сгенерирована ошибка времени компиляции.
Тогда исходное выражение builder { comp-expr } будет преобразовано в
где вызовы методов Run
и Delay
обрабатываются особым образом. В случае отсутствия одного из них или сразу обоих ошибка уже не генерируется, просто соответствующая часть опускается. Например, когда оба метода не определены, то все сокращается до преобразованного выражения «comp-expr». Когда метод Delay
определён, он фактически делает вычисление отложенным, т. е. ленивым.
Для реализации монады достаточно определить методы Bind
, Return
и, возможно, обработчики try
. Через них можно выразить остальные необходимые методы построителя, но часто в случае конкретной монады бывает так, что можно найти более эффективные определения. Это — одна из причин, по которой F#, например, не предоставляет готовых реализаций для методов While
и For
.
Для реализации моноида достаточно определить методы Zero
, Combine
и, возможно, For
.
В качестве примера предположим, что у нас имеется построитель maybe
, который реализует методы Bind
, Return
и Delay
. Тогда следующая функция:
будет преобразована транслятором на этапе компиляции в следующее определение:
Эта функция достаточно общая, и она походит для очень многих вычислительных выражений. На практике имеет смысл создать отдельный модуль, назовем его Maybe
, куда можно и поместить определение функции ap
. Туда же можно поместить определение комбинатора (<*>)
, который в данном случае будет совпадать с функцией ap
. Такое разбиение на модули позволяет различать функции с одинаковыми именами.
Теперь поставим задачу реализовать такой построитель maybe
, чтобы мы могли проводить некие вычисления с возможностью их быстрой остановки. Используя вычислительные выражения, мы можем реализовать такой механизм остановки прозрачно. Для этого создадим отдельный класс MaybeBuilder
, который бы реализовывал необходимые методы.
Здесь аннотации типов можно было и опустить. Они использованы лишь чтобы получить на выходе следующую сигнатуру типа:
Читатель, знакомый с языком программирования Haskell, сразу увидит в этом определение монады, причём для большего сходства метод Delay
придаёт вычислению ленивость. В действительности, такое определение — одна из возможных реализаций известной монады Maybe
.
Собственно сам построитель определяется просто:
Теперь мы умеем создавать вычисления, причём они будут отложенными. Чтобы получить результат, необходимо уже применить функцию запуска runMaybe
к самому вычислению.
Итак, в этом примере основным методом построителя является Bind
. Он принимает исходное вычисление и его продолжение. Если результатом исходного вычисления является fail
, то вычисление-продолжение не запускается. В противном случае результат выполнения первого вычисления попадает на вход второго. Методу Bind
в коде соответствуют конструкции let
!
. Теперь, чтобы прервать вычисление немедленно, в правой части конструкции следует вернуть fail
.
Подобное поведение вычислительного выражения является лишь частным случаем. Мы можем интерпретировать код вычисления самым разным образом. Если ключевыми шагами вычисления считать места использования конструкций let
!
, do
!
и use
!
, т. е. где вызывается метод Bind
, то само выражение мы можем рассматривать с позиции встраивания кода между этими шагами, которые уже отвечают за обработку встроенного кода, причём сама обработка происходит прозрачно. Это позволяет меньше думать о деталях и заметно повышает уровень абстракции. Так открывается путь к созданию DSL. Также значительно упрощается написание вычислений, выполняемых асинхронно, о чем мы расскажем далее.
Более того, генератор последовательности (sequence comprehension) является частным случаем вычислительного выражения, только в нём особую роль играют уже конструкции yield
и yield
!
. Буквально это означает, что средствами самого языка можно создать аналог генератора seq
, но уже с другим ключевым словом. Также можно создать аналоги других двух стандартных генераторов: списка и массива.
Представляет особый практический интерес то обстоятельство, что вычислительное выражение можно использовать вместе с цитированиями — ещё одной специальной возможностью F#, о которой мы расскажем ниже. Мы можем получить синтаксическое дерево с результатом раскрытия вычислительного выражения. Это позволяет создавать трансляторы таких выражений. В конце статьи приводится практический пример WebSharper, где используется такая возможность.
Так, если взять вышеописанный построитель maybe
или любой другой, то в F# Interactive можно увидеть результат раскрытия вычислительного выражения, который будет представлен в виде синтаксического дерева:
Теперь несколько слов об эффективности. Выше было дано общее определение для функции ap
. Если заменить имя построителя, то определение будет работать со многими другими построителями, реализующими методы Bind
и Return
. В языке Haskell аналогичная функция определена с помощью класса типов Monad
. Там одна и та же функция будет работать с любой монадой. В F# мы должны определять функцию ap
для каждой монады отдельно, но здесь кроется один важный момент. Для использованного в примере построителя maybe
мы можем создать более эффективную реализацию функции ap
: как минимум две лямбды не нужны. Причем, такая ситуация встречается часто: для этого построителя мы можем написать более эффективные реализации методов While
, For
, Delay
и т. д., хотя всё это можно было бы выразить по-прежнему через Bind
и Return
. Вырисовывается характерная черта F# в области обработки монад, когда по сравнению с Haskell выбор делается в пользу менее общего, но, как правило, более эффективного кода.
Другим отличительным моментом по сравнению с Haskell является то, что вычислительные выражения могут иметь побочный эффект, не отражённый в системе типов. Например, наиболее ярко это проявляется при использовании асинхронных вычислений.
4.2 Средства для параллельного программирования
4.2.1 Асинхронные потоки операций (async workflows)
Асинхронные потоки операций — это один из самых интересных примеров практического использования вычислительных выражений. Код, выполняющий какие либо неблокирующие операции ввода-вывода, как правило сложен для понимания, поскольку представляет из себя множество callback-методов, каждый из которых обрабатывает какой-то промежуточный результат и возможно начинает новую асинхронную операцию. Асинхронные потоки операций позволяют писать асинхронный код последовательно, не определяя callback-методы явно. Для создания асинхронного потока операций используется блок async
:
Здесь мы объявили функцию getPage
, которая должна возвращать содержимое страницы по заданному адресу. Эта функция имеет тип string
->
Async
<
string
>
и возвращает асинхронную операцию, которая может быть использована для получения строки с содержимым страницы. Стоит отметить, что классы WebRequest
и StreamReader
не имеют методов AsyncGetResponse
и AsyncReadToEnd
, это методы расширения.
Построитель асинхронного потока операций, работает следующим образом. Встретив оператор let
!
или do
!
, он начинает выполнять операцию асинхронно, при этом метод, начинающий асинхронную операцию, получит оставшуюся часть блока async
в качестве функции обратного вызова. После завершения асинхронной операции переданный callback продолжит выполнение асинхронного потока операций, но, возможно, уже в другом потоке операционной системы (для выполнения кода используется пул потоков). В результате код выглядит так, как будто он выполняется последовательно. То, что может быть с легкостью записано внутри блока async
с использованием циклов и условных операторов, достаточно сложно реализуется с использованием обычной техники, требующей описания множества callback-методов и передачей состояния между их вызовами.
Обработка исключений — самый наглядный пример удобства асинхронных потоков операций. Если мы пишем асинхронный код в традиционном стиле, то каждый метод обратного вызова должен обрабатывать исключения самостоятельно. Блок async
может включать оператор try
, с помощью которого можно обрабатывать исключения.
В этом примере поток операций возвращает значение типа string
~
option
, то есть, либо строку либо пустое значение, чтобы вызывающий код мог обработать ошибку.
Значение типа Async
<
_
>
нужно передать в один из статических методов класса Async, чтобы начать выполнение соответствующего потока операций. В простейшем случае можно воспользоваться методом Async
.
RunSynchronously
, который просто заблокирует вызывающий поток до тех пор, пока все операции не будут выполнены. Такой код:
просто вернёт содержимое веб-страницы по указанному адресу.
Метод Async
.
Parallel
— это комбинатор, который позволяет объединить несколько асинхронных потоков операций в один. Этот метод принимает на вход последовательность асинхронных операций, возвращающих значение типа X
, и возвращает одну асинхронную операцию, возвращающую массив значений типа X
.
Этот код вернёт массив из двух строк, представляющий результаты выполнения двух асинхронных операций. Помимо этого, класс Async
содержит методы, позволяющие обрабатывать исключения, прерывать работу асинхронных потоков операций и многое другое.
Возможности асинхронных потоков операций не ограничены вводом/выводом22, а также набором стандартных классов. Существует простой способ добавлять произвольные асинхронные операции, однако его рассмотрение выходит за рамки этой статьи.
4.2.2 MailboxProcessor
MailboxProcessor
— это класс из стандартной библиотеки F#, реализующий один из паттернов параллельного программирования. MailboxProcessor
является агентом23, обрабатывающим очередь сообщений, которые поставляются ему извне при помощи метода Post
. Вся конкурентность поддерживается реализацией класса, который содержит очередь с возможностью одновременной записи несколькими писателями и чтения одним единственным читателем, которым является сам агент.
Выше приведена реализация простейшего агента, который при получении сообщения, содержащего строку, выводит его на экран. Послать агенту сообщение, как уже было сказано выше, можно при помощи метода Post
:
Интересно отметить тип функции, являющейся единственным параметром конструктора агента (и статического метода Start
)24:
Из этого определения видно, что основной «рабочей лошадкой» агента является функция, на вход получающая экземпляр самого агента и возвращающая асинхронную операцию, о которых говорилось чуть выше.
Естественно, что прямое соответствие агентов и потоков25 было бы не очень удобно и крайне неэффективно, потому что сильно ограничивало бы количество одновременно работающих агентов. Благодаря использованию асинхронных потоков операций, агент большую часть времени является просто структурой в памяти, которая содержит некоторое внутреннее состояние и только в те моменты, когда в очередь поступает очередное сообщение, функция обработки регистрируется для исполнения в потоке из системного пула. Функцией обработки как раз и является та, что была передана в конструктор или метод Start
. Таким образом, всё внутреннее состояние агента поддерживается инфраструктурой, а не ложится тяжким грузом на плечи пользователя. Для подтверждения этого факта можно попробовать создать несколько тысяч агентов, запустить их и начать случайным образом отправлять им сообщения.
Стоит сказать ещё пару слов об инкапсуляции. Несмотря на то, что пользователю недоступно внутреннее состояние самого объекта агента, функция обработки сообщений вовсе не обязана быть чистой, а может иметь побочные эффекты. Вполне работоспособно следующее решение, подсчитывающее количество обработанных сообщений:
Здесь ref
— это ещё один способ работы с изменяемым состоянием. Это надстройка над простыми mutable
значениями, описанными в начале статьи. Данный пример показывает, что функция обработки сообщений агента является полноценным хранилищем внутреннего состояния, которое сохраняется между вызовами. Такого же эффекта можно добиться и без использования изменяемого состояния:
Оптимизация хвостовой рекурсии не допустит переполнения стека.
Такой довольно простой в использовании инструмент позволяет строить масштабируемые параллельные системы с целыми иерархиями управляющих и управляемых процессов без использования разделяемого состояния и всех проблем, с ним связанных.
4.2.3 Обработка событий
Изначально .NET позволяет обрабатывать события по одному. Обработчиком события является функция, которая вызывается каждый раз с некоторыми аргументами, и если разработчику необходимо хранить какое-то дополнительное состояние между вызовами событий — это приходится делать самостоятельно. Кроме того, оригинальная модель подписки может приводить к утечкам памяти из-за наличия неявных взаимных ссылок в подписчике и генераторе событий.
F#, конечно, позволяет работать с событиями в классическом понимании. Правда, делается это при помощи немного необычного синтаксиса: вместо использования операторов +=
и -=
для регистрации и деактивации обработчика события используется пара методов Add/Remove.
С другой стороны, F# позволяет манипулировать потоками событий, и работать с ними как с последовательностями, используя функции filter
, map
, split
и другие. Например, следующий код фильтрует поток событий нажатия клавиш внутри поля ввода, выбирая только те из них, которые были нажаты в сочетании с Ctrl, после чего, из всех аргументов событий выбираются только значения поля KeyCode
. Таким образом, значением keyCodes
будет поток событий, содержащих только коды клавиш, нажатых с удерживаемым Ctrl.
Стоит отметить, что обработка потоков событий позволяет разработчику не заботиться о тонкостях отписки, а просто генерировать новые потоки событий на основе уже существующих, использовать эти потоки в качестве значений, то есть передавать их в качестве аргументов и возвращать из функций.
Использование подобной техники может привести к значительному упрощению кода для реализации, например, функциональности Drag&Drop. Ведь это есть ни что иное, как композиция события нажатия кнопки мыши, перемещения курсора с нажатой кнопкой и затем отпускания. Для примера часть, отвечающую за drag, можно легко перевести с русского на F#:
Сочетание обработки потоков событий с асинхронными потоками операций позволяет также довольно просто решать печально известную проблему GUI-приложений, когда обработчик события графического компонента должен исполняться в основном потоке и любое промедление приведёт к «зависанию» интерфейса приложения26.
Компания Microsoft разработала библиотеку Reactive Extensions, которая предоставляет разработчикам на других .NET-языках аналогичным образом манипулировать потоками событий.
4.3 Цитирования (Quotations)
Цитирования представляют собой интересный инструмент мета-программирования, отчасти похожий на .NET Expression Trees. Цитирования предоставляют разработчику доступ к исходному коду конструкций F# в виде синтаксического дерева. Если Expression Trees позволяют обрабатывать только ограниченное подмножество программных конструкций, то цитирования полностью охватывают все конструкции, возможные в F#. С другой стороны, цитирования можно сравнить с механизмом Reflection, правда в более структурированном виде.
По существу цитирования — это представление абстрактного синтаксического дерева программы. При желании эта мета-информация может сохраняться в скомпилированной сборке вместе с кодом, которому она соответствует. Цитирования можно использовать по-разному: для получения информации об исходном коде конструкций и генерации, например, запросов к базе данных или для преобразования одних конструкций в другие.
Цитирования бывают типизированные и нетипизированные. Друг от друга они отличаются, как нетрудно догадаться, наличием или отсутствием информации о типах. Нетипизированными цитированиями лучше всего пользоваться при необходимости преобразования АСТ. В случае использования только для чтения лучше подходят типизированные.
Преобразовать код в цитату можно несколькими способами: используя оператор <
@
~
@
>
, возвращающий типизированное цитирование; оператор <
@@
~
@@
>
, возвращающий нетипизированное; и помечая определения атрибутом [<
ReflectedDefinitionAttribute
>]
27.
Цитирования — это очень интересная тема, довольно слабо освещенная в официальных источниках. Подробнее о ней можно узнать в блоге Томаша Петричека [9], где он приводит примеры преобразования F#-кода в команды графического процессора.
Механизм Quotations активно используется в F# PowerPack для интеграции с механизмом LINQ, который появился в .NET 3.5. С февраля 2010 года F# PowerPack является проектом с открытым исходным кодом и также может стать интересным источником знаний о возможностях цитирований28.
5 Примеры использования
Существует интересный пример [7] использования quotations в F# как средства мета-программирования. Задача связана с массивной параллельной обработкой данных с помощью специальной библиотеки либо на многоядерной процессорной системе x64, либо на графическом процессоре GPU. Определяется небольшое подмножество F#, код из которого может быть оттранслирован и запущен на целевой платформе. Это позволяет писать «обычные» функции на F#, отлаживать их стандартным образом, а затем с помощью механизма quotations обрабатывать эти функции и запускать на GPU. Более того, программа на F# может создавать «на лету» такие функции, которые уже затем будут оттранслированы и запущены на другой платформе. Примечательно, что при реализации такой трансляции широко используется специальная возможность F# в виде активного сопоставления с образцом (active pattern matching), которая заметно упрощает написание транслятора.
Язык F# может быть удобен для создания DSL, которые становятся частью самого F#, причем такие языки могут быть достаточно краткими и выразительными. Например, в книге Real-World Functional Programming [10] приводится библиотека для описания анимации. Первоначальная идея была реализована в проекте Fran [Elliot, Hudak, 1997] на языке Haskell. Описание идеи ещё можно найти в книге The Haskell School of Expression [6]. Анимация моделируется как зависящая от времени величина. На основе примитивов строятся уже составляющие лексикон предметной области функции, с помощью которых можно описывать достаточно сложные анимации и делать это декларативно. Что касается реализации, то там много общего с монадами.
Вот пример того, как выглядит на языке F# определение анимированной части солнечной системы, которую затем можно визуализировать на экране компьютера:
Следующим примером использования F# является коммерческий продукт WebSharper фирмы IntelliFactory [2]. Это платформа для создания клиентских web-приложений. Она позволяет писать клиентский код на F#, который затем будет оттранслирован на JavaScript. Такая трансляция распространяется на достаточное большое подмножество F#, включая функциональное ядро языка, алгебраические типы данных, классы, объекты, исключения и делегаты. Также поддерживается значительная часть стандартной библиотеки F#, включая работу с последовательностями (sequences), событиями и асинхронными вычислительными выражениями (async workflows). Всё может быть автоматически оттранслировано на целевой язык JavaScript. Кроме того, поддерживается некоторая часть стандартных классов самого .NET, и объём поддержки будет расти.
Этот продукт примечателен тем, что здесь используется целый ряд приёмов, характерных для функционального программирования. Так, в WebSharper встроен DSL для задания HTML-кода, в котором широко применяются комбинаторы.
Например, следующий кусок кода на F#:
будет преобразован на выходе в такой код HTML:
<div class="header"> <h1>Our Website</h1> <br /> <p>Hello, world!</p> <img src="smile.jpg" /> </div>
Для самой же трансляции в JavaScript используются цитирования. Код для трансляции должен быть помечен атрибутом JavaScriptAttribute
, унаследованным от стандартного ReflectedDefinitionAttribute
.
Коньком WebSharper является работа с HTML-формами. Вводится обобщенный тип Formlet
<'
T
>
, значения которого извлекают некоторую информацию типа '
T
из формы. Важно, что этот тип является монадой и имеет соответствующий построитель вычислительных выражений. Это позволяет к значениям типа Formlet
<'
T
>
применить определенный для данного типа аппликативный комбинатор (<*>)
. В результате можно объединить и упростить сбор информации из формы.
Здесь метод Formlet
.
Yield
имеет тот же смысл, что и монадическая функция return
, а методы Controls
.
Input
создают значения типа Formlet
<
string
>
, которые извлекают информацию из текстовых полей Street
, City
и Country
, соответственно. На выходе получаем значение типа Formlet
<
Address
>
, который уже извлекает всю информацию об адресе.
Некоторый интерес представляет случай, когда пользователь непосредственно использует синтаксис вычислительных выражений для создания значений Formlet
внутри участков кода, помеченных атрибутом JavaScript
. Получается, что такое выражение раскрывается, и синтаксическое дерево результата раскрытия сохраняется в бинарной сборке наряду с кодом. Затем WebSharper извлекает это дерево и анализирует, переводя на JavaScript, т. е. транслируется само вычислительное выражение.
6 Дополнительные материалы
F#, как уже неоднократно было сказано выше, является наследником языков ML и OCaml. К счастью, эти языки существуют уже довольно давно и для них есть обширная документация. В части изучения теоретических основ F# можно обратиться к русскому переводу лекций Джона Харрисона [3].
Кроме того, сам язык уже успел обзавестись рядом интересных книг, среди которых «Foundations of F#» [11], «Expert F#» [13], «F# for scientists» [5] и «Real World Functional Programming» [10].
Самыми значительными интернет-ресурсами, ориентированными на F#, можно назвать блоги Дона Сайма [12] и Томаша Петричека [9], а также специализированный форум «hubFS» [1].
Источником практических знаний о возможностях F# могут служить проекты с открытым исходным кодом [4].
7 Заключение
Согласно гипотезе лингвистической относительности, люди, говорящие на разных языках, мыслят и воспринимают окружающую действительность по-разному. Если предположить, что используемый язык программирования влияет на то, как программист видит решение проблемы, то нельзя не признать, что F# будет подталкивать его к поиску более простых и элегантных решений.
В том, что F# найдет широкое применение в финансовой сфере и в области статистики, сомневаться не приходится. Помимо этого, F# заслуживает того, чтобы стать достаточно популярным в других областях, хотя бы потому, что делает многопоточное программирование проще и понятнее.
Следует также отметить, что на данный момент F# является, пожалуй, единственным функциональным языком программирования, который продвигается одним из ведущих производителей в области разработки программного обеспечения. Он позволяет использовать множество уже существующих библиотек, писать приложения для самых разных платформ и что не менее важно — делать всё это в современной IDE.
Список литературы
- [1]
- hubFS: THE place for F#. Адрес форума, http://cs.hubfs.net/.
- [2]
- IntelliFactory’s WebSharper. Официальная страница, http://www.intellifactory.com/.
- [3]
- Русский перевод курса лекций Джона Харрисона «Introduction to Functional Programming». Страница проекта, http://code.google.com/p/funprog-ru/.
- [4]
- Список проектов с открытым исходным кодом, использующих F#. Адрес сайта,http://stackoverflow.com/questions/383848/f-open-source-projects.
- [5]
- Jon Harrop. F# for Scientists. Wiley-Interscience, New York, NY, USA, 2008.
- [6]
- Paul Hudak. The Haskell School of Expression: Learning Functional Programming through Multimedia. Cambridge University Press, 2000.
- [7]
- Tomas Petricek. Accelerator and F#. Статья в блоге, http://tomasp.net/blog/accelerator-quotations.aspx.
- [8]
- Tomas Petricek. F# Overview — Introduction. Статья в блоге, http://tomasp.net/articles/fsharp-i-introduction.aspx.
- [9]
- Tomas Petricek. Блог Томаша Петричека. Адрес блога, http://tomasp.net/blog.
- [10]
- Tomas Petricek and Jon Skeet. Real-World Functional Programming with examples in F# and C#. Manning Publications, 2009.
- [11]
- Robert Pickering. Foundations of F#. Apress, Berkely, CA, USA, 2007.
- [12]
- Don Syme. Блог Дона Сайма. Адрес сайта, http://blogs.msdn.com/dsyme/.
- [13]
- Don Syme, Adam Granicz, and Antonio Cisternino. Expert F# (Expert’s Voice in .Net).
- [14]
- Душкин, Роман. Алгебраические типы данных и их использование в программировании. Журнал «Практика функционального программирования», 2, 2009.
- [15]
- Кирпичёв, Евгений. Элементы функциональных языков. Журнал «Практика функционального программирования», 3, 2009.
- 1
- Стоит учесть тот факт, что в Microsoft Research работает небезызвестный Simon Peyton Jones — один из ведущих разработчиков Haskell.
- 2
- Read-eval-print loop — вариант интерактивной среды программирования, когда отдельные команды языка вводятся пользователем, результат исполнения которых тут же вычисляется и выводится на экран.
- 3
- По следующей ссылке можно найти довольно подробное описание интеграции F# в Emacs:http://samolisov.blogspot.com/2010/04/f-emacs.html
- 4
- За более подробной информацией о каррировании и частичном применении читатель может обратиться к разделу 5 статьи «Элементы функциональных языков» [15].
- 5
- О функциональной композиции можно также прочитать в разделе 6 статьи «Элементы функциональных языков» [15].
- 6
- Речь идёт, конечно, о промышленных языках, таких как Java и C#, хотя в последнем есть определённые подвижки в правильном направлении.
- 7
- Рассмотрение атрибутов выходит за рамки данной статьи. Узнать о них подробнее можно по ссылкеhttp://msdn.microsoft.com/en-us/library/dd233179(VS.100).aspx
- 8
-
Следует иметь в виду, что экземпляры класса
Tuple
всегда создаются в куче. В случае, если это недопустимо, нужно использовать структуру. - 9
- В англоязычной литературе чаще всего используется термин list comprehension, одним из вариантов перевода которого может быть абстракция списка.
- 10
- List comprehensions — это частный случай использования такой возможности языка, как computation expressions, о которой будет рассказано ниже.
- 11
- Следует отметить, что копирования данных каждый раз не происходит. В новом списке всегда используются ссылки на элементы старого.
- 12
- О другом способе разбиения списка на голову и хвост будет рассказано в разделе о сопоставлении с образцом.
- 13
-
В примере использована анонимная функция. Анонимные функции аналогичны лямбда-выражениям из C#. Создаются они с помощью ключевого слова
fun
, за которым следует перечисление параметров, ‘->
’ и тело функции. - 14
-
Функция
List
.
sum
позволяет сделать то же самое намного проще. - 15
- Single-case active patterns.
- 16
- Banana clips.
- 17
-
Тип данных
option
<
T
>
для простоты можно считать аналогичнымNullable
<
T
>
, с той разницей, что непустые значения создаются при помощи функцииSome
, а пустые —None
. - 18
- Этот тип является размеченным объединением, о которых будет рассказано чуть позже.
- 19
- Поскольку это наиболее естественный способ работы с размеченными объединениями.
- 20
- За подробным описанием теоретической базы, лежащей в основе АТД, рекомендуется обратиться к статье с одноимённым названием[14].
- 21
- В англоязычной литературе по F# для обозначения данного понятия используется термин Discriminated Union, однако Tagged Union обозначает то же самое.
- 22
- Пример их использования для программирования пользовательского интерфейсам можно посмотреть здесь:http://lorgonblog.spaces.live.com/Blog/cns!701679AD17B6D310!1842.entry
- 23
- В силу ограниченного знакомства с языком Erlang автор смеет предположить, что знатокам этого языка описываемый класс напомнит агентов.
- 24
- Для простоты в типе опущен опциональный параметр, отвечающий за отмену операций агента. За более подробной информацией читателю рекомендуется обратиться к документации.
- 25
- Под потоком здесь, конечно же, понимается thread, а не stream.
- 26
- Замечательный пример подобного решения можно найти по ссылке http://lorgonblog.spaces.live.com/Blog/cns!701679AD17B6D310!1842.entry?sa=863426610
- 27
- Как раз в случае использования этого атрибута мета-информация будет сохранена в сборке вместе с исполняемым кодом и будет доступна во время исполнения.
- 28
- Страница проекта находится по адресу http://fsharppowerpack.codeplex.com/
Этот документ был получен из LATEX при помощи HEVEA
Оставить комментарий: