Введение в 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.ByteSystem.Int32 и System.Char. Как и в C#, для удобства разработчиков компилятор языка также поддерживает псевдонимы для этих типов: byteint и char соответственно. Чтобы различать числовые литералы разных типов, используются суффиксы:

  • uy — для значений типа System.Byte;
  • s — для System.Int16;
  • u — для System.UInt32;
  • и др., с полным списком можно ознакомиться на странице документации.

Для преобразования значений из одного типа в другой нужно использовать одноимённые встроенные функции bytesbyteint16intuint и т. п. Ещё раз отметим, что эти функции следует вызывать явно.

Помимо прочего существует единственное значение типа unit, обозначаемое (). Оно обозначает отсутствие результата исполнения функции, аналогично void в C# или C++.

3.1.2  Типы функций

Функции в F# также имеют тип, обозначение которого выглядит, например, так:

string -> string -> string

Такая запись означает функцию, принимающую на входе два параметра типа string, и возвращающую string в качестве результата.

Типы шаблонных функций записываются аналогично:

'a -> 'b -> 'c

Заметьте, что вместо указания конкретных типов значений используются просто метки. Данное описание типа аналогично (но не идентично) следующему делегату из C#:

delegate C MyFunction<A, B, C>(A a, B b);

Это любая функция двух аргументов, которая возвращает некоторое значение. Типы AB и C могут быть разными, но могут и совпадать.

3.1.3  Присвоение значений

Сопоставление имени и значения производится при помощи оператора связывания let:

> let value = 42;; val value : int = 42

В данном контексте value — значение, а не переменная. При этом по умолчанию, все значения неизменяемые. Настоящую переменную можно создать, указав перед её именем ключевое слово mutable:

> let mutable value = 0 value <- 42;; val mutable value : int = 42

Принципиальной разницы между значениями и функциями нет. Функции — это те же значения, а следовательно их точно так же можно использовать в выражениях, передавать в качестве параметров в другие функции и возвращать в качестве результата функции. При вызове функций параметры записываются последовательно и разделяются пробелами, при этом список параметров не надо заключать в круглые скобки, как это делается в Java или C#.

> let sqr a = a*a;; val sqr : int -> int > sqr 2;; val it : int = 4

Для удобства последнее выражение в теле функции автоматически возвращается в качестве результата функции.

3.1.4  Подробнее о функциях

В функциональном программировании есть два близких понятия: каррирование и частичное применение. Для того, чтобы объяснить каррирование, рассмотрим следующие два определения на C#:

int ClassicAdd(int a, int b){ return a + b; } Func<int, int> CurriedAdd(int a){ return delegate(int b){ return a + b; } }

Обе функции делают одно и то же: вычисляют сумму двух целых чисел, но при этом вторая из них является функцией от одного аргумента — первого слагаемого — и возвращает другую функцию от одного аргумента, внутри которой происходит вычисление результата. Вызовы этих функций будут выглядеть следующим образом:int res = ClassicAdd(40, 2) и int res = CurriedAdd(40)(2)CurriedAdd можно переписать с использованием лямбда выражений следующим образом:

Func<int, int> CurriedAdd(int a){ return b => a + b; }

Функции, подобные CurriedAdd из нашего примера, которые берут свои аргументы по одному, каждый раз формируя функцию меньшего числа аргументов, называются каррированными, а преобразование обычных функций в такой вид — каррированием или каррингом. Функции в F# каррированы по умолчанию, а чтобы сделать функцию как в C#, следует объединить параметры в кортеж. Именно так F# и трактует функции стандартной библиотеки .NET Framework.

let curriedAdd a b = a + b let classicAdd (a, b) = a + b

Соответствующие вызовы будут выглядеть так: 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# не требует явно указывать типы всех значений. Механизм вывода типов позволяет определить недостающие типы значений, глядя на их использование. При этом некоторые значения должны иметь заранее известный тип. В роли таких значений, к примеру, могут выступать числовые литералы, так как их тип однозначно определяется суффиксом. Рассмотрим простой пример:

> let add a b = a + b;; val add : int -> int -> int > add 3 5;; val it : int = 8

Функция add возвращает сумму своих параметров и имеет тип int -> int -> int. Если не смотреть на сигнатуру функции, то можно подумать, что она складывает значения любых типов, но это не так. Попытка вызвать её для аргументов типа float или decimal приведёт к ошибке компиляции. Причина такого поведения кроется в механизме вывода типов. Поскольку оператор + может работать с разными типами данных, а никакой дополнительной информации о том, как эта функция будет использоваться, нет, компилятор по умолчанию приводит её к типу int -> int -> int. Это можно проиллюстрировать на примере следующей сессии FSI:

> let add a b = a + b in add 2.0 3.0;; val add : float -> float -> float

В данном примере функция add имеет тип float -> float -> float, так как компилятор может вывести её тип, глядя на её использование. F# также позволяет указывать аннотации для явного указания типов аргументов или возвращаемого значения функции.

> let add (a:float) (b:float) = a + b;; val add : float -> float -> float

В данном примере типы аргументов функции заданы явно с помощью аннотаций. Указывать типы всех аргументов не обязательно: достаточно указать тип любого из них или тип возвращаемого значения, как в следующем примере:

> let add a b : float = a + b;; val add : float -> float -> float

3.1.6  Единицы измерения (units of measure)

Как правило, программы работают не просто с переменными типа int или float. Например, метод System.String.Length возвращает не просто значение типа int, а количество символов в строке, и складывать его с другим значением того же типа int, содержащим количество секунд, прошедших с 1-го января 1970-го года, бессмысленно. Язык программирования F# позволяет программисту связать с любым значением числового типа произвольную единицу измерения. В дальнейшем, во время компиляции, будет выполняться проверка корректности приложения, на основе анализа размерностей.

Для создания единицы измерения нужно перед объявлением типа указать атрибут Measure7.

> [<Measure>] type m [<Measure>] type s;;

После этого типы s и m можно использовать в качестве единиц измерения, для чего их следует указать в угловых скобках после соответствующего значения или типа:

> let a = 10<m>;; val a : int<m> = 10 > let b = 2<s>;; val b : int<s> = 2

Значения a и b имеют одинаковые типы, но разные размерности, что означает, что их нельзя вычитать, складывать и сравнивать. Значения разных размерностей, однако, можно умножать и делить, результат операции в таком случае будет иметь составную размерность.

> let c = a + b;;

Попытка сложить метры с секундами приводит к ошибке «error FS0001: The unit of measure ’s’ does not match the unit of measure ’m’».

> let v = a/b;; val v : int<m/s> = 5

Значение v имеет составную размерность m/s. В предыдущем примере, единицы измерения m и s являются независимыми, то есть не выражаются друг через друга, однако F# позволяет определять зависимости между разными единицами измерения:

> [<Measure>] type Hz = s ^ -1;;

или просто:

> [<Measure>] type Hz = 1/s;;

Значения, имеющие размерность Hz, можно будет использовать в качестве значений с размерностью 1/s.

3.1.7  Обобщённые единицы измерения

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

> let sqr a = a*a;; val sqr : int -> int > let a = 2<s>;; val a : int<s> = 2 > sqr (int a);; val it : int = 4

Чтобы избежать ненужных преобразований, можно не указывать единицу измерения явно. Для этого вместо имени размерности следует использовать символ подчёркивания, в результате чего механизм вывода типов определит используемую размерность автоматически, иначе она будет обобщённой.

