Pull to refresh

Грокаем функторы

Reading time 6 min
Views 11K
Original author: Matt Thornton

Часть 1 Грокаем функторы
Часть 2 Грокаем монады
Часть 3 Грокаем монады императивно
Часть 4 Грокаем аппликативные функторы
Часть 5 Грокаем валидацию при помощи аппликативного функтора
Часть 6 Грокаем Traversable

Прим. переводчика: Это перевод статьи из целого цикла постов "Грокаем функциональное программирование" Мэта Торнтона. Я позволил себе немного поменять порядок постов. В оригинале, функторы идут после монад, что мне показалось неверным. Всякая монада - это функтор, но не всякий функтор - это монада. Также я убрал дублирование из поста про монады и добавил необходимые пояснения. Мне нравится практическая направленность материала. Он довольно подробно останавливается на базовых вещах, так что скорее предназначен для тех, кто только знакомится с функциональным программированием.

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

Краткое введение в 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 с большим уровнем вложенности.

Продвигаясь дальше

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

Tags:
Hubs:
+8
Comments 11
Comments Comments 11

Articles