JavaRush /Курси /Go SELF /CSV‑нюанси — лапки, розділювачі, заголовок, BOM

CSV‑нюанси — лапки, розділювачі, заголовок, BOM

Go SELF
Рівень 47 , Лекція 0
Відкрита

1. CSV як формат: записи, поля та перенос рядка

Коли ви вперше дивитеся на CSV, дуже хочеться сказати: «Ну це ж strings.Split(line, ","), що тут узагалі вивчати?» І саме в цей момент CSV тихо дістає лапки, перенос рядка й каже: «Сюрприз». CSV — це послідовність записів (records), і кожен запис складається з полів (fields). На папері все просто, але на практиці CSV — це домовленість, яка допускає складні випадки.

Головне, що варто зафіксувати одразу: рядок у файлі не зобов’язаний збігатися із записом CSV. Зазвичай записи справді розділяються переносом рядка. Але якщо поле взяте в лапки, то перенос рядка всередині лапок вважається частиною значення. Тому «читати файл по рядках» і «читати CSV‑записи» — це різні дії.

Уявіть наш навчальний проєкт: маленький список справ (todo‑лист). Ми хочемо зберігати задачі в tasks.csv приблизно так:

id title done note
1 Buy milk false remember lactose-free
2 Call mom true (empty)

Поки note не містить ком і переносів рядка, усе виглядає невинно. Але варто користувачу написати нотатку «купити яблука, банани й молоко» — і раптом кома стає частиною даних, а не розділювачем.

Чому strings.Split ламає CSV

Нижче — приклад «невинного» CSV, де кома всередині поля робить Split марним:

package main

import (
	"fmt"
	"strings"
)

func main() {
	line := `2,"Buy apples, bananas",false`
	parts := strings.Split(line, ",")
	fmt.Println(parts) // [2 "Buy apples  bananas" false]
}

Коментар із виводом тут навмисно «кривий»: ви побачите, що Split ріже по кожній комі й не розуміє лапок (а отже, ламає структуру запису). І це не рідкісний виняток формату — це звичайна реальність.

Чому рядок у файлі не завжди дорівнює запису

Щоб закріпити думку, ось схема, яка показує, чому «рядок у файлі» може не збігатися із «записом CSV»:

flowchart TD
    A["Зчитали файл (байти)"] --> B["Переноси рядків (LF)"]
    B --> C["Рядки файлу"]
    A --> D["CSV-парсер"]
    D --> E["Записи (records)"]
    E --> F["Поля (fields)"]
    C -. "не завжди 1:1" .-> E

2. Розділювач і пробіли навколо нього

Майже всі знають CSV як «Comma‑Separated Values», тобто «значення, розділені комами». Але індустрія й бухгалтерія люблять дивувати: дуже часто трапляється розділювач ; (крапка з комою), особливо в локалях, де кома використовується як десятковий розділювач у числах. І тоді «CSV» за змістом залишається CSV, але «C» із назви ніби дивиться вам просто в очі й бреше.

Тут важливий правильний спосіб мислення: розділювач — це частина формату вхідного файлу, а не те, що ми вгадуємо «за настроєм». Якщо ви очікуєте ;, а файл прийшов із ,, ви маєте або чесно повідомити про помилку, або явно підтримати обидва варіанти за правилами продукту. Але «просто зробимо Split по комі» — це так само надійно, як лагодити сервер ударом долоні (інколи допомагає, але ви не хочете, щоб це стало стандартом компанії).

Міні‑приклад, який показує проблему на пальцях: два рядки виглядають майже однаково, але сенс змінюється через розділювач.

package main

import "fmt"

func main() {
	fmt.Println("id;title;done") // id;title;done
	fmt.Println("id,title,done") // id,title,done
}

Для людини це «схоже». Для коду — це два різні діалекти. І хороший імпорт зобов’язаний це враховувати.

Ще один нюанс: пробіли. CSV‑файли часто містять пробіли після розділювача, особливо якщо їх редагували вручну:

id, title, done
1, Buy milk, false

Людині зручно. Для строгого парсингу це може бути окремим правилом: вважати пробіл частиною даних чи ігнорувати його. Тут важливо лише зафіксувати, що такий ввід існує, і «ручний Split» не дає вам акуратної та єдиної поведінки.

3. Лапки та екранування

Коли поле зобов’язане бути в лапках

