JavaRush /Курсы /Swift SELF /Цепочки map/filter: стиль и читаемость

Цепочки map/filter: стиль и читаемость

Swift SELF
17 уровень , 2 лекция
Открыта

1. Ментальная модель пайплайна: «много → много → много»

Когда мы впервые видим array.filter(...).map(...).filter(...).map(...), это ощущается как магия: «ого, один выразительный выраженец вместо трёх циклов и четырёх временных переменных». И да, это один из суперсиловых приёмов Swift: писать преобразования данных компактно и довольно безопасно.

Но у любой суперсилы есть слабость. Цепочки легко обрастают условиями, нормализацией строк, if/else, guard, попытками «чуть-чуть подлогировать» через print, и внезапно у вас не код, а новогодняя гирлянда на 40 лампочек: вроде красиво, но если одна перегорит — ищи потом, какая именно.

Отдельная (занудная, но полезная) причина: длинные цепочки на lazy-коллекциях могут порождать очень «страшные» типы и неприятные сообщения компилятора. В Swift сообществе это прямо называют проблемой: “large, painful type names” при цепочках map/filter на ленивых коллекциях. Мы lazy сегодня не изучаем, но вывод для стиля всё равно важный: чем проще и понятнее шаги, тем меньше сюрпризов.

Чтобы писать цепочки читабельно, полезно держать в голове простую модель. filter, map, compactMap — это про преобразование коллекции в другую коллекцию (то есть «много элементов на входе, много на выходе»). filter уменьшает количество элементов, map меняет каждый элемент, compactMap меняет и одновременно выкидывает nil.

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

Небольшая табличка, чтобы закрепить «кто что делает»:

Метод Что делает с элементами Ключевой вопрос
filter
оставляет часть элементов «Этот элемент подходит?»
map
превращает каждый элемент в новый «Во что преобразовать элемент?»
compactMap
превращает, но может выкинуть элемент «Могу ли я получить значение? Если нет — выбросить»

2. Мини-проект: «карманная библиотека»

Чтобы примеры не висели в воздухе, давайте продолжать наш учебный мини-проект: маленькая «библиотека» книг в памяти. Мы пока не дошли до struct, поэтому используем tuples — легковесно и по делу.

Начнём с данных:

import Foundation

let books: [(id: Int, title: String, year: Int)] = [
    (1, "Swift для тех, кто любит чай", 2021),
    (2, "Алгоритмы без боли", 2019),
    (3, "Паттерны проектирования на салфетке", 2004),
    (4, "Unicode: месть смайликов", 2020)
]

print(books.count) // 4

Наша цель на лекцию: научиться получать из этого массива разные «представления» (списки названий, списки id, отфильтрованные результаты) так, чтобы код оставался читаемым.

4. Стиль оформления цепочек: переносы и «ступеньки»

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

Сравним.

Вот так — технически нормально, но глаз быстро устаёт:

import Foundation

let recentTitles = books.filter { $0.year >= 2020 }.map { $0.title }
print(recentTitles) // ["Swift для тех, кто любит чай", "Unicode: месть смайликов"]

А вот так — обычно читается легче, потому что каждый шаг — отдельная строка:

import Foundation

let recentTitles = books
    .filter { $0.year >= 2020 }
    .map { $0.title }

print(recentTitles) // ["Swift для тех, кто любит чай", "Unicode: месть смайликов"]

Заметьте важную вещь: мы не стали «умнее», мы стали понятнее. Это главный стиль в функциональных цепочках: показывать шаги как шаги.

5. Порядок шагов: чаще filter раньше map

Есть простое правило, которое полезно и для читаемости, и для здравого смысла: если вы можете отфильтровать раньше — фильтруйте раньше. Тогда дальше по цепочке «течёт» меньше элементов, и вы не делаете лишней работы.

Но ещё важнее: так цепочка читается как «сначала выбрали, потом преобразовали». В бытовой логике это почти всегда естественнее.

Например, нам нужны названия книг, в которых есть слово "Swift", и мы хотим привести названия к нижнему регистру:

import Foundation

let swiftTitlesLowercased = books
    .filter { $0.title.contains("Swift") }
    .map { $0.title.lowercased() }

print(swiftTitlesLowercased) // ["swift для тех, кто любит чай"]

Если сделать наоборот (сначала map, потом filter), код всё ещё будет работать, но мысль станет менее прямой: «сначала преобразуем всё, потом выберем часть».

6. Когда цепочка становится «простынёй»

Обычно момент, когда цепочка перестаёт быть «рецептом» и превращается в «квест», заметен по нескольким симптомам.

Три признака, что пора остановиться

Первый симптом — внутри map или filter появляется замыкание на 10–15 строк. Формально это всё ещё «один шаг», но по факту вы засунули туда мини-программу.

