Map, CompactMap и FlatMap в Swift

В типичном приложении работа с массивами  —  вполне обыденное дело. Нам нередко приходится перебирать все элементы, применяя к ним необходимые изменения.

Для этой задачи многие начинающие и продвинутые разработчики прибегают к классической итерации с использованием, к примеру forEach, не зная, что для того во фреймворке Foundation есть более удачные функции.

Map

Map  —  это стандартная применяемая к коллекции функция Swift, которая на входе получает замыкание, применяя его к каждому элементу и возвращая новую коллекцию. Иными словами  —  у вас есть начальный массив, элементы которого вы поочередно перебираете, применяя к каждому трансформацию, определяемую замыканием, и добавляете получаемое значение в новый массив.

Для наглядности разберем пример. Предположим, что нужно создать игру, где игроку даются анаграммы имен героев Marvel, и он должен угадать по ним персонажа. У нас есть список имен, к каждому элементу которого нужно применить следующие шаги.

  1. Перевести имена в нижний регистр.
  2. Удалить пробелы.
  3. Перемешать буквы.

Если не использовать map, а попытаться обойтись forEach, то придется для каждого шага создавать пустой массив, применять действие к каждому элементу и добавлять результат. Три массива, слишком много всего, что усложнит чтение и понимание кода. Посмотрим, насколько этот подход оказывается многословен:

let heroes = ["Iron Man", "Spiderman", "Star Lord", "Black Widow"]

var noWhitespaceHeroes = [String]()
heroes.forEach { hero in
    let noWhitespaceHero = hero.replacingOccurrences(of: " ", with: "")
    noWhitespaceHeroes.append(noWhitespaceHero)
}
// noWhitespaceHeroes = ["IronMan", "Spiderman", "StarLord", "BlackWidow"]

var lowercasedHeroes = [String]()
noWhitespaceHeroes.forEach { hero in
    let lowercasedHero = hero.lowercased()
    lowercasedHeroes.append(lowercasedHero)
}
// lowercasedHeroes = ["ironman", "spiderman", "starlord", "blackwidow"]

var heroAnagrams = [String]()
lowercasedHeroes.forEach { hero in
    let heroAnagram = String(Array(hero).shuffled())
    heroAnagrams.append(heroAnagram)
}
// пример heroAnagrams: ["oranimn", "arisendmp", "sraotlrd", "wlcwabiokd"]

Если же задействовать map, то код получится намного проще и понятнее  —  подобно магии!

let heroes = ["Iron Man", "Spiderman", "Star Lord", "Black Widow"]

let heroAnagrams = heroes.map {
    String(Array($0.replacingOccurrences(of: " ", with: "")
                    .lowercased()
                    .shuffled())
    )
}

Как видите, читать его чрезвычайно легко, даже не зная все внутренние шаги.

CompactMap

CompactMap работает аналогично map, но имеет дополнительный бонус —  удаляет все элементы nil, создаваемые трансформацией.

Рассмотрим пример:

let urlStrings = [
    "https://www.marvel.com",
    "https://www.😈.com",
    "https://www.dccomics.com"
]

let urls = urlStrings.compactMap { URL(string: $0) }
// urls = [https://www.marvel.com, https://www.dccomics.com]

Очевидно, что иконка не способна создать валидный url. С помощью compactMap можно без проблем отфильтровать, не прибегая к дополнительным проверкам.

CompactMap также предоставляет отличный способ для фильтрации списка с целью удаления всех значений nil. Я недавно работал над приложением, в котором все метки UI в одном из представлений управлялись данными, получаемыми с серверами. Если значение присутствовало, нужно было показывать метку, если нет  —  метка скрывалась, не оставляя пустого места.

Все работало динамически в UITableView, так что мне нужно было лишь добавлять или не добавлять элемент в источник данных.

В общем, фильтрация всего с помощью compacMap оказалась очень быстрой и понятной для чтения. В приложении же подобный прием выглядит немного сложнее:

let fields = ["John", "Appleseed", nil, "Cupertino"]
let items = fields.compactMap { $0 }
// items = ["John", "Appleseed", "Cupertino"]

Обратите внимание на одну деталь в этом примере. Массив fields имеет тип [String?], а массив items  —  [String], так что возвращаемый тип compacMap никогда не будет optional. Изумительно!

FlatMap

Последний тип карты  —  это функция, которая уплощает массив массивов. Она одним действием преобразует набор массивов в один. Как-то так:

[[1, 1, 1], [2, 2, 2], [3, 3, 3]] -> [1, 1, 1, 2, 2, 2, 3, 3, 3]

Разберем пример.

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

В ходе каждого раунда количество пораженных врагов прибавляется в статистику соответствующего героя. Теперь нужно подсчитать среднее число побежденных вашим отрядом врагов в каждом раунде. То есть создать один массив из всех статистик всех героев и вычислить по нему среднее значение.

Рассмотрим код:

struct HeroTeam {
    let name: String
    let heroes: [Hero]
}

struct Hero {
    let name: String
    let enemiesDefeated: [Int]
}

extension Array where Element == Int {
    var mean: Double {
        return Double(self.reduce(0, +)) / Double(self.count)
    }
}

let hero01 = Hero(name: "Iron Man", enemiesDefeated: [12, 14, 8, 19])
let hero02 = Hero(name: "Captain America", enemiesDefeated: [8, 20, 21])
let hero03 = Hero(name: "The Hulk", enemiesDefeated: [12, 25, 19, 28, 32])

let team = HeroTeam(name: "Super Team", heroes: [hero01, hero02, hero03])

let averageDefeated = team.heroes
    .compactMap { $0 }
    .flatMap { $0.enemiesDefeated }
    .mean
// averageDefeated = 18.166666666666668

// Массив массивов до применения flatMap = [[12, 14, 8, 19], [8, 20, 21], [12, 25, 19, 28, 32]]

Вся магия происходит на строке 25. Заметьте, что я для вычисления среднего использовал расширение (строка 26), а не функцию vDSP.mean из фреймворка Accelerate, чтобы сохранить аккуратный стиль функционального программирования.

И еще кое что

Если вы внимательно читали начало статьи, то могли заметить, что я писал не о простых массивах, а о коллекциях. Как думаете, почему? Потому что, функции map также применимы и к словарям! Хотя для этого нужно быть более внимательным:

let heroInfo = [
    "firstName":"Anthony Edward",
    "lastName" : "Stark",
    "nickname": "Iron Man",
    "superPowers": "Genius, billionaire, playboy, philanthropist"
]

let infos = heroInfo.map { key, value in
    [key: value]
}
// infos = [["lastName": "Stark"], ["nickname": "Iron Man"], ["firstName": "Anthony Edward"], ["superPowers": "Genius, billionaire, playboy, philanthropist"]]

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

Также в Swift для всех трех вышеприведенных примеров есть разные версии карт, с помощью которых можно реализовать эту задачу. Просто используйте вариант mapValues:

let heroInfo = [
    "firstName":"Anthony Edward",
    "lastName" : "Stark",
    "nickname": "Iron Man",
    "superPowers": "Genius, billionaire, playboy, philanthropist"
]

let  infoUppercased = heroInfo.mapValues{ $0.uppercased() }
// infoUppercased = ["nickname": "IRON MAN", "superPowers": "GENIUS, BILLIONAIRE, PLAYBOY, PHILANTHROPIST", "lastName": "STARK", "firstName": "ANTHONY EDWARD"]

Теперь мы получили [String: String], а не [[String: String}}, как в предыдущем примере.

Успешного вам написания кода!

Читайте также:

Читайте нас в Telegram, VK


Перевод статьи Alessandro Manilii: Map, CompactMap, and FlatMap in Swift

Предыдущая статьяИндексация строк в Rust и TypeScript в сравнениях
Следующая статья5 уникальных подходов Google к инженерии данных