З лапками в CSV є просте правило: лапки з’являються тоді, коли поле не можна безпечно записати «як є». Найтиповіші причини — усередині поля є розділювач, подвійна лапка або перенос рядка. Тоді значення береться в подвійні лапки "...", і парсер уже розуміє: «це один шматок даних, не ріж його по комах».

Тут корисно запам’ятати одну побутову аналогію: лапки в CSV — це як коробка для крихкого товару. Поки у вас «звичайні речі», можна носити їх без коробки. Але щойно всередині з’являється «скло» (кома/перенос рядка/лапка), коробка стає обов’язковою, інакше все розлетиться в дорозі.

Знову на прикладі нашого todo‑файлу. Нехай note містить кому. Тоді коректний CSV‑рядок виглядає так:

2,"Buy apples, bananas",false,"for smoothie"

А якщо в нотатці є перенос рядка (наприклад, користувач пише список), CSV може виглядати ось так:

3,"Plan trip",false,"step1: tickets
step2: hotel"

І ось тут багато новачків спотикаються: вони бачать два рядки у файлі й думають, що це два записи. А це один запис, просто поле містить перенос рядка.

Покажемо цей ефект на маленькому Go‑прикладі. Він не парсить CSV, а лише демонструє, що перенос рядка всередині лапок — це цілком нормальний текст.

package main

import "fmt"

func main() {
	data := "id,note\n1,\"line1\nline2\"\n"
	fmt.Print(data)
	// id,note
	// 1,"line1
	// line2"
}

Тут важливо не те, що вивід «гарний», а те, що усередині одного значення реально живе перенос рядка. Якщо ви читатимете файл по рядках і намагатиметеся на кожному рядку робити Split, ви розітнете одне поле на два, а потім дивуватиметеся, чому у вас раптом «з’їхали колонки».

Як кодується лапка всередині значення

Коли в полі трапляється подвійна лапка ", CSV не використовує зворотну скісну риску, як багато мов (\"). У CSV правило інше: подвійна лапка всередині поля кодується як дві подвійні лапки підряд. Тобто:

  • значення: He said "hi"
  • CSV‑подання поля: "He said ""hi"""

Так, виглядає так, ніби лапки розмножуються поділом брунькуванням. Але це стандартна логіка CSV: «лапка відкриває або закриває поле, а подвійна лапка всередині — це просто символ лапки».

Невеликий приклад із рядковим літералом у Go, щоб ви звикли до цього візуально. І так, це той випадок, коли починаєш цінувати raw‑рядки в зворотних лапках.

package main

import "fmt"

func main() {
	field := `"He said ""hi"""`
	fmt.Println(field) // "He said ""hi"""
}

Зверніть увагу: це рядок, який містить лапки як символи. Ми поки не парсимо його — просто вчимося впізнавати такі шматки очима, бо вони траплятимуться в реальних файлух.

4. Порожні поля та стабільність структури

У CSV порожнє поле — не помилка й не пробіл. Це нормальна ситуація, яку потрібно зберігати як факт. Наприклад, рядок:

a,b,

містить три поля, а не два. Третє поле — порожнє. Це важливо, бо порожнє поле може означати «немає нотатки», «невідомо», «не задано користувачем», «значення буде обчислено пізніше» — і так далі. У будь‑якому разі, це не те, що варто втрачати.

Чому це особливо чутливо для нашого todo‑проєкту? Бо якщо ви експортуєте задачі й у задачі немає note, ви все одно хочете зберегти кількість колонок і позицію колонки note, інакше під час імпорту «попливуть» індекси. CSV — штука про порядок і регулярність: якщо у вас чотири колонки в заголовку, то й у рядках очікується чотири поля, хай деякі з них і порожні.

5. Заголовок і BOM

Заголовок як практичний контракт

Заголовок у CSV — це перший запис, де замість даних записані імена колонок: id,title,done,note. З погляду формату CSV це просто ще один запис. Але з погляду інженерії — це домовленість, яка економить вам години життя та літри кави.

Якщо ви спираєтеся на індекси («друга колонка — це title»), будь-яка зміна формату перетворюється на мінне поле. Додали колонку created_at посередині — і все, ваш імпорт починає читати done як note, а note як «ну, взагалі не знаю що». Якщо ж ви використовуєте заголовок, ви робите крок до зрілішого підходу: зіставляєте «ім’я колонки → індекс» і далі працюєте за іменем.

Навіть якщо ви зараз не пишете імпорт, корисно розуміти, як це виглядатиме логічно:

flowchart TD
    A["Прочитали заголовок: [id title done note]"] --> B["Будуємо мапу: name -> index"]
    B --> C["Далі читаємо рядки даних"]
    C --> D["Беремо потрібні поля за іменем (через index)"]

Тобто заголовок — це як зміст у книжці: можна, звісно, читати все підряд і шукати очима, але зміст робить життя простішим і знижує ймовірність, що ви раптом опинитеся в розділі «Про драконів» замість «Про оплату».

BOM: невидимий символ у першій колонці

BOM — це така маленька технічна штука, яка може з’явитися на початку UTF‑8‑файлу. Найчастіше BOM вам не потрібен, але деякі програми, особливо з офісного світу, люблять додавати його «про всяк випадок». І якщо BOM присутній, він приклеюється до початку першого поля. У CSV це майже завжди ламає заголовок: замість "id" ви отримуєте "\ufeffid".

Проблема в тому, що очима ви цього майже не побачите. У логах звичайний fmt.Println теж часто не рятує. Тому тут корисно пам’ятати формат %q, який показує рядок з екрануванням.

Символ BOM у Go зручно подавати як рядок "\ufeff". Це звичайний рядковий літерал, просто з Unicode‑кодом.

Демонстрація:

package main

import (
	"fmt"
	"strings"
)

func main() {
	first := "\ufeffid"        // BOM + "id"
	fmt.Println(first == "id") // false

	fmt.Printf("%q\n", first) // "\ufeffid"

	fmt.Println(strings.TrimPrefix(first, "\ufeff") == "id") // true
}

Тут дві ключові думки. Перша: порівняння рядків ламається, бо це справді інший набір символів. Друга: нормалізація заголовка, хоча б першого поля, — це не «костиль», а цілком практичний крок, якщо ваші CSV надходять із зовнішнього світу.

6. Міні‑шпаргалка CSV

Перед фіналом зберімо міні‑шпаргалку: таблиця зручніша, коли за тиждень ви відкриєте чужий CSV і тихо зітхнете.

Що Як виглядає Чому важливо
Запис (record) рядок даних (але не завжди «рядок файлу») одна логічна сутність: одна задача, один користувач, одна операція
Поле (field) значення всередині запису окремий атрибут: id, title, done
Розділювач частіше ,, іноді ; визначає, як відокремлюються поля
Лапки "..." захищають коми, перенос рядка й лапки всередині значення
Лапка всередині значення "" це одна " у підсумковому значенні
Порожнє поле a,b, поле існує, просто порожнє
Заголовок id,title,done контракт формату: зіставлення колонок за іменем
BOM \ufeff на початку файлу може «зламати» перше ім’я колонки

7. Типові помилки

Помилка № 1: парсити CSV через strings.Split і вважати задачу розв’язаною.
Зазвичай це працює рівно до першого «незручного» значення: коми в тексті, лапок у нотатці або переносу рядка. Після цього імпорт або розвалюється, або, що гірше, починає мовчки спотворювати дані. CSV спеціально задуманий як формат із правилами, тому йому потрібен парсер, який ці правила знає.

Помилка № 2: думати, що перенос рядка завжди означає новий запис.
Щойно поле взяте в лапки, перенос рядка всередині лапок — частина значення. Тому «прочитав файл по рядках» не дорівнює «прочитав записи CSV». Це особливо часто трапляється, коли CSV експортують із систем, де в коментарях або описах зберігають багаторядковий текст.

Помилка № 3: ігнорувати порожні поля та «схлопувати» хвіст.
Рядок a,b, містить три поля. Якщо ви перетворюєте його на «два поля, бо третє порожнє», ви втрачаєте інформацію та ламаєте відповідність колонок заголовку. Порожнеча — це теж значення, просто порожнє.

Помилка № 4: спиратися на індекси колонок та ігнорувати заголовок.
Поки формат не змінювався, здається, що «колонка 0 — id» — це надійно. Потім приходить новий експорт із додатковою колонкою, і все розвалюється. Заголовок дає стабільний контракт «ім’я → індекс» і робить імпорт набагато стійкішим.

Помилка № 5: не враховувати BOM і потім годину шукати, чому id «не знаходиться».
BOM візуально майже невидимий, але в рядках він реальний символ. У підсумку "\ufeffid" != "id", і ваше зіставлення колонок ламається на першому ж кроці. Нормалізація першого заголовка через strings.TrimPrefix(header[0], "\ufeff") часто рятує від дуже дивних помилок.

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