> let sqr (a : int<_>) = a*a;; val sqr : int<'u> -> int<'u ^ 2> > sqr a;; val it : int<s ^ 2> = 4

В данном примере, функция sqr принимает параметр типа int, имеющий любую размерность, либо не имеющий её вовсе.

Следует также отметить, что единицы измерения не вписываются в рамки общей системы типов .NET Framework, поэтому информация о них сохраняется в мета-данных и может быть использована только из другой F#-сборки.

3.1.8  Некоторые встроенные типы

Стандартная библиотека F# предоставляет программисту ряд типов данных, предназначенных в первую очередь для создания программ в функциональном стиле.

Кортеж — упорядоченный набор элементов, экземпляр класса Tuple. Элементы кортежа могут иметь разные типы. Для создания нового экземпляра кортежа8 нужно перечислить значения, входящие в него, через запятую:

> let tuple = "first element", 2, 3.0;; val tuple : string * int * float = ("first element", 2, 3.0)

В данном примере string * int * float — это тип кортежа. Существуют две встроенные функции для работы с кортежами — fst и snd, возвращающие первый и второй элемент кортежа соответственно. Эти функции определены только для кортежей, состоящих из двух элементов. Для извлечения элементов из кортежа можно использовать оператор связывания. Для этого, в левой части, через запятую, должны быть перечислены идентификаторы, соответствующие элементам кортежа.

> let first, second, third = tuple;; val third : float = 3.0 val second : int = 2 val first : string = "first element"

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

> let lst = [ 1; 3; 6; 10; 15 ];; val lst : int list = [1; 3; 6; 10; 15]

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

> let a = [1 .. 10];; val a : int list = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]

Кроме того можно указать шаг приращения значений, который может быть отрицательным:

> let a = [1 .. 2 .. 10];; val a : int list = [1; 3; 5; 7; 9]

В более сложных случаях можно использовать генераторы списков. Генератор списка — это фрагмент кода, заключённый в квадратные скобки, используемый для создания всех элементов списка. Элементы списка добавляются с помощью ключевого слова yield. С помощью такого выражения, например, можно получить список чётных чисел:

> let evenlst = [ for i in 1..10 do if i % 2 = 0 then yield i ] val evenlst : int list = [2; 4; 6; 8; 10]

Внутри генераторов списков10 можно использовать практически любые выражения F#: yieldforiftry и т.д.

3.1.9  Операции над списками

Списки в F# не являются экземплярами типа System.Collections.Generic.List<T>, и в отличие от последних — неизменяемы. Добавить или удалить элемент из списка нельзя, вместо этого можно создать новый список на основе существующего. Вообще, любые операции над списками в F# приводят к созданию нового неизменяемого списка11. Для добавления элемента в начало списка служит оператор ‘::’, который является псевдонимом функции Cons из модуля List:

> let newlst = 0 :: lst;; val newlst : int list = [0; 1; 3; 6; 10; 15] > let newlst = List.Cons(0, lst);; val newlst : int list = [0; 1; 3; 6; 10; 15]

Модуль List содержит множество функций, работающих со списками. Два списка можно объединить с помощью оператора конкатенации ‘@’:

> let cclst = lst @ [21; 28];; val cclst : int list = [1; 3; 6; 10; 15; 21; 28]

При обработке неизменяемых списков довольно часто требуется получить из исходного списка его первый элемент и список всех остальных элементов. Для этого служат функции List.head и List.tail, соответственно12.

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

List.map создает новый список, применяя пользовательскую функцию ко всем элементам исходного списка. Её тип: ('a -> 'b) -> 'a list -> 'b list. То есть, она принимает на вход пользовательскую функцию преобразования и исходный список, и возвращает новый список, с элементами другого типа.

> List.map (fun i -> i*i) [1; 2; 3; 4];; val it : int list = [1; 4; 9; 16]

В данном примере пользовательская функция возвращает квадрат своего аргумента13. В C# аналогичного эффекта можно добиться c помощью Enumerable.Select и дальнейшего преобразования результата к списку:

var list = new List<int>{1, 2, 3, 4}; var result = list.Select(i => i*i).ToList();

