1. Файл як ресурс: відкрили — закрийте його
Коли ми тільки починаємо програмувати, файл здається чимось на кшталт «папки з літерами», до якої можна звернутися будь-коли. Але для операційної системи файл — це ресурс, який ви відкриваєте, отримуєте на нього «ручку» (дескриптор), а потім зобовʼязані закрити. Якщо не закривати файли, програма спочатку ніби працює, а потім раптово починає поводитися дивно: не може відкрити нові файли, не записує дані до кінця або ловить неочікувані помилки.
У Go відкритий файл представлено типом *os.File. Це значення не зберігає весь файл цілком — і саме це важливо. Воно лише дає доступ до файла через ОС. Тому головна дисципліна сьогоднішньої лекції звучить так: відкрив файл — закрий файл, і найкраще робити це одразу через defer.
Щоб це краще запамʼятати, зручно тримати мінісхему життєвого циклу:
flowchart TD
A[Підготували шлях до файла] --> B[Відкрили або створили файл -> отримали *os.File]
B --> C["Одразу поставили defer Close()"]
C --> D["Читаємо або пишемо (Read/Write/WriteString)"]
D --> E[Функція завершується -> defer закриває файл]
2. defer f.Close(): ставимо після перевірки err
defer у Go — це не магія, а дуже практичний механізм: він гарантує, що потрібний виклик відбудеться під час виходу з функції, навіть якщо вихід станеться через return посередині. В офіційному прикладі копіювання файлів саме так і показано: defer ставлять одразу після успішного os.Open або os.Create, щоб не забути закрити ресурс у будь-якій гілці виконання.
Ключовий момент для новачка: defer не можна ставити до перевірки помилки. Спочатку ви перевіряєте err, і лише якщо err == nil, у вас є валідний *os.File, який можна закривати.
Правильний шаблон виглядає так:
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("items.txt")
if err != nil {
fmt.Println("відкрити:", err)
return
}
defer f.Close() // ок: f точно не nil і файл справді відкрито
fmt.Println("файл відкрито")
}
Якщо поставити defer f.Close() до if err != nil, то за помилки f буде nil, і ви отримаєте вже іншу проблему — найімовірніше паніку під час спроби закрити nil-вказівник або вторинну помилку, яка сховає справжню причину. Тобто ви хотіли виправити одне, а поламали інше — класична програмістська пастка.
3. Режими відкриття файлів: Open, Create, OpenFile
os.Open: читання наявного файла
os.Open(name) — це найпростіший спосіб відкрити файл для читання. Він не створює файл, не перезаписує його і не дописує до нього — просто відкриває наявний файл. Це чудовий режим, коли ви хочете завантажити дані, наприклад список завдань у навчальному мініприкладі.
Читання на низькому рівні виконується через метод Read(p []byte) (n int, err error). Він намагається прочитати дані у ваш буфер p і повертає, скільки байтів реально прочиталося. Помилка io.EOF у цьому контексті — не катастрофа, а штатний сигнал: «дійшли до кінця файла».
Мініприклад: читаємо перші 8 байтів суто для демонстрації механіки:
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("items.txt")
if err != nil {
fmt.Println("відкрити:", err)
return
}
defer f.Close()
buf := make([]byte, 8)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
fmt.Println("читання:", err)
return
}
fmt.Printf("прочитано %d байтів: %q\n", n, buf[:n]) // виведено прочитані байти
}
Зверніть увагу на перевірку: err != nil && err != io.EOF. Ми явно кажемо: «будь-яка помилка погана, але EOF — це нормально». Це проста звичка, яка дуже економить нерви, коли ви починаєте читати файли шматками.
Важливо не намагатися інтерпретувати buf цілком: використовуйте buf[:n]. Інакше ви виведете зайві нульові байти й думатимете, що файл «повний якихось \x00».
os.Create: запис із обнуленням
Якщо os.Open — це «тільки читати», то os.Create(name) — це «писати заново». Він створює файл, якщо його немає, а якщо файл уже є — обнуляє його вміст (truncate). Така поведінка корисна, коли ви хочете перезаписати файл повністю, але небезпечна, якщо ви випадково очікували «дописати».
Тобто Create — це як кнопка «Нова версія документа», яка не питає: «Ви впевнені?». Тож використовуємо її усвідомлено.
Приклад: перезапишемо файл "items.txt" одним рядком.
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Create("items.txt")
if err != nil {
fmt.Println("створити:", err)
return
}
defer f.Close()
_, err = f.WriteString("купити молоко\n")
if err != nil {
fmt.Println("запис:", err)
return
}
}
Метод WriteString зручний, коли ви пишете текст. Він повертає (n int, err error) — тобто скільки байтів записалося. Для навчальних прикладів ми часто ігноруємо n, але в дорослому коді за часткового запису, хоч це й трапляється рідко, така перевірка може бути важливою.
os.OpenFile: універсальний спосіб і прапорці
Коли ви бачите os.Open, os.Create, а потім ще й os.OpenFile, виникає відчуття: «чому не можна було залишити щось одне?». Насправді OpenFile — це універсальний механізм. А Open і Create — лише зручні попередньо налаштовані варіанти для найчастіших випадків.
Сигнатура така:
os.OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
Тут flag — це бітова маска. Сьогодні нам не потрібно занурюватися в біти: достатньо сприймати її як набір прапорців, які можна поєднувати через |. А perm — це права доступу, що застосовуються під час створення файла.
Мінімальний набір прапорців, який нам сьогодні потрібен:
- os.O_WRONLY — відкрити лише для запису,
- os.O_CREATE — створити файл, якщо його немає,
- os.O_EXCL — «створити лише якщо не існує» (зазвичай разом із O_CREATE),
- os.O_APPEND — писати завжди в кінець файла.
Щоб не тримати все в голові у вигляді каші, ось маленька таблиця-памʼятка:
| Завдання | Як відкрити |
|---|---|
| Прочитати наявний файл | |
| Перезаписати файл заново | |
| Дописувати в кінець (лог, список рядків) | |
| Створити «строго один раз» (не затирати) | |
O_APPEND: дописуємо в кінець
Коли ви зберігаєте дані як список рядків, наприклад завдання по одному рядку, природний сценарій — додавати новий рядок у кінець. Саме це й робить O_APPEND.
Уявімо навчальний мінізастосунок «список справ»: ми хочемо додавати нове завдання у файл, не перезаписуючи все. Мініфункція appendLine:
package main
import (
"fmt"
"os"
)
func appendLine(filename string, line string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("відкрити для дописування %q: %w", filename, err)
}
defer f.Close()
if _, err := f.WriteString(line + "\n"); err != nil {
return fmt.Errorf("дописати рядок до %q: %w", filename, err)
}
return nil
}
func main() {
if err := appendLine("items.txt", "вигуляти собаку"); err != nil {
fmt.Println("помилка:", err)
return
}
fmt.Println("успішно")
}
Тут одразу кілька корисних звичок в одному флаконі: ми використовуємо OpenFile, бо нам потрібен режим append; ставимо defer f.Close() одразу після успішного відкриття; додаємо контекст до помилок через "%w", щоб не загубити початкову причину й можна було розпізнати її вище за стеком.
O_CREATE | O_EXCL: створити лише якщо файла немає
Іноді потрібна поведінка «створи файл, але лише якщо його ще немає». Це корисно для сценаріїв на кшталт первинної ініціалізації, lock-файла або створення шаблону конфігурації без перезапису користувацьких даних.
Для цього є комбінація O_CREATE|O_EXCL. Сенс простий: O_CREATE дозволяє створення, а O_EXCL вимагає, щоб файла не існувало. Якщо файл уже є — отримаєте помилку.
Приклад: створити файл "items.txt" лише один раз і записати заголовок:
package main
import (
"fmt"
"os"
)
func createOnce(filename string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return fmt.Errorf("створити один раз %q: %w", filename, err)
}
defer f.Close()
if _, err := f.WriteString("# список справ\n"); err != nil {
return fmt.Errorf("записати заголовок: %w", err)
}
return nil
}
func main() {
if err := createOnce("items.txt"); err != nil {
fmt.Println("помилка:", err)
return
}
fmt.Println("створено")
}
Зверніть увагу: ми поки не класифікуємо помилку «файл уже існує» як окрему гілку. Це буде окремою темою про класи помилок. У цій лекції важливо зрозуміти механіку та саму ідею «створити строго один раз».
4. Типові I/O помилки та нормальний io.EOF
Робота з файлами — це місце, де помилки не просто можливі, а закономірні. Файла могло не існувати. Процес міг не мати прав. Шлях міг указувати на директорію, а не на файл. Файл міг бути зайнятий іншим процесом у деяких сценаріях ОС. І найнеприємніше: помилка може статися не на Open, а на Write, наприклад коли закінчився диск.
Психологічно корисно прийняти факт: «I/O-помилки — це не соромно, це реальність». Завдання програміста — не уникнути їх назавжди, а акуратно перевіряти err після операцій, додавати зрозумілий контекст, не забувати закривати ресурси й не плутати нормальні сигнали на кшталт io.EOF із реальною поломкою.
До речі, хороша новина: помилки в Go — це значення; їх можна загортати, передавати нагору та аналізувати. У стандартній бібліотеці помилки часто містять структуру й додаткові поля, а не лише текст. Це одна з причин, чому в Go так люблять підхід «errors are values».
Практичний висновок для поточного рівня: ніколи не пишіть код, який ігнорує err у файлових операціях. Навіть якщо ніби працює.
5. Приклад: todo у файлі
Щоб не залишати сьогоднішню лекцію набором розрізнених викликів, зберемо маленьку й чесну історію. Припустімо, у нас є найпростіша команда «додати завдання». Ми ще не робимо повноцінний CLI-парсер, складні формати, JSON тощо. Нам достатньо показати, що файл можна використати як дуже простий журнал.
Зробимо два кроки: спочатку «ініціалізуємо» файл, якщо його немає, через createOnce, а потім дописуємо завдання через appendLine.
Ось компактний варіант. Так, це все ще один файл "main.go": сьогодні нам важливіша механіка файлів, ніж архітектура пакетів.
package main
import (
"fmt"
"os"
)
func createOnce(filename string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return fmt.Errorf("створити один раз %q: %w", filename, err)
}
defer f.Close()
_, err = f.WriteString("# список справ\n")
if err != nil {
return fmt.Errorf("записати заголовок: %w", err)
}
return nil
}
func appendLine(filename string, line string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("відкрити для дописування %q: %w", filename, err)
}
defer f.Close()
_, err = f.WriteString(line + "\n")
if err != nil {
return fmt.Errorf("дописати: %w", err)
}
return nil
}
func main() {
_ = createOnce("items.txt") // якщо файл уже існує, тут буде помилка; поки ігноруємо
if err := appendLine("items.txt", "вивчити прапорці os.OpenFile"); err != nil {
fmt.Println("помилка:", err)
return
}
fmt.Println("збережено")
}
Що тут важливо за змістом: ми не використовуємо os.Create, бо він би стирав файл щоразу, а нам потрібен «журнал»; використовуємо OpenFile з O_APPEND, бо це ідеально лягає на модель «кожне завдання — новий рядок»; чесно закриваємо файл через defer, щоб навіть за помилки запису не залишити ресурс відкритим. Так, ми тимчасово ігноруємо помилку createOnce — це навчальний трюк, щоб файл створювався під час першого запуску. У реальному застосунку ви б розрізняли причини помилки та поводилися обережніше, але зараз наша мета — зрозуміти режими відкриття.
6. Типові помилки під час роботи з файлами та defer Close
Помилка №1: забули закрити файл або закрили його «десь потім».
Найчастіша проблема новачків — відкрити файл і «потім десь закрити». Це «потім» легко губиться, особливо коли в коді багато return через помилки. Набагато надійніше ставити defer f.Close() одразу після перевірки err, щойно файл успішно відкрито. Такий стиль не просто красивий — він реально запобігає витокам ресурсів.
Помилка №2: defer f.Close() поставили до перевірки err.
Це виглядає як «ну я ж хочу завжди закривати», але якщо Open або Create повернув помилку, змінна f не є валідним файлом. Далі ви ризикуєте отримати паніку або вторинну помилку, яка замаскує справжню причину. Правильний порядок такий: спочатку if err != nil, потім defer.
Помилка №3: використали os.Create, очікуючи «дописати в кінець».
Create не просто відкриває файл для запису. Він саме створює файл заново й обнуляє наявний. Тому для логів, журналів і списків рядків потрібен os.OpenFile з O_APPEND. Якщо не памʼятати цю різницю, можна одного дня випадково стерти файл із даними… і дуже швидко вивчити слово «truncate» на емоційному рівні.
Помилка №4: не перевіряють err у WriteString або Write.
Іноді здається: «ну запис же простий, що там може піти не так». А потім раптово закінчується місце на диску, відвалюється мережевий диск або файл виявляється read-only. Помилки запису — такі самі реальні, як і помилки відкриття. Тому if _, err := f.WriteString(...); err != nil { ... } — це не бюрократія, а страховка.
Помилка №5: сприймають io.EOF як фатальну помилку.
Під час читання шматками EOF означає: «все, більше даних немає». Це не ситуація «зламалося», а штатний сигнал завершення. Тому типова перевірка виглядає так: якщо err не nil і не EOF — тоді проблема. Інакше ви почнете «лікувати» нормальну поведінку й плутатиметеся в логіці читання.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