[Перевод] Функциональное мышление. Часть 10
Вы представляете, это уже десятая часть цикла! И хотя до этого повествование было сфокусировано на чисто функциональном стиле, иногда удобно переключиться на объектно-ориентированный стиль. А одними из ключевых особенностей объектно-ориентированного стиля являются возможность прикреплять функции к классу и обращение к классу через точку для получения желаемого поведения.
В 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#! Если у вас возникнут вопросы по изучению языка, мы будем рады обсудить их в чатах:
Об авторах перевода
Автор перевода @kleidemos
Перевод и редакторские правки сделаны усилиями русскоязычного сообщества F#-разработчиков. Мы также благодарим @schvepsss и @shwars за подготовку данной статьи к публикации.