1. Навіщо потрібен chunked I/O
Коли ви тільки починаєте працювати з файлами, Data(contentsOf:) видається майже ідеальним рішенням: один виклик — і весь вміст файла вже у вас. На маленьких файлах так і справді варто робити: код короткий, читабельний, а ризик помилок менший. Але щойно файли стають великими, ця «ідеальність» розбивається об просте запитання: чому програма раптом зʼїла всю памʼять і пішла шкереберть?
Уявіть відеофайл, архів, великий лог або дамп бази. Якщо файл має 2 ГБ, то спроба «прочитати все в Data» означає виділити в памʼяті приблизно 2 ГБ, плюс службові витрати. На звичайній машині це легко може закінчитися помилкою, свопом, зависанням або тим моментом, коли вентилятор починає звучати як турбіна літака.
Для контрасту — маленький приклад «усе в памʼять» (він коректний, просто не завжди доречний):
import Foundation
let url = URL(fileURLWithPath: "/tmp/big.bin")
let data = try Data(contentsOf: url)
print(data.count) // наприклад: 2048
Проблема тут не в синтаксисі. Проблема в тому, що ви не контролюєте споживання памʼяті. А хочеться тримати його під контролем.
Ідея chunked I/O: читаємо порціями і працюємо як конвеєр
Chunked I/O — це підхід, за якого файл обробляють порціями: прочитали невеликий шматок (chunk), щось із ним зробили або відразу записали, потім прочитали наступний — і так до кінця. Це не «магічна оптимізація», яка завжди швидша. Це радше спосіб зробити програму передбачуваною щодо памʼяті: ви тримаєте в оперативній памʼяті лише один chunk, а не весь файл.
Корисно уявляти chunked I/O як простий цикл із чітким завершенням: доки вдається прочитати непорожню порцію — працюємо. Щойно порція порожня — файл закінчився. У новачків у цей момент зазвичай зʼявляється полегшення: «А, тобто кінець файла — це просто порожні дані».
Нижче — схема на рівні ідеї (без претензій на «низькорівневість»):
flowchart TD
A[Відкрили файл для читання] --> B[Відкрили або підготували файл для запису]
B --> C{Прочитати chunk}
C -->|chunk не порожній| D[Обробити chunk]
D --> E[Записати chunk]
E --> C
C -->|chunk порожній| F[Закрити файли, завершити]
Головне в цій лекції: ми вивчаємо каркас, щоб ви впевнено бачили структуру рішення. Це не курс «я пишу свій cp на рівні ядра ОС».
2. Інструменти Foundation: FileHandle і defer
FileHandle: мінімальний інструмент для читання й запису порціями
Щоб читати й писати порціями, нам потрібен API, який уміє сказати: «дай мені N байтів». У Foundation для цього є FileHandle. Його можна відкрити на читання або на запис, а потім викликати методи читання чи запису.
Тут важлива дисципліна: FileHandle — це ресурс, тобто відкритий файл, і його потрібно закривати. Інакше можна натрапити на витоки ресурсів і дивні баги. У Swift для такої дисципліни є чудовий інструмент — defer: ставимо defer одразу після успішного відкриття і майже гарантуємо собі, що закриття відбудеться під час виходу з області видимості. Такий підхід часто подають як базову практику очищення ресурсів.
У Swift Evolution обговорюють ідею ресурсного FileHandle як значення, яким потрібно правильно керувати протягом усього життєвого циклу. Це радше цікавий факт зі світу «дорослих» інструментів, але він добре підкріплює інтуїцію: відкрив — закрий.
Мініприклад: відкрили файл і прочитали перші 8 байтів.
import Foundation
let url = URL(fileURLWithPath: "/tmp/input.bin")
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let chunk = try handle.read(upToCount: 8) ?? Data()
print(chunk.count) // наприклад: 8
Зверніть увагу на дві речі. По-перше, read(upToCount:) повертає Data?, тому ми підставляємо порожній Data() через ??. По-друге, close() ми викликаємо через try? — це той рідкісний випадок, коли «проковтнути» помилку зазвичай допустимо, тому що закриття — це прибирання, а не основна логіка програми.
Чому defer варто ставити одразу
Новачку часто хочеться написати так: «спершу прочитаю, а потім десь наприкінці закрию». Але в такого коду є неприємна особливість: якщо десь сталася помилка, спрацював return, throw або ви додали ще одну гілку if, про закриття легко забути. А незакриті хендли — це як залишити кран відкритим: спочатку наче нічого, а потім раптом вода на підлозі.
defer вирішує цю проблему дисципліною: ви відкрили ресурс — і одразу поруч написали, як його закривати. Навіть якщо далі буде throw, закриття все одно виконається під час виходу з області видимості. Саме тому defer часто подають як базову практику «прибирання» ресурсів у CLI та серверному коді.
Якщо вам хочеться запамʼятати це однією фразою: defer — це «нагадування компілятору: коли підемо звідси, вимкни світло».
3. Каркас: копіювання порціями
Читаємо chunk → пишемо chunk
Найчесніший навчальний приклад chunked I/O — копіювання файла. Він зрозумілий: ми не змінюємо дані, а просто передаємо байти від джерела до призначення. Так, у реальному житті іноді простіше викликати FileManager.copyItem, але наша мета зараз — побачити структуру порційного читання й запису та закріпити навичку не тримати все в памʼяті.
Почнемо з каркаса функції. Вона приймає URL джерела і призначення та розмір порції. Функція throws, тому що читання й запис файлів — це нормальне джерело помилок.
import Foundation
func copyChunked(from src: URL, to dst: URL, chunkSize: Int) throws {
try Data().write(to: dst) // створюємо або обнуляємо файл призначення
let input = try FileHandle(forReadingFrom: src)
defer { try? input.close() }
let output = try FileHandle(forWritingTo: dst)
defer { try? output.close() }
while true {
let chunk = try input.read(upToCount: chunkSize) ?? Data()
if chunk.isEmpty { break }
try output.write(contentsOf: chunk)
}
}
Тут є кілька нюансів.
FileHandle(forWritingTo:) зазвичай очікує, що файл вже існує, тому ми заздалегідь створюємо порожній файл через try Data().write(to:). Ми не розглядаємо в цій лекції стратегії «не перезаписувати наявний файл» або «робити резервну копію» — це окремі великі теми. Зараз фіксуємо просте правило: призначення створюємо або обнуляємо.
Цикл завершується, коли chunk.isEmpty. Для новачка це один із найважливіших моментів: «кінець файла» в такому підході — це не виняток і не магічна константа, а просто порожнє читання.
Виклик цієї функції виглядає так:
import Foundation
let src = URL(fileURLWithPath: "/tmp/input.bin")
let dst = URL(fileURLWithPath: "/tmp/output.bin")
do {
try copyChunked(from: src, to: dst, chunkSize: 64 * 1024)
print("Готово") // Готово
} catch {
print("Копіювання не вдалося:", error)
}
Розмір 64 * 1024 (64 КБ) — частий нормальний старт. Це не закон природи, але добра відправна точка.
Вбудовуємо в CLI: мінімальна обгортка
Оскільки ми робимо курс, де приклади поступово складаються в один застосунок, зручно уявити chunked-копіювання як маленьку команду службових файлових операцій. Навіть якщо ваш основний застосунок про бібліотеку книжок, вам усе одно часто потрібні утиліти: скопіювати файл бази, зробити резервну копію, перегнати дані до іншого каталогу.
Не будемо ускладнювати повноцінним парсером команд (він уже був раніше в курсі). Зробимо максимально простий «скелет»: беремо шляхи з CommandLine.arguments. Це дуже прямолінійно й добре підходить для демонстрації ідеї.
import Foundation
let args = CommandLine.arguments
if args.count == 3 {
let src = URL(fileURLWithPath: args[1])
let dst = URL(fileURLWithPath: args[2])
do {
try copyChunked(from: src, to: dst, chunkSize: 64 * 1024)
print("Готово") // Готово
} catch {
print("Помилка:", error)
}
} else {
print("Використання: app <src> <dst>")
}
Так, це не «ідеальний UX», але для навчальної мети — чудово: ви бачите, як chunked-функція підключається як звичайний будівельний блок.
4. Розмір chunk і обробка «на льоту»
Розмір порції: чому «занадто маленький» і «занадто великий» — погано в обох випадках
Розмір chunk — це компроміс. Маленький chunk, наприклад 64 байти, означає дуже багато операцій читання й запису, а I/O-операції самі по собі не безкоштовні. Великий chunk, наприклад 100 МБ, знову повертає нас до проблеми памʼяті — ми майже читаємо «все в памʼять», тільки шматками по 100 МБ.
Важливо, що зараз ми не женемося за абсолютним рекордом швидкості. Ми вчимося робити код передбачуваним і безпечним. Тому для новачка розумна стратегія — обрати помірний chunk і не чіпати його, доки не зʼявиться реальна причина.
Невелика таблиця для орієнтиру:
| Розмір chunk | Що це дає | Типовий ефект |
|---|---|---|
|
наближено до «сторінкових» розмірів памʼяті/ФС | багато I/O-викликів, але мало памʼяті |
|
хороший баланс: «часто нормально» | зазвичай достатньо швидко і стабільно |
|
менше системних викликів | швидше на деяких дисках, але більше памʼяті |
Ця таблиця не обіцяє чудес. Вона допомагає вам не обирати «7 байтів» лише тому, що число гарне.
«Обробити chunk»: приклад із простою checksum
Поки ми копіювали файл, chunked I/O виглядав як щось складніше, ніж потрібно. Але найцікавіше починається, коли ви хочете щось обчислити або змінити, не читаючи весь файл.
Наприклад, можна обчислити просту «контрольну суму-іграшку»: суму всіх байтів за модулем 256. Це не криптографія і не справжня checksum, але чудовий навчальний приклад агрегації даних у потоці.
Спочатку функція, яка оновлює суму:
import Foundation
func updateChecksum(_ checksum: inout UInt8, with chunk: Data) {
for b in chunk {
checksum &+= b
}
}
Тепер вбудуємо її в цикл читання:
import Foundation
func checksumChunked(of url: URL, chunkSize: Int) throws -> UInt8 {
let input = try FileHandle(forReadingFrom: url)
defer { try? input.close() }
var checksum: UInt8 = 0
while true {
let chunk = try input.read(upToCount: chunkSize) ?? Data()
if chunk.isEmpty { break }
updateChecksum(&checksum, with: chunk)
}
return checksum
}
І виклик:
import Foundation
let url = URL(fileURLWithPath: "/tmp/input.bin")
let sum = try checksumChunked(of: url, chunkSize: 64 * 1024)
print(sum) // наприклад: 137
Сенс цього прикладу в тому, що в памʼяті ніколи не лежить увесь файл. Ви можете проганяти так хоч десятки гігабайтів (у розумних межах системи), і програма поводитиметься передбачувано щодо оперативної памʼяті.
5. Типові помилки
Помилка №1: забути, що кінець файла — це порожній chunk.
Найчастіша логічна помилка — написати while true { read; write } і не зробити умову виходу. Тоді цикл або стає нескінченним, або ви пишете у вихідний файл нескінченні нульові дані, якщо самі їх підставляєте. Правильний якір у каркасі: if chunk.isEmpty { break }.
Помилка №2: відкрити FileHandle для запису в неіснуючий файл.
FileHandle(forWritingTo:) зазвичай не створює файл «з повітря». Якщо шлях призначення не існує, ви отримуєте помилку ще на етапі відкриття. У навчальному каркасі найпростіше перед відкриттям запису зробити try Data().write(to:) і тим самим створити порожній файл.
Помилка №3: «проковтнути» помилки читання й запису через try? в основній логіці.
Іноді новачок думає: «якщо try? простіше, писатиму всюди try?». У I/O це майже завжди погано: ви втрачаєте причину збою, а програма продовжує працювати в напівзламаному стані. try? залишайте лише для прибирання в defer (наприклад, try? handle.close()), а основні операції читання й запису робіть через звичайний try і throws.
Помилка №4: обрати chunkSize без здорового глузду, наприклад 1 байт або 500 МБ.
Занадто маленький chunk створює величезну кількість I/O-операцій і сповільнює роботу. Занадто великий chunk повертає проблему памʼяті. Якщо ви не профілюєте й не оптимізуєте під конкретну систему, беріть щось на кшталт 64 * 1024 і живіть спокійно.
Помилка №5: вважати chunked I/O «обовʼязковим» завжди.
Іноді студенти після цієї теми починають читати навіть файли на 2 КБ порціями, бо «так правильно». Насправді правильно — залежно від ситуації. Маленькі файли зручніше читати цілком: код коротший, менше місць для помилок. Chunked I/O потрібен тоді, коли розмір файла або сценарій обробки робить читання цілком ризикованим чи недоречним.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