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

- Первая часть
- Вторая часть
- Третья часть
- Четвертая часть
- Пятая часть
- Шестая часть
- Седьмая часть
- Восьмая часть
- Девятая часть
В F# это возможно с помощью фичи, которая называется "расширение типов" ("type extensions"). У любого F# типа, не только класса, могут быть прикреплённые функции.
Вот пример прикрепления функции к типу записи.
module Person = type T = {First:string; Last:string} with // функция-член, объявленная вместе с типом member this.FullName = this.First + " " + this.Last // конструктор let create first last = {First=first; Last=last} let person = Person.create "John" "Doe" let fullname = person.FullName
Ключевые моменты, на которые следует обратить внимание:
- Ключевое слово
withобозначает начало списка членов - Ключевое слово
memberпоказывает, что функция является членом (т.е. методом) - Слово
thisявляется меткой объекта, на котором вызывается данный метод (также называемая "self-identifier"). Это слово является префиксом имени функции, и внутри функции можно использовать его для обращения к текущему экземпляру. Не существует требований к словам, используемым в качестве самоидентификатора, достаточно чтобы они были устойчивы. Можно использоватьthis,self,meили любое другое слово, которое обычно используется как отсылка на самого себя.
Нет нужды добавлять член вместе с объявлением типа, всегда можно добавить его позднее в том же модуле:
module Person = type T = {First:string; Last:string} with // член, объявленный вместе с типом member this.FullName = this.First + " " + this.Last // конструктор let create first last = {First=first; Last=last} // другой член, объявленный позже type T with member this.SortableName = this.Last + ", " + this.First let person = Person.create "John" "Doe" let fullname = person.FullName let sortableName = person.SortableName
Эти примеры демонстрируют вызов "встроенных расширений" ("intrinsic extensions"). Они компилируются в тип и будут доступны везде, где бы тип ни использовался. Они также будут показаны при использовании рефлексии.
Внутренние расширения позволяют даже разделять определение типа на несколько файлов, пока все компоненты используют одно и то же пространство имён и компилируются в одну сборку. Так же как и с partial классами в C#, это может быть полезным для разделения сгенерированного и написанного вручную кода.
Опциональные расширения
Альтернативный вариант заключается в том, что можно добавить дополнительный член из совершенно другого модуля. Их называют "опциональными расширениями". Они не компилируются внутрь класса, и требуют другой модуль в области видимости для работы с ними (данное поведение напоминает методы-расширения из C#).
Например, пусть определен тип Person:
module Person = type T = {First:string; Last:string} with // член, объявленный вместе с типом member this.FullName = this.First + " " + this.Last // конструктор let create first last = {First=first; Last=last} // ещё один член, объявленный позже type T with member this.SortableName = this.Last + ", " + this.First
Пример ниже демонстрирует, как можно добавить расширение UppercaseName к нему в другом модуле:
// в другом модуле module PersonExtensions = type Person.T with member this.UppercaseName = this.FullName.ToUpper()
Теперь можно попробовать это расширение:
let person = Person.create "John" "Doe" let uppercaseName = person.UppercaseName
Упс, получаем ошибку. Она произошла потому, что PersonExtensions не находится в области видимости. Как и в C#, чтобы использовать любые расширения, их нужно ввести в область видимости.
Как только мы сделаем это, все заработает:
// Сначала сделаем расширение доступным! open PersonExtensions let person = Person.create "John" "Doe" let uppercaseName = person.UppercaseName
Расширение системных типов
Можно также расширять типы из .NET библиотек. Но следует иметь ввиду, что при расширении типа надо использовать его фактическое имя, а не псевдоним.
Например, если попробовать расширить int, ничего не получится, т.к. int не является правильным именем для типа:
type int with member this.IsEven = this % 2 = 0
Вместо этого нужно использовать System.Int32:
type System.Int32 with member this.IsEven = this % 2 = 0 let i = 20 if i.IsEven then printfn "'%i' is even" i
Статические члены
Можно создавать статические функции-члены с помощью:
- добавления ключевого слова
static - удаления метки
this
module Person = type T = {First:string; Last:string} with // член, определённый вместе с типом member this.FullName = this.First + " " + this.Last // статический конструктор static member Create first last = {First=first; Last=last} let person = Person.T.Create "John" "Doe" let fullname = person.FullName
Можно создавать статические члены для системных типов:
type System.Int32 with static member IsOdd x = x % 2 = 1 type System.Double with static member Pi = 3.141 let result = System.Int32.IsOdd 20 let pi = System.Double.Pi
Прикрепление существующих функций
Очень распространённый паттерн — прикрепление уже существующих самостоятельных функций к типу. Он даёт несколько преимуществ:
- Во время разработки можно объявлять самостоятельные функции, которые ссылаются на другие самостоятельные функции. Это упростит разработку, поскольку вывод типов гораздо лучше работает с функциональным стилем, нежели с объектно-ориентированным ("через точку").
- Но некоторые ключевые функции можно прикрепить к типу. Это позволяет пользователям выбирать, какой из стилей использовать — функциональный или объектно-ориентированный.
Примером подобного решения является функция из F# библиотеки, которая вычисляет длину списка. Можно использовать самостоятельную функцию из модуля List или вызывать ее как метод экземпляра.
let list = [1..10] // функциональный стиль let len1 = List.length list // объектно-ориентированный стиль let len2 = list.Length
В следующем примере тип изначально не имеет каких-либо членов, затем определяются несколько функций, и наконец к типу прикрепляется функция fullName.
module Person = // тип, изначально не имеющий членов type T = {First:string; Last:string} // конструктор let create first last = {First=first; Last=last} // самостоятельная функция let fullName {First=first; Last=last} = first + " " + last // присоединение существующей функции в качестве члена type T with member this.FullName = fullName this let person = Person.create "John" "Doe" let fullname = Person.fullName person // ФП let fullname2 = person.FullName // ООП
Самостоятельная функция fullName имеет один параметр, person. Присоединённый же член получает параметр из self-ссылки.
Добавление существующих функций с несколькими параметрами
Есть ещё одна приятная особенность. Если определённая ранее функция принимает несколько параметров, то когда вы будете прикреплять её к типу, вам не придётся перечислять все эти параметры снова. Достаточно указать параметр this первым.
В примере ниже функция hasSameFirstAndLastName имеет три параметра. Однако при прикреплении достаточно упомянуть всего лишь один!
module Person = // Тип без членов type T = {First:string; Last:string} // конструктор let create first last = {First=first; Last=last} // самостоятельная функция let hasSameFirstAndLastName (person:T) otherFirst otherLast = person.First = otherFirst && person.Last = otherLast // присоединение функции в качестве члена type T with member this.HasSameFirstAndLastName = hasSameFirstAndLastName this let person = Person.create "John" "Doe" let result1 = Person.hasSameFirstAndLastName person "bob" "smith" // ФП let result2 = person.HasSameFirstAndLastName "bob" "smith" // ООП
Почему это работает? Подсказка: подумайте о каррировании и частичном применении!
Кортежные методы
Когда у нас появляются методы с более чем одним параметром, необходимо принять решение:
- мы можем использовать стандартную (каррированную) форму, где параметры разделяются пробелами, и поддерживается частичное применение.
- или можем передавать все параметры за один раз в виде разделённого запятыми кортежа.
Каррированая форма более функциональная, в то время как кортежная форма более объектно-ориентированная.
Кортежная форма также используется для взаимодействия F# со стандартными библиотеками .NET, поэтому стоит рассмотреть данный подход более детально.
Нашим испытательным полигоном будет тип Product с двумя методами, каждый из которых реализован одним из способов, описанных выше. Методы CurriedTotal и TupleTotal делают одно и то же: вычисляют итоговую стоимость товара по заданным количеству и скидке.
type Product = {SKU:string; Price: float} with // каррированная форма member this.CurriedTotal qty discount = (this.Price * float qty) - discount // кортежная форма member this.TupleTotal(qty,discount) = (this.Price * float qty) - discount
Тестовый код:
let product = {SKU="ABC"; Price=2.0} let total1 = product.CurriedTotal 10 1.0 let total2 = product.TupleTotal(10,1.0)
Пока нет особой разницы.
Но мы знаем, что каррированная версия может быть частично применена:
let totalFor10 = product.CurriedTotal 10 let discounts = [1.0..5.0] let totalForDifferentDiscounts = discounts |> List.map totalFor10
С другой стороны, кортежная версия способна на то, что не может каррированая, а именно:
- Именованные параметры
- Необязательные параметры
- Перегрузки
Именованные параметры с параметрами в форме кортежа
Кортежний подход поддерживает именованные параметры:
let product = {SKU="ABC"; Price=2.0} let total3 = product.TupleTotal(qty=10,discount=1.0) let total4 = product.TupleTotal(discount=1.0, qty=10)
Как видите, это позволяет менять порядок аргументов с помощью явного указания имен.
Внимание: если лишь у некоторой части параметров есть имена, то эти параметры всегда должна находиться в конце.
Необязательные параметры с параметрами в форме кортежа
Для методов с параметрами в форме кортежа можно помечать параметры как опциональные при помощи префикса в виде знака вопроса перед именем параметра.
- Если параметр задан, то в функцию будет передано
Some value - Иначе придет
None
Пример:
type Product = {SKU:string; Price: float} with // Опциональная скидка member this.TupleTotal2(qty,?discount) = let extPrice = this.Price * float qty match discount with | None -> extPrice | Some discount -> extPrice - discount
И тест:
let product = {SKU="ABC"; Price=2.0} // скидка не передана let total1 = product.TupleTotal2(10) // скидка передана let total2 = product.TupleTotal2(10,1.0)
Явная проверка на None и Some может быть утомительной, но для обработки опциональных параметров существует более элегантное решение.
Существует функция defaultArg, которая принимает имя параметра в качестве первого аргумента и значение по умолчанию в качестве второго. Если параметр установлен, будет возвращено соответствующее значение, иначе — значение по умолчанию.
Тот же код с применением defaulArg:
type Product = {SKU:string; Price: float} with // опциональная скидка member this.TupleTotal2(qty,?discount) = let extPrice = this.Price * float qty let discount = defaultArg discount 0.0 extPrice - discount
Перегрузка методов
В C# можно создать несколько методов с одинаковым именем, которые отличаются своей сигнатурой (например, различные типы параметров и/или их количество).
В чисто функциональной модели это не имеет смысла — функция работает с конкретным типом аргумента (domain) и конкретным типом возвращаемого значения (range). Одна и та же функция не может взаимодействовать с другими domain и range.
Однако, F# поддерживает перегрузку методов, но только для методов (которые прикреплены к типам) и только тех из них, которые написаны в кортежном стиле.
Вот пример с еще одним вариантом метода TupleTotal!
type Product = {SKU:string; Price: float} with // без скидки member this.TupleTotal3(qty) = printfn "using non-discount method" this.Price * float qty // со скидкой member this.TupleTotal3(qty, discount) = printfn "using discount method" (this.Price * float qty) - discount
Как правило компилятор F# ругается на то, что существует два метода с одинаковым именем, но в данном случае это приемлемо, т.к. они объявлены в кортежной нотации и их сигнатуры различаются. (Чтобы было понятно, какой из методов вызывается, я добавил небольшие сообщения для отладки)
Пример использования:
let product = {SKU="ABC"; Price=2.0} // скидка не указана let total1 = product.TupleTotal3(10) // скидка указана let total2 = product.TupleTotal3(10,1.0)
Эй! Не так быстро… Недостатки использования методов
Придя из объектно-ориентированного мира, можно поддаться соблазну использовать методы везде, потому что это что-то привычное. Но следует быть осторожным, т.к. у них существует ряд серьезных недостатков:
- Методы плохо работают с выводом типов
- Методы плохо работают с функциями высшего порядка
На самом деле, злоупотребляя методами, можно упустить самые сильные и полезные стороны программирования на F#.
Посмотрим, что я имею ввиду.
Методы плохо взаимодействуют с выводом типов
Вернемся к примеру с Person, в котором одна и та же логика была реализована в самостоятельной функции и в методе:
module Person = // тип без методов type T = {First:string; Last:string} // конструктор let create first last = {First=first; Last=last} // самостоятельная функция let fullName {First=first; Last=last} = first + " " + last // функция-член type T with member this.FullName = fullName this
Теперь посмотрим, насколько хорошо вывод типов работает с каждым из способов. Допустим, я хочу вывести полное имя человека, тогда я определю функцию printFullName, которая принимает person в качестве параметра.
Код, использующий самостоятельную функцию из модуля:
open Person // использование самостоятельной функции let printFullName person = printfn "Name is %s" (fullName person) // Сработал вывод типов // val printFullName : Person.T -> unit
Компилируется без проблем, а вывод типов корректно идентифицирует параметр как Person.
Теперь попробуем версию через точку:
open Person // обращение к методу "через точку" let printFullName2 person = printfn "Name is %s" (person.FullName)
Этот код вообще не скомпилируется, т.к. вывод типов не имеет достаточной информации, чтобы определить тип параметра. Любой объект может реализовывать .FullName — этого недостаточно для вывода.
Да, мы можем аннотировать функцию типом параметра, но из-за этого теряется весь смысл автоматического вывода типов.
Методы плохо сочетаются с функциями высшего порядка
Подобная проблема возникает и в функциях высшего порядка. Например, есть список людей, и нам надо получить список их полных имен.
В случае самостоятельной функции решение тривиально:
open Person let list = [ Person.create "Andy" "Anderson"; Person.create "John" "Johnson"; Person.create "Jack" "Jackson"] // получение всех полных имён list |> List.map fullName
В случае метода объекта, придется везде создавать специальную лямбду:
open Person let list = [ Person.create "Andy" "Anderson"; Person.create "John" "Johnson"; Person.create "Jack" "Jackson"] // получение всех имён list |> List.map (fun p -> p.FullName)
А ведь это еще достаточно простой пример. Методы объектов довольно поддаются композиции, неудобны в конвейере и т.д.
Поэтому, если вы новичок в функциональном программировании, то призываю вас: если можете, не используйте методы, особенно в процессе обучения. Они будут костылём, который не позволит извлечь из функционального программирования максимальную выгоду.
Дополнительные ресурсы
Для F# существует множество самоучителей, включая материалы для тех, кто пришел с опытом C# или Java. Следующие ссылки могут быть полезными по мере того, как вы будете глубже изучать F#:
Также описаны еще несколько способов, как начать изучение F#.
И наконец, сообщество F# очень дружелюбно к начинающим. Есть очень активный чат в Slack, поддерживаемый F# Software Foundation, с комнатами для начинающих, к которым вы можете свободно присоединиться. Мы настоятельно рекомендуем вам это сделать!
Не забудьте посетить сайт русскоязычного сообщества F#! Если у вас возникнут вопросы по изучению языка, мы будем рады обсудить их в чатах:
- комната
#ru_generalв Slack-чате F# Software Foundation - чат в Telegram
- чат в Gitter
- комната #ru_general в Slack-чате F# Software Foundation
Об авторах перевода
Автор перевода @kleidemos
Перевод и редакторские правки сделаны усилиями русскоязычного сообщества F#-разработчиков. Мы также благодарим @schvepsss и @shwars за подготовку данной статьи к публикации.
