JavaRush /Курсы /Swift SELF /Ленивые вычисления: lazy‑цепочки

Ленивые вычисления: lazy‑цепочки

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

1. Как красивые цепочки иногда тратят ваши деньги

Если вы когда-нибудь писали что-то вроде array.filter { ... }.map { ... }, вы уже делали маленький конвейер обработки данных. Это удобно: читается почти как предложение на человеческом языке. Проблема в том, что по умолчанию многие такие операции работают «жадно» (eager): они сразу пробегают по всем элементам и создают промежуточные коллекции. На маленьких массивах это не страшно, но на больших — может стать сюрпризом (примерно как «почему ноутбук взлетает», хотя вы всего лишь «отфильтровали списочек»).

Жадное выполнение на пальцах

Посмотрим на простой пример. Мы специально добавим print, чтобы увидеть момент выполнения:

import Foundation

let numbers = [1, 2, 3]

let doubled = numbers.map { n -> Int in
    print("map:", n)
    return n * 2
}

print("result:", doubled) // result: [2, 4, 6]

Здесь важно уловить поведение: map отработал сразу, ещё до финального print. То есть «вычисление» произошло в момент построения doubled.

И это в целом нормально. Но представьте, что у вас не 3 числа, а 3 миллиона записей, и вы дальше хотите взять только первый подходящий элемент. Если всё делается «сразу», вы проделаете работу по всем 3 миллионам — даже если ответ находится на первых пяти.

В Swift ленивые последовательности как раз и ценятся тем, что позволяют не создавать лишние промежуточные массивы и не выполнять работу, которая не понадобится. Это одна из мотиваций ленивых цепочек — избежать ненужных аллокаций при превращении циклов в цепочки методов.

2. Как работает .lazy и когда оно вычисляется

.lazy — «ленивый взгляд» на данные, а не новая коллекция

Слово lazy звучит так, как будто Swift предлагает нам официально «не делать работу». И да, примерно так и есть, только культурно. .lazy в контексте коллекций/последовательностей — это не «создать новый массив», а «создать обёртку», которая будет отдавать элементы по мере запроса. То есть вы строите конвейер преобразований, но он реально выполняется только тогда, когда кто-то начинает потреблять из него элементы.

Представьте конвейер на заводе. Обычный map/filter — это как «выпустить сразу всю партию и складировать между этапами». .lazy — это как «делать следующую деталь только когда её попросили на выходе». Между этапами не копятся ящики с промежуточными деталями — значит, и склад (память) не забивается.

Eager vs lazy по моменту выполнения

Вот почти тот же код, но с .lazy:

import Foundation

let numbers = [1, 2, 3]

let lazyDoubled = numbers.lazy.map { n -> Int in
    print("map:", n)
    return n * 2
}

// Пока ничего не напечаталось — мы только описали конвейер.
for x in lazyDoubled {
    print("x =", x)
}

В этом варианте вы увидите, что map: печатается во время for-in, то есть в момент потребления. До цикла преобразование не выполняется.

И это ключевая модель: .lazy откладывает вычисления до момента реального перебора элементов.

«Триггеры потребления»: что запускает ленивую цепочку

Когда люди впервые видят .lazy, они иногда думают, что «ленивость» — это какая-то новая магическая оптимизация, которая ускоряет всё всегда. На практике .lazy — это всего лишь другой момент выполнения: «позже», «по требованию». Поэтому важно понимать, какие действия заставляют цепочку реально вычислять элементы, то есть что является потребителем.

Ниже — небольшая таблица‑памятка. Она помогает предсказывать поведение, не запуская код десять раз (хотя запускать тоже полезно, мы же программисты).

Действие-потребитель Что происходит Сколько элементов реально вычислится
for-in перебор до конца или до break до конца или до остановки
Array(lazySeq) материализация в массив все элементы (если последовательность конечна)
reduce(...) сворачиваем в одно значение обычно все элементы
first(where:) ищем первый подходящий до первого совпадения
contains() / contains(where:) проверяем наличие до первого совпадения

Обратите внимание на «короткое замыкание»: методы вроде first(where:) и contains могут остановиться раньше конца. И вот здесь .lazy особенно приятно «выстреливает»: вы не вычисляете то, что не потребуется.

Демонстрация: «остановились раньше — сделали меньше работы»

Сделаем простой счётчик «дорогих вычислений»:

import Foundation

let numbers = Array(1...10)

var calls = 0
let found = numbers.lazy
    .map { n -> Int in
        calls += 1
        return n * n
    }
    .first { $0 > 20 }

print("found:", found as Any) // found: Optional(25)
print("map calls:", calls)    // map calls: 5