List.reduce выполняет операцию свёртки, её тип: ('a -> 'a -> 'a) -> 'a list -> 'a. Пользовательская функция принимает на вход два аргумента: аккумулятор и следующий элемент списка и должна вернуть новое значение аккумулятора.

> List.reduce (fun acc i -> i + acc) [1; 2; 3; 4; 5];; val it : int = 15

В данном примере с помощью функции List.reduce, вычисляется сумма всех элементов списка14. При первом вызове функция, переданная пользователем, получает голову списка в качестве аккумулятора.

Функция List.fold в целом аналогична функции List.reduce, за исключением того, что тип элементов списка и тип аккумулятора могут быть разными. Описанная выше функция List.reduce является частным случаемList.fold. Эта функция имеет тип ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a. В отличие от List.reduce, она содержит один дополнительный аргумент, который выступает в роли начального значения аккумулятора. Это можно проиллюстрировать на примере выражения, преобразующего список чисел в строку:

let lst = [1; 2; 3; 4] > List.fold (fun acc i -> acc + ", " + (string i)) (string <| List.head lst) (List.tail lst);; val it : string = "1, 2, 3, 4"

Поскольку тип результата отличается от типа элементов списка, использовать функцию List.reduce в данном случае нельзя. Итак, первый аргумент List.fold — функция с двумя аргументами: строкой и числом. Она прибавляет к строке-аккумулятору запятую, пробел и строковое представление числа — элемента списка. Второй аргумент функции List.fold — начальное значение аккумулятора, равное строковому представлению первого элемента списка. Третий и последний аргумент List.fold — обрабатываемый список. Так как строковое представление первого элемента списка уже содержится в аккумуляторе, туда передаётся только хвост списка.

Оба варианта функций свёртки списков реализованы в C# с помощью перегруженного метода Enumerable.Aggregate, принимающего 2 и 3 аргумента.

3.2  Последовательности, ленивость

Обычно выражения в F# вычисляются «энергично». Это означает, что значение выражения будет вычислено независимо от того, используется оно где-либо или нет. Например, если определить список с помощью генератора, то список будет создан в памяти целиком, независимо от дальнейшего его использования.

В противоположность жадному подходу существует стратегия ленивых вычислений, которая позволяет вычислять значение выражения только тогда, когда оно становится необходимо. Преимуществами такого подхода являются:

  • производительность, поскольку неиспользуемые значения просто не вычисляются;
  • возможность работать с бесконечными или очень большими последовательностями, так как они никогда не загружаются в память полностью;
  • декларативность кода. Использование ленивых вычислений избавляет программиста от необходимости следить за порядком вычислений, что делает код проще.

Основной недостаток ленивых вычислений — плохая предсказуемость. В отличие от энергичных вычислений, где очень просто определить пространственную и временную сложность алгоритма, с ленивыми вычислениями всё может быть куда менее очевидно. F# позволяет программисту самостоятельно решать, что именно должно вычисляться лениво, а что нет. Это в значительной степени устраняет проблему предсказуемости, так как ленивые вычисления применяются только там, где это действительно необходимо, позволяя сочетать лучшее из обоих миров.

3.2.1  Тип данных Lazy

С помощью значения данного типа можно представить отложенное вычисление.

> let lv = Lazy<_>.Create( fun () -> printfn "Eval"; 42);; val lv : System.Lazy<int> = <unevaluated> > lv.Value;; Eval val it : int = 42 > lv.Value;; val it : int = 42

При первом обращении к свойству Value происходит вычисление значения. При последующих — используется ранее вычисленное значение. Аналогично использовать ключевое слово lazy:

> let lv = lazy(printfn "Eval"; 42);; val lv : Lazy<int> = <unevaluated>

3.2.2  Последовательности

Последовательность — всего лишь синоним для .NET-интерфейса IEnumerable<T>, поэтому последовательности легко могут быть использованы из сборок, написанных на других .NET-языках.

Последовательности создаются похожим со списками образом. Можно задать последовательность в виде диапазона:

> let intseq = seq {1 .. System.Int32.MaxValue};; val intseq : seq<int>

Попытка создать список такого же размера приведёт к ошибке.

Последовательность может быть создана при помощи генератора последовательности (sequence comprehension), аналогично генераторам списка. В отличии от генератора списка, он не создает все элементы последовательности сразу. Выполнение кода прерывается после того, как очередной элемент последовательности был создан с помощью ключевого слова yield. Дальнейшее выполнение кода будет продолжено тогда, когда потребуется следующий элемент. Код генератора последовательности записывается в фигурных скобках, расположенных после идентификатора seq:

let symbols = seq { for c in 'A' .. 'Z' do yield c yield System.Char.ToLower(c) }

Генераторы последовательностей F# аналогичны итераторам, которые появились в C# версии 2. Так, например, приведённый выше код аналогичен следующему:

// ABC == "ABC...YZ" IEnumerable<char> GetLowerABC(){ for(ch in ABC){ yield ch yield System.Char.ToLower(ch); } }

Однако возможности генераторов последовательностей несколько шире. Например, они могут быть вложенными или рекурсивными. Чтобы включить в одну последовательность элементы другой, используется ключевое слово yield! (читается «Yield bang»):

let rec collatz n = seq { yield n match n%2 with | 0 -> yield! collatz (n/2) | _ -> yield! collatz (3*n + 1) }

В данном примере функция collatz возвращает все числа, входящие в последовательность Коллатца, начиная с n. Во-первых, функция рекурсивна (поэтому объявлена с помощью ключевого слова rec). Во-вторых, функция возвращает последовательность, так как её тело состоит из одного sequence expression. При этом последовательности, возвращаемые рекурсивными вызовами функции collatz, объединяются с помощью оператора yield! в одну плоскую последовательность.

Модуль Seq содержит функции для работы с последовательностями. В нём есть как аналоги функций lengthfilterfoldmap модуля List, так и специфические для последовательностей функции, такие как take и unfold.

3.3  Сопоставление с образцом (Pattern matching)

Сопоставление с образцом — основной способ работы со структурами данных в F#.

Эта языковая конструкция состоит из ключевого слова match, анализируемого выражения, ключевого слова with и набора правил. Каждое правило — это пара образец–результат. Всё выражение сопоставления с образцом принимает значение того правила, образец которого соответствует анализируемому выражению. Все правила сопоставления с образцом должны возвращать значения одного и того же типа. В простейшем случае в качестве образцов могут выступать константы:

> let xor x y = match x, y with | true, true -> false | false, false -> false | true, false -> true | false, true -> true ;; val xor : bool -> bool -> bool

В правилах сопоставления с образцом можно использовать символ подчеркивания («wildcard»), если конкретное значение неважно.

> let testAnd x y = match x, y with | true, true -> true | _ -> false;; val testAnd : bool -> bool -> bool > testAnd false false;; val it : bool = false

Если набор правил сопоставления не покрывает все возможные значения образца, компилятор F# выдаёт предупреждение. На этапе исполнения, если ни одно правило не будет соответствовать образцу, будет сгенерировано исключение MatchFailureException.

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

> let printValue x = match x with | 0 -> printfn "is zero" | a -> printfn "is %d" a;;

Конечно, данный пример выглядит надуманным, так как в выражении можно использовать непосредственно значение-образец, а вместо именованного значения — wildcard. Однако, это может быть очень удобно в тех случаях, когда мы имеем дело с более сложными данными, например с кортежами или списками. Тогда с помощью правила сопоставления с образцом можно разобрать сложную структуру данных на составные части.

Разные правила могут быть объединены между собой.

> let testOr x y = match x, y with | (_, true) | (true, _) -> true | _ -> false;; val testOr : bool -> bool -> bool

В этом примере первое правило сработает в том случае, если один из аргументов равен true.

В некоторых случаях, простого сопоставления бывает мало, и требуется использовать в правилах более сложную логику. Тогда можно указать дополнительные ограничения после ключевого слова when:

> let testOr x y = match x, y with | _ when x = true -> true | _ when y = true -> true | _ -> false;;

3.3.1  Активные шаблоны (active patterns)

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

open System.IO type FileType = Document | Application | Unknown let fileType filename = match filename with | _ when Path.GetExtension(filename) = ".doc" -> Document | _ when Path.GetExtension(filename) = ".odf" -> Document | _ when Path.GetExtension(filename) = ".exe" -> Application | _ -> Unknown

Сопоставление с образцом здесь не даёт каких-либо преимуществ по сравнению с обычным сравнением.

Для восполнения этого и подобных пробелов, служат активные шаблоны — особые функции, которые могут быть использованы в правилах сопоставления с образцом.

Одно-вариантные активные шаблоны15 позволяют выполнить простое преобразование данных, к примеру, из одного типа в другой. Это может быть полезно тогда, когда данные исходного типа не могут быть использованы в правилах сопоставления с образцом, либо когда нам нужно использовать более сложную логику при выполнении сравнения. Активный шаблон этого типа — простая функция одного аргумента, имя которой при объявлении заключено в прямые, а затем в круглые скобки16.

Предыдущий пример можно переписать следующим образом:

open System.IO type FileType = Document | Application | Unknown let (|FileExtension|) filePath = Path.GetExtension(filePath) let fileType filename = match filename with | FileExtension ".doc" | FileExtension ".odf" -> Document | FileExtension ".exe" -> Application | _ -> Unknown

Здесь FileExtension — активный шаблон, который просто возвращает расширения файла. За именем шаблона следует его результат. Если результат представлен константой, то результат сравнивается с ней и при совпадении, правило считается выполненным. Если результат — именованное значение, правило считается выполненным, а результат выполнения функции-активного шаблона можно получить по имени. Например:

let ext = match "file.txt" with | FileExtension ext -> ext

Значение ext будет содержать расширение файла или пустую строку, если файл не имеет расширения.

Частичные активные шаблоны (partial-case active patterns) используются в тех случаях, когда преобразование не всегда возможно. Функция-шаблон должна возвращать значение типа option<T>, равное None, если преобразование невозможно17.

type ValueType = | Integer of int | Float of float | Boolean of bool | Unknown let makeParsePattern tryParseFn str = let success, result = tryParseFn(str) if success then Some result else None let (|IsInteger|_|) = makeParsePattern System.Int32.TryParse let (|IsFloat|_|) = makeParsePattern System.Double.TryParse let (|IsBoolean|_|) = makeParsePattern System.Boolean.TryParse let parseStr str = match str with | IsInteger x -> Integer x | IsFloat x -> Float x | IsBoolean x -> Boolean x | _ -> Unknown

В данном примере определяется тип ValueType, значения которого представляют собой результаты разбора строки18. Функция makeParsePattern используется для создания активных шаблонов. Далее определяются шаблоны: IsIntegerIsFloat и IsBoolean. Эти шаблоны являются функциями, которые принимают один аргумент строкового типа и возвращают опциональное значение. Частичный активный шаблон задаётся с помощью конструкции (|«имя»|_|). Как и в случае single-case шаблона, на вход функции передается значение-образец. Если функция вернула None, считается, что значение не удовлетворяет правилу.

Активные шаблоны могут быть параметризованными. При этом в match-выражении указывается сначала имя шаблона, затем параметры шаблона, а потом его результат. В качестве иллюстрации можно переписать предыдущий пример следующим образом:

type FileType = Document of string | Application of string | Unknown let (|FileExtension|_|) fileExt filePath = let ext = Path.GetExtension(filePath) if ext = fileExt then Some <| Path.GetFileNameWithoutExtension(filePath) else None let fileType filename = match filename with | FileExtension ".doc" name -> Document name | FileExtension ".odf" name -> Document name | FileExtension ".exe" name -> Application name | _ -> Unknown

Результат шаблона используется здесь для захвата имени файла без расширения, а параметр — для определения типа.

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

let classify a = match a with | Integer i -> printfn "integer %d" i | Float f -> printfn "float %f" f | Boolean true -> printfn "true" | Boolean false -> printfn "false" | Unknown -> printfn "unknown value" > classify <| parseStr "42";; integer 42 val it : unit = ()

Многовариантные активные шаблоны (multi-case active patterns) позволяют упростить классификацию данных при использовании размеченных объединений. Например:

let (|Integer|Float|Boolean|Unknown|) input = match Int32.TryParse(input) with | true, result -> Integer result | false, _ -> match Double.TryParse(input) with | true, result -> Float result | false, _ -> match Boolean.TryParse(input) with | true, result -> Boolean result | false, _ -> Unknown let classify a = match a with | Integer i -> printfn "integer %d" i | Float f -> printfn "float %f" f | Boolean true -> printfn "true" | Boolean false -> printfn "false" | Unknown -> printfn "unknown value" > classify "42";; integer 42 val it : unit = ()

Нам больше не нужна функция parseStr, которая занималась разбором строки и возвращала элемент размеченного объединения.

3.4  Пользовательские типы данных

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

В следующем примере создаётся тип-запись с двумя полями.

> type Person = { Name : string; Age : int };; type Person = {Name: string; Age: int;} > let mary = { Name = "Mary"; Age = 22 };; val mary : Person = {Name = "Mary"; Age = 22;} > let age = mary.Age;; val age : int = 22

Записи F# аналогичны структурам из C# или C++ и могут использоваться для группировки нескольких логически связанных значений в одну сущность, оставляя при этом, в отличие от кортежей, возможность доступа к отдельным элементам по имени. Доступ к полям производится при помощи знакомого синтаксиса: через точку.

Заметьте, мы не указывали тип значения mary явно: компилятор смог вывести его самостоятельно на основе того, какие поля использовались при создании экземпляра. Механизм вывода типов работает с пользовательскими типами данных, в большинстве случаев избавляя от необходимости указывать типы явно.

Размеченное объединение — это алгебраический тип данных20, такой же как data в Haskell21. Определение такого типа состоит из названия и списка конструкторов с параметрами. Понятие конструктора в данном случае не совсем совпадает с принятым в объектно-ориентированном программировании толкованием, тем не менее, это тоже функция, вызов которой приводит к появлению нового экземпляра. Отличие от конструкторов классов состоит в том, что конструктор размеченного объединения может использоваться в шаблонах механизма сопоставления с образцом для «разборки» объекта на составные части.

type MyDateTime = | FromTicks of int | FromString of string

С объектно-ориентированной точки зрения такой тип выглядит как небольшая иерархия классов, где MyDateTime выступает в роли базового класса, а FromTicks и FromString — классы-наследники. Таким образом, несмотря на то, что размеченное объединение является специфической для F# структурой данных, её можно использовать в публичных API, которые будут вызываться из других .NET языков, хотя такое использование будет далеко не самым удобным.

.NET-перечисления, которые в C# объявляются при помощи ключевого слова enum, в F# также объявляются как размеченные объединения. Правда, такое объединение не может иметь параметризованных конструкторов. Кроме того, каждому конструктору должно соответствовать числовое значение.

type Color = | None = 0 | Red = 1 | Green = 2 | Blue = 4

Аналогичный тип можно объявить на C# следующим образом:

enum Color { None = 0, Red = 1, Green = 2, Blue = 3 }

3.4.1  Объекты и классы

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

В случае явного объявления класса программист должен объявить поля класса и хотя бы один конструктор:

type Book = val title : string val author : string val publishDate : DateTime new (t, a, pd) = { title = t author = a publishDate = pd }

С помощью ключевого слова val объявляются члены-данные класса, а с помощью ключевого слова newсоздаётся конструктор класса. Члены-данные класса обязательно должны инициализироваться в конструкторе, а точнее — внутри выражения-конструктора (constructor expression), которое записывается в фигурных скобках после символа равенства. Если какое-либо поле не будет инициализировано, компилятор выдаст ошибку. Внутри выражения-конструктора можно только инициализировать поля класса, причем каждое поле — только один раз: попытка сделать что-нибудь ещё опять же приведет к ошибке компиляции.

На первый взгляд это ограничение кажется неразумным, так как иногда всё же требуется выполнить какие-либо действия во время создания экземпляра класса. Например выполнить валидацию параметров конструктора или записать что-нибудь в лог. На самом деле всё не так печально: в конструкторе можно не только инициализировать члены-данные, но и добавить произвольный код, который будет выполняться до инициализации. Он записывается перед constructor expression. Также можно написать код, который будет выполняться после инициализации полей, для чего следует использовать блок then после соответствующего выражения-конструктора.

type Book = val title : string val author : string val publishDate : DateTime new (t:string, a:string, pd) = if t.Length = 0 then failwithf "Book title is empty (%s, %A)" a pd if a.Length = 0 then failwithf "Book author is empty (%s, %A)" t pd { title = t author = a publishDate = pd } then printfn "New book is constructed %s %s %A" t a pd

В данном примере перед инициализацией полей класса происходит проверка переданных параметров, а после неё следует вывод сообщения об успешном создании экземпляра. Обратите внимание, что код конструктора не может обращаться к членам-данным объекта иначе, чем через выражение-конструктор.

Все члены-данные по умолчанию неизменяемы. Так же, как и в случае обычных значений, это поведение можно изменить с помощью ключевого слова mutable.

Во многих случаях классы можно объявлять намного проще. Для этого нужно сразу после имени класса в круглых скобках перечислить параметры его основного конструктора. Эти параметры будут доступны для использования в методах класса, при этом объявлять их явно с помощью ключевого слова val не нужно.

type Book(title:string, author:string, publishDate:DateTime) = member this.Title = title member this.Author = author

В этом случае конструктор класса и его члены-данные создаются неявно. Конструктор класса принимает три параметра, но сам класс будет иметь два поля: author и title. Поле publishDate создано не будет, так как нигде не используется. Попытка использовать val в классе, объявленном с использованием неявного синтаксиса приведёт к ошибке компиляции.

Класс, объявленный неявно, может иметь более одного конструктора, которые также объявляются с помощью ключевого слова new, за которым следует список параметров и код конструктора. Но, в отличие от явного объявления класса, в этом случае мы не можем использовать constructor expression. Вместо этого конструктор обязательно должен вызывать основной конструктор, созданный неявно, и передавать в него соответствующие параметры.

type Book(title:string, author:string, publishDate:DateTime) = member this.Title = title member this.Author = author member this.PublishDate = publishDate new (str:string) = let parts = str.Split(';') let title = parts.[0] let author = parts.[1] let publishDate = DateTime.Parse(parts.[2]) new Book(title, author, publishDate) then printfn "Created from string - %s" str

В этом примере определён дополнительный конструктор, который получает на вход строку, разбирает её и вызывает основной конструктор.

Внутри неявного определения класса можно объявлять переменные, используя оператор связывания let, а также выполнять произвольный код с помощью ключевого слова do.

type Book(title:string, author:string, publishDate:DateTime) = do if title.Length = 0 then failwithf "Book title is empty (%s, %A)" author publishDate do if author.Length = 0 then failwithf "Book author is empty (%s, %A)" title publishDate let mutable publishDate = publishDate member this.Title = title member this.Author = author member this.PublishDate = publishDate member this.SetPublishDate(newdate:DateTime) = publishDate <- newdate new (str:string) = let parts = str.Split(';') let title = parts.[0] let author = parts.[1] let publishDate = DateTime.Parse(parts.[2]) new Book(title, author, publishDate) then printfn "Created from string %s" str

Здесь добавлены проверки для параметров основного конструктора, которые будут выполняться во время создания объекта независимо от того, с помощью какого конструктора объект создаётся. Помимо этого, с помощью оператора связывания здесь объявлена переменная класса publishDate и метод класса SetPublishDate, с помощью которого можно изменять значение этой переменной.

Классы, как и записи или размеченные объединения, могут быть обобщёнными. Для этого нужно после имени класса в фигурных скобках перечислить обобщённые параметры класса:

type Point<'a>(x:'a, y:'a) = member this.X = x member this.Y = y

Создать экземпляр класса можно следующим образом:

> let p = Point(1.0, 1.0);; val p : Point<float>

3.4.2  Свойства и методы

В предыдущих примерах методы уже использовались, теперь настало время поговорить о них подробнее. Объявление метода или свойства начинается с ключевого слова member, за которым следует имя self-идентификатора. Это имя используется для обращения к членам класса внутри метода или свойства. В случае, если метод или свойство статические, self-идентификатор указывать не нужно. В отличие от C#, здесь нет неявной переменной this, которая передаётся в каждый нестатический метод класса и предоставляет доступ к объекту, для которого вызван этот метод. Вместо этого, разработчик волен сам выбирать удобное имя, под которым будет известен текущий объект.

type Point<'a>(x:'a, y:'a) = let mutable m_x = x let mutable m_y = y member this.Reset(other:Point<'a>) = m_x <- other.X m_y <- other.Y member this.X with get() = m_x and set x = m_x <- x member this.Y with get() = m_y and set y = m_y <- y

