JavaRush /Курси /Go SELF /Робота з файлами в Go: Open/Create/OpenFile

Робота з файлами в Go: Open/Create/OpenFile

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

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 — писати завжди в кінець файла.

Щоб не тримати все в голові у вигляді каші, ось маленька таблиця-памʼятка:

Завдання Як відкрити
Прочитати наявний файл
os.Open(name)
Перезаписати файл заново
os.Create(name)
Дописувати в кінець (лог, список рядків)
os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
Створити «строго один раз» (не затирати)
os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)

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 — тоді проблема. Інакше ви почнете «лікувати» нормальну поведінку й плутатиметеся в логіці читання.

1
Задача
Go SELF, 39 рівень, 1 лекція
Недоступна
Вітальний файл
Вітальний файл
1
Задача
Go SELF, 39 рівень, 1 лекція
Недоступна
Читання частинами
Читання частинами
1
Задача
Go SELF, 39 рівень, 1 лекція
Недоступна
Доповнення в лог
Доповнення в лог
1
Задача
Go SELF, 39 рівень, 1 лекція
Недоступна
Одноразова ініціалізація
Одноразова ініціалізація
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