Работа с файлами
Учимся читать и записывать файлы, работаем с путями и создаём программу-дневник
До сих пор все наши программы работали с данными, которые жили только в оперативной памяти. Как только программа завершалась — всё исчезало. Но настоящие программы должны сохранять данные между запусками: настройки, логи, заметки, результаты вычислений. И самый простой способ это сделать — записать данные в файл. В этом уроке мы научимся читать файлы целиком и построчно, записывать данные в файлы, работать с путями к файлам и директориям. В конце мы соберём полноценную программу-дневник, которая сохраняет заметки пользователя в текстовый файл и позволяет их перечитать.
📖 Чтение файла целиком: os.ReadFile
Самый простой способ прочитать файл в Go — функция os.ReadFile. Она принимает путь к файлу, читает его содержимое целиком и возвращает два значения: срез байтов и ошибку.
Допустим, у нас есть файл hello.txt со следующим содержимым:
Привет, мир!
Это мой первый файл.
Go — отличный язык.
Вот как его прочитать:
package main
import (
"fmt"
"os"
)
func main() {
// os.ReadFile возвращает []byte и error
data, err := os.ReadFile("hello.txt")
if err != nil {
fmt.Println("Ошибка чтения файла:", err)
return
}
// data — это срез байтов, преобразуем в строку
text := string(data)
fmt.Println(text)
}
Разберём каждый шаг:
os.ReadFile("hello.txt") — читает весь файл в память. Возвращает []byte (содержимое) и error (ошибку, если что-то пошло не так).
if err != nil — проверяем ошибку. Файл может не существовать, у нас может не быть прав на чтение, путь может быть неверным — всё это ошибки.
string(data) — преобразуем срез байтов в строку, чтобы удобно вывести на экран.
Помните урок 4.2 про обработку ошибок? Вот она в деле. Файл — внешний ресурс, и мы всегда должны проверять, удалось ли с ним поработать.
💡 Подсказка: os.ReadFile удобна для небольших файлов (конфигурации, заметки, логи). Для файлов размером в сотни мегабайт лучше использовать построчное чтение — об этом дальше.
📜 Построчное чтение: bufio.Scanner
Иногда нужно обработать файл строку за строкой. Например, у вас лог-файл на миллион строк, и вы ищете конкретную ошибку. Загружать всё в память бессмысленно. Для этого есть пакет bufio и его Scanner.
Построчное чтение работает в два этапа: сначала мы открываем файл с помощью os.Open, потом читаем его через bufio.Scanner:
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
// Шаг 1: Открываем файл
file, err := os.Open("hello.txt")
if err != nil {
fmt.Println("Ошибка открытия файла:", err)
return
}
defer file.Close() // Не забываем закрыть файл!
// Шаг 2: Создаём сканер и читаем построчно
scanner := bufio.NewScanner(file)
lineNumber := 1
for scanner.Scan() {
line := scanner.Text()
fmt.Printf("%d: %s\n", lineNumber, line)
lineNumber++
}
// Шаг 3: Проверяем, не было ли ошибки при чтении
if err := scanner.Err(); err != nil {
fmt.Println("Ошибка чтения:", err)
}
}
Вывод:
1: Привет, мир!
2: Это мой первый файл.
3: Go — отличный язык.
Давайте подробно разберём каждый элемент:
os.Open("hello.txt") — открывает файл для чтения. Возвращает *os.File (указатель на файл) и error.
defer file.Close() — помните defer из урока 2.6? Он гарантирует, что файл будет закрыт при выходе из функции, даже если произойдёт ошибка. Всегда закрывайте файлы!
bufio.NewScanner(file) — создаёт сканер, который будет читать из нашего файла.
scanner.Scan() — переходит к следующей строке. Возвращает true, пока есть что читать, и false, когда файл закончился.
scanner.Text() — возвращает текущую строку (без символа перевода строки на конце).
scanner.Err() — после цикла проверяем, не было ли ошибки (например, файл повредился во время чтения).
💡 Важно: Всегда используйте defer file.Close() сразу после os.Open. Если вы забудете закрыть файл, операционная система может не дать его открыть другим программам, а при большом количестве открытых файлов программа упадёт.
Вот практический пример — поиск строки в файле:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
file, err := os.Open("hello.txt")
if err != nil {
fmt.Println("Ошибка:", err)
return
}
defer file.Close()
search := "Go"
scanner := bufio.NewScanner(file)
lineNum := 1
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, search) {
fmt.Printf("Найдено '%s' в строке %d: %s\n", search, lineNum, line)
}
lineNum++
}
}
// Вывод:
// Найдено 'Go' в строке 3: Go — отличный язык.
⚖️ Когда что использовать
У нас теперь два способа чтения файлов. Когда какой выбрать?
os.ReadFile — читает файл целиком в память. Быстро, просто, одна строка кода. Используйте для небольших файлов: конфигурации, шаблоны, заметки (до нескольких мегабайт).
bufio.Scanner — читает построчно, без загрузки всего файла. Используйте для больших файлов, логов, потоковой обработки, когда размер файла неизвестен или может быть огромным.
Если сомневаетесь — для простых задач начинайте с os.ReadFile. Если файл может быть большим — берите bufio.Scanner.
✏️ Запись в файл: os.WriteFile
Для записи данных в файл самый простой способ — os.WriteFile. Она принимает три аргумента: путь к файлу, данные (срез байтов) и права доступа.
package main
import (
"fmt"
"os"
)
func main() {
text := "Привет из Go!\nЭтот файл создан программой.\n"
// Записываем текст в файл
err := os.WriteFile("output.txt", []byte(text), 0644)
if err != nil {
fmt.Println("Ошибка записи:", err)
return
}
fmt.Println("Файл успешно записан!")
}
Разберём аргументы:
"output.txt" — путь к файлу. Если файл не существует, он будет создан. Если существует — его содержимое будет полностью заменено!
[]byte(text) — преобразуем строку в срез байтов, потому что os.WriteFile принимает именно []byte.
0644 — права доступа к файлу в формате Unix. 0644 означает: владелец может читать и писать, остальные — только читать. Для обычных файлов 0644 — стандартный выбор.
💡 Внимание: os.WriteFile перезаписывает файл полностью! Если вам нужно добавить данные в конец файла, не удаляя старые — используйте os.OpenFile с флагом os.O_APPEND. Мы разберём это чуть ниже.
📝 Создание файла и построчная запись: os.Create
Если вам нужно больше контроля — например, записывать данные порциями — используйте os.Create. Она создаёт файл (или перезаписывает существующий) и возвращает *os.File, в который можно записывать:
package main
import (
"fmt"
"os"
)
func main() {
// Создаём файл
file, err := os.Create("notes.txt")
if err != nil {
fmt.Println("Ошибка создания файла:", err)
return
}
defer file.Close() // Обязательно закрываем!
// Записываем строки по одной
lines := []string{
"Заметка 1: Изучить работу с файлами",
"Заметка 2: Написать программу-дневник",
"Заметка 3: Повторить обработку ошибок",
}
for _, line := range lines {
_, err := fmt.Fprintln(file, line)
if err != nil {
fmt.Println("Ошибка записи:", err)
return
}
}
fmt.Println("Заметки записаны в notes.txt!")
}
Обратите внимание на fmt.Fprintln(file, line). Это как обычный fmt.Println, только вместо вывода на экран — запись в файл. Первый аргумент — куда записывать (наш файл), остальные — что записывать. Буква F в начале означает «File» — записать в файл.
Другие полезные функции записи:
fmt.Fprintln(file, ...) — записывает строку с переводом строки в конце.
fmt.Fprintf(file, format, ...) — форматированная запись (как Printf, но в файл).
file.WriteString("текст") — записывает строку напрямую в файл.
package main
import (
"fmt"
"os"
)
func main() {
file, err := os.Create("report.txt")
if err != nil {
fmt.Println("Ошибка:", err)
return
}
defer file.Close()
// Разные способы записи
file.WriteString("=== Отчёт ===\n")
fmt.Fprintf(file, "Дата: %s\n", "2026-04-06")
fmt.Fprintf(file, "Студентов: %d\n", 42)
fmt.Fprintln(file, "Статус: отлично")
fmt.Println("Отчёт записан!")
}
➕ Добавление данных в конец файла
os.WriteFile и os.Create перезаписывают файл. Но часто нужно добавить новую строку, не удаляя старые. Для этого используем os.OpenFile с флагами:
package main
import (
"fmt"
"os"
)
func main() {
// Открываем файл для добавления (append)
// os.O_APPEND — добавлять в конец
// os.O_CREATE — создать файл, если его нет
// os.O_WRONLY — только для записи
file, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
defer file.Close()
// Добавляем строку в конец файла
fmt.Fprintln(file, "Новая запись в лог!")
fmt.Println("Запись добавлена!")
}
Флаги комбинируются через | (побитовое «или»). Вот самые важные:
os.O_RDONLY — открыть только для чтения (то же, что делает os.Open).
os.O_WRONLY — открыть только для записи.
os.O_CREATE — создать файл, если его не существует.
os.O_APPEND — добавлять данные в конец файла.
os.O_TRUNC — очистить файл перед записью (то же, что делает os.Create).
Запустите эту программу несколько раз — каждый раз в файл log.txt будет добавляться новая строка, а старые останутся на месте.
🔍 Проверка существования файла
Прежде чем читать файл, часто полезно проверить, существует ли он. В Go для этого используют os.Stat:
package main
import (
"errors"
"fmt"
"os"
)
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return !errors.Is(err, os.ErrNotExist)
}
func main() {
if fileExists("hello.txt") {
fmt.Println("Файл hello.txt существует")
} else {
fmt.Println("Файл hello.txt не найден")
}
if fileExists("несуществующий.txt") {
fmt.Println("Файл найден")
} else {
fmt.Println("Файл несуществующий.txt не найден")
}
}
os.Stat возвращает информацию о файле. Если файла нет — вернётся ошибка os.ErrNotExist. Мы проверяем это через errors.Is, который вы уже знаете из урока 4.2 об обработке ошибок.
💡 Подсказка: В реальном коде часто не проверяют существование заранее, а просто пытаются открыть файл и обрабатывают ошибку. Это считается более идиоматичным подходом в Go. Но для учебных целей и для пользовательских интерфейсов проверка бывает полезна.
📂 Работа с путями: пакет path/filepath
Когда вы работаете с файлами, часто нужно манипулировать путями: получить имя файла, расширение, родительскую директорию, собрать полный путь из частей. Для этого в Go есть пакет path/filepath.
Почему нельзя просто склеивать строки? Потому что на Windows разделитель путей — обратный слеш \, а на macOS и Linux — прямой /. Пакет filepath учитывает это автоматически.
package main
import (
"fmt"
"path/filepath"
)
func main() {
// Собираем путь из частей
path := filepath.Join("documents", "notes", "diary.txt")
fmt.Println(path) // documents/notes/diary.txt (Linux/macOS)
// documents\notes\diary.txt (Windows)
// Получаем имя файла из пути
fmt.Println(filepath.Base(path)) // diary.txt
// Получаем расширение файла
fmt.Println(filepath.Ext(path)) // .txt
// Получаем директорию (без имени файла)
fmt.Println(filepath.Dir(path)) // documents/notes
// Получаем абсолютный путь
absPath, err := filepath.Abs("diary.txt")
if err == nil {
fmt.Println(absPath) // /home/user/projects/diary.txt (полный путь)
}
}
Разберём каждую функцию:
filepath.Join(parts...) — собирает путь из частей, автоматически подставляя правильный разделитель для операционной системы.
filepath.Base(path) — возвращает последний элемент пути (имя файла).
filepath.Ext(path) — возвращает расширение файла (включая точку).
filepath.Dir(path) — возвращает директорию (путь без имени файла).
filepath.Abs(path) — превращает относительный путь в абсолютный.
Ещё один полезный приём — получение имени файла без расширения:
package main
import (
"fmt"
"path/filepath"
"strings"
)
func main() {
filename := "photo_2026.jpg"
// Получаем имя без расширения
ext := filepath.Ext(filename)
name := strings.TrimSuffix(filename, ext)
fmt.Println("Полное имя:", filename) // photo_2026.jpg
fmt.Println("Расширение:", ext) // .jpg
fmt.Println("Имя без расширения:", name) // photo_2026
}
💡 Правило: Никогда не склеивайте пути вручную через + и символ /. Всегда используйте filepath.Join. Это гарантирует, что ваша программа будет работать на любой операционной системе.
🗂 Создание директорий
Прежде чем записать файл в определённую папку, нужно убедиться, что папка существует. В Go для создания директорий используются функции os.Mkdir и os.MkdirAll:
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
// Создаём одну директорию
err := os.Mkdir("data", 0755)
if err != nil {
fmt.Println("Директория уже существует или ошибка:", err)
}
// Создаём вложенные директории (аналог mkdir -p)
path := filepath.Join("data", "users", "notes")
err = os.MkdirAll(path, 0755)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Println("Директории созданы:", path)
// Теперь можем записать файл внутрь
filePath := filepath.Join(path, "diary.txt")
os.WriteFile(filePath, []byte("Моя первая заметка\n"), 0644)
fmt.Println("Файл создан:", filePath)
}
os.Mkdir(name, perm) — создаёт одну директорию. Вернёт ошибку, если она уже существует или родительская директория не найдена.
os.MkdirAll(path, perm) — создаёт всю цепочку вложенных директорий. Не возвращает ошибку, если директория уже существует. Используйте эту функцию чаще — она безопаснее.
0755 — права доступа для директории. Владелец может всё, остальные — читать и входить. Стандартный выбор для директорий.
🗺 Шпаргалка: все способы работы с файлами
Вот краткая сводка всех функций, которые мы изучили:
Чтение:
os.ReadFile(path) — прочитать файл целиком в []byte
os.Open(path) + bufio.Scanner — читать построчно
os.Stat(path) — проверить, существует ли файл
Запись:
os.WriteFile(path, data, perm) — записать файл целиком (перезапись)
os.Create(path) — создать/перезаписать файл, писать порциями
os.OpenFile(path, flags, perm) — открыть с нужными флагами (например, для добавления)
Пути:
filepath.Join(parts...) — собрать путь из частей
filepath.Base(path) — имя файла
filepath.Dir(path) — директория
filepath.Ext(path) — расширение
filepath.Abs(path) — абсолютный путь
🛠 Практика: программа-дневник
Пришло время собрать всё вместе! Напишем полноценную программу-дневник, которая умеет:
- Добавлять новую заметку с автоматической датой и временем
- Показывать все заметки из файла
- Хранить заметки между запусками программы
- Создавать файл автоматически, если его ещё нет
Вот полный код:
package main
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// diaryPath возвращает путь к файлу дневника
func diaryPath() string {
return filepath.Join("data", "diary.txt")
}
// ensureDataDir создаёт директорию data, если её нет
func ensureDataDir() error {
return os.MkdirAll("data", 0755)
}
// addNote добавляет заметку в файл дневника
func addNote(note string) error {
// Убедимся, что директория существует
if err := ensureDataDir(); err != nil {
return fmt.Errorf("не удалось создать директорию: %w", err)
}
// Открываем файл для добавления (создаём, если нет)
file, err := os.OpenFile(diaryPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("не удалось открыть файл: %w", err)
}
defer file.Close()
// Формируем запись с датой и временем
timestamp := time.Now().Format("2006-01-02 15:04:05")
_, err = fmt.Fprintf(file, "[%s] %s\n", timestamp, note)
if err != nil {
return fmt.Errorf("не удалось записать: %w", err)
}
return nil
}
// showNotes выводит все заметки из файла
func showNotes() error {
path := diaryPath()
// Проверяем, существует ли файл
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
fmt.Println("Дневник пуст. Добавьте первую заметку!")
return nil
}
// Открываем файл для чтения
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("не удалось открыть файл: %w", err)
}
defer file.Close()
// Читаем построчно
scanner := bufio.NewScanner(file)
count := 0
fmt.Println("=== Ваш дневник ===")
fmt.Println()
for scanner.Scan() {
count++
fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("ошибка чтения: %w", err)
}
fmt.Println()
fmt.Printf("Всего записей: %d\n", count)
return nil
}
// showMenu выводит меню и возвращает выбор пользователя
func showMenu() string {
fmt.Println()
fmt.Println("--- Дневник ---")
fmt.Println("1. Добавить заметку")
fmt.Println("2. Показать все заметки")
fmt.Println("3. Выход")
fmt.Print("Выберите действие: ")
reader := bufio.NewReader(os.Stdin)
choice, _ := reader.ReadString('\n')
return strings.TrimSpace(choice)
}
func main() {
reader := bufio.NewReader(os.Stdin)
fmt.Println("Добро пожаловать в программу-дневник!")
for {
choice := showMenu()
switch choice {
case "1":
fmt.Print("Введите заметку: ")
note, _ := reader.ReadString('\n')
note = strings.TrimSpace(note)
if note == "" {
fmt.Println("Заметка не может быть пустой!")
continue
}
if err := addNote(note); err != nil {
fmt.Println("Ошибка:", err)
} else {
fmt.Println("Заметка добавлена!")
}
case "2":
if err := showNotes(); err != nil {
fmt.Println("Ошибка:", err)
}
case "3":
fmt.Println("До свидания!")
return
default:
fmt.Println("Неизвестная команда. Выберите 1, 2 или 3.")
}
}
}
Пример работы программы:
Добро пожаловать в программу-дневник!
--- Дневник ---
1. Добавить заметку
2. Показать все заметки
3. Выход
Выберите действие: 1
Введите заметку: Сегодня начал изучать работу с файлами в Go
Заметка добавлена!
--- Дневник ---
1. Добавить заметку
2. Показать все заметки
3. Выход
Выберите действие: 1
Введите заметку: Написал программу-дневник — это круто!
Заметка добавлена!
--- Дневник ---
1. Добавить заметку
2. Показать все заметки
3. Выход
Выберите действие: 2
=== Ваш дневник ===
[2026-04-06 14:30:15] Сегодня начал изучать работу с файлами в Go
[2026-04-06 14:30:42] Написал программу-дневник — это круто!
Всего записей: 2
Давайте разберём ключевые моменты программы:
Структура кода — мы разбили программу на маленькие функции: diaryPath, ensureDataDir, addNote, showNotes, showMenu. Каждая делает одно дело. Это хорошая практика!
filepath.Join — путь к файлу собирается через filepath.Join, а не склеивается вручную.
os.MkdirAll — директория data создаётся автоматически при первом запуске.
os.OpenFile с O_APPEND — новые заметки добавляются в конец, старые не удаляются.
time.Now().Format("2006-01-02 15:04:05") — в Go форматирование даты работает через «магическую дату» 2 января 2006 года 15:04:05. Это выглядит странно, но на самом деле запоминается легко: 01/02 03:04:05 2006 (месяц 1, день 2, час 3, минута 4, секунда 5, год 6).
Обёртка ошибок через %w — мы оборачиваем ошибки с контекстом, как учили в уроке 4.2.
bufio.NewReader(os.Stdin) — для чтения пользовательского ввода. В отличие от fmt.Scan, Reader корректно читает строки с пробелами.
💡 Задание для самостоятельной работы: Попробуйте добавить функцию поиска заметок по ключевому слову. Подсказка: используйте strings.Contains при построчном чтении файла, как мы делали в примере с поиском строки.
🚫 Частые ошибки новичков
Работа с файлами — одна из тех тем, где новички допускают типичные ошибки. Давайте разберём самые распространённые, чтобы вы их избежали.
Ошибка 1: Забыли закрыть файл
// ПЛОХО — файл никогда не закрывается!
file, err := os.Open("data.txt")
if err != nil {
return err
}
// ... работа с файлом ...
// file.Close() — забыли!
// ХОРОШО — defer гарантирует закрытие
file, err := os.Open("data.txt")
if err != nil {
return err
}
defer file.Close() // Файл будет закрыт при выходе из функции
Ошибка 2: Склеивание путей строками
// ПЛОХО — не будет работать на Windows
path := "data" + "/" + "notes" + "/" + "diary.txt"
// ХОРОШО — работает на любой ОС
path := filepath.Join("data", "notes", "diary.txt")
Ошибка 3: Игнорирование ошибок
// ПЛОХО — если файла нет, программа упадёт позже
data, _ := os.ReadFile("config.txt")
fmt.Println(string(data))
// ХОРОШО — обрабатываем ошибку
data, err := os.ReadFile("config.txt")
if err != nil {
fmt.Println("Не удалось прочитать конфигурацию:", err)
return
}
fmt.Println(string(data))
Ошибка 4: defer file.Close() до проверки ошибки
// ПЛОХО — если err != nil, то file может быть nil
file, err := os.Open("data.txt")
defer file.Close() // ПАНИКА, если file == nil!
if err != nil {
return err
}
// ХОРОШО — сначала проверяем ошибку, потом defer
file, err := os.Open("data.txt")
if err != nil {
return err
}
defer file.Close() // Теперь file точно не nil
💡 Запомните порядок: 1) Открыть файл. 2) Проверить ошибку. 3) defer file.Close(). Именно в таком порядке, и никак иначе.
📋 Итоги урока
os.ReadFile(path)— самый простой способ прочитать файл целиком в[]bytebufio.Scanner— построчное чтение для больших файлов; создаётся черезbufio.NewScanner(file)os.WriteFile(path, data, perm)— записать файл целиком (перезаписывает существующий)os.Create(path)— создать файл и записывать порциями черезfmt.Fprintlnилиfile.WriteStringos.OpenFileс флагомos.O_APPEND— добавить данные в конец файла, не удаляя старые- Всегда проверяйте ошибки при работе с файлами и используйте
defer file.Close() - Пакет
path/filepath— собирает пути (Join), извлекает части (Base,Dir,Ext), работает кроссплатформенно os.MkdirAll— создаёт цепочку директорий безопасно- Мы написали программу-дневник, использующую чтение, запись, добавление, проверку существования файла и работу с путями
В следующем уроке мы познакомимся с интерфейсами — одной из самых мощных концепций Go. Интерфейсы позволяют писать код, который работает с разными типами данных одинаково. Например, bufio.Scanner, который мы сегодня использовали, принимает не конкретно файл, а любой объект, реализующий интерфейс io.Reader. Вы узнаете, как создавать свои интерфейсы, почему в Go они реализуются неявно (и почему это гениально), и напишете систему геометрических фигур с вычислением площади и периметра через общий интерфейс.