JavaRush /Курсы /Swift SELF /Простые форматы: CSV и TSV

Простые форматы: CSV и TSV

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

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 особенно важно, чтобы поведение было предсказуемым: пользователь должен понимать, потерялись ли данные или импорт остановился.

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