Здесь класс Point имеет два свойства и один метод. Свойства X и Y доступны для чтения и для записи. Если свойство не предполагает возможности чтения или установки нового значения, соответствующую часть объявления можно опустить.

Видимость методов, свойств, полей и классов можно изменять с помощью ключевых слов publicprivate и internal. В отличие от других .NET языков, F# не поддерживает модификатор protected, однако правильно работает с защищёнными методами и свойствами классов, созданных с использованием других языков программирования.

Ключевые слова sealed или abstract, которыми можно помечать классы в C#, не поддерживаются в F# на уровне языка. Тем не менее, в стандартной библиотеке есть соответствующие атрибуты: Sealed и AbstractClass, которые обрабатываются компилятором и приводят к ожидаемому результату.

В F# полностью поддерживается перегрузка методов. Можно определить несколько методов, имеющих одинаковое имя, но отличающихся типом и количеством параметров.

3.4.3  Наследование

Как и любой объектно-ориентированный язык, F# позволяет создать новый класс, наследующий свойства уже существующего.

При явном описании класса-наследника необходимо после ключевого слова inherit указать имя базового класса, и вызвать его конструктор в constructor expression с помощью того же ключевого слова.

type Base = val a : int new (a:int) = {a = a} type Derived = inherit Base val b : int new (a:int, b:int) = { inherit Base(a) b = b }

