Сказ о том как один из flatMap превратился в compactMap

Swift совмещает возможности объектно-ориентированного и функционального программирования. Если объектно-ориентированная часть оттачивалась десятилетиями на Objective-C и вошла составной частью в SwiftObjective-C и Swift практически одинаковый SDK), то часть, связанная с функциональным программированием продолжает оставаться в тени, изредка «поблескивая» отдельными довольно простыми решениями. Но вот появился прекрасный ресурс pointfree.co, посвященный исключительно функциональному программированию в Swift. Мы решили воспользоваться этим и представить вашему вниманию перевод бесплатного фрагмента, касающегося переименования одной из перегрузок (overload) функции высшего порядка flatMap в compactMap. Но это лишь повод для того, чтобы вы смогли проследить ход мысли программистов, заточенных на функциональное программирование. Столкнувшись с переименованием перегрузки (overload) функции обычный один программист лишь испытает раздражение, другой проведет методичный, но узкий анализ причины этого переименования, а гуру по функциональному программированию может сделать пару любопытных открытий, элегантно дополняющих арсенал функционального программирования на Swift.

Swift 4.1 упразднил и переименовал определенную перегрузку (overload) функции flatMap. Что делает этот flatMap отличным от других? Мы исследуем это и в результате увидим, как понимание этого различия поможет нам изучить обобщение этой операции над другими структурами и получить новый полезный код!


Введение

00:05
Для Swift 4.1 была предложено изменение, упраздняющее одну из перегрузок (overloads) flatMap, немного отличающуюся от других перегрузок flatMap, и дающее новое имя этому методу. Изменение было встречено неоднозначно, но в итоге было принято, хоть и с другим именем новой функции, предложенным сообществом.
00:37
Мы хотим рассмотреть прежде всего вопрос о том, почему такое изменение было предложено, а также почему не изначально предложенное новое имя стало поводом для дальнейших изысканий. Мы надеемся показать, что иногда именование действительно важно и позволяет нам использовать прежние интуитивные соображения по-новому и неожиданно.

Сказ о двух flatMap

01:02
С самого начала, Swift охватывал паттерны функционального программирования предоставляя функции наподобие map, filter, reduce, и flatMap в стандартной библиотеке.

01:02
Swift 1.0 поставлялся с flatMap определенной на массиве Array с известной всем нам сигнатурой:

// extension Array {
//   func flatMap<B>(_ f: @escaping (Element) -> [B]) -> [B] {
//   }
// }

01:34
Это чрезвычайно полезно для выстраивания цепочек операторов, возвращающих массивы.

01:40
Возьмём к примеру строку, содержащую список значений, разделенных запятыми и символами “начала новой строки”:

let csv = """
1,2,3,4
3,5,2
8,9,4
"""

01:49
Что если бы мы захотели обработать эту строку и извлечь все эти значения меж запятых.
01:54
Можем начать с разбивки по строкам.

csv
  .split(separator: "\n")
// ["1,2,3,4", "3,5,2", "8,9,4"]

02:03
Теперь мы хотим нырнуть в массив поглубже, чтобы продолжить разбивать каждую строчку по запятым.
02:10
Нам знакомо использование map для модификации массивов.

csv
  .split(separator: "\n")
  .map { $0.split(separator: ",") }
// [["1", "2", "3", "4"], ["3", "5", "2"], ["8", "9", "4"]]

02:19
Получаем вложенные (nested) массивы значений, а нужен-то нам «плоский» массив.
02:33
Именно это flatMap и позволяет нам сделать. Она применяет заданную функцию к каждому элементу массива и затем «выравнивает» (flatten) его.

csv
  .split(separator: "\n")
  .flatMap { $0.split(separator: ",") }
// ["1", "2", "3", "4", "3", "5", "2", "8", "9", "4"]

02:41
Вскоре после этого в Swift вводится flatMap для Optional со следующей сигнатурой:

// extension Optional {
//   func flatMap<B>(_ f: @escaping (Element) -> B?) -> B? {
//   }
// }

03:00
Это полезно для построения цепочек операторов, возвращающих Optional.

03:04

К примеру, у строки String есть “проваливающийся” (failable) инициализатор, который берет данные Data, а возвращает Optional <String>:

String(data: Data(), encoding: .utf8)
// Optional("")

Так же, как и в случае с массивами, мы можем применить map на Optionals, чтобы трансформировать «завёрнутое» значение. Допустим, мы хотим трансформировать нашу строку Optional <String> в целое число.

String(data: Data(), encoding: .utf8)
  .map(Int.init)
// Optional(nil)

03:24

На выходе мы получаем nil, поскольку мы вызываем инициализатор Int с пустой строкой на входе, но здесь есть ещё одна странность. Тип полученного значения будет Optional<Int>?

_: Int? =  String(data: Data(), encoding: .utf8)
  .map(Int.init)
// Optional значение 'Int??' не “развернуто”

03:37
Вот и нет! Применение map приводит к возникновению двойного Optional, то есть Int??. Мы применяем map над Optional, используя инициализатор, вернувший Optional, и в результате получаем вложенные Optionals! По-хорошему, надо было бы использовать flatMap.

_: Int? =  String(data: Data(), encoding: .utf8)
  .flatMap(Int.init)
// nil

03:53
Теперь это компилируется, что означает, что наше значение стало «плоским». Всё ещё возвращает nil, так что давайте дадим какие-нибудь данные.

String(data: Data([55]), encoding: .utf8)
  .flatMap(Int.init)
// Optional(7)

04:00
С flatMap мы смогли взять Optional строку, применить функцию к её развёрнутому значению, а затем взять полученный Optional результат и “выровнять” (flatten) его обратно в Optional целое.

04:10
Был ещё третий flatMap для массива Array (что способствовало размыванию смысла): он брал элементы массива, пропускал их через трансформации, возвращающие Optionals. Взглянем на массив строк.

["1", "2", "buckle", "my", "shoe"]

map, мы получаем следующее:

["1", "2", "buckle", "my", "shoe"]
  .map(Int.init)
// [{some 1}, {some 2}, nil, nil, nil]

04:30
Нам возвращается массив, где успешные преобразования завёрнуты в Optional, а неудавшиеся преобразования равны nil.

04:40
Если бы вместо этого мы применили flatMap, мы могли бы избавиться от этих nils и безопасно развернуть Ints.

["1", "2", "buckle", "my", "shoe"]
  .flatMap(Int.init)
// [1, 2]

04:47
Чрезвычайно полезно! Ведь подобные вещи нам приходится делать очень часто. Но вот эта версия flatMap немного отличается от других тем, что смешивает качества массивов с качествами Optionals.

05:01
Всё становится ещё запутанней, когда мы начинаем использовать оба этих метода вместе. Возьмём-ка наш предыдущий пример с CSV и продолжим конвертацию каждого значения в целое перед тем как сложить их.

csv.split(separator: "\n")
  .flatMap { $0.split(separator: ",") }
  .flatMap { Int($0) }
  .reduce(0, +)
// 41

05:51
Здесь первый flatMap отвечает за получение всех значений, разделённых запятыми и «выравнивание» их в «плоский» массив, а затем следующий flatMap пытается создать Int из строки и выбрасывает то, что преобразовать не получается. Это две очень разные разные операции, а мы используем одинаковое имя для них, и различить их сходу становится трудно.

06:12
Мы тратим много времени размышляя о типах, особенно о форме функций. Порой это может затеряться в определении метода: тип контейнера исчезает из декларации, а имена функций и аргументов способны ещё более всё затуманить. Давайте-ка изолируем сигнатуры функций от самих типов. Для этого используем свой вольный синтаксис вида (Configuration) -> (Data) -> ReturnValue:

// flatMap : ((A) -> [B]) -> ([A]) -> [B]
// flatMap : ((A) ->  B?) -> ( A?) ->  B?

// flatMap : ((A) ->  B?) -> ([A]) -> [B]

07:24
Одна из этих форм не такая, как остальные. Два верхних flatMaps оперируют над одним типом контейнера: Array или Optional, а вот третий flatMap оперирует над обоими контейнерами сразу.

07:43
Если мы избавимся от синтаксического сахара, то обобщенные (generics) перегрузки будут выглядеть так:

// flatMap : ((A) ->    Array<B>) -> (   Array<A>) ->    Array<B>
// flatMap : ((A) -> Optional<B>) -> (Optional<A>) -> Optional<B>

// flatMap : ((A) -> Optional<B>) -> (   Array<A>) ->    Array<B>

08:00
Что если представить себе контейнеры типов Array и Optional как самостоятельные обобщенные (generics) типы. Тогда мы можем выбросить их имена и получится что-то вроде этого:

// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>
// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>

// flatMap : ((A) -> N<B>) -> (M<A>) -> M<B>

08:36
Ух-ты, первые две сигнатуры оказались абсолютно равны! А вот в случае с третьей сигнатурой мы имеем дело с двумя разными обобщенными (generic) типами контейнеров и семантика становится более запутанной. Как именно из N будет получено M? В двух верхних версиях, можно понять, что есть трансформирующая функция, которая как-то осуществляет композицию контейнерных типов на новый лад. В этой версии N мало что даёт нам для дальнейшего продвижения.
09:27
Чтобы понять логику, придётся вернуться к конкретным типам.

// flatMap : ((A) -> B?) -> (M<A>) -> M<B>

Всё ещё немного запутанно. Может ли любой M работать с Optional таким образом? Может быть этот flatMap вообще не является обобщённым (generic), как другие.

Продвижение до Optional (Optional promotion)[1]

10:13
Была еще одна проблема с перегрузкой flatMap для операций с Array, возвращающих Optionals: продвижение до Optional (Optional promotion). Для целей эргономики Swift в коде автоматически “заворачивает” значения в Optional везде, где требуется Optional параметр.[2] В результате код становится более лаконичным и снимает некоторую часть нагрузки с инженера, которому иначе пришлось бы ЯВНО “заворачивать” значения с помощью .some, но выведение типа из контекста (inference) — это палка о двух концах, и в случае с замыканием, возвращающим Optional результат, это — честная игра.
10:54
Рассмотрим следующий сниппет:

[1, 2, 3]
  .flatMap { $0 + 1 }
// [2, 3, 4]

Он компилируется, исполняется и выдаёт результат, но семантика странная: мы не возвращаем Optional из предоставляемого замыкания. Компилятор автоматически “заворачивает” возвращаемое значение, делая это за нас, вот так.

[1, 2, 3]
  .flatMap { .some($0 + 1) }
// [2, 3, 4]

11:22
“Заворачивание” возвращаемого значения вручную показывает некоторую странность во всей логике. Мы всегда возвращаем .some и никогда не «проваливаемся» до nil. И поскольку эта операция НИКОГДА ни при каких обстоятельствах не может «проваливаться», то можно было бы просто использовать map.

[1, 2, 3]
  .map { $0 + 1 }
// [2, 3, 4]

11:36
Из-за “продвижения до Optional” (Optional promotion) любая операция, работающая с map также будет работать и с flatMap. Может даже показаться заманчивым избегать вообще использования map, поскольку кажется, что flatMap работает всегда, в то время как map иногда не работает. Это слегка прискорбно, потому что мы теряем часть семантического смысла используя повсюду flatMap. При использовании как flatMap, так и map, мы как бы самим кодом ЯВНО документируем, какие операции могут, а какие не могут «провалиться» (fail).

12:08
Что ещё хуже, мы можем изменить наши типы так, что это должно бы привести к ошибкам компилирования, но из-за перегружаемого (overloaded) flatMap и продвижения до Optional (Optional promotion) всё прекрасно компилируется, но демонстрирует неожиданное поведение на этапе выполнения. Например, есть структура User, соответствующая пользователю с Optional именем name, и массив этих пользователей users:

struct User {
  let name: String?
}

let users = [User(name: "Blob"), User(name: "Math")]

12:15
С данным массивом пользователей users у нас есть соблазн использовать map для извлечения их имен names.

users
  .map { $0.name }
// [{some "Blob"}, {some "Math"}]

Но теперь у нас массив Optional значений. В действительности мы бы хотели использовать flatMap.

users
  .flatMap { $0.name }
// ["Blob", "Math"]

12:32
Мы могли бы построить много кода вокруг типа подобного структуре struct User, а затем однажды решить, что имя name в типе User является обязательным. Мы выросли с пониманием того, что мощная система типизации может помочь провести нам рефакторинг кода каждый раз, когда мы решили круто изменить наш тип, [а тут такой сюрприз].

struct User {
  let name: String
}

После перекомпиляции, что же происходит с поведением при выполнении?

users
  .flatMap { $0.name }
// ["B", "l", "o", "b", "M", "a", "t", "h"]

Оно неожиданное! Может и к лучшему, что подобная аномалия была переименована.

compactMap и filterMap

13:18
Итак, с учётом осложнений, пришедших с перегружаемым вариантом (overloaded) flatMap, который был не совсем таким, как другие flatMaps, было решено упразднить его и ввести новое имя. Очень важно помнить, что flatMap над массивами с трансформациями, также возвращающими массивы, и flatMap над Optionals, возвращающий Optionals, не упразднены. Речь идет лишь об аномальном (третьем) методе.

13:48
Изначально было предложено имя filterMap для поэлементного преобразования (mapping) массива с выбрасыванием значений nil. Это метод мог быть определён так:

extension Array {
  func filterMap<B>(_ transform: (Element) -> B?) -> [B] {
    var result = [B]()
    for x in self {
      switch transform(x) {
      case let .some(x):
        result.append(x)
      case let .none:
        continue
      }
    }
    return result
  }
}

14:29
После небольшого «эффекта велосипедного сарая», ставший метафорой закона тривиальности, с вовлечением участников списка рассылки Swift Evolution, неизбежного в таких случаях, имя метода в итоге было изменено на compactMap, и это отлично, поскольку соответствует аналогичному прототипу в Ruby, где есть метод compact работы с массивами, избавляющий массив от значений nil. Продолжим и взглянем на определение compactMap:

extension Array {
  func compactMap<B>(_ transform: (Element) -> B?) -> [B] {
    var result = [B]()
    for x in self {
      switch transform(x) {
      case let .some(x):
        result.append(x)
      case let .none:
        continue
      }
    }
    return result
  }
}

14:53
Это просто переименование — тело функции такое же.

Обобщения (generalizations) filterMap

15:06
Одним из минусов имени compactMap является то, что он крепко привязан к тому, что мы делаем с массивом: мы «сжимаем» его путем удаления nils. Это мешает нам увидеть более широкие возможности применения compactMap.

15:30
А замечательной возможностью имени filterMap является то, что оно могло бы привести к некоторым удачным обобщениям.

15:41
Для начала заметим, что предикат (A) -> Bool над A естественно порождает функцию (A) -> A?:

func filterSome<A>(_ p: @escaping (A) -> Bool) -> (A) -> A? {
  return { p($0) ? .some($0) : .none }
}

Она просто возвращает .some значения, если результат предиката равен true, и .none в противном случае.

16:33
С помощью этой функции мы можем заново реализовать обычный OEM filter над массивами:

func filter<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> [A] {
  return { $0.filterMap(filterSome(p)) }
}

16:41
Попробуем. Мы можем создать фильтр для чётных чисел.

filter { $0 % 2 == 0 }
// ([Int]) -> [Int]

Пропустим сквозь него массив первых десяти целых чисел [при помощи pipeline операции |>].

Array(0..<10)
  |> filter { $0 % 2 == 0 }
// [2, 4, 6, 8, 10]

16:51

Это очень понятно, но так сразу не слишком полезно, Мы точно также могли бы определить filter для своего типа или использовать из стандартной библиотеки. Причина, по которой нам бы захотелось рассмотреть filter в подобном ключе в том, что это могло бы привести нас к дальнейшим обобщениям.

17:08

Один из способов сделать это заключается в том, чтобы  вспомнить, что Either<A, B> является общим случаем Optional<A>.

enum Either<A, B> {
  case left(A)
  case right(B)
}

Вместо моделирования отсутствия A при помощи nil, мы можем подставить другое значение с типом B вместо него.

17:31

Функция аналогичная filterSome для Either выглядела бы как-то так:

func partitionEither<A>(_ p: @escaping (A) -> Bool) -> (A) -> Either<A, A> {
  return { p($0) ? .right($0) : .left($0) }
}

Даётся предикат (A) -> Bool, он возвращает функцию, которая может разделять значения типа (A) в один из двух case Either<A, A>.

18:08

Теперь обобщим filterMap, используя эту связку разделения и Either, и получим partitionMap:

extension Array {
  func partitionMap<A, B>(_ transform: (Element) -> Either<A, B>) -> (lefts: [A], rights: [B]) {
    var result = (lefts: [A](), rights: [B]())
    for x in self {
      switch transform(x) {
      case let .left(a):
        result.lefts.append(a)
      case let .right(b):
        result.rights.append(b)
      }
    }
    return result
  }
}

19:17

Мы видели, как можно вывести filter из filterMap. Можем ли мы вывести partition из partitionMap?

func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
  return { $0.partitionMap(partitionEither(p)) } // error
}

20:09

Система типизации здесь слегка спотыкается с именами кортежей, но мы можем заставить всё компилироваться, разобрав и собрав заново.

func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
  return {
    let (lefts, rights) = $0.partitionMap(partitionEither(p))
    return (lefts, rights)
  }
}

20:28

Это замечательно, ведь  partition даже не присутствует в стандартной библиотеке, а нас к нему автоматически подвело исследование связи между filter и filterMap, а также обобщение от Optional к Either.

20:51

Итак, мы видим, как здесь формируются две параллельные истории:

  • Подъём предиката (A) -> Bool в функцию и в Optionals : (A) -> A? естественным образом привел нас к функции filterMap, которая в свою очередь порождает уже знакомую нам функцию filter.
  • Подъём предиката (A) -> Bool в функцию и в Either :  (A) -> Either<A, A> естественным образом привел нас к функции partitionMap, которая в свою очередь порождает функцию partition.

21:17

Также мы можем скомбинировать это с прочими механизмами, построенными нами в предыдущих эпизодах, в частности с функциональными установками (functional setters). Мы убедились, что можем определять очень компактные обобщённые установки (setters), соединенные вместе очень сложными способами, Лучше всего это работало со свободными функциями (free functions — функции, не привязанные ни к какому-либо классу, структуре, перечислению), так что давайте определим свободную версию partitionMap:

func partitionMap<A, B, C>(_ p: @escaping (A) -> Either<B, C>) -> ([A]) -> (lefts: [B], rights: [C]) {
  return { $0.partitionMap(p) }
}

21:36

Определим функцию, которую можно использовать с таким partitionMap

let evenOdds = { $0 % 2 == 0 ? Either.left($0) : .right($0) }
// (Int) -> Either<Int, Int>

21:48

Мы можем вручить эту функцию вот этому partitionMap.

partitionMap(evenOdds)
// ([Int]) -> (lefts: [Int], rights: [Int])

И вот уже у нас есть совершенно новая функция, которая берет массив целых чисел, а  возвращает кортеж (tuple), разделяющий целые числа по принципу: чётные — «налево», нечётные — «направо».

21:57

Попробуем.

Array(1...10)
  |> partitionMap(evenOdds)
// ([2, 4, 6, 8, 10], [1, 3, 5, 7, 9])

[3]Работает, как ожидалось.

Мы даже можем продолжить составлять композицию этого, используя наши композиционные установки кортежей (tuple composable setters). Погрузимся глубже в чётные числа и возведём их все в квадрат.

Array(1...10)
  |> partitionMap(evenOdds)
  |> (first <<< map)(square)
// ([4, 16, 36, 64, 100], [1, 3, 5, 7, 9])

22:21

Всего в двух строчках одного выражения мы смогли применить сложный предикат, чтобы разбить массив на две части перед тем, как модифицировать одну из полученных частей, оставив прочие части неизменными.
[4]

В чём смысл?

Мы потратили целый эпизод на разговоры о правильном именовании, а именование — это такая тема, которая может быстро пойти по пути «велосипедного сарая» [согласно закону тривиальности Паркинсона], как это и вышло с  flatMap. Чтобы подытожить: прежде всего было ли переименование оправдано? Упразднение вызывает перетряхивание огромного количества основного кода, Люди в своём повседневном кодировании в основном были довольны существующим flatMap, а теперь, загружая свои проекты в Swift 4.1, некоторые могут задаться вопросом: «В чём смысл?» Стоило ли обрекать людей на многочисленные переделки ради этого переименования.

23:24

Интересно, что нам удалось сделать шаг назад и взглянуть на вещи чуть более абстрактно и связать одно с другим любопытным образом. Глядя лишь на формы flatMap, мы смогли выяснить, почему одна из них являлась аномалией, и как две другие могут предоставить общие интуитивные соображения для новых flatMaps в будущем.

23:53

Тем временем при переименовании аномалии в filterMap, мы получили возможность обобщить его таким способом, который мы могли бы и не увидеть в противном случае.

24:09

Переименование может быть спорным! Однако опираясь на имена мы можем возводить прочные узы между связанными конценциями, что может приводить нас к интересным открытиям: мы видели как filter и filterMap привели нас к partition и partitionMap!

24:28

Теперь, когда аномальный flatMap был переименован в compactMap, мы сможем исследовать его на многих и многих типах в дальнейшем.


Упражнения

  1. Определите filtered как функцию от [A?] к [A].
  2. Определите partitioned как функцию от [Either<A, B>] к (left: [A], right: [B]).
  3. Что общего у этой функции с filtered?
  4. Определите partitionMap на Optional.
  5. У словаря Dictionary есть mapValues, которая берет трансформирующую функцию от  (Value) -> B к созданию нового словаря Dictionary типа  [Key: B].
  6. Определите filterMapValues на словаре Dictionary.
  7. Определите partitionMapValues на словаре  Dictionary.
  8. Перепишите filterMap и filte в терминах partitionMap.
  9. Возможно ли определить partitionMap на Either?

Ссылки

Источник: A Tale of Two flatMaps
Код: Playground к статье
Переведено Laconic.Website специально для Bestkora


Примечания

[1⇑]Примечание переводчика. Термин Продвижение до Optional (Optional promotion) означает «продвижение» НЕ-Optional до Optional везде, где в качестве аргумента требуется Optional.

[2⇑]Примечание переводчика. Чаще всего возвращаемое значение трансформирующей функции.

[3⇑]Примечание переводчика. Здесь использована pipeline операция |>, которая дает возможность построить “цепочку” обрабатывающих функций, организованную таким образом, что выход каждой функции является входом следующей. Вот ее реализация в Swift:

precedencegroup ForwardApplication {
   associativity: left }

Infix operator |>: ForwardApplication
public func |> <A,B>(x: A, f: (A) ->B) -> B {
return f(x)
}

В нашем случае мы подаем на вход pipeline операции |> массив целых чисел Array(1…10) и применяем нашу новую функцию partitionMap для выделения четных и нечетных чисел.

[4⇑]Примечание переводчика. Здесь использованы свободные функции first (взятие первого элемента из кортежа и применение только к нему определенной функции), map для массивов, square и оператор композиции функций в обратном порядке <<<.

Вот их реализация в Swift, если кому-то интересно:

func first<A, B, C>(_f: @escappng (A) -> C) -> ((A,B)) -> (C,B) {
return { pair in
(f(pair.0), pair.1)
}
}

 func map<A, B>(_f: @escappng (A) -> B) -> ([A]) -> [B] {
return { $0.map(f) }
}

 public func square(_x: Int) -> Int{
return x * x
}

precedencegroup BackwardsComposion {
   associativity: left }

Infix operator <<<: BackwardsComposion
public func <<< <A, B, C>(
_f: @escaping (B) ->C,
_g: @escaping (A) ->B,
) -> (A)  -> C {
return f(g($0))
}

В нашем случае мы по-прежнему подаем на вход pipeline операции |> массив целых чисел Array(1…10) и применяем нашу новую функцию partitionMap для выделения четных и нечетных чисел и размещения их в кортеже из двух компонентов. Затем мы используем композиционные функции setter для того, чтобы погружаться все глубже во вложенные структуры данных. Мы выбираем первый компонент кортежа (то есть четные числа), затем используем для его элементов map с функцией трансформации square (квадрат).