1. У всего есть обратная сторона
Когда вы впервые начинаете использовать map, filter, reduce, появляется ощущение: “О, я пишу чистую математику!”. И это почти правда — пока вы держите замыкания как маленькие функции “вход → выход”. Но как только внутри замыкания появляется print, изменение внешней переменной или запись в какой‑то массив “снаружи”, вы добавляете побочные эффекты и превращаете математику в кулинарию: вроде тот же суп, но кто-то уже посолил, кто-то помешал, а кто-то съел ложку прямо из кастрюли.
Побочный эффект — это действие, которое не выражается только возвращаемым значением замыкания. Самые частые примеры: печать в консоль (print), изменение переменной, объявленной вне замыкания, добавление элементов во внешний массив, запись в файл, чтение ввода. Плохая новость: эффекты иногда нужны. Хорошая новость: их можно делать явными и “приземлять” в правильные места.
Мини‑пример, чтобы нащупать идею:
import Foundation
let values = [1, 2, 3]
let doubled = values.map { x in
print("Удваиваю \(x)") // побочный эффект
return x * 2 // полезный результат
}
print(doubled) // [2, 4, 6]
Код рабочий, но у него сразу два “результата”: массив doubled и напечатанные строки. И дальше мы будем разбираться, когда это нормально, а когда это уже хитрая ловушка.
Небольшой факт “из мира стандартной библиотеки”: операции map, filter, forEach — это часть привычных инструментов Sequence, то есть они в Swift задуманы как базовые способы обработки последовательностей.
2. Два жанра: преобразование данных и действие
Очень помогает, если вы мысленно разделяете код на два жанра. Первый жанр — преобразование данных: вы берёте вход, делаете из него выход, и внутри не меняете внешний мир. Второй жанр — выполнение действия: вы идёте по элементам и делаете что-то “наружу” (печатаете, пишете в лог, обновляете переменную, складываете результат в уже существующий контейнер).
Чтобы не превращать лекцию в список на 30 пунктов (мы же обещали не злоупотреблять), зафиксируем эту идею в таблице:
| Инструмент | Как воспринимать | Хорошее место для эффектов |
|---|---|---|
|
“вычисление” (вход → выход) | обычно нет, кроме редкой диагностики |
|
“действие” (пройтись и сделать) | да, эффекты ожидаемы |
|
“действие/контроль” | да, если так понятнее |
Главная мысль: если вы читаете .map { ... }, вы ожидаете новый массив, а не то, что внутри тайно обновится какой-то внешний var. И наоборот: если вы читаете forEach, вы ожидаете, что там будет действие, а не “я собираю данные, честно-честно”.
3. Что можно мутировать внутри closure
Сейчас будет важная тонкость. Слово “мутировать” само по себе не зло. В Swift есть сценарии, где мутация — это нормальная, читабельная часть решения. Проблема начинается, когда мутация прячется и становится сюрпризом для читателя.
Есть три типичных категории мутации в замыканиях.
Первая категория — мутация локального аккумулятора, который вы сами создали в выражении, например в reduce(into:). Это почти всегда окей, потому что аккумулятор — часть самой операции, он локальный, и его жизнь ограничена свёрткой.
Вторая категория — мутация внешней переменной, объявленной где-то снаружи (например, var total = 0, потом внутри forEach делаем total += x). Это работает, но делает логику менее “самодостаточной”: теперь forEach зависит от внешнего состояния.
Третья категория — мутация той же коллекции, по которой вы прямо сейчас проходите. Вот это уже пахнет приключениями. Иногда Swift не даст это сделать (и это хорошо), иногда даст — но результат будет неожиданным. Мы ещё вернёмся к этой ловушке отдельно.
Для начала — пример “хорошей мутации”, когда всё локально и понятно:
import Foundation
let rawTitles = ["Swift", "C++", "Swift", "Rust"]
let counts = rawTitles.reduce(into: [String: Int]()) { acc, title in
acc[title, default: 0] += 1 // мутация аккумулятора — ок
}
print(counts) // ["Swift": 2, "C++": 1, "Rust": 1]
Здесь мутация не сюрприз: мы прямо видим reduce(into:), видим acc, и понимаем: “ага, собираем словарь”.
4. Ловушка: внешний var + forEach
Очень частая привычка новичка выглядит так: “мне надо посчитать сумму — заведу var sum = 0, потом сделаю forEach”. Это не ошибка, но это как ходить по квартире с мокрыми руками: один раз нормально, а потом внезапно начинаете хвататься за всё подряд и удивляться, почему вокруг вода.
Сравним два подхода.
import Foundation
let pages = [120, 300, 180]
// Вариант с внешним состоянием
var sum1 = 0
pages.forEach { sum1 += $0 }
// Вариант как вычисление
let sum2 = pages.reduce(0, +) //оператор + это тоже функция
print(sum1) // 600
print(sum2) // 600
Оба результата одинаковые. Но reduce(0, +) читателю сообщает: “это вычисление суммы”. А forEach с внешним var сообщает: “я сейчас что-то делаю по элементам”, но что именно — надо читать тело и искать, что за sum1 и где он объявлен.
Психологически это важно: чем больше внешних переменных вы мутируете внутри closures, тем сложнее потом рефакторить код. Вы захотите вынести часть в функцию, а она внезапно тянет за собой внешнее состояние. Вы захотите переиспользовать правило — а оно привязано к конкретной переменной.
5. Не делайте скрытую сборку через map
Сейчас будет типичный анти‑пример. Он компилируется, но это плохая привычка. Новички иногда делают так: “я хочу получить новый массив, значит использую map, но внутри буду append-ить во внешний массив”. Это работает… но ломает жанр: map должен возвращать результат, а не строить его тайно где-то сбоку.
Правильный вариант (чистое преобразование):
import Foundation
let pages = [120, 300, 180]
let doubled = pages.map { $0 * 2 }
print(doubled) // [240, 600, 360]
А вот “скрытая сборка”, которую лучше не делать:
import Foundation
let pages = [120, 300, 180]
var result: [Int] = []
pages.forEach { x in
result.append(x * 2) // внешний массив мутируется
}
print(result) // [240, 600, 360]
Почему это ловушка, даже если “работает”? Потому что теперь результат зависит от того, что result был пустым, и что вы не использовали его где-то ещё. Если кто-то случайно переиспользует result до этого блока — появится баг “почему там лишние значения”.
Если уж вам нужно “собирать” структуру пошагово, делайте это либо явным циклом for-in (и это нормально), либо reduce(into:), где аккумулятор внутри выражения.
6. print внутри map/filter: только для отладки
В реальной разработке все печатают в консоль. Даже те, кто говорит “я никогда не использую print, у меня только дебаггер”. Они просто печатают в душЕ. Так что да: print внутри closures иногда нужен — как временная диагностика. Но важно понимать, что вы делаете: вы смешиваете вычисление и действие.
import Foundation
let pages = [120, 300, 180, 90]
let big = pages.filter { x in
print("Проверяю \(x)") // побочный эффект
return x >= 200
}
print(big) // [300]
Этот пример полезен, чтобы увидеть порядок прохода и убедиться, что фильтр работает. Но как только вы оставляете такой print в “боевом” коде, пайплайн перестаёт быть чистым преобразованием. Становится трудно читать: теперь .filter не только фильтрует, но ещё и печатает.
Более аккуратный стиль — отделить вычисление от действия. Например, сначала вычислить big, а потом отдельно вывести его:
import Foundation
let pages = [120, 300, 180, 90]
let big = pages.filter { $0 >= 200 }
big.forEach { print("Большая книга: \($0) стр.") } // действие отдельно
Так ваш пайплайн остаётся “про данные”, а эффекты — “про действие”.
7. Ловушка захвата: зависимость от внешнего состояния
Когда вы пишете closure, он может “захватить” переменную снаружи и потом к ней обращаться. Мы уже обсуждали захват раньше, но сейчас важен именно аспект побочных эффектов: если вы захватили изменяемую переменную, то чтение/изменение этой переменной становится неявной зависимостью.
Пример, который выглядит невинно:
import Foundation
var multiplier = 2
let pages = [120, 300]
let scaled = pages.map { $0 * multiplier }
print(scaled) // [240, 600]
multiplier = 3
let scaled2 = pages.map { $0 * multiplier }
print(scaled2) // [360, 900]
Это не баг, это демонстрация: замыкание использует текущее значение multiplier в момент выполнения. Если вы ожидаете, что “оно запомнит двойку навсегда”, вы получите сюрприз.
В учебных задачах это не страшно. В реальном коде такие штуки становятся источником “плавающих” багов: правило преобразования зависит от внешнего состояния, которое могло поменяться “между строк”.
Практический приём: если вам важно, чтобы значение было фиксированным, сделайте локальную константу и используйте её:
import Foundation
var multiplier = 2
let fixed = multiplier // фиксируем значение
let pages = [120, 300]
let scaled = pages.map { $0 * fixed }
print(scaled) // [240, 600]
multiplier = 3
print(multiplier) // 3
print(fixed) // 2
Теперь замыкание зависит не от “живой” переменной, а от “снимка” значения.
8. Ловушки прохода: мутация коллекции и порядок эффектов
Меняю коллекцию, по которой иду
На этом месте многие впервые встречают ощущение: “Swift умнее меня и не даёт мне делать глупости”. И это тот редкий момент, когда хочется сказать компилятору спасибо, а не спорить с ним.
Проблема такая: вы проходите по массиву и внутри замыкания пытаетесь изменить этот же массив. На уровне здравого смысла это опасно: индексы могут сдвинуться, элементы “перепрыгнут”, часть будет обработана дважды, часть пропущена.
Даже если вы заставите это компилироваться через хитрые трюки, это будет плохой стиль. Гораздо надёжнее разделить на две фазы: сначала вычислить новый массив, потом заменить старый.
Правильный подход (создали новый массив — присвоили):
import Foundation
var pages = [120, 300, 180, 90]
let filtered = pages.filter { $0 >= 200 }
pages = filtered
print(pages) // [300]
Это чуть “длиннее”, чем попытка мутировать на ходу, но зато читабельно и предсказуемо.
Порядок вычислений и эффекты
Когда в выражении есть побочные эффекты, важен не только сам факт эффекта, но и порядок, в котором эффекты происходят. В Swift порядок вычисления аргументов функций — слева направо, и это важно, если внутри аргументов есть вызовы, которые печатают/меняют состояние. И это не академическая придирка: если вы когда-нибудь отлаживали “почему у меня лог в странном порядке”, вы уже сталкивались с этим.
Для нашей темы вывод простой: если в пайплайне есть эффекты, чтение кода становится сложнее, потому что вы должны держать в голове ещё и порядок выполнения частей выражения. Поэтому общий стиль на сегодня такой: сначала вычисляем, потом делаем эффекты (печать/лог/сохранение).
9. Пример: статистика для “нашей библиотеки”
Чтобы не оставлять всё на уровне философии, давайте продолжим нашу маленькую консольную “библиотеку” (без структур, без классов — только то, что мы уже проходили). Представим, что у нас есть словарь “название → страниц”.
import Foundation
let library: [String: Int] = [
"Swift Basics": 180,
"Algorithms": 320,
"Clean Code": 450,
"Short Notes": 90
]
Наша задача: посчитать суммарное число страниц у книг, которые считаются “серьёзными”, то есть от 200 страниц, и при этом вывести названия этих книг. Здесь очень легко сделать “пюре” из вычислений и печати. Сделаем аккуратно, в две фазы.
Сначала вычислим список названий:
import Foundation
let library: [String: Int] = ["Swift Basics": 180, "Algorithms": 320, "Clean Code": 450, "Short Notes": 90]
let seriousTitles = library
.filter { $0.value >= 200 }
.map { $0.key }
print(seriousTitles) // ["Algorithms", "Clean Code"] (порядок может отличаться)
Теперь отдельно действие: красиво напечатаем. Да, Dictionary не гарантирует порядок — и это нормально для текущего уровня, мы просто показываем идею “не мешать жанры”.
import Foundation
let seriousTitles = ["Algorithms", "Clean Code"]
seriousTitles.forEach { title in
print("Рекомендую: \(title)")
}
// Рекомендую: Algorithms
// Рекомендую: Clean Code
И отдельно вычисление суммы страниц (без печати внутри):
import Foundation
let seriousPages = [320, 450]
let total = seriousPages.reduce(0, +)
print("Итого страниц: \(total)") // Итого страниц: 770
Если вы захотите, вы можете собрать это “в одну цепочку”, но сегодня мы специально тренируем стиль: вычисления отдельно, эффекты отдельно. Так код легче читать, легче тестировать и легче менять.
10. Типичные ошибки при работе с побочными эффектами в closures
Ошибка №1: использовать map/filter как место для “действий”, а не преобразований.
Когда внутри map появляется печать, запись в лог или обновление внешней переменной, выражение перестаёт быть чистым вычислением. Оно начинает производить “два результата”: и возвращаемое значение, и изменения в мире. Через неделю такой код сложно читать: вы видите map, ожидаете “новый массив”, а получаете ещё и пачку скрытых действий.
Ошибка №2: собирать результат через внешний var, хотя задача — это reduce или reduce(into:).
Внешний накопитель (например, var sum = 0, потом forEach) работает, но создаёт лишнюю связанность: вычисление зависит от внешнего состояния, которое можно случайно переиспользовать или изменить до/после. reduce делает смысл (“мы сворачиваем в одно значение”) явным прямо в выражении, и читатель быстрее понимает, что происходит.
Ошибка №3: оставлять отладочные print внутри пайплайнов.
Как временная диагностика это нормально, но в чистовом коде это превращает преобразования данных в смесь “и считаем, и болтаем”. Если нужно логировать — лучше вычислить результат, а потом отдельно распечатать, либо вынести диагностику в отдельный проход. Так вы не разрушаете композицию пайплайна.
Ошибка №4: опираться на внешние изменяемые переменные, захваченные замыканием, как на “стабильные настройки”.
Если замыкание читает var multiplier снаружи, то любое изменение multiplier меняет смысл вычисления. Это может быть и нужным поведением, и источником сюрпризов. Если вам важно “зафиксировать” значение, лучше сохранить его в let и использовать эту константу внутри замыкания.
Ошибка №5: пытаться менять коллекцию во время прохода по ней.
Это почти всегда ведёт к непредсказуемости: элементы могут пропускаться или обрабатываться повторно, индексы сдвигаются, а код превращается в загадку. Гораздо безопаснее сначала построить новый массив/словарь через filter/map/reduce(into:), а потом одним присваиванием заменить исходные данные.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