1. Коли читати весь файл у String уже не смішно
Якщо ви лише починаєте програмувати, вам природно здається: «Ну я ж хочу текст — отже, зчитую файл у String і працюю з ним». На невеликих файлах так і треба робити. Проблеми починаються, коли файл стає великим: десятки мегабайт, сотні мегабайт, гігабайт. Тоді стратегія «прочитав усе» може раптово перетворитися на стратегію «прочитав усе, а потім комп’ютер прочитав вас».
Почнімо з чесного зізнання: зчитування файлу у String — це зручно. Це один рядок коду, його легко налагоджувати, легко друкувати перші символи, легко робити split. Саме тому так багато утиліт у світі починаються з «давайте просто завантажимо все в пам’ять». І саме тому так багато утиліт закінчуються словами «чому воно впало на файлі з робочого середовища?».
Проблема не лише у розмірі самого тексту. Поверх нього ви часто створюєте ще й проміжні структури: масив рядків, масиви полів, словники, тимчасові String і Substring — і все це може збільшити споживання пам’яті у рази.
Чому split може стати дуже дорогим
Коли ми бачимо багаторядковий текст, рука сама тягнеться до split: «розіб’ю за рядками, а потім кожен рядок ще раз split за \t». На невеликих даних це абсолютно нормально. Але важливо пам’ятати: стандартний split — жадібна (eager) операція. Вона одразу створює колекцію частин, а не віддає їх по одній на запит. Для величезного тексту це означає: ви хотіли просто переглянути рядки, а фактично створили цілий склад рядків у пам’яті.
Щоб відчути це на пальцях, достатньо невеликого мисленного експерименту. Уявіть файл на 500 000 рядків. Навіть якщо середній рядок короткий, один лише масив із 500 000 елементів — уже зовсім не «нічого». А якщо кожен рядок потім ще перетворюється на масив полів, ви легко будуєте «матрицю» з мільйонів шматочків тексту.
Мініприклад: розбиваємо текст на рядки. Код простий, але сенс важливий.
import Foundation
let text = """
a
b
c
"""
let lines = text.split(whereSeparator: { $0.isNewline })
print(lines.count) // 3
На трьох рядках — ідеально. На мільйонах рядків — lines уже не просто «результат», а ще й серйозне навантаження на пам’ять, плюс час на створення всіх цих шматочків.
Тут важливо не злякатися самого split. Це чудовий інструмент. Але щойно файл стає великим, ви маєте поставити собі доросле запитання: «Чи справді мені потрібен масив усіх рядків одразу, чи достатньо обробляти їх по одному?»
2. Построкова обробка: enumerateLines без масиву рядків
Коли ви розумієте, що масив рядків — це дорого, хочеться чогось більш «ледачого», де рядки надходять по одному. У Swift для тексту є дуже приємний компроміс: String.enumerateLines. Він не створює масив усіх рядків, а викликає ваш блок для кожного рядка по черзі. Це все ще робота з уже завантаженим у пам’ять текстом, зате ви не плодите додатковий «мішок рядків».
Почнімо з простого: зчитаємо файл у String, а потім пройдемося рядками й порахуємо їх. Так, файл усе ще цілком у пам’яті, але масиву рядків уже немає.
import Foundation
let url = URL(fileURLWithPath: "/tmp/big.txt")
do {
let text = try String(contentsOf: url, encoding: .utf8)
var linesCount = 0
text.enumerateLines { _, _ in
linesCount += 1
}
print("рядків:", linesCount) // наприклад: рядків: 1200
} catch {
print("Не вдалося зчитати файл:", error)
}
Зверніть увагу на стиль: усередині enumerateLines ми намагаємося робити мінімум роботи. Це важлива звичка: построкова обробка — про дисципліну «не тягніть за собою зайве».
Ще один типовий сценарій — підсумовування чисел із файла. Умовно: файл містить по одному числу в рядку, а ми хочемо обчислити суму, ігноруючи все зайве.
import Foundation
let url = URL(fileURLWithPath: "/tmp/numbers.txt")
do {
let text = try String(contentsOf: url, encoding: .utf8)
var sum = 0
text.enumerateLines { line, _ in
if let x = Int(line) {
sum += x
}
}
print("сума:", sum) // сума: 42
} catch {
print("Не вдалося зчитати файл:", error)
}
Такий підхід уже значно «доросліший», ніж split → map → map → …, тому що ви не створюєте зайві колекції. Але тут є фундаментальне обмеження, про яке ми поговоримо трохи нижче: увесь вихідний String усе одно має поміститися в пам’ять цілком.
3. Імпорт TSV на льоту в LibraryCLI
Щоб тема не залишалася абстрактною, уявімо практичну ситуацію в нашому консольному застосунку LibraryCLI. Припустімо, нам дали величезний TSV-файл експорту: кожен рядок — книга, поля розділені табуляцією. Наша мета — пройтися по файлу, підрахувати коректні рядки й акуратно пропустити пошкоджені, не перетворюючи все на гігантський [[String]].
Зробімо простий парсер одного рядка. Ми свідомо тримаємо формат простим: рівно 3 колонки, без лапок і екранування.
import Foundation
func parseBookTSVLine(_ line: String) -> (title: String, author: String, year: Int)? {
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count == 3 else { return nil }
let title = parts[0].trimmingCharacters(in: .whitespaces)
let author = parts[1].trimmingCharacters(in: .whitespaces)
let year = Int(parts[2].trimmingCharacters(in: .whitespaces)) ?? 0
return (title, author, year)
}
Тепер застосуймо це построково. Зверніть увагу: ми не збираємо масив усіх рядків, і навіть масив усіх книжок теж не обов’язковий — можна одразу записувати дані або оновлювати сховище. Тут для простоти підрахуємо статистику.
import Foundation
let url = URL(fileURLWithPath: "/tmp/books.tsv")
do {
let text = try String(contentsOf: url, encoding: .utf8)
var ok = 0
var bad = 0
text.enumerateLines { line, _ in
if parseBookTSVLine(line) != nil {
ok += 1
} else {
bad += 1
}
}
print("коректних:", ok, "пошкоджених:", bad) // коректних: 120 пошкоджених: 3
} catch {
print("Імпорт не вдався:", error)
}
Якщо хочеться додати «людяності», можна поставити ліміт на кількість помилок, щоб не друкувати 50 000 повідомлень у консоль, а зупинитися й сказати: «файл явно не той».
import Foundation
var bad = 0
let badLimit = 10
func registerBadLine() -> Bool {
bad += 1
return bad >= badLimit
}
Ця дрібниця на практиці рятує нерви: логувати кожну помилку у великому файлі — це окремий спосіб влаштувати собі DDoS-атаку від власного застосунку.
4. Обмеження enumerateLines: увесь текст усе ще в пам’яті
На цьому місці зазвичай з’являється відчуття перемоги: «Ура! Я ж обробляю рядки по одному!». І це правда… наполовину. Тому що enumerateLines економить проміжні масиви, але не скасовує базового факту: щоб викликати enumerateLines, ви вже створили String, а отже — уже завантажили весь файл у пам’ять.
Щоб побачити це чітко, корисно розділяти два етапи: «як ми читаємо файл» і «як ми обробляємо текст». enumerateLines — це про обробку тексту. А читання через String(contentsOf:encoding:) — це читання «усе цілком».
Ось таблиця, яка допомагає не заплутатися у відчуттях:
| Підхід | Зчитування з диска | Обробка рядків | Проміжні структури | Де болить на великих файлах |
|---|---|---|---|---|
| String(contentsOf:) + split | цілком | через масив | масив рядків (часто величезний) | пам’ять (масив рядків + додаткові копії) |
| String(contentsOf:) + enumerateLines | цілком | по одному | мінімально | пам’ять (увесь String) |
| читання шматками Data | частинами | по одному або шматками | буфер із залишком | код складніший, але економніший за пам’яттю |
Саме тому ми зараз говоримо про це як про ідею та напрям: наступний крок — справжнє потокове читання, де файл не зобов’язаний цілком поміщатися в RAM.
5. Потокове читання: читаємо файл шматками Data
Справжнє потокове читання — це коли ми беремо файл із диска не цілком, а порціями: наприклад, по 64 КБ. Це схоже на те, як ви їсте піцу: нормальна людина їсть шматками, а не намагається запхати в рот цілу коробку.
Технічна ідея проста: ми читаємо шматок байтів, додаємо його до буфера, шукаємо роздільники рядків (\n), витягуємо готові рядки, а «хвіст» (незавершений рядок) залишаємо в буфері до наступного шматка. Це дає змогу тримати в пам’яті лише невеликий робочий буфер, а не весь файл.
Схема пайплайна
flowchart TD
A[Відкрили файл] --> B[Читаємо порцію N байтів]
B --> C[Додали порцію до буфера]
C --> D{Є символ переведення рядка?}
D -- так --> E[Виділили рядок]
E --> F[Обробили рядок]
F --> D
D -- ні --> G{Настав кінець файла?}
G -- ні --> B
G -- так --> H[Обробили залишок буфера як останній рядок]
Ключовий «мозок» тут — буфер із залишком. Без нього ви або втрачаєте шматки рядків на межі чанків, або починаєте робити неймовірні трюки з відкатом читання назад.
Підрахунок рядків без декодування UTF‑8
Іноді вам не потрібно взагалі перетворювати байти на String. Наприклад, ви хочете швидко оцінити розмір файла, підрахувати кількість рядків, знайти кількість порожніх рядків або грубо порахувати кількість записів. У таких завданнях можна працювати на рівні байтів і просто рахувати \n (байт 0x0A). Це не ламається через Unicode і майже не потребує пам’яті.
Ось невеликий — і доволі практичний — приклад: рахуємо рядки через FileHandle, читаючи шматками. Це вже ближче до справжнього потокового читання.
import Foundation
func countLinesStreaming(url: URL) throws -> Int {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var lines = 0
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
for b in chunk where b == 0x0A { lines += 1 }
}
return lines
}
І використання:
import Foundation
do {
let url = URL(fileURLWithPath: "/tmp/big.tsv")
let n = try countLinesStreaming(url: url)
print("рядків:", n) // рядків: 500000
} catch {
print("Не вдалося підрахувати рядки:", error)
}
Зверніть увагу, як «по-дорослому» виглядає споживання пам’яті: у нас є лише chunk (64 КБ) і кілька Int. На тлі гігабайтного файла це майже безкоштовно.
Звісно, це рахує саме \n. Якщо файл закінчується без \n у кінці, останній рядок не потрапить до лічильника. Це не «помилка Swift», а питання контракту формату, і його треба вирішувати явно: або вимагати завершальний символ переведення рядка, або коригувати логіку, наприклад додавати +1 за ненульового залишку.
Отримання рядків: буфер із залишком та розбір за \n
Коли вам потрібні рядки як текст (наприклад, ви справді імпортуєте книжки з TSV), доведеться не лише рахувати байти, а й декодувати їх у String. Тут з’являється тонкість: UTF‑8 — змінної довжини, і символ може «розрізатися» на межі чанків. Тому повноцінний надійний потоковий декодер — тема, в яку можна занурюватися глибоко.
Але нам зараз важлива передусім ідея та базовий каркас мислення: «читаємо шматок → накопичуємо в буфер → витягуємо повні рядки → залишок лишаємо». Нижче — саме ескіз алгоритму. Він показує структуру, а не обіцяє, що ви вже завтра напишете універсальний tail -f на Swift.
import Foundation
// Псевдокод/схема: показує ідею «буфер + розбиття за \n».
var buffer = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
buffer.append(chunk)
while let nl = buffer.firstIndex(of: 0x0A) { // '\n'
let lineData = buffer[..<nl]
buffer.removeSubrange(...nl) // включно з '\n'
let line = String(data: lineData, encoding: .utf8) ?? ""
processLine(line)
}
}
if !buffer.isEmpty {
let lastLine = String(data: buffer, encoding: .utf8) ?? ""
processLine(lastLine)
}
Тут є два важливі смислові моменти. Перший — ми шукаємо роздільник рядків на рівні байтів; це просто. Другий — ми декодуємо лише готовий рядок, а не весь файл. Навіть якщо рядків мільйони, у пам’яті буде максимум буфер і один рядок.
Якщо ви уважно читали, то помітили потенційну пастку: «а що, якщо UTF‑8 символ зламався на межі й у lineData потрапили пошкоджені байти?» На практиці це вирішується акуратніше, ніж ?? "": частіше ви або зупиняєтеся з помилкою формату, або робите сувору діагностику. Але як стартова ментальна модель цей каркас корисний: він показує, де саме народжується потоковість.
Як обрати підхід
Після всіх цих технік легко впасти в крайність: «від сьогодні я читаю все тільки потоково!». Це приблизно як після першого тренування вирішити, що тепер ви завжди ходите пішки, навіть в інше місто. Іноді це справді корисно, але частіше — не потрібно.
Вибір підходу зазвичай визначають дві речі: розмір файла і те, що ви хочете зробити з даними. Якщо вам потрібен увесь текст цілком (наприклад, ви справді хочете показати користувачу весь файл), потокове читання не дасть магії, тому що підсумок усе одно буде великим об’єктом. Якщо вам потрібні агрегація, фільтрація або імпорт записів, потоковість часто дає величезну економію пам’яті.
Практичне правило для нашого CLI звучить так: поки ви можете чесно сказати «файл очікувано невеликий», зчитування у String залишається найпростішим і найзручнішим у підтримці варіантом. Щойно з’являються файли із зовнішнього світу, які можуть бути величезними, краще переходити або на построкову обробку без масивів (enumerateLines), або на читання чанками, якщо питання пам’яті стає критичним.
Хороший розробник відрізняється від новачка не тим, що завжди обирає «найрозумнішу» техніку, а тим, що обирає техніку, яка відповідає задачі й не перетворює код на музей складних рішень.
6. Типові помилки
Коли починаєте оптимізувати читання файлів, легко наступити на граблі, які не видно на невеликих тестових даних. Іронія в тому, що ці граблі часто виглядають як «акуратний код», просто він акуратний не там, де треба. Давайте розглянемо найчастіші помилки, які я бачу у новачків, а іноді й у себе, якщо чесно.
Помилка №1: «Я ж використовую enumerateLines, отже я читаю потоково».
enumerateLines справді обробляє рядки по одному й економить масив рядків, але вихідний текст усе одно вже лежить у пам’яті цілком, тому що ви спочатку зробили String(contentsOf:). Це чудовий крок до економії пам’яті, але це не читання файла «частинами».
Помилка №2: будувати ланцюжок split → map → split → map на великих даних «для краси».
Такий код виглядає функціонально й сучасно, але легко створює кілька великих проміжних колекцій. На невеликому файлі ви не помітите нічого. На великому — отримаєте стрибок пам’яті або різке сповільнення. Іноді звичайний цикл і мінімум алокацій читаються навіть простіше.
Помилка №3: використовувати try? у критичному імпорті та втрачати причину.
У задачах на кшталт імпорту даних «не вдалося прочитати файл» — це важлива подія. Якщо ви робите let text = (try? String(...)) ?? "", ви перетворюєте серйозну помилку на «порожній файл», а потім довго шукаєте, чому імпорт «просто нічого не робить». Для імпорту зазвичай краще do/catch і зрозуміле повідомлення користувачеві або в лог.
Помилка №4: безкінечно логувати кожен пошкоджений рядок.
Коли файл великий і трохи пошкоджений, логування кожної проблеми перетворюється на ще більшу проблему: ви й диск, і консоль, і мозок користувача засипаєте мільйонами однакових повідомлень. Краще рахувати помилки, показувати перші N прикладів, а далі говорити «і ще 12 345 подібних».
Помилка №5: забути про останній рядок без \n.
Це класична гранична ситуація: багато файлів закінчуються переведенням рядка, але не всі. Якщо ви рахуєте рядки за \n, останній може «випасти». Це не катастрофа, але це той випадок, коли акуратний розробник явно визначає контракт: або «вимагаємо завершальний \n», або «додаємо обробку залишку».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