1. Зачем нужен lazy: eager vs «по запросу»
Если вы когда-нибудь делали бутерброд «на будущее», а потом забывали его в холодильнике, вы уже понимаете идею eager-вычислений. Eager — это «приготовь всё заранее, вдруг пригодится». lazy — это «я приготовлю, когда ты реально попросишь», то есть вычисление происходит только при доступе к очередному элементу. В Swift это особенно важно из‑за цепочек map/filter, которые наивно могут создавать промежуточные массивы и делать больше работы, чем нужно.
Начнём с контрастного примера: обычный map у массива выполняется сразу.
import Foundation
let numbers = [1, 2, 3]
let eager = numbers.map { n -> Int in
print("map:", n) // выполнится сразу
return n * 2
}
print("eager =", eager) // eager = [2, 4, 6]
Обратите внимание на поведение: как только мы написали numbers.map { ... }, Swift прошёл по всем элементам и выполнил замыкание три раза. То есть "map:" будет напечатано до финального print.
Теперь то же самое, но через lazy. Здесь мы не «ускоряем», а меняем момент выполнения.
import Foundation
let numbers = [1, 2, 3]
let lazySeq = numbers.lazy.map { n -> Int in
print("map:", n) // выполнится только при потреблении
return n * 2
}
print("created") // created
for x in lazySeq {
print("x =", x) // x = 2, x = 4, x = 6
}
Важная мысль: lazySeq — это не «готовый массив», а «ленивый взгляд» на исходные данные. Он хранит ссылку (логическую) на базовую коллекцию и правило преобразования, но не хранит готовые результаты.
Наглядно это удобно держать в голове так:
flowchart LR
A["Исходные данные (Array)"] --> B["lazy (обёртка-взгляд)"]
B --> C["map/filter (правила)"]
C --> D["Потребление: for-in / reduce / Array(...)"]
Пока не случится «потребление», вычисления как будто «висят в воздухе».
2. Когда lazy действительно помогает
Самая частая ошибка новичка — включать lazy «потому что это вроде быстрее». Но lazy полезен не сам по себе, а когда ваша задача позволяет не делать лишнюю работу: не обрабатывать весь массив, не создавать промежуточные массивы, не считать то, что вы всё равно не используете. В некоторых ситуациях это даёт ощутимую выгоду, в других — ноль пользы и плюс один повод запутаться. Поэтому в этом разделе мы будем говорить про типовые сценарии, где ленивость действительно оправдана.
Сценарий A: можно остановиться раньше, чем дойдём до конца
Классика: вы ищете первый подходящий элемент. Если вы делаете eager‑цепочку и строите массив результатов, вы уже проиграли: вы обработали всё, чтобы взять один элемент.
Пример (eager — делает больше, чем нужно):
import Foundation
let numbers = Array(1...10)
let doubled = numbers.map { $0 * 2 }
let bigOnes = doubled.filter { $0 >= 12 }
print(bigOnes.first as Any) // Optional(12)
Да, это работает. Но map прошёл по всем 10 элементам, потом filter прошёл по всем 10 элементам, а нам нужен был первый результат 12.
Пример (lazy — делает работу «по требованию»):
import Foundation
let numbers = Array(1...10)
let firstBig = numbers.lazy
.map { $0 * 2 }
.first { $0 >= 12 }
print(firstBig as Any) // Optional(12)
Здесь вычисления идут до тех пор, пока first(where:) не найдёт подходящий элемент. Как только нашёл — прекращаем.
Сценарий B: цепочка map/filter длинная, а данные большие
Когда цепочка длинная, eager‑подход легко создаёт «слоёный пирог» из промежуточных массивов. В обсуждениях стандартной библиотеки прямо отмечали, что смысл lazy‑последовательностей — как раз не создавать лишние коллекции при функциональных цепочках.
Представьте: у нас большой список строк, и мы хотим нормализовать, отфильтровать, преобразовать. Если это будет eager, на каждом шаге может появиться новый массив.
Пример (ленивый конвейер + ограничение потребления):
import Foundation
let lines = [" Swift ", " ", " Lazy ", "Map", "Filter"]
let cleaned = lines.lazy
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
for word in cleaned {
print(word) // Swift, Lazy, Map, Filter
}
Смысл не в том, что это «магически быстрее», а в том, что мы не создаём отдельные массивы после каждого шага, а прогоняем элементы через правила по одному.
Сценарий C: вы работаете не с Array, а с типами, где «результат фильтрации» — уже не Set/Dictionary
Для Set, например, часто хочется отфильтровать, но при этом помнить: после lazy.filter результат — уже не Set, а «ленивая коллекция‑обёртка», у которой нет set‑операций (типа isSubset(of:)). В материалах по Set/Dictionary это приводят как реальную мотивацию: иногда после lazy.filter нужно «вернуть» тип Set, прогнав результат через Set(...).
import Foundation
let numberSet = Set(1...10)
let lazyFiltered = numberSet.lazy.filter { $0.isMultiple(of: 3) }
// lazyFiltered — это НЕ Set, а ленивый результат
let backToSet = Set(lazyFiltered)
print(backToSet) // {3, 6, 9} (порядок не гарантирован)
Здесь lazy можно использовать как «промежуточный режим», чтобы не торопиться материализовать результат, а потом материализовать ровно там, где это действительно нужно.
3. Что запускает вычисления в lazy
У lazy есть коварная особенность: на глаз код выглядит «как будто мы уже что-то посчитали», но на самом деле мы только описали правила. Если держать это в голове, становится проще понимать, почему print внутри lazy.map может сработать «не сейчас», почему дебаг иногда выглядит странно, и почему один и тот же ленивый конвейер может выполнять работу несколько раз. В этом разделе мы соберём практическую модель: какие операции считаются «потребителями» и запускают вычисления.
Самый простой потребитель — for-in: он запрашивает элементы по одному.
import Foundation
let numbers = [1, 2, 3]
let seq = numbers.lazy.map { $0 * 10 }
for x in seq {
print(x) // 10, 20, 30
}
Второй очевидный триггер — материализация в массив: Array(seq). Чтобы построить массив, Swift обязан «дотащить» все элементы до конца (иначе массив не получится).
import Foundation
let numbers = [1, 2, 3]
let seq = numbers.lazy.map { $0 * 10 }
let arr = Array(seq)
print(arr) // [10, 20, 30]
Дальше идут потребители, которые могут быть «короткими» (short-circuit). Например, contains(where:) может остановиться раньше, если условие выполнено.
import Foundation
let numbers = [1, 2, 3, 4, 5]
let hasBig = numbers.lazy
.map { $0 * 2 }
.contains { $0 >= 8 }
print(hasBig) // true
И вот здесь lazy особенно приятен: если условие сработало на четвёртом элементе, пятый уже может не вычисляться.
4. Когда lazy мешает
Иногда lazy напоминает очень экономного человека, который выключает свет в коридоре, пока вы ещё не дошли до кухни: экономия есть, но нервирует. Проблема не в lazy, а в неправильных ожиданиях. Новички часто думают, что lazy «считает один раз и запоминает» — но это не кеш, а «рецепт». Если вы дважды пройдёте по ленивой последовательности, вы дважды приготовите блюдо. И если внутри замыкания есть побочные эффекты (например, print), вы внезапно получите их повторно.
Ловушка A: lazy не кеширует результат
import Foundation
let numbers = [1, 2, 3]
let seq = numbers.lazy.map { n -> Int in
print("calc", n)
return n * 2
}
for x in seq { print("first", x) }
for x in seq { print("second", x) }
Вывод будет таким (схематично):
calc 1
first 2
calc 2
first 4
calc 3
first 6
calc 1
second 2
calc 2
second 4
calc 3
second 6
То есть вычисления повторились. Если вам нужен результат «посчитать один раз и потом много раз использовать», вы обычно должны материализовать: let arr = Array(seq).
Ловушка B: «огромные типы» и неочевидность цепочек
С точки зрения чтения кода у lazy есть цена: цепочки могут стать менее очевидными, а в реальных обсуждениях стандартной библиотеки отмечали ещё и технический момент — длинные цепочки lazy.map/filter/map/filter могут приводить к громоздким обёрткам и не самому приятному коду, который компилятору потом надо оптимизировать.
Мы не будем уходить в «внутренности компилятора», но практический вывод простой: не надо делать lazy‑простыни «на 12 методов в одну строку». Если цепочка стала нечитаемой, лучше упростить: либо разбить на переменные, либо использовать цикл, если он понятнее.
Ловушка C: lazy не делает маленькие коллекции «быстрее по определению»
Если у вас массив из 10 элементов, и внутри map простая операция * 2, реальной пользы почти не будет. Зато будет дополнительная «абстракция» в голове: когда именно это выполнится? почему не сейчас? почему я не вижу результата? Новичку это чаще мешает, чем помогает.
5. Простые правила выбора
Хочется иметь «чек‑лист», который можно держать в голове, когда рука тянется поставить .lazy просто потому, что слово короткое и выглядит профессионально. Но реальность такая: правило зависит от цели — экономим память, экономим CPU, хотим ранний выход, хотим читаемость. Поэтому ниже я дам таблицу, а затем поясню её человеческими словами, чтобы это было не как «заповеди», а как понятная логика выбора.
| Ситуация в задаче | Что чаще лучше | Почему |
|---|---|---|
| Нужен только первый/несколько первых результатов | lazy + короткий потребитель (first, contains, prefix) | Можно остановиться раньше и не обрабатывать хвост |
| Данные большие, цепочка преобразований длинная | lazy на промежуточных шагах | Меньше промежуточных массивов |
| Результат нужен много раз (несколько проходов) | Материализовать (Array(...)) | lazy не кеширует, иначе будет повторная работа |
| Коллекция маленькая и операция дешёвая | Обычный map/filter | Читаемость важнее, разницы в скорости почти нет |
| Внутри замыканий есть print, логирование, счётчики | Осторожно с lazy | Побочные эффекты откладываются и повторяются |
| Нужно API конкретного типа (Set-операции, словарные методы) | Материализовать обратно (Set(...), Array(...)) | После lazy.filter тип обычно становится «обёрткой» |
| Цепочка стала «простынёй» | Разбить на переменные или цикл | Цена lazy — в читаемости и дебаге |
Теперь важные пояснения.
Когда вы хотите «взять пару первых», lazy почти идеален: вы описали правила, а затем потребитель вроде first(where:) сам остановится. Это один из самых приятных случаев, потому что выигрывает и производительность, и код остаётся коротким.
Когда вы хотите «сделать результат один раз и использовать много раз», lazy обычно не подходит в чистом виде. Он не кеширует, поэтому повторный проход повторит и вычисления. В таких ситуациях логичнее сделать ленивую цепочку, а затем один раз материализовать её в массив, и уже массив использовать дальше.
Когда коллекция маленькая, lazy часто превращается в «оптический шум». Это как надевать каску, чтобы дойти от дивана до холодильника: формально безопаснее, но выглядит странно, и времени на надевание каски вы потратите больше.
Когда у вас Set или Dictionary, полезно помнить, что после ленивых операций вы часто получаете не «тот же тип», а специальный ленивый результат. Это нормально, но иногда вы теряете удобные методы типа (у Set — операции множеств). Тогда материализация обратно через Set(...) — это не «костыль», а штатный шаг.
6. Пример: lazy в учебном приложении
До этого момента мы смотрели на lazy в вакууме, как на отдельную фичу. Но лучше всего оно запоминается, когда вы используете его «по делу» в маленьком приложении, где есть смысл: например, мы храним список книг (строки) и хотим быстро найти первые подходящие по запросу пользователя. Мы пока не строим настоящую архитектуру и не вводим struct (это будет позже), поэтому используем массивы, строки и простые функции.
Пусть у нас есть каталог — просто массив строк. Пользователь вводит подстроку для поиска, а мы хотим вывести максимум 3 первых совпадения. Важный момент: «максимум 3» означает, что нам не обязательно обрабатывать каталог полностью, если совпадений уже достаточно.
import Foundation
let books = [
"Clean Code",
"Swift Programming",
"The Pragmatic Programmer",
"Swift Algorithms",
"Code Complete"
]
let query = "swift"
let matches = books.lazy
.filter { $0.lowercased().contains(query) }
.prefix(3)
for title in matches {
print(title) // Swift Programming, Swift Algorithms
}
Здесь lazy — не украшение. Он позволяет превратить задачу в «поток»: мы читаем книги одну за другой, проверяем условие, и как только prefix(3) набрал три элемента (или закончились книги), всё завершилось. Мы не строим массив всех совпадений, если он не нужен.
Если вы хотите явно увидеть разницу «лениво vs сразу», можно сравнить с eager‑версией. Она тоже корректная, просто делает лишнюю работу, если каталог большой.
Пример (eager):
import Foundation
let books = ["A", "Swift B", "Swift C", "D", "Swift E"]
let query = "swift"
let allMatches = books
.filter { $0.lowercased().contains(query) }
let firstThree = Array(allMatches.prefix(3))
print(firstThree) // ["Swift B", "Swift C", "Swift E"]
Тут мы всё равно фильтруем весь массив, даже если нам нужны первые три.
Чуть усложним и добавим «рейтинг», как словарь. Мы хотим вывести названия книг с рейтингом >= 8, но не больше двух строк, чтобы не захламлять консоль. Заметьте: у словаря порядок не гарантирован — это нормально, мы на порядок не рассчитываем.
import Foundation
let ratings: [String: Int] = [
"Clean Code": 9,
"Swift Programming": 7,
"Code Complete": 10,
"Some Book": 5
]
let top = ratings.lazy
.filter { (_, score) in score >= 8 }
.prefix(2)
for (title, score) in top {
print("\(title): \(score)")
}
Опять же, lazy здесь полезен ровно потому, что мы заранее знаем «мне хватит двух». Если бы мы хотели отсортировать по рейтингу — это уже другая история (и мы бы обсуждали другие инструменты), но в рамках текущего дня мы держим фокус на переборе и потреблении.
7. Типичные ошибки при работе с lazy
Ошибка №1: ожидать, что lazy “посчитает и запомнит”.
Это самая распространённая ментальная ловушка: человек воспринимает lazy как кеш. На самом деле lazy — это «взгляд + правила», и при каждом новом проходе вычисления будут выполнены снова. Если результат нужен многократно, обычно правильнее один раз материализовать его в Array(...) и дальше работать с массивом.
Ошибка №2: добавлять lazy “на всякий случай” в маленьких коллекциях.
На небольших массивах с простыми преобразованиями lazy редко даёт ощутимую выгоду, но почти всегда добавляет когнитивную нагрузку: “а когда это выполнится?”, “почему сейчас ничего не происходит?”. В учебных задачах это особенно мешает, потому что дебаг становится менее прямолинейным.
Ошибка №3: удивляться, что print внутри lazy.map срабатывает “поздно” или “несколько раз”.
Побочные эффекты откладываются до потребления. А если вы потребили последовательность дважды (например, два цикла for-in), эффекты повторятся. Поэтому lazy и побочные эффекты — плохие друзья: лучше держать замыкания чистыми, без print и счётчиков, либо очень чётко понимать, когда будет потребление.
Ошибка №4: потерять методы исходного типа и пытаться вызвать их на ленивом результате.
После numberSet.lazy.filter { ... } вы получаете не Set, а ленивую обёртку. Это нормальное поведение: lazy даёт вам последовательность для перебора, но не «все возможности Set». Если вам нужны set‑операции, материализуйте обратно через Set(...).
Ошибка №5: превращать цепочку в нечитаемую “простыню” и надеяться, что lazy всё исправит.
lazy — не антипаттерн, но и не универсальная таблетка. Если выражение стало слишком длинным, лучше разнести этапы на промежуточные переменные или даже вернуться к обычному циклу. Иначе вы выигрываете пару микросекунд (в лучшем случае), но проигрываете часы на чтение и отладку.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