Второй симптом — внутри шага появляется вложенный if/else, а ещё хуже — несколько if/else подряд. Это почти всегда признак, что вы смешали несколько смысловых шагов в один.

Третий симптом — вы ловите себя на мысли «я сейчас добавлю print, чтобы понять, что происходит». В этот момент у вас либо баг, либо цепочка стала слишком сложной для головы, и её пора развернуть.

Антипример: компилируется, но читать тяжело

Покажу типичный антипример «простыня». Он компилируется, но читать его тяжело:

import Foundation

let titles = books
    .filter { book in
        let t = book.title.lowercased()
        if t.contains("swift") { return true }
        if t.contains("алгоритм") { return true }
        return false
    }
    .map { book in
        "\(book.title) (\(book.year))"
    }

print(titles) // ["Swift для тех, кто любит чай (2021)", "Алгоритмы без боли (2019)"]

Да, оно работает. Но уже хочется спросить: «а можно так, чтобы без расследования?»

7. Приёмы против «простыни»

Тут важная мысль: это не «проигрыш функциональности». Это «выигрыш мозгов». Мы не обязаны держать всё в одной цепочке, если из-за этого теряется смысл.

Разделяйте шаги промежуточными let

Самый доступный способ сделать цепочку читабельной — разложить её на несколько промежуточных let.

Перепишем предыдущий пример так, чтобы каждый шаг имел имя:

import Foundation

let lowered = books.map { (id: $0.id, title: $0.title.lowercased(), year: $0.year) }

let filtered = lowered.filter { book in
    book.title.contains("swift") || book.title.contains("алгоритм")
}

let formatted = filtered.map { book in
    "\(book.title) (\(book.year))"
}

print(formatted) // ["swift для тех, кто любит чай (2021)", "алгоритмы без боли (2019)"]

Плюс такого подхода: вы можете поставить print(filtered) и понять, что происходит, не внедряя «отладку» внутрь filter.

Минус: кода стало больше. Но это тот случай, когда «больше строк» не значит «хуже». Важно, чтобы строки были честными и говорящими.

Используйте compactMap, когда логика «получилось — берём, нет — пропускаем»

Иногда «правильная» цепочка получается из трёх шагов: преобразовали в Optional, отфильтровали nil, развернули Optional. Практически: если вы видите конструкцию «если не получилось — пропусти», compactMap часто выглядит честнее.

Допустим, мы хотим получить id книг, год которых можно «прочитать» из строки (сценарий притянут, но показывает механику). Пусть у нас есть массив строк, а мы хотим вытащить корректные числа:

import Foundation

let rawYears = ["2021", "oops", "2019", ""]

let years = rawYears.compactMap { Int($0) }
print(years) // [2021, 2019]

Тут compactMap очень читабелен: «попробуй превратить, не получилось — выкинь».

И важная мысль про стиль: иногда лучше сделать один compactMap со спокойным guard, чем собирать длинную гирлянду из map/filter/map.

Вынесите правила из цепочки в именованные замыкания

Мы ещё не дошли до лекции, где будем активно передавать функции как значения, но уже можем вынести замыкание в константу, чтобы цепочка читалась как набор именованных правил.

Например, хотим отбирать «современные книги» и форматировать строку для вывода:

import Foundation

let isModern: ((id: Int, title: String, year: Int)) -> Bool = { book in
    book.year >= 2020
}

let formatLine: ((id: Int, title: String, year: Int)) -> String = { book in
    "\(book.title) — \(book.year)"
}

let modernLines = books
    .filter(isModern)
    .map(formatLine)

print(modernLines) // ["Swift для тех, кто любит чай — 2021", "Unicode: месть смайликов — 2020"]

Цепочка стала похожа на предложение: «отфильтруй modern → преобразуй в line».

Следите за типами: что именно «течёт» по цепочке

Цепочка становится нечитаемой ещё и тогда, когда вы перестаёте понимать, какой тип на каждом шаге. Особенно часто это происходит при compactMap, потому что он убирает Optional.

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

import Foundation

let lines = ["Swift 2021", "Algo ???", "Unicode 2020"]

let years = lines.compactMap { line -> Int? in
    let parts = line.split(separator: " ")
    guard let last = parts.last else { return nil }
    return Int(last)
}

print(years) // [2021, 2020]

Здесь стиль важен в мелочах: я явно написал -> Int?, чтобы было понятно, что compactMap ожидает Optional, и я не «теряю мысль» в середине замыкания.

8. Пример: поиск по запросу как пайплайн

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

Сначала сделаем простую функцию нормализации запроса и названия. Пока без CharacterSet-фильтрации (это глубже и нам сейчас не нужно), ограничимся trimmingCharacters(in:) и lowercased().

import Foundation

func normalize(_ s: String) -> String {
    s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}

let query = "  sWiFt "
let normalizedQuery = normalize(query)

print(normalizedQuery) // swift

Теперь — сам пайплайн поиска:

import Foundation

let queryNorm = normalize("  sWiFt ")

let matches = books
    .filter { normalize($0.title).contains(queryNorm) }
    .map { $0.title }

print(matches) // ["Swift для тех, кто любит чай"]

Это уже неплохо, но есть «скрытая» проблема: мы нормализуем title для каждой книги прямо внутри filter. Для маленького массива это не страшно. Но для стиля и ясности иногда лучше сделать нормализацию отдельным шагом, чтобы filter был максимально «логическим».

Развернём в два шага — и получим код, который легче объяснить новичку:

import Foundation

let queryNorm = normalize("  sWiFt ")

let prepared = books.map { book in
    (id: book.id, titleNorm: normalize(book.title), titleRaw: book.title)
}

let titles = prepared
    .filter { $0.titleNorm.contains(queryNorm) }
    .map { $0.titleRaw }

print(titles) // ["Swift для тех, кто любит чай"]

Да, чуть больше строк. Зато теперь пайплайн буквально такой: «подготовь данные → фильтруй по нормализованному → верни оригинальные названия».

Можно даже представить это как блок-схему:

flowchart TD
    A["books: [(id, title, year)]"] --> B["map: подготовка titleNorm"]
    B --> C["filter: titleNorm contains queryNorm"]
    C --> D["map: titleRaw"]
    D --> E["[String] результатов"]

9. Когда лучше обычный for-in

Иногда лучший стиль — это не цепочка. Это нормально. Swift не выдаёт медаль за «самый функциональный код месяца» (а если бы выдавал — она бы была Optional, и вы бы не знали, получите ли вы её вообще).

Если у вас сложная логика, несколько условий, нужно собирать разные результаты (например, отдельно ошибки, отдельно успешные элементы), или нужно остановиться на первом совпадении — обычный for-in часто будет честнее и понятнее.

Например, хотим найти первую книгу, в названии которой есть “unicode” (без учёта регистра). Да, можно сделать first(where:), но он пока у нас не был центральной темой. Покажу через цикл как «читабельный референс»:

import Foundation

let target = "unicode"
var foundTitle: String? = nil

for book in books {
    if book.title.lowercased().contains(target) {
        foundTitle = book.title
        break
    }
}

print(foundTitle ?? "Not found") // Unicode: месть смайликов

Смысл раздела: цепочки — это инструмент. Когда инструмент начинает мешать мысли — инструмент меняют.

10. Типичные ошибки при работе с цепочками map/filter

Ошибка №1: цепочка из 5–7 шагов в одну строку.
Такой код может выглядеть «компактно», но он не компактен для мозга. Если цепочка длиннее двух-трёх шагов, почти всегда полезно поставить переносы строк и сделать «ступеньки». Это не косметика, это способ показать структуру.

Ошибка №2: замыкание на 15 строк внутри filter или map.
Когда правило отбора или преобразования занимает полэкрана, вы фактически спрятали ещё одну программу внутри шага. Чаще всего это сигнал вынести часть логики в отдельный шаг (промежуточный let) или переписать на compactMap с guard, если логика «либо получилось, либо выкинули».

Ошибка №3: смешивать преобразование и побочные эффекты.
print внутри filter или map может быть полезен для отладки, но он резко ухудшает чтение пайплайна: вы уже не уверены, это «логика данных» или «логика вывода». Если хочется логировать — временно разверните пайплайн на шаги с промежуточными переменными и печатайте их отдельно.

Ошибка №4: потерять типы по дороге.
Особенно часто новички путаются в compactMap: «почему после map у меня [Int?], а после compactMap уже [Int]?» Старайтесь явно прописывать возвращаемый тип замыкания (-> Int?) в местах, где это помогает, и давайте переменным говорящие имена (rawYears, parsedYears).

Ошибка №5: делать цепочку ради цепочки.
Если код в цепочке читается хуже, чем простой for-in, значит, вы выбрали не тот инструмент. Это не поражение, это взрослая инженерная привычка: выбирать самое понятное решение, а не самое «модное».

1
Задача
Swift SELF, 17 уровень, 2 лекция
Недоступна
Контроль качества
Контроль качества
1
Задача
Swift SELF, 17 уровень, 2 лекция
Недоступна
Список городов
Список городов
1
Задача
Swift SELF, 17 уровень, 2 лекция
Недоступна
Валидные квадраты
Валидные квадраты
1
Задача
Swift SELF, 17 уровень, 2 лекция
Недоступна
Поиск по книгам
Поиск по книгам
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