Мы искали первый квадрат, который больше 20. Это 25, то есть число 5. Значит, map реально был вызван только 5 раз — и остановился. В «жадном» варианте map отработал бы для всех 10 элементов, потому что он сначала строит целый массив квадратов, а потом уже ищет. С .lazy поиск встроился в процесс.

Нюанс: .lazy откладывает не только вычисления, но и побочные эффекты

В программировании есть старое правило: «если внутри функции есть print, значит, это уже не чистая функция, а маленький театр». Так вот, .lazy умеет откладывать и этот театр тоже. Иногда это удобно (меньше шума), иногда пугает (почему ничего не печатается?).

Покажем разницу на коротком примере.

Eager‑версия: print происходит сразу.

import Foundation

let xs = [1, 2, 3]

let ys = xs.map { x -> Int in
    print("mapping", x)
    return x * 10
}

print("done") // done

Сначала отпечатается "mapping 1", "mapping 2", "mapping 3", и только потом "done".

Lazy‑версия: print происходит при потреблении.

import Foundation

let xs = [1, 2, 3]

let ys = xs.lazy.map { x -> Int in
    print("mapping", x)
    return x * 10
}

print("before loop") // before loop
for y in ys {
    print("y =", y)
}

Печать "mapping" начнётся только в цикле.

Это поведение естественно: пока элементы не «запрошены», замыкания не выполняются.

3. lazy‑цепочки на примере мини‑библиотеки

Чтобы это было не «в вакууме», продолжим наш мини‑проект: консольную «библиотеку», где у нас есть список книг и простейший поиск. Пока без struct (они будут позже), поэтому используем tuples: это уже знакомо и достаточно выразительно.

Пусть книга — это кортеж (id, title, year, tags):

import Foundation

typealias Book = (id: Int, title: String, year: Int, tags: [String])

let books: [Book] = [
    (1, "Swift Basics", 2021, ["swift", "intro"]),
    (2, "Algorithms in Swift", 2020, ["swift", "algorithms"]),
    (3, "Gardening 101", 2018, ["hobby"]),
    (4, "Advanced Swift", 2022, ["swift", "advanced"])
]

Теперь представим задачу из реальной жизни: пользователь вводит строку поиска, а мы хотим показать первые 2–3 результата, красиво отформатированные. И тут появляется типичный соблазн: «давайте красиво цепочкой».

Вариант A: красиво, но жадно

import Foundation

let query = "swift"

let matches = books
    .filter { $0.title.lowercased().contains(query) }
    .map { book in "\(book.id). \(book.title) (\(book.year))" }

print(matches)

Это выглядит отлично. Но давайте честно: filter создаёт новый массив подходящих книг, а map создаёт второй новый массив строк. Если books огромный, мы наделаем кучу временных объектов — даже если пользователю нужно показать только первые 2 результата.

Вариант B: добавили ограничение, но всё ещё жадно

Новички часто улучшают так:

import Foundation

let query = "swift"

let filtered = books.filter { $0.title.lowercased().contains(query) }
let top3 = Array(filtered.prefix(3))
let lines = top3.map { "\($0.id). \($0.title)" }

print(lines)

Стало чуть лучше по объёму результата, но промежуточные структуры всё равно есть: filtered и top3prefix(3) у массива даёт ArraySlice, поэтому мы ещё и материализуем его через Array(), чтобы дальше было удобнее).

Вариант C: ленивый конвейер «делаем только то, что показываем»

Теперь делаем то же самое, но лениво: фильтруем, берём первые 3, форматируем — и всё это вычисляется по мере печати.

import Foundation

let query = "swift"

let lines = books.lazy
    .filter { $0.title.lowercased().contains(query) }
    .prefix(3)
    .map { "\($0.id). \($0.title) (\($0.year))" }

for line in lines {
    print(line)
}

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

Если результатов всего 3 — мы сделаем ровно столько работы. Если бы результатов было 3000 — мы всё равно остановились бы на первых трёх, потому что prefix(3) ограничивает потребление.

Схема: как течёт обработка в lazy‑конвейере

flowchart LR
    A[books: Array<Book>] --> B[books.lazy]
    B --> C[filter: title contains query]
    C --> D["prefix(3)"]
    D --> E[map: format to String]
    E --> F[consumer: for-in prints]

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

Мини‑рефакторинг: «первые N строк» как параметр

Давайте чуть причешем пример и добавим ограничение количества результатов как переменную. Это полезно даже для «игрушечного» CLI: выводить всё подряд — это быстро превращает консоль в «стену текста», которая пугает пользователей сильнее, чем слово IteratorProtocol.

