1. Основы CSV и TSV
Если вы когда‑нибудь открывали Excel/Google Sheets, вы уже интуитивно понимаете, что такое «табличные данные»: строки, колонки, значения. CSV/TSV — это попытка сохранить такую таблицу в обычный текст, чтобы его можно было передавать по почте, хранить в репозитории и читать даже без «тяжёлых» форматов. По сути это «таблица для бедных», но иногда именно это и нужно.
CSV расшифровывается как comma-separated values — значения, разделённые запятыми. TSV — tab-separated values — значения, разделённые табуляцией (\t). И у обоих форматов есть простая философия: одна строка файла обычно соответствует одной строке таблицы, а внутри строки поля разделяются одним выбранным символом.
Например, TSV с данными о книгах может выглядеть так:
id title author year
1 Clean Code Robert C. Martin 2008
2 The Pragmatic Programmer Andrew Hunt 1999
С CSV всё то же самое, только разделитель — запятая:
id,title,author,year
1,Clean Code,Robert C. Martin,2008
2,The Pragmatic Programmer,Andrew Hunt,1999
2. Когда CSV/TSV подходят, а когда нет
CSV/TSV очень соблазнительны: это просто текст, его легко посмотреть глазами, легко закоммитить в git, легко «быстренько распарсить» через split. И именно поэтому новички часто пытаются запихнуть в CSV вообще всё — от сложных объектов до «любого текста пользователя», включая переносы строк и запятые. Дальше начинаются веселые истории и грустные баги, а в конце вы внезапно обнаруживаете, что написали половину парсера настоящего CSV… и он всё равно неправильный.
Чтобы сохранять здравый смысл, удобно держать в голове простое правило: CSV/TSV хороши, когда данные действительно табличные и простые. Они уместны для импорта/экспорта, тестовых датасетов, логов в виде строк (если вы контролируете формат), небольшой базы справочников. Они становятся плохим выбором, когда у вас появляются вложенные структуры, произвольный текст (где пользователь может ввести что угодно), необходимость хранить переносы строк внутри одного поля, или строгие требования к совместимости с «настоящими» CSV‑парсерами из мира бухгалтерии.
Ниже небольшая табличка‑ориентир, чтобы мозг не перегревался:
| Сценарий | CSV/TSV подходят? | Почему |
|---|---|---|
| Набор тестовых данных для разработки | Да | Легко редактировать и читать глазами |
| Экспорт списка книг «для человека» | Да | Одна строка = одна книга, удобно |
| Импорт/экспорт между Sheets и CLI | Да (часто) | Sheets умеет CSV/TSV из коробки |
| Поля содержат произвольный текст пользователя (включая запятые/переносы) | Скорее нет | Начинаются кавычки/экранирование/многострочные поля |
| Данные сложные: массивы, вложенность, разные типы в одном поле | Нет | Табличная модель ломается |
| Нужна строгая спецификация и совместимость с индустрией | Осторожно | «Простой CSV» и «настоящий CSV» — разные звери |
3. Упрощённый CSV/TSV для курса
Правила простого CSV/TSV
Чтобы CSV/TSV были предсказуемыми, нам нужен контракт — набор правил. Здесь важно сделать шаг назад и честно сказать: «Мы поддерживаем ровно столько, сколько можем объяснить и проверить, а всё остальное — за рамками».
В этой лекции мы фиксируем максимально практичную и ограниченную модель, которая хорошо подходит для учебного проекта и многих реальных CLI‑утилит. Она звучит скучно, зато работает. И да, это тот случай, когда скука — комплимент.
Правила простого CSV/TSV в курсе:
| Правило | Что это значит |
|---|---|
| Одна строка файла = одна запись | Например, одна книга — одна строка |
| Поля разделяются одним символом | CSV: ,; TSV: \t |
| Нет кавычек и экранирования | Мы не поддерживаем "field,with,comma" как одно поле |
| Поля не содержат переносов строк | Если поле содержит \n, формат «ломается» |
| Допускаем заголовок (header) | Первая строка может быть id,title,author,year |
| Пробелы вокруг полей можно триммить | " Bob " превращаем в "Bob" |
Почему мы так упрощаем? Потому что в Swift разрезание строки на части — это действительно легко (split), но «корректный CSV» быстро требует много дополнительных правил. В стандартной библиотеке есть удобный split(separator:), а trimmingCharacters(in:) помогает нормализовать пробелы вокруг данных. Но ни один split не спасёт вас, если внутри поля могут быть запятые, кавычки и переносы строк — там уже нужен полноценный разбор.
Почему TSV часто спокойнее CSV
На бумаге CSV выглядит «естественно»: запятая, значения, всё красиво. Но в реальных данных запятая встречается постоянно: фамилия‑имя‑отчество, названия организаций, подзаголовки, «Стамбул, Турция». И если вы не используете кавычки и экранирование, CSV начинает разваливаться на ровном месте.
TSV в этом плане немного более «дзен»: табуляция (\t) в обычном человеческом тексте встречается редко. Поэтому если мы договорились не поддерживать кавычки, TSV часто оказывается безопаснее: меньше шансов, что разделитель внезапно встретится внутри поля.
Есть и обратная сторона: табуляция визуально «невидима». Откроете файл в простом редакторе — и не всегда сразу поймёте, где кончается одно поле и начинается другое. Но это лечится: многие редакторы показывают табы, а ещё TSV приятно открывается в таблицах.
Практический вывод для курса такой: если вы контролируете формат — используйте TSV для данных, где потенциально могут быть запятые в тексте. CSV оставляем как формат «для внешнего мира», потому что его любят таблицы и интеграции.
4. Практика: парсинг и экспорт
Парсинг строки: split, trim и проверка полей
Сейчас мы сделаем самую полезную «микро‑функцию» этого дня: «возьми строку → разрежь на поля → нормализуй пробелы → проверь, что количество полей ожидаемое». Это маленький шаг, но он превращает хаотичный текст в структуру, с которой можно работать без страха выйти за индекс и без желания всё бросить и уйти в садоводы.
В Swift для разбиения строки удобно использовать split(separator:): он возвращает части, а мы дальше аккуратно переведём их в String и применим trimmingCharacters(in:), чтобы убрать лишние пробелы. Самое важное — не забыть про валидацию: если полей не столько, сколько мы ждём, строка «битая», и её нужно либо пропустить, либо превратить в ошибку.
import Foundation
func parseSimpleRow(_ line: String, separator: Character, expectedCount: Int) -> [String]? {
let parts = line.split(separator: separator, omittingEmptySubsequences: false)
let fields = parts.map { String($0).trimmingCharacters(in: .whitespaces) }
guard fields.count == expectedCount else { return nil }
return fields
}
Здесь есть один тонкий момент: omittingEmptySubsequences: false означает, что строка a,,b даст три поля ("a", "", "b"), а не два. Для табличных данных это чаще полезно: пустое поле — это тоже поле, просто пустое.
Ещё один маленький пример с проверкой и диагностикой через print:
import Foundation
let line = "1\tClean Code\tRobert C. Martin\t2008"
if let fields = parseSimpleRow(line, separator: "\t", expectedCount: 4) {
print(fields[1]) // Clean Code
} else {
print("Bad row:", line)
}
TSV с заголовком: “колонка → значение”
Когда у вас появляется заголовок, жизнь становится одновременно легче и сложнее. Легче — потому что вы больше не привязаны к магическим индексам вроде fields[2], и код начинает читаться по‑человечески. Сложнее — потому что нужно сопоставить имена колонок со значениями и обработать случаи, когда заголовок битый или неожиданный.
В учебных CLI‑проектах заголовок почти всегда окупается: вы можете добавить новую колонку, не ломая старый код, или хотя бы получить понятную ошибку. Мы сделаем простую функцию: первая строка — header, остальные строки — данные. На выходе массив словарей [String: String].
import Foundation
func parseTSVWithHeader(_ text: String) -> [[String: String]] {
let lines = text.split(whereSeparator: { $0.isNewline })
guard let headerLine = lines.first else { return [] }
let header = headerLine.split(separator: "\t").map { String($0) }
var rows: [[String: String]] = []
for line in lines.dropFirst() {
let values = line.split(separator: "\t").map { String($0) }
guard values.count == header.count else { continue }
var row: [String: String] = [:]
for i in 0..<header.count { row[header[i]] = values[i] }
rows.append(row)
}
return rows
}
Это минималистичная версия: «битые строки пропускаем». В реальном приложении вы бы, возможно, логировали такие строки и показывали пользователю статистику: сколько строк прочитано, сколько пропущено. Но сама схема «header → map» уже даёт вам приятный API.
Импорт в CLI: книги из TSV
Теперь давайте аккуратно привяжем CSV/TSV к нашему CLI‑приложению (в духе LibraryCLI). Мы пока не строим «идеальную архитектуру хранения» — цель дня проще: показать, как текст превращается в данные, и где ставить проверки формата. Представим, что нам прилетел books.tsv, и мы хотим прочитать его и получить массив «черновиков книг», которые затем можно добавить в систему.
Сделаем очень простую модель входных данных — BookDraft. Да, это не «идеальная доменная модель», но для импорта это нормально: импорт почти всегда сначала создаёт черновики, а потом валидирует и превращает их в настоящие сущности.
import Foundation
struct BookDraft {
let title: String
let author: String
let year: Int
}
func bookDraft(from row: [String: String]) -> BookDraft? {
guard let title = row["title"], !title.isEmpty else { return nil }
guard let author = row["author"], !author.isEmpty else { return nil }
guard let yearStr = row["year"], let year = Int(yearStr) else { return nil }
return BookDraft(title: title, author: author, year: year)
}
Обратите внимание: здесь нет !. Мы не верим файлу. Файл — это внешний мир, а внешний мир иногда приносит сюрпризы (чаще всего в пятницу вечером).
Теперь склеим это с чтением файла в строку (то, что вы делали в предыдущих лекциях дня), и применим наш парсер:
import Foundation
func importBooksTSV(from url: URL) throws -> [BookDraft] {
let text = try String(contentsOf: url, encoding: .utf8)
let rows = parseTSVWithHeader(text)
var result: [BookDraft] = []
for row in rows {
if let draft = bookDraft(from: row) {
result.append(draft)
}
}
return result
}
Если хочется минимальной диагностики, можно посчитать, сколько строк «упало»:
import Foundation
func importBooksTSVWithStats(from url: URL) throws -> [BookDraft] {
let text = try String(contentsOf: url, encoding: .utf8)
let rows = parseTSVWithHeader(text)
var ok = 0
var drafts: [BookDraft] = []
for row in rows {
if let d = bookDraft(from: row) { drafts.append(d); ok += 1 }
}
print("Imported:", ok, "Skipped:", rows.count - ok) // Imported: X Skipped: Y
return drafts
}
Экспорт в TSV/CSV: “сериализация” без магии
Импорт — это половина жизни. Вторая половина — экспорт, причём экспорт часто нужен даже не пользователю, а вам самим: чтобы быстро посмотреть состояние данных, отладить фильтрацию, передать табличку коллегам, или просто сделать «снимок» в текстовом виде.
Экспорт в простом TSV выглядит почти как join строк: берём заголовок, потом для каждой книги делаем строку с \t между полями и добавляем \n. Тут есть важная инженерная честность: раз мы не поддерживаем кавычки и экранирование, мы должны решить, что делать, если в поле внезапно оказался таб или перенос строки. Самый адекватный вариант для простого формата — либо запрещать такие символы (и выдавать ошибку), либо заменять их на пробел.
Для учебного проекта сделаем мягкую нормализацию: заменим \t и \n на пробел, чтобы файл не развалился.
import Foundation
func safeField(_ value: String) -> String {
value
.replacingOccurrences(of: "\t", with: " ")
.replacingOccurrences(of: "\n", with: " ")
}
func exportBooksTSV(_ books: [BookDraft]) -> String {
var lines: [String] = ["title\tauthor\tyear"]
for b in books {
lines.append("\(safeField(b.title))\t\(safeField(b.author))\t\(b.year)")
}
return lines.joined(separator: "\n") + "\n"
}
И теперь запись в файл (повторяем идею прошлых лекций дня, но уже с осмысленным текстом):
import Foundation
func saveTSV(_ text: String, to url: URL) {
do {
try text.write(to: url, atomically: true, encoding: .utf8)
print("Saved TSV") // Saved TSV
} catch {
print("Save failed:", error)
}
}
5. Почему “настоящий CSV” сложнее
Очень хочется верить, что CSV — это «запятая между значениями» и всё. Но реальный CSV живёт в мире, где внутри поля может быть запятая, кавычка и даже перенос строки. Чтобы это работало, появляются правила: кавычки вокруг полей, удвоение кавычек внутри текста, разные диалекты, иногда даже разные ожидания у разных программ. И вот вы уже не пишете «парсер на 10 строк», а строите мини‑компилятор для табличек. Весело, но это уже другой уровень ответственности.
В рамках текущего дня наша цель не в том, чтобы победить все спецификации мира. Наша цель — научиться осознанно ограничивать формат и писать код так, чтобы он либо корректно разбирал данные, либо корректно отказывался их разбирать. В инженерии это вообще один из самых взрослых навыков: не «делать вид, что всё работает», а явно фиксировать границы.
Если вам нужна совместимость со сложными CSV‑файлами из внешних систем, правильное решение обычно не «дописать ещё пару split», а использовать специализированный парсер/библиотеку или заранее договариваться о TSV/упрощённом формате экспорта.
6. Типичные ошибки при работе с CSV/TSV
Ошибка №1: считать, что split — это полноценный CSV‑парсер.
split(separator:) отлично разрезает строку по символу, но он не знает ничего про кавычки, экранирование и многострочные поля. Если ваши данные могут содержать разделитель внутри поля, «простой CSV» перестаёт быть простым. Правильный выход — либо ужесточить формат (TSV без табов внутри), либо использовать реальный CSV‑парсер.
Ошибка №2: не проверять количество полей и падать на индексе.
Классика: вы делаете fields[3], а строка оказалась короче, потому что кто‑то удалил колонку или пропустил разделитель. Такие ошибки особенно неприятны в CLI, потому что пользователь получает аварийное завершение вместо понятного сообщения. Даже простая проверка fields.count == expected спасает много нервных клеток.
Ошибка №3: игнорировать пробелы вокруг значений.
В табличных форматах пробелы вокруг разделителей — обычное явление: "Bob, 20, London". Если вы не нормализуете поля, дальше ломаются сравнения строк и поиск: "Bob" и " Bob" для программы разные. trimmingCharacters(in:) — маленькая, но очень практичная привычка.
Ошибка №4: смешивать чтение файла, разбор строк и бизнес‑валидацию в одном “монолитном” месте.
Когда всё в одной функции, вы быстро перестаёте понимать, что именно сломалось: файл не прочитался, строка битая или год не распарсился? Гораздо спокойнее сначала получить текст, потом превратить его в строки/поля, а уже потом превращать поля в типы и применять правила.
Ошибка №5: не договориться о политике для “плохих строк”.
Если строка не распарсилась, что делать: пропускать, считать ошибкой, логировать и продолжать? Любой вариант допустим, но недопустим вариант «как получится». В CLI особенно важно, чтобы поведение было предсказуемым: пользователь должен понимать, потерялись ли данные или импорт остановился.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