При неявном объявлении класса аргументы для вызова конструктора указываются сразу же после объявления базового класса.

type Base = val a : int new (a:int) = {a = a} type Derived(a:int, b:int) = inherit Base(a) let b = b

Класс-наследник может уточнять либо переопределять поведение базового класса переопределяя его методы. В отличие от C#, все члены класса, которые могут быть переопределены, являются абстрактными и объявляются при помощи ключевого слова abstract. Но, опять же в отличие от C#, абстрактный метод может иметь реализацию по умолчанию, которая задаётся с помощью ключевого слова default.

type Vertex = Point<float> type Shape = new () = {} abstract NumberOfVertexes : int default this.NumberOfVertexes = failwith "not implemented" abstract Item : int -> Vertex default this.Item ix = failwith "not implemented" type Line(a:Vertex, b:Vertex) = inherit Shape() override this.NumberOfVertexes = 2 override this.Item (ix:int) = match ix with | 0 -> a | 1 -> b | _ -> failwith "index out of range"

В данном примере мы создали класс Shape, который является базовым для всех геометрических фигур. Он имеет одно абстрактное свойство и абстрактный метод, которые имеют реализацию по умолчанию и переопределяются в наследнике. Странное объявление типа Vertex в начале примера просто создаёт псевдоним для типа Point<float>.

> let ln = Line(Vertex(0.0, 0.0), Vertex(1.0, 1.0));; val ln : Line > ln.[0];; val it : Vertex = FSI_0145+Point`1[System.Double] {X = 0.0; Y = 0.0;} > ln.[1];; val it : Vertex = FSI_0145+Point`1[System.Double] {X = 1.0; Y = 1.0;} > ln.NumberOfVertexes;; val it : int = 2

Метод Item является методом-индексатором, который позволяет обращаться к объекту с помощью оператора ‘.[]’ (обратите внимание на точку перед квадратными скобками, она обязательна). Аналогичные конструкции в C# объявляются при помощи специального синтаксиса индексаторов this[].

Интерфейсы — важная часть платформы .NET. Они позволяют описать контракт, который обязуется выполнить класс, реализуя методы данного интерфейса. Если обычное наследование реализует отношение «является частным случаем» и применяется для расширения базового класса, то наследование от интерфейса реализует отношение «поддерживает» и применяется для того, что бы определить возможные точки соприкосновения разных подсистем, которые могут ничего не знать друг о друге, кроме того, что одна из них поддерживает нужный интерфейс.

Интерфейс в F# — это просто чистый абстрактный класс.

type ICommand = abstract Execute : unit -> unit type DoStuff() = interface ICommand with override this.Execute () = printfn "Do stuff"

Реализация интерфейса отличается от наследования класса, что впрочем логично. Нелогичным может показаться то, что нельзя вызывать перегруженные методы интерфейса — это приведет к ошибке компиляции. Чтобы вызвать метод интерфейса, нужно выполнить приведение типа с помощью оператора ‘:>’.

> let cmd = DoStuff();; val cmd : DoStuff > (cmd :> ICommand).Execute();; Do stuff val it : unit = ()

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

