Проверка существования файла в Go: скорость, ошибки и лучшие практики

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему вопрос "How to check if a file exists in Go?" стал вирусным

Если вы когда-нибудь писали на Go программу, которая работает с файлами, вы наверняка сталкивались с этим вопросом. На Stack Overflow он собрал сотни тысяч просмотров, а под ним — десятки комментариев с жаркими спорами. Казалось бы, что сложного? В Python есть os.path.exists(), в PHP — file_exists(), а что в Go?

Проблема в том, что в Go нет встроенной функции FileExists(). И это не случайность. Разработчики языка сознательно ушли от такого подхода, потому что проверка существования файла — это гонка состояний (race condition) в чистом виде. Пока вы проверяете, файл может быть удалён или создан другой горутиной. Поэтому в Go принято просто пытаться открыть файл и обрабатывать ошибку.

Но как сделать это правильно? Как не потерять производительность и не нарваться на скрытые баги? Сегодня мы разберём проверку существования файла в Go от и до: от базовых примеров до production-решений.

Первый подход: os.Stat и os.IsNotExist (классика Stack Overflow)

Самый популярный ответ на Stack Overflow выглядит так:

package main

import (
    "fmt"
    "os"
)

func fileExists(filename string) bool {
    info, err := os.Stat(filename)
    if os.IsNotExist(err) {
        return false
    }
    return err == nil && !info.IsDir()
}

func main() {
    if fileExists("example.txt") {
        fmt.Println("Файл существует")
    } else {
        fmt.Println("Файл не найден")
    }
}

Этот код использует os.Stat(), который возвращает информацию о файле. Если файла нет, функция возвращает ошибку, и мы проверяем её через os.IsNotExist(). Дополнительно мы проверяем, что это не директория — ведь os.Stat() сработает и на папку.

Плюсы: простота, читаемость, минимум зависимостей.

Минусы: потенциальная гонка данных между проверкой и открытием файла. Если вы проверяете существование, а потом открываете файл, между этими операциями файл может исчезнуть. В Go принято сразу пытаться открыть файл и обрабатывать ошибку.

Производительность os.Stat

Вызов os.Stat() — это системный вызов. На Linux это stat() или lstat(). Он быстрый, но не мгновенный. В среднем один вызов занимает 1-5 микросекунд на SSD. Если вы проверяете сотни тысяч файлов, это может стать узким местом.

Оптимизация: если вам нужно проверить существование множества файлов, рассмотрите кэширование результатов или использование filepath.Glob() с маской.

Второй подход: открытие файла и обработка ошибок (Go Way)

Более идиоматичный способ — сразу пытаться открыть файл. Это решает проблему race condition:

package main

import (
    "fmt"
    "os"
)

func openFile(filename string) (bool, error) {
    file, err := os.Open(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return false, nil
        }
        return false, err // Другая ошибка: нет прав, битая ссылка и т.д.
    }
    defer file.Close()
    return true, nil
}

func main() {
    exists, err := openFile("example.txt")
    if err != nil {
        fmt.Printf("Ошибка: %v\n", err)
        return
    }
    if exists {
        fmt.Println("Файл существует и доступен")
    }
}

Этот код сразу открывает файл. Если возникает ошибка, мы проверяем, именно ли это ошибка "файл не найден". Любую другую ошибку (например, недостаточно прав) мы возвращаем наверх — это даёт больше контроля.

Плюсы: атомарность операции, правильная обработка ошибок, отсутствие race condition.

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

Производительность os.Open

os.Open() также совершает системный вызов open(). Он чуть тяжелее stat(), но разница обычно несущественна. На практике, если вы всё равно собираетесь работать с файлом, открывайте его сразу — это сэкономит один системный вызов.

Третий подход: пакет os и ошибка-индикатор (Best Practice для 2024)

В Go 1.13+ появилась функция errors.Is(), которая делает код чище. Вот современный вариант:

package main

import (
    "errors"
    "fmt"
    "os"
)

func fileExistsSafe(filename string) (bool, error) {
    _, err := os.Stat(filename)
    if err == nil {
        return true, nil
    }
    if errors.Is(err, os.ErrNotExist) {
        return false, nil
    }
    return false, err // Неизвестная ошибка
}

func main() {
    exists, err := fileExistsSafe("config.yaml")
    if err != nil {
        fmt.Printf("Ошибка проверки: %v\n", err)
        return
    }
    if exists {
        fmt.Println("Конфиг существует")
    } else {
        fmt.Println("Конфиг не найден, создаём...")
    }
}

Здесь errors.Is() заменяет устаревший os.IsNotExist(). Это более гибкий подход: он работает с обёрнутыми ошибками (wrapped errors), что важно в сложных проектах.

Четвёртый подход: os.Lstat для символических ссылок

Если ваша программа работает с символическими ссылками, стандартный os.Stat() проследует по ссылке. Если ссылка битая, он вернёт ошибку. Чтобы проверить существование самой ссылки, используйте os.Lstat():

package main

import (
    "errors"
    "fmt"
    "os"
)

func symlinkExists(path string) (bool, error) {
    _, err := os.Lstat(path)
    if err == nil {
        return true, nil
    }
    if errors.Is(err, os.ErrNotExist) {
        return false, nil
    }
    return false, err
}

func main() {
    // Создаём символическую ссылку для теста
    os.Symlink("nonexistent.txt", "broken.lnk")
    defer os.Remove("broken.lnk")

    exists, _ := symlinkExists("broken.lnk")
    fmt.Printf("Ссылка существует: %v\n", exists) // true, даже если файл-цель удалён
}

Это критично для инструментов резервного копирования, CI/CD пайплайнов и любых систем, работающих с файловыми структурами.

Сравнение производительности: бенчмарки

Давайте напишем простой бенчмарк, чтобы увидеть разницу:

package main

import (
    "os"
    "testing"
)

var testFile = "test_bench.txt"

func BenchmarkStat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, err := os.Stat(testFile)
        if err != nil && !os.IsNotExist(err) {
            b.Fatal(err)
        }
    }
}

func BenchmarkOpen(b *testing.B) {
    for i := 0; i &                

Блоги

Рекомендаций по книгам