$ sudo teach IT
МОДУЛЬ 4 · УРОК 4.3

Работа с файлами

Учимся читать и записывать файлы, работаем с путями и создаём программу-дневник

До сих пор все наши программы работали с данными, которые жили только в оперативной памяти. Как только программа завершалась — всё исчезало. Но настоящие программы должны сохранять данные между запусками: настройки, логи, заметки, результаты вычислений. И самый простой способ это сделать — записать данные в файл. В этом уроке мы научимся читать файлы целиком и построчно, записывать данные в файлы, работать с путями к файлам и директориям. В конце мы соберём полноценную программу-дневник, которая сохраняет заметки пользователя в текстовый файл и позволяет их перечитать.

📖 Чтение файла целиком: 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) — самый простой способ прочитать файл целиком в []byte
  • bufio.Scanner — построчное чтение для больших файлов; создаётся через bufio.NewScanner(file)
  • os.WriteFile(path, data, perm) — записать файл целиком (перезаписывает существующий)
  • os.Create(path) — создать файл и записывать порциями через fmt.Fprintln или file.WriteString
  • os.OpenFile с флагом os.O_APPEND — добавить данные в конец файла, не удаляя старые
  • Всегда проверяйте ошибки при работе с файлами и используйте defer file.Close()
  • Пакет path/filepath — собирает пути (Join), извлекает части (Base, Dir, Ext), работает кроссплатформенно
  • os.MkdirAll — создаёт цепочку директорий безопасно
  • Мы написали программу-дневник, использующую чтение, запись, добавление, проверку существования файла и работу с путями

В следующем уроке мы познакомимся с интерфейсами — одной из самых мощных концепций Go. Интерфейсы позволяют писать код, который работает с разными типами данных одинаково. Например, bufio.Scanner, который мы сегодня использовали, принимает не конкретно файл, а любой объект, реализующий интерфейс io.Reader. Вы узнаете, как создавать свои интерфейсы, почему в Go они реализуются неявно (и почему это гениально), и напишете систему геометрических фигур с вычислением площади и периметра через общий интерфейс.

Работа с файлами — Язык Go – для начинающих! | $ sudo teach IT