import Foundation

typealias Book = (id: Int, title: String, year: Int, tags: [String])

let limit = 2
let query = "swift"

let lines = books.lazy
    .filter { $0.title.lowercased().contains(query) }
    .prefix(limit)
    .map { "\($0.id). \($0.title)" }

for line in lines {
    print(line)
}

Обратите внимание: prefix(limit) здесь — это не «возьми два элемента из готового массива результатов», а именно «не давай потребителю больше двух элементов». То есть остальная часть цепочки даже не будет пытаться «форматировать строки дальше», потому что потребитель больше не просит.

Это и есть тот самый прагматичный смысл .lazy: не делать работу, которую никто не попросил.

4. Цена .lazy и материализация результата

Очень хочется сделать универсальный вывод «всё делаем через .lazy и будет счастье». Но в разработке универсальные выводы обычно заканчиваются универсальными багами. Поэтому поговорим о цене.

Во-первых, .lazy иногда усложняет чтение кода. Цепочка становится более «умной»: вычисления происходят позже, а значит отладка «в голове» требует чуть больше внимания. Во-вторых, ленивые обёртки — это дополнительные уровни абстракции. На маленьких коллекциях вы можете даже не увидеть выигрыша, а в каких-то случаях (из-за накладных расходов) получить микроскопический проигрыш.

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

Кстати, забавный факт из мира Swift: в обсуждениях стандартной библиотеки отдельно отмечали, что длинные цепочки lazy.map/filter могут порождать «болезненные» имена типов и не всегда идеальную генерацию кода. Это не значит «не используйте .lazy», это значит «используйте осознанно и локально».

Практическое правило для новичка

Если вы собираетесь всё равно получить итоговый массив целиком (например, вам действительно нужно 100% результата), то .lazy может не дать преимуществ по памяти, потому что Array() всё материализует. Он может дать преимущества по промежуточным массивам (не создадутся временные), но итоговый массив всё равно появится.

Если же вы планируете «потреблять по чуть-чуть» (печать, поиск первого, проверка наличия, ограничение prefix, остановка через break) — вот здесь .lazy обычно чувствуется лучше всего.

Материализация: «я всё-таки хочу массив»

Иногда итог вам нужен как [String], а не как «какая-то ленивая штука». Это нормально. .lazy не запрещает материализацию — он просто делает её осознанной точкой.

import Foundation

let xs = [1, 2, 3, 4]

let seq = xs.lazy
    .filter { $0.isMultiple(of: 2) }
    .map { $0 * 10 }

let arr = Array(seq)
print(arr) // [20, 40]

Тут логика проста: до строки Array(seq) вычисления шли лениво, а в момент материализации Swift честно прошёлся по элементам и собрал массив.

5. Типичные ошибки при работе с lazy

Ошибка №1: ожидать, что .lazy.map {} сразу «посчитал результат».
Новички часто пишут let x = array.lazy.map {}, а потом удивляются, что «ничего не произошло». На самом деле всё произошло правильно: вы описали конвейер, но не включили его. Пока нет потребителя (for-in, Array(), first(where:), reduce), вычислений не будет.

Ошибка №2: удивляться, что print внутри lazy не печатает «сразу».
Это прям классика. В eager‑версии print внутри map срабатывает при создании массива, а в lazy‑версии — при потреблении элементов. Если вы используете print как «отладку момента выполнения», помните: с .lazy момент выполнения сдвигается.

Ошибка №3: случайно «убить» ленивость ранней материализацией.
Иногда цепочка выглядит так: let arr = Array(array.lazy.filter {}.map {}), а потом вы делаете arr.first. Если вам нужен только первый элемент, вы материализовали весь массив зря. В таких случаях лучше сначала попробовать first(where:) или перебор с break, чтобы остановиться раньше.

Ошибка №4: использовать lazy «на всякий случай» везде.
.lazy — это инструмент, а не религия. На маленьких коллекциях и в простых местах чаще выигрывает читаемость: обычный map/filter понятнее и предсказуемее для начинающего. Ленивость стоит включать там, где вы реально либо избегаете больших промежуточных массивов, либо можете рано остановиться.

Ошибка №5: путать lazy последовательностей с lazy свойствами.
В Swift есть lazy var у свойств (ленивая инициализация поля), и есть .lazy у коллекций/последовательностей (ленивый конвейер элементов). Это два разных механизма с общей идеей «не делать работу заранее». Если вы чувствуете, что в голове они смешались, это нормально — просто держите в уме: сегодня мы про .lazy у коллекций, а lazy var — совсем другая история.

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