JavaRush /Курси /Swift SELF /Ліниві обчислення: ліниві ланцюжки

Ліниві обчислення: ліниві ланцюжки

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("результат:", doubled) // результат: [2, 4, 6]

Тут важливо помітити поведінку: map спрацював одразу, ще до фінального print. Тобто обчислення відбулося в момент створення doubled.

І це загалом нормально. Але уявіть, що у вас не 3 числа, а 3 мільйони записів, і ви далі хочете взяти лише перший відповідний елемент. Якщо все робиться «одразу», ви виконаєте роботу для всіх 3 мільйонів — навіть якщо відповідь знайдеться на перших п’яти.

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

2. Як працює .lazy і коли відбувається обчислення

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

Слово lazy звучить так, ніби Swift пропонує нам офіційно «не робити роботу». І так, приблизно так воно і є, тільки культурно. .lazy у контексті колекцій і послідовностей — це не «створити новий масив», а «створити обгортку», яка віддаватиме елементи в міру запиту. Тобто ви будуєте конвеєр перетворень, але він реально запускається лише тоді, коли хтось починає споживати його елементи.

Уявіть конвеєр на заводі. Звичайний map/filter — це як «випустити одразу всю партію й складати її між етапами». .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 as Any) // знайдено: Optional(25)
print("викликів map:", calls)    // викликів map: 5

Ми шукали перший квадрат, більший за 20. Це 25, тобто число 5. Отже, map реально був викликаний лише 5 разів і зупинився. У жадібному варіанті map відпрацював би для всіх 10 елементів, тому що спочатку він створює цілий масив квадратів, а вже потім шукає. З .lazy пошук вбудувався в процес.

Нюанс: .lazy відкладає не лише обчислення, а й побічні ефекти

У програмуванні є старе правило: якщо всередині функції є print, значить, це вже не чиста функція, а маленький театр. Так ось, .lazy уміє відкладати й цей театр також. Іноді це зручно, бо менше шуму, іноді лякає: чому нічого не друкується?

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

Жадібна версія: print відбувається одразу.

import Foundation

let xs = [1, 2, 3]

let ys = xs.map { x -> Int in
    print("мапування", x)
    return x * 10
}

print("готово") // готово

Спочатку надрукуються "мапування 1", "мапування 2", "мапування 3", і лише потім "готово".

Лінива версія: print відбувається під час споживання.

import Foundation

let xs = [1, 2, 3]

let ys = xs.lazy.map { x -> Int in
    print("мапування", x)
    return x * 10
}

print("перед циклом") // перед циклом
for y in ys {
    print("y =", y)
}

Виведення "мапування" почнеться лише в циклі.

Ця поведінка природна: поки елементи не запитано, замикання не виконуються.

3. lazy-ланцюжки на прикладі мінібібліотеки

Щоб це не лишалося відірваним від практики, продовжимо наш міні-проєкт: консольну «бібліотеку», де є список книг і найпростіший пошук. Поки без struct (до них дійдемо пізніше), тому використаємо кортежі: це вже знайомо й достатньо виразно.

Нехай книга — це кортеж (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: назва містить query]
    C --> D["prefix(3)"]
    D --> E[map: форматування в String]
    E --> F[споживач: for-in друкує]

Таку схему корисно тримати в голові: .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 не друкує «одразу».
Це просто класична ситуація. У жадібній версії print усередині map спрацьовує під час створення масиву, а в лінивій версії — під час споживання елементів. Якщо ви використовуєте 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 — зовсім інша історія.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