type ICommand = abstract Execute : unit -> unit type IExecutable = abstract Execute : unit -> unit type DoStuff() = interface ICommand with override this.Execute () = printfn "Command: Do stuff" interface IExecutable with override this.Execute () = printfn "Executable: Do stuff" member this.Execute() = printfn "Class: Other do stuff"

Мы определили три метода Execute, один из них — метод класса, два других — методы интерфейсов, реализуемых классом. В F# это не приводит к ошибкам, так как программист всегда указывает явно и интерфейс, метод которого реализуется, и интерфейс, метод которого он пытается вызвать.

> let cmd = DoStuff();; val cmd : DoStuff > cmd.Execute();; Class: Other do stuff val it : unit = () > (cmd :> ICommand).Execute();; Command: Do stuff val it : unit = () > (cmd :> IExecutable).Execute();; Executable: Do stuff val it : unit = ()

3.4.4  Объектные выражения

В любом более или менее серьёзном приложении встречаются вспомогательные классы, объекты которых используются только в одном месте, например в качестве предиката сравнения для алгоритма сортировки. Такой класс, как правило, реализует какой-либо интерфейс и служит только одной цели. При этом класс объекта нигде в программе не используется, используются только объекты этого класса. В качестве примера можно привести метод Sort контейнера List. Этот метод принимает в качестве одного из аргументов объект, реализующий интерфейс IComparer, который в дальнейшем используется для сортировки элементов контейнера.

F# позволяет создавать подобные объекты по месту использования с помощью так называемых объектных выражений или object expressions. Объектное выражение — это выражение, результатом которого является объект анонимного класса. Записывается оно следующим образом:

// prepare a list let books = new List<Book>( [| Book("Programming F#;Cris Smith;October 2009"); Book("Foundations of F#;Robert Pickering;May 30, 2007"); Book("F# for Scientists;Jon Harrop;August 4, 2008") |] ) books.Sort( { new IComparer<_> with member this.Compare(left:Book, right:Book) = left.PublishDate.CompareTo(right.PublishDate) } )

После выполнения метода Sort элементы контейнера будут упорядочены по дате публикации.

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

let obj = { new SomeType(arg1, arg2, ..argN) with member this.Property = 42 }

С помощью объектных выражений нельзя добавлять новые методы или свойства.

3.4.5  Методы расширения

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

type System.Int32 with member this.Times fn = for x = 1 to this do fn ()
> (3).Times(fun () -> printfn "I <3 F#");; I <3 F# I <3 F# I <3 F# val it : unit = ()

К сожалению, метод расширения, созданный подобным образом можно будет использовать только из другого F#-кода. Чтобы создать метод расширения, доступный другим языкам .NET, необходимо, как и во многих других случаях, воспользоваться магическими атрибутами:

open System.Runtime.CompilerServices [<Extension>] module IntExtensions = [<Extension>] let Times(count:int, fn) = for x in 1..count do fn ()

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!returnreturn!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 } будет преобразовано в

let b = builder in b.Run(b.Delay(fun ()  «comp-expr»))

где вызовы методов Run и Delay обрабатываются особым образом. В случае отсутствия одного из них или сразу обоих ошибка уже не генерируется, просто соответствующая часть опускается. Например, когда оба метода не определены, то все сокращается до преобразованного выражения «comp-expr». Когда метод Delay определён, он фактически делает вычисление отложенным, т. е. ленивым.

Для реализации монады достаточно определить методы BindReturn и, возможно, обработчики try. Через них можно выразить остальные необходимые методы построителя, но часто в случае конкретной монады бывает так, что можно найти более эффективные определения. Это — одна из причин, по которой F#, например, не предоставляет готовых реализаций для методов While и For.

Для реализации моноида достаточно определить методы ZeroCombine и, возможно, For.

В качестве примера предположим, что у нас имеется построитель maybe, который реализует методы BindReturn и Delay. Тогда следующая функция:

let ap m1 m2 = maybe { let! x1 = m1 let! x2 = m2 return (x1 x2) }

будет преобразована транслятором на этапе компиляции в следующее определение:

let ap m1 m2 = maybe.Delay (fun () -> maybe.Bind (m1, fun x1 -> maybe.Bind (m2, fun x2 -> maybe.Return (x1 x2))))

Эта функция достаточно общая, и она походит для очень многих вычислительных выражений. На практике имеет смысл создать отдельный модуль, назовем его Maybe, куда можно и поместить определение функции ap. Туда же можно поместить определение комбинатора (<*>), который в данном случае будет совпадать с функцией ap. Такое разбиение на модули позволяет различать функции с одинаковыми именами.

[<CompilationRepresentation (CompilationRepresentationFlags.ModuleSuffix)>] module Maybe = let ap m1 m2 = ... // the function definition let (<*>) m1 m2 = ap m1 m2

Теперь поставим задачу реализовать такой построитель maybe, чтобы мы могли проводить некие вычисления с возможностью их быстрой остановки. Используя вычислительные выражения, мы можем реализовать такой механизм остановки прозрачно. Для этого создадим отдельный класс MaybeBuilder, который бы реализовывал необходимые методы.

