JavaRush /Курсы /Swift SELF /Побочные эффекты в замыканиях

Побочные эффекты в замыканиях

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

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, filter, compactMap, reduce
“вычисление” (вход → выход) обычно нет, кроме редкой диагностики
forEach
“действие” (пройтись и сделать) да, эффекты ожидаемы
for-in
“действие/контроль” да, если так понятнее

Главная мысль: если вы читаете .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:), а потом одним присваиванием заменить исходные данные.

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