Часть 1 Грокаем функторы
Часть 2 Грокаем монады
Часть 3 Грокаем монады императивно
Прим. переводчика: Это перевод статьи из целого цикла постов "Грокаем функциональное программирование" Мэта Торнтона. Я позволил себе немного поменять порядок постов. В оригинале, функторы идут после монад, что мне показалось неверным. Всякая монада - это функтор, но не всякий функтор - это монада. Также я убрал дублирование из поста про монады и добавил необходимые пояснения. Мне нравится практическая направленность материала. Он довольно подробно останавливается на базовых вещах, так что скорее предназначен для тех, кто только знакомится с функциональным программированием.
В этом посте мы постараемся разобраться, что такое функтор
собственноручно переизобретая его на рабочем примере.
Краткое введение в F#
Мы будем использовать язык F#, но даже если вы не сталкивались с ним ранее, вам будет нетрудно разобраться. Достаточно усвоить следующий минимум:
В F# есть тип
option
. Он представляет либо наличие какого-то значения (Some
), либо его отсутствие через значениеNone
. Этот тип обычно используется вместо null, чтобы указать отсутствие значения.Pattern matching (сопоставление с образцом) для типа
option
выглядит следующим образом:
match anOptionalValue with
| Some x -> // выражение на случай, если значение существует
| None -> // выражение, если значение отсутствует
В F# есть оператор конвейера, который записывается так:
|>
(если быть совсем точным, это оператор прямого конвейера -forward pipe operator
прим. переводчика). Это инфиксный оператор, то есть он применяет значение слева от себя к функции справа. Для примера, если функцияtoLower
принимает строку и приводит ее к нижнему регистру, тогда выражение"ABC" |> toLower
вернет"abc"
.
Тестовый сценарий
Допустим, нас попросили написать функцию, которая выводит данные кредитной карты пользователя. Модель данных проста, у нас есть тип CreditCard
и тип User
.
type CreditCard =
{ Number: string
Expiry: string
Cvv: string }
type User =
{ Id: UserId
CreditCard: CreditCard }
Наша первая реализация
Нам нужна функция, которая принимает пользователя и возвращает строковое представление данных его кредитной карты. Ее очень легко реализовать.
let printUserCreditCard (user: User) : string =
let creditCard = user.CreditCard
$"Number: {creditCard.Number}
Exiry: {creditCard.Expiry}
Cvv: {creditCard.Cvv}"
Мы можем даже сразу отрефакторить ее, разделив на функции getCreditCard
и printCreditCard
, из которых при помощи композиции мы всегда можем получить функцию для вывода данных карты пользователя.
let getCreditCard (user: User) : CreditCard = user.CreditCard
let printCreditCard (card: CreditCard) : string =
$"Number: {card.Number}
Exiry: {card.Expiry}
Cvv: {card.Cvv}"
let printUserCreditCard (user: User) : string =
user
|> getCreditCard
|> printCreditCard
Красота!
Внезапный поворот
Все складывается хорошо до тех пор, пока мы не поймем, что сперва должны получить пользователя из БД по его Id. К счастью, у нас уже есть функция для этого.
let lookupUser (id: UserId): User option =
// пытается получить пользователя из БД,
// если пользователь существует, возвращает Some, иначе None
К несчастью, она возвращает тип User option
вместо User
, поэтому мы не можем просто написать
userId
|> lookupUser
|> getCreditCard
|> printCreditCard
потому что getCreditCard
ожидает другой тип.
Посмотрим, сможем ли мы преобразовать функцию getCreditCard
таким образом, чтобы она принимала на вход тип option
, не изменяя при этом оригинальную функцию getCreditCard
. Мы можем добиться этого обернув функцию в другую функцию. Назовем ее liftGetCreditCard
, потому что она как бы «поднимает» функцию getCreditCard
для работы с входными данными типа option
.
Сперва это может показаться неочевидным, но мы знаем, что у нас есть 2 параметра для liftGetCreditCard
. Первый - сама функция getCreditCard
, а второй - User option
. Мы также знаем, что возвращаемым значением будет CreditCard option
. Таким образом сигнатура функции должна быть
(User -> CreditCard) -> User option -> CreditCard option
Если следовать типам, единственное, что нам на самом деле нужно сделать - использовать pattern matching над параметром option
, чтобы применить переданную функцию к значению User
. Если пользователя не существует, мы не сможем вызвать функцию и тогда мы должны вернуть None
.
let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
match user with
| Some u -> u |> getCreditCard |> Some
| None -> None
Обратите внимание как в случае с Some
нам приходится обернуть результат getCreditCard
снова в Some
. Так происходит, потому что обе ветви исполнения должны вернуть одинаковый тип - CreditCard option
.
Теперь наш код выглядит так
userId
|> lookupUser
|> liftGetCreditCard getCreditCard
|> printCreditCard
Частично применив liftGetCreditCard
к getCreditCard
мы создали функцию с сигнатурой которая и требовалась: User option -> CreditCard option
.
Что ж, теперь у нас точно такая же проблема на последней строке. printCreditCard
может работать только с типом CreditCard
, а не CreditCard option
. Применим этот прием еще раз.
let liftPrintCreditCard printCreditCard (card: CreditCard option): CreditCard option =
match card with
| Some cc -> cc |> printCreditCard |> Some
| None -> None
и получившийся код
userId
|> lookupUser
|> liftGetCreditCard getCreditCard
|> liftPrintCreditCard printCreditCard
Это не функтор ли я вижу?
Если теперь посмотреть немного со стороны, можно заметить, что две наши функции lift...
очень похожи. Обе они выполняют одну основную задачу - развернуть option
, применить функцию, если значение существует и обернуть результат обратно в option
. На самом деле эти функции не зависят от содержания option
и того, какую функцию мы передаем.
Посмотрим, сможем ли мы написать единую функцию lift
, которая будет обладать аналогичным поведением для любой функции (f
) и аргумента option
(x
). Мы также можем удалить все определения типов и позволить F# вывести их.
let lift f x =
match x with
| Some y -> y |> f |> Some
| None -> None
Получилось аккуратно, но возможно излишне абстрактно. F# выводит сигнатуру
('a -> 'b option) -> ('a option -> 'b option)
где 'a
и 'b
- обобщенные типы.
Напишем наши функции рядом, чтобы понять, что к чему.
(User -> CreditCard) -> User option -> CreditCard option
('a -> 'b) -> 'a option -> 'b option
Конкретный тип User
был заменен на обобщенный 'a
, а конкретный тип CreditCard
на обобщенный тип 'b
. Это произошло потому что функции lift
все равно, что находится внутри контейнера option
, она просто говорит: «дайте мне какую-нибудь функцию «f», и я применю ее к значению, содержащемуся в «x», если это значение существует, и упакую все обратно в option».
Можем назвать функцию более понятным именем map
(отображение), потому что все что она делает - отображает одно значение внутри option
на другое значение внутри option
.
Перепишем код с новой функцией map
userId
|> lookupUser
|> map getCreditCard
|> map printCreditCard
Здорово! Получилось очень похоже на версию, что мы писали до того, как на нас свалилась необходимость разобраться с option
. Код практически не потерял в читаемости.
Поздравляю, вы только что открыли функторы!
Функция map
, которую мы написали, это то, что делает тип option
функтором. Функтор это просто класс вещей, который имеет операцию отображения (при сохранении структуры, в данном конкретном случае - option. Автор как-то не акцентирует внимание на этой важной детали. Так-то любая функция - это отображение. прим. переводчика). К счастью для нас F# уже содержит функцию map
в модуле Option
, так что можем переписать код используя ее.
userId
|> lookupUser
|> Option.map getCreditCard
|> Option.map printCreditCard
Функторы - это просто контейнеры с "отображением"
Еще один хороший способ интуитивно понять функторы — думать о них как о контейнерах значений. Для каждого типа контейнера мы должны определить операцию отображения.
Мы только что придумали, как это сделать для типа option
, но есть еще много контейнеров, которые тоже можно превратить в функторы. Result
это контейнер, который может содержать либо значение, либо ошибку (частный случай контейнера Either, который содержит одно из 2х заданных значений. прим. переводчика).
Наиболее часто используемые контейнеры - List
и Array
. Большинство программистов сталкивались с задачей, когда нужно преобразовать все элементы списка. Если вы когда-либо использовали Select
в C# или map
в JavaScript, Java и т. д., то вы, вероятно, уже грокнули функтор, даже если не осознаете этого.
Протестируйте себя
Посмотрим, сможете ли вы написать метод map
для типов Result<'a>
и List<'a>
решение для Result
let map f x =
match x with
| Ok y -> y |> f |> Ok
| Error e -> Error e
Этот метод почти идентичен тому, что мы написали для option
. Мы просто применяем функцию к значению при совпадении с Ok
, в противном случае передаем дальше Error
.
решение для List
let rec map f x =
match x with
| y:ys -> f y :: map f ys
| [] -> []
Этот метод немного сложнее, чем остальные, но главная идея та же. Если в списке есть элементы, мы берем первый из них, применяем к нему функцию f
, а затем объединяем с новым списком, созданным с помощью рекурсивного вызова этой же функции map
на оставшейся части списка. Если список пуст, просто возвращаем другой пустой список. Сокращая длину списка по одному элементу, при каждом вызове метода map
, мы гарантируем, что в конечном счете достигнем базового случая, когда список пуст, что завершит рекурсивный вызов.
Чему мы научились?
Мы узнали, что функторы - это типы-контейнеры, для которых определена функция map
. Мы можем использовать эту функцию, чтобы трансформировать значение внутри контейнера. Это позволяет нам применять композицию функций, которые работают с обычными значениями, даже если эти значения упакованы в один из таких контейнеров.
Этот паттерн встречается во множестве различных задач, и, извлекая функцию map
, мы можем избавиться от довольно большого количества шаблонного кода. Например, в случае с option
нам бы пришлось писать много условных выражений или pattern matching с большим уровнем вложенности.
Продвигаясь дальше
Если вам понравились функторы, вы полюбите и монады. Следуя похожему плану мы откроем еще один тип функций, который так же очень полезен.
Комментарии (8)
nickolaym
07.09.2022 20:53-1Что-то мне кажется, что выбор языка из семейства ML для демонстрации теорката - плохая затея.
Потому что, внезапно, в SML и OCaML словом "функтор" называются параметризованные модули.
Да, в F# до них не доросли, но... кто знает, вдруг дорастут?Gotcha7770 Автор
08.09.2022 13:48Я бы хотел обратить внимание на подход, выбранный автором, он не пытается демонстрировать теоркат, он показывает суть паттерна, который, по совпадению, тоже называется функтор.
nickolaym
07.09.2022 21:02Ещё один важный момент.
Поскольку в F# нет ни параметризованных модулей, ни классов типов, то нельзя просто так взять и ввести сущность "функтор" или "монада". Только duck typing. Если некий параметризованный тип ведёт себя, как функтор, - это функтор. Но имена служебных функций будут разные - Option.map, List.map...Как писать обобщённый код, который с любым функтором (или монадой) будет работать единообразно?
nickolaym
07.09.2022 21:15+2Вообще, как человек, испорченный/очарованный хаскеллом, я с болью смотрю на эти гроканья.
Система типов должна быть достаточно гибкой, чтобы вместить в себя абстракции более высокого уровня, чем параметризованный тип.
Как вариант, она может быть максимально пофигистичной - вплоть до нетипизированного лямбда-исчисления и очень чистых рук программиста, чтоб тот сам следил за корректностью.
Питон с его рантаймовыми типами и C++ с его шаблонами.
В питоне, опять же, есть синтаксис list/generator comprehensions, поверх которого можно с любыми монадами работать, а не только со списком.funca
08.09.2022 01:30+1У меня тоже первая мысль была, что в статье про монады явно не хватает хаскеля и теорката.
Но собственно какой в этом прикладной смысл? Ни кто же не рассказывает про абелевы группы и копродакты, показывая как в языке использовать, допустим, сложение.
TheDenis
Другими словами, монады – это подмножество функторов. Если повествование идёт об частного к общему, то начинать с монад было бы логичнее.
funca
Обычно объясняют от простого к сложному. Если идти от теории (монада это моноид в категории эндофункторов), то функторы объяснить проще - как более примитивное. Если от практики, то действительно проще будут монады - как более конкретное.
Gotcha7770 Автор
Вы меня, конечно, заставили задуматься. Я как раз хотел идти от общего к конкретному, ну просто по сумме свойств. То есть функтор это объект со свойством
A
, это свойство заключается в том то. Монада плюсом к свойствуA
еще имеет свойствоB
, смотрите в чем отличия. У самого автора в оригинале в конце поста про функторы идет ссылка на пост про монады, как продолжение чтения (то есть с 3ей части серии, на 1ую). Что мне показалось свидетельством нарушенного порядка.