type M<'a> = unit -> 'a option let runMaybe m = m () let fail = fun () -> None type MaybeBuilder () = member b.Return (x): M<'a> = fun () -> Some x member b.ReturnFrom (m): M<'a> = m member b.Bind (m: M<'a>, k): M<'b> = match runMaybe m with | None -> fail | Some x -> k x member b.Delay (k: unit -> M<'a>): M<'a> = fun () -> runMaybe (k ())

Здесь аннотации типов можно было и опустить. Они использованы лишь чтобы получить на выходе следующую сигнатуру типа:

type MaybeBuilder = new: unit -> MaybeBuilder member Return: 'a -> M<'a> member ReturnFrom: M<'a> -> M<'a> member Bind: M<'a> -> ('a -> M<'b>) -> M<'b> member Delay: (unit -> M<'a>) -> M<'a>

Читатель, знакомый с языком программирования Haskell, сразу увидит в этом определение монады, причём для большего сходства метод Delay придаёт вычислению ленивость. В действительности, такое определение — одна из возможных реализаций известной монады Maybe.

Собственно сам построитель определяется просто:

let maybe = MaybeBuilder ()

Теперь мы умеем создавать вычисления, причём они будут отложенными. Чтобы получить результат, необходимо уже применить функцию запуска runMaybe к самому вычислению.

Итак, в этом примере основным методом построителя является Bind. Он принимает исходное вычисление и его продолжение. Если результатом исходного вычисления является fail, то вычисление-продолжение не запускается. В противном случае результат выполнения первого вычисления попадает на вход второго. Методу Bind в коде соответствуют конструкции let!. Теперь, чтобы прервать вычисление немедленно, в правой части конструкции следует вернуть fail.

let failIfBig n = maybe { if n > 1000 then return! fail else return n } let failIfEitherBig (inp1, inp2) = maybe { let! n1 = failIfBig inp1 let! n2 = failIfBig inp2 return (n1,n2) }

Подобное поведение вычислительного выражения является лишь частным случаем. Мы можем интерпретировать код вычисления самым разным образом. Если ключевыми шагами вычисления считать места использования конструкций let!do! и use!, т. е. где вызывается метод Bind, то само выражение мы можем рассматривать с позиции встраивания кода между этими шагами, которые уже отвечают за обработку встроенного кода, причём сама обработка происходит прозрачно. Это позволяет меньше думать о деталях и заметно повышает уровень абстракции. Так открывается путь к созданию DSL. Также значительно упрощается написание вычислений, выполняемых асинхронно, о чем мы расскажем далее.

Более того, генератор последовательности (sequence comprehension) является частным случаем вычислительного выражения, только в нём особую роль играют уже конструкции yield и yield!. Буквально это означает, что средствами самого языка можно создать аналог генератора seq, но уже с другим ключевым словом. Также можно создать аналоги других двух стандартных генераторов: списка и массива.

Представляет особый практический интерес то обстоятельство, что вычислительное выражение можно использовать вместе с цитированиями — ещё одной специальной возможностью F#, о которой мы расскажем ниже. Мы можем получить синтаксическое дерево с результатом раскрытия вычислительного выражения. Это позволяет создавать трансляторы таких выражений. В конце статьи приводится практический пример WebSharper, где используется такая возможность.

Так, если взять вышеописанный построитель maybe или любой другой, то в F# Interactive можно увидеть результат раскрытия вычислительного выражения, который будет представлен в виде синтаксического дерева:

> let a = <@ maybe { return 777 } @>;;

Теперь несколько слов об эффективности. Выше было дано общее определение для функции ap. Если заменить имя построителя, то определение будет работать со многими другими построителями, реализующими методы Bind и Return. В языке Haskell аналогичная функция определена с помощью класса типов Monad. Там одна и та же функция будет работать с любой монадой. В F# мы должны определять функцию ap для каждой монады отдельно, но здесь кроется один важный момент. Для использованного в примере построителя maybe мы можем создать более эффективную реализацию функции ap: как минимум две лямбды не нужны. Причем, такая ситуация встречается часто: для этого построителя мы можем написать более эффективные реализации методов WhileForDelay и т. д., хотя всё это можно было бы выразить по-прежнему через Bind и Return. Вырисовывается характерная черта F# в области обработки монад, когда по сравнению с Haskell выбор делается в пользу менее общего, но, как правило, более эффективного кода.

Другим отличительным моментом по сравнению с Haskell является то, что вычислительные выражения могут иметь побочный эффект, не отражённый в системе типов. Например, наиболее ярко это проявляется при использовании асинхронных вычислений.

4.2  Средства для параллельного программирования

4.2.1  Асинхронные потоки операций (async workflows)

Асинхронные потоки операций — это один из самых интересных примеров практического использования вычислительных выражений. Код, выполняющий какие либо неблокирующие операции ввода-вывода, как правило сложен для понимания, поскольку представляет из себя множество callback-методов, каждый из которых обрабатывает какой-то промежуточный результат и возможно начинает новую асинхронную операцию. Асинхронные потоки операций позволяют писать асинхронный код последовательно, не определяя callback-методы явно. Для создания асинхронного потока операций используется блок async:

open System.IO open System.Net open Microsoft.FSharp.Control.WebExtensions let getPage (url:string) = async { let req = WebRequest.Create(url) let! res = req.AsyncGetResponse() use stream = res.GetResponseStream() use reader = new StreamReader(stream) let! result = reader.AsyncReadToEnd() return result }

Здесь мы объявили функцию getPage, которая должна возвращать содержимое страницы по заданному адресу. Эта функция имеет тип string -> Async<string> и возвращает асинхронную операцию, которая может быть использована для получения строки с содержимым страницы. Стоит отметить, что классы WebRequest и StreamReader не имеют методов AsyncGetResponse и AsyncReadToEnd, это методы расширения.

Построитель асинхронного потока операций, работает следующим образом. Встретив оператор let! или do!, он начинает выполнять операцию асинхронно, при этом метод, начинающий асинхронную операцию, получит оставшуюся часть блока async в качестве функции обратного вызова. После завершения асинхронной операции переданный callback продолжит выполнение асинхронного потока операций, но, возможно, уже в другом потоке операционной системы (для выполнения кода используется пул потоков). В результате код выглядит так, как будто он выполняется последовательно. То, что может быть с легкостью записано внутри блока async с использованием циклов и условных операторов, достаточно сложно реализуется с использованием обычной техники, требующей описания множества callback-методов и передачей состояния между их вызовами.

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

let getPage (url:string) = async { try let req = WebRequest.Create(url) let! res = req.AsyncGetResponse() use stream = res.GetResponseStream() use reader = new StreamReader(stream) let! result = reader.AsyncReadToEnd() return Some result with | _ as e -> printfn "error: %s" e.Message return None }

В этом примере поток операций возвращает значение типа string~option, то есть, либо строку либо пустое значение, чтобы вызывающий код мог обработать ошибку.

Значение типа Async<_> нужно передать в один из статических методов класса Async, чтобы начать выполнение соответствующего потока операций. В простейшем случае можно воспользоваться методом Async.RunSynchronously, который просто заблокирует вызывающий поток до тех пор, пока все операции не будут выполнены. Такой код:

getPage "http://google.com" |> Async.RunSynchronously

просто вернёт содержимое веб-страницы по указанному адресу.

Метод Async.Parallel — это комбинатор, который позволяет объединить несколько асинхронных потоков операций в один. Этот метод принимает на вход последовательность асинхронных операций, возвращающих значение типа X, и возвращает одну асинхронную операцию, возвращающую массив значений типа X.

let result = ["http://www.google.com"; "http://www.ya.ru"] |> Seq.map getPage |> Async.Parallel |> Async.RunSynchronously

Этот код вернёт массив из двух строк, представляющий результаты выполнения двух асинхронных операций. Помимо этого, класс Async содержит методы, позволяющие обрабатывать исключения, прерывать работу асинхронных потоков операций и многое другое.

Возможности асинхронных потоков операций не ограничены вводом/выводом22, а также набором стандартных классов. Существует простой способ добавлять произвольные асинхронные операции, однако его рассмотрение выходит за рамки этой статьи.

4.2.2  MailboxProcessor

MailboxProcessor — это класс из стандартной библиотеки F#, реализующий один из паттернов параллельного программирования. MailboxProcessor является агентом23, обрабатывающим очередь сообщений, которые поставляются ему извне при помощи метода Post. Вся конкурентность поддерживается реализацией класса, который содержит очередь с возможностью одновременной записи несколькими писателями и чтения одним единственным читателем, которым является сам агент.

let agent = MailboxProcessor.Start(fun inbox -> async { while true do let! msg = inbox.Receive() printfn "message received: '%s'" msg })

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

agent.Post("Hello world!")

Интересно отметить тип функции, являющейся единственным параметром конструктора агента (и статического метода Start)24:

static member Start : (MailboxProcessor<'Msg> -> Async<unit>) -> MailboxProcessor<'Msg>

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

Естественно, что прямое соответствие агентов и потоков25 было бы не очень удобно и крайне неэффективно, потому что сильно ограничивало бы количество одновременно работающих агентов. Благодаря использованию асинхронных потоков операций, агент большую часть времени является просто структурой в памяти, которая содержит некоторое внутреннее состояние и только в те моменты, когда в очередь поступает очередное сообщение, функция обработки регистрируется для исполнения в потоке из системного пула. Функцией обработки как раз и является та, что была передана в конструктор или метод Start. Таким образом, всё внутреннее состояние агента поддерживается инфраструктурой, а не ложится тяжким грузом на плечи пользователя. Для подтверждения этого факта можно попробовать создать несколько тысяч агентов, запустить их и начать случайным образом отправлять им сообщения.

Стоит сказать ещё пару слов об инкапсуляции. Несмотря на то, что пользователю недоступно внутреннее состояние самого объекта агента, функция обработки сообщений вовсе не обязана быть чистой, а может иметь побочные эффекты. Вполне работоспособно следующее решение, подсчитывающее количество обработанных сообщений:

let agent = MailboxProcessor.Start(fun inbox -> async { let i = ref 0 while true do let! msg = inbox.Receive() incr i printfn "#%d..." !i })

Здесь ref — это ещё один способ работы с изменяемым состоянием. Это надстройка над простыми mutableзначениями, описанными в начале статьи. Данный пример показывает, что функция обработки сообщений агента является полноценным хранилищем внутреннего состояния, которое сохраняется между вызовами. Такого же эффекта можно добиться и без использования изменяемого состояния:

let agent = MailboxProcessor.Start(fun inbox -> let rec loop n = async { let! msg = inbox.Receive() printfn "#%d..." n return! loop (n+1) } loop 0)

Оптимизация хвостовой рекурсии не допустит переполнения стека.

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

4.2.3  Обработка событий

Изначально .NET позволяет обрабатывать события по одному. Обработчиком события является функция, которая вызывается каждый раз с некоторыми аргументами, и если разработчику необходимо хранить какое-то дополнительное состояние между вызовами событий — это приходится делать самостоятельно. Кроме того, оригинальная модель подписки может приводить к утечкам памяти из-за наличия неявных взаимных ссылок в подписчике и генераторе событий.

F#, конечно, позволяет работать с событиями в классическом понимании. Правда, делается это при помощи немного необычного синтаксиса: вместо использования операторов += и -= для регистрации и деактивации обработчика события используется пара методов Add/Remove.

button.Click.Add(fun args -> printfn "Button clicked" )

С другой стороны, F# позволяет манипулировать потоками событий, и работать с ними как с последовательностями, используя функции filtermapsplit и другие. Например, следующий код фильтрует поток событий нажатия клавиш внутри поля ввода, выбирая только те из них, которые были нажаты в сочетании с Ctrl, после чего, из всех аргументов событий выбираются только значения поля KeyCode. Таким образом, значением keyCodes будет поток событий, содержащих только коды клавиш, нажатых с удерживаемым Ctrl.

let keyCodes = textBox.KeyDown |> Event.filter (fun args -> args.Modifiers = Keys.Control) |> Event.map (fun args -> args.KeyCode)

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

Использование подобной техники может привести к значительному упрощению кода для реализации, например, функциональности Drag&Drop. Ведь это есть ни что иное, как композиция события нажатия кнопки мыши, перемещения курсора с нажатой кнопкой и затем отпускания. Для примера часть, отвечающую за drag, можно легко перевести с русского на F#:

form.MouseDown |> Event.merge form.MouseMove |> Event.filter (fun args -> args.Button = MouseButtons.Left) |> Event.map (fun args -> (args.X, args.Y)) |> Event.add (fun (x, y) -> printfn "(%d, %d)" x y)

Сочетание обработки потоков событий с асинхронными потоками операций позволяет также довольно просто решать печально известную проблему GUI-приложений, когда обработчик события графического компонента должен исполняться в основном потоке и любое промедление приведёт к «зависанию» интерфейса приложения26.

Компания Microsoft разработала библиотеку Reactive Extensions, которая предоставляет разработчикам на других .NET-языках аналогичным образом манипулировать потоками событий.

4.3  Цитирования (Quotations)

Цитирования представляют собой интересный инструмент мета-программирования, отчасти похожий на .NET Expression Trees. Цитирования предоставляют разработчику доступ к исходному коду конструкций F# в виде синтаксического дерева. Если Expression Trees позволяют обрабатывать только ограниченное подмножество программных конструкций, то цитирования полностью охватывают все конструкции, возможные в F#. С другой стороны, цитирования можно сравнить с механизмом Reflection, правда в более структурированном виде.

<@ 42 @> // has type Qutations.Expr<int> // Value(42) <@ fun i -> i * i @> // has type Quotations.Expr<int -> int> // Lambda (i, // Call (None, // Int32 op_Multiply[Int32,Int32,Int32](Int32,Int32), // [i, i])

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

Цитирования бывают типизированные и нетипизированные. Друг от друга они отличаются, как нетрудно догадаться, наличием или отсутствием информации о типах. Нетипизированными цитированиями лучше всего пользоваться при необходимости преобразования АСТ. В случае использования только для чтения лучше подходят типизированные.

Преобразовать код в цитату можно несколькими способами: используя оператор <@~@>, возвращающий типизированное цитирование; оператор <@@~@@>, возвращающий нетипизированное; и помечая определения атрибутом [<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# определение анимированной части солнечной системы, которую затем можно визуализировать на экране компьютера:

let solarSystem = sun -- (rotate 80.00f 4.1f mercury) -- (rotate 150.0f 1.6f venus) -- (rotate 215.0f 1.0f (earth -- (rotate 20.0f 12.0f moon)))

Следующим примером использования F# является коммерческий продукт WebSharper фирмы IntelliFactory [2]. Это платформа для создания клиентских web-приложений. Она позволяет писать клиентский код на F#, который затем будет оттранслирован на JavaScript. Такая трансляция распространяется на достаточное большое подмножество F#, включая функциональное ядро языка, алгебраические типы данных, классы, объекты, исключения и делегаты. Также поддерживается значительная часть стандартной библиотеки F#, включая работу с последовательностями (sequences), событиями и асинхронными вычислительными выражениями (async workflows). Всё может быть автоматически оттранслировано на целевой язык JavaScript. Кроме того, поддерживается некоторая часть стандартных классов самого .NET, и объём поддержки будет расти.

Этот продукт примечателен тем, что здесь используется целый ряд приёмов, характерных для функционального программирования. Так, в WebSharper встроен DSL для задания HTML-кода, в котором широко применяются комбинаторы.

Например, следующий кусок кода на F#:

Div [Class "Header"] -< [ H1 ["Our Website"] Br [] P ["Hello, world!"] Img [Src "smile.jpg"] ]

будет преобразован на выходе в такой код HTML:

<div class="header">
  <h1>Our Website</h1>
  <br />
  <p>Hello, world!</p>
  <img src="smile.jpg" />
</div>

Для самой же трансляции в JavaScript используются цитирования. Код для трансляции должен быть помечен атрибутом JavaScriptAttribute, унаследованным от стандартного ReflectedDefinitionAttribute.

[<JavaScriptType>] type MyControl() = inherit IntelliFactory.WebSharper.Web.Control() [<JavaScript>] override this.Body = Div ["Hello, world!"]

Коньком WebSharper является работа с HTML-формами. Вводится обобщенный тип Formlet<'T>, значения которого извлекают некоторую информацию типа 'T из формы. Важно, что этот тип является монадой и имеет соответствующий построитель вычислительных выражений. Это позволяет к значениям типа Formlet<'T>применить определенный для данного типа аппликативный комбинатор (<*>). В результате можно объединить и упростить сбор информации из формы.

[<JavaScript>] let AddressFormlet () : Formlet<Address> = Formlet.Yield (fun st ct cnt -> {Street = st; City = ct; Country = cnt}) <*> Controls.Input "Street" <*> Controls.Input "City" <*> Controls.Input "Country"

Здесь метод Formlet.Yield имеет тот же смысл, что и монадическая функция return, а методы Controls.Inputсоздают значения типа Formlet<string>, которые извлекают информацию из текстовых полей StreetCity и 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

Оставить комментарий: