Часть 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)


  1. TheDenis
    06.09.2022 20:49
    +1

    В оригинале, функторы идут после монад, что мне показалось неверным. Всякая монада - это функтор, но не всякий функтор - это монада

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


    1. funca
      06.09.2022 21:31
      +2

      Обычно объясняют от простого к сложному. Если идти от теории (монада это моноид в категории эндофункторов), то функторы объяснить проще - как более примитивное. Если от практики, то действительно проще будут монады - как более конкретное.


      1. Gotcha7770 Автор
        07.09.2022 14:44
        +1

        Вы меня, конечно, заставили задуматься. Я как раз хотел идти от общего к конкретному, ну просто по сумме свойств. То есть функтор это объект со свойством A, это свойство заключается в том то. Монада плюсом к свойству A еще имеет свойство B, смотрите в чем отличия. У самого автора в оригинале в конце поста про функторы идет ссылка на пост про монады, как продолжение чтения (то есть с 3ей части серии, на 1ую). Что мне показалось свидетельством нарушенного порядка.


  1. nickolaym
    07.09.2022 20:53
    -1

    Что-то мне кажется, что выбор языка из семейства ML для демонстрации теорката - плохая затея.
    Потому что, внезапно, в SML и OCaML словом "функтор" называются параметризованные модули.
    Да, в F# до них не доросли, но... кто знает, вдруг дорастут?


    1. Gotcha7770 Автор
      08.09.2022 13:48

      Я бы хотел обратить внимание на подход, выбранный автором, он не пытается демонстрировать теоркат, он показывает суть паттерна, который, по совпадению, тоже называется функтор.


  1. nickolaym
    07.09.2022 21:02

    Ещё один важный момент.
    Поскольку в F# нет ни параметризованных модулей, ни классов типов, то нельзя просто так взять и ввести сущность "функтор" или "монада". Только duck typing. Если некий параметризованный тип ведёт себя, как функтор, - это функтор. Но имена служебных функций будут разные - Option.map, List.map...

    Как писать обобщённый код, который с любым функтором (или монадой) будет работать единообразно?


  1. nickolaym
    07.09.2022 21:15
    +2

    Вообще, как человек, испорченный/очарованный хаскеллом, я с болью смотрю на эти гроканья.
    Система типов должна быть достаточно гибкой, чтобы вместить в себя абстракции более высокого уровня, чем параметризованный тип.
    Как вариант, она может быть максимально пофигистичной - вплоть до нетипизированного лямбда-исчисления и очень чистых рук программиста, чтоб тот сам следил за корректностью.
    Питон с его рантаймовыми типами и C++ с его шаблонами.
    В питоне, опять же, есть синтаксис list/generator comprehensions, поверх которого можно с любыми монадами работать, а не только со списком.


    1. funca
      08.09.2022 01:30
      +1

      У меня тоже первая мысль была, что в статье про монады явно не хватает хаскеля и теорката.

      Но собственно какой в этом прикладной смысл? Ни кто же не рассказывает про абелевы группы и копродакты, показывая как в языке использовать, допустим, сложение.