Данные и базы данных

Работа с файлами в C#: полное руководство

Обновлено: · 6 мин чтения

Чтение и запись файлов в C# решаются классами из пространства имён System.IO: статическим File для коротких операций и парой StreamReader и StreamWriter для больших данных. Здесь разобраны ReadAllText, WriteAllText, ReadAllLines, оператор using, выбор кодировки, работа с путями и каталогами, проверка существования и асинхронное чтение.

Примеры написаны для .NET 8 и подходят и для консольных программ, и для WinForms.

Работа с файлами в C#: полное руководство
Содержание
  1. Класс File: быстрые операции целиком
    1. ReadAllText и WriteAllText
    2. ReadAllLines, WriteAllLines и ReadLines
  2. StreamReader и StreamWriter
  3. Оператор using и освобождение ресурсов
  4. Кодировка текстовых файлов
  5. Класс Path: работа с путями
  6. Проверка существования файла и каталога
  7. Класс Directory и операции с файлами
  8. Асинхронное чтение и запись
  9. Обработка ошибок при работе с файлами
  10. Частые вопросы

Класс File: быстрые операции целиком

Статический класс File позволяет прочитать или записать файл одной строкой кода. Он сам открывает файл, выполняет операцию и закрывает его, поэтому освобождать ресурсы вручную не нужно. Такой подход подходит для конфигураций, небольших логов и текстовых данных размером до нескольких десятков мегабайт, ведь содержимое загружается в память полностью.

ReadAllText и WriteAllText

WriteAllText создаёт файл и записывает в него строку. Если файл уже есть, его содержимое будет полностью заменено. ReadAllText возвращает весь текст одной строкой.

string path = "notes.txt";

File.WriteAllText(path, "Первая строка\nВторая строка");
string text = File.ReadAllText(path);
Console.WriteLine(text);

// дописать в конец, не затирая содержимое
File.AppendAllText(path, "\nТретья строка");

ReadAllLines, WriteAllLines и ReadLines

ReadAllLines разбивает файл на строки и возвращает массив string[]. WriteAllLines делает обратное: записывает набор строк, каждую с новой строки. Для очень больших файлов лучше метод ReadLines: он возвращает IEnumerable<string> и читает данные порциями по мере перебора, не загружая всё в память.

string[] lines = File.ReadAllLines("notes.txt");
Console.WriteLine($"Строк: {lines.Length}");

var result = new List<string> { "яблоко", "груша", "слива" };
File.WriteAllLines("fruits.txt", result);

foreach (string line in File.ReadLines("fruits.txt"))
{
    if (line.StartsWith("г")) Console.WriteLine(line);
}

Строки из массива удобно обрабатывать циклом, а если нужно потом преобразовать их в числа, пригодится материал о преобразовании строки в int. Для двоичных данных существуют парные методы ReadAllBytes и WriteAllBytes.

StreamReader и StreamWriter

Когда файл большой или данные нужно обрабатывать построчно, используют потоки. StreamReader читает текст, StreamWriter пишет. Они держат файл открытым, поэтому важно корректно их закрыть, иначе буфер может не сброситься на диск, а файл останется заблокированным.

using (var writer = new StreamWriter("log.txt", append: true))
{
    writer.WriteLine($"{DateTime.Now:HH:mm:ss} запуск");
    writer.WriteLine("Работа выполнена");
}

using (var reader = new StreamReader("log.txt"))
{
    string? line;
    while ((line = reader.ReadLine()) != null)
        Console.WriteLine(line);
}

Метод ReadLine возвращает null в конце файла, на этом построен классический цикл чтения. Метод ReadToEnd вернёт весь остаток текста, а Read читает по одному символу и применяется редко. Параметр append в конструкторе StreamWriter определяет, дописывать ли данные в конец (true) или перезаписать файл (false, значение по умолчанию).

Оператор using и освобождение ресурсов

Потоки реализуют интерфейс IDisposable, а значит, их нужно закрывать вызовом Dispose. Оператор using делает это автоматически, даже если внутри возникло исключение. С C# 8 появилась краткая форма: объявление using var, которое закрывает ресурс по выходу из текущего блока.

static void Save(string path, IEnumerable<string> rows)
{
    using var writer = new StreamWriter(path);
    foreach (var r in rows)
        writer.WriteLine(r);
}   // здесь writer автоматически закрывается

Если забыть про using, файл останется занятым до сборки мусора, и повторное открытие даст ошибку. Эта ситуация подробно описана в статье про IOException: файл занят другим процессом.

Кодировка текстовых файлов

В .NET методы File и конструкторы StreamReader и StreamWriter по умолчанию работают с UTF-8. Для файлов, созданных старыми программами под Windows, часто нужна кодировка windows-1251. В .NET Core и новее она не включена по умолчанию, и её сначала нужно зарегистрировать через CodePagesEncodingProvider.

using System.Text;

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1251 = Encoding.GetEncoding(1251);

File.WriteAllText("old.txt", "Привет, мир", win1251);
string text = File.ReadAllText("old.txt", win1251);

using var reader = new StreamReader("old.txt", win1251);
Console.WriteLine(reader.ReadToEnd());

// UTF-8 с BOM и без
var utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
File.WriteAllText("bom.txt", "Текст", utf8Bom);

Если вместо русских букв вы видите "кракозябры", значит, файл прочитан не в той кодировке. Метод ReadAllText умеет распознавать BOM (метку порядка байтов) и сам выбирает UTF-8, UTF-16 или UTF-32, но для файлов без BOM угадывать придётся вручную. Новые файлы лучше всегда сохранять в UTF-8.

Класс Path: работа с путями

Склеивать пути строками вида folder + "\" + name неудобно и небезопасно: на Linux и macOS разделитель другой. Класс Path берёт заботу об этом на себя и содержит набор методов для разбора и построения путей.

МетодНазначениеПример результата
Path.Combine(a, b)Склеивает части путиdata/report.txt
Path.GetFileName(p)Имя файла с расширениемreport.txt
Path.GetFileNameWithoutExtension(p)Имя без расширенияreport
Path.GetExtension(p)Расширение с точкой.txt
Path.GetDirectoryName(p)Каталог, в котором лежит файлdata
Path.GetFullPath(p)Абсолютный путьC:\project\data\report.txt
Path.GetTempPath()Папка временных файловC:\Users\...\Temp\
Path.ChangeExtension(p, ext)Меняет расширениеreport.csv
string folder = "data";
string path = Path.Combine(folder, "report.txt");

Console.WriteLine(Path.GetFileName(path));                   // report.txt
Console.WriteLine(Path.GetExtension(path));                  // .txt
Console.WriteLine(Path.GetFileNameWithoutExtension(path));   // report
Console.WriteLine(Path.ChangeExtension(path, ".csv"));       // data/report.csv

string temp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

Для файлов рядом с программой используйте AppContext.BaseDirectory, а не текущий каталог: рабочая папка процесса зависит от способа запуска и может отличаться.

Проверка существования файла и каталога

Метод File.Exists возвращает true, если по пути лежит именно файл. Для папок есть Directory.Exists. Обратите внимание, что оба метода не выбрасывают исключений: при неверном пути или отсутствии прав они просто вернут false. Это значит, что "нет файла" и "нет доступа" различить таким способом нельзя.

string path = "settings.json";

if (File.Exists(path))
{
    string json = File.ReadAllText(path);
    Console.WriteLine($"Размер: {json.Length} символов");
}
else
{
    Console.WriteLine("Файл не найден, создаём настройки по умолчанию");
    File.WriteAllText(path, "{}");
}

if (!Directory.Exists("logs"))
    Directory.CreateDirectory("logs");

Проверка перед чтением не защищает от гонки: между Exists и открытием файл могут удалить. Поэтому надёжный код дополнительно оборачивает открытие в try и ловит FileNotFoundException, DirectoryNotFoundException и UnauthorizedAccessException.

Класс Directory и операции с файлами

Класс Directory создаёт, удаляет и перечисляет каталоги. Метод GetFiles возвращает массив путей, а EnumerateFiles отдаёт их лениво, что экономнее для папок с тысячами файлов. Вторым параметром задаётся маска, третьим - режим поиска во вложенных папках.

Directory.CreateDirectory(Path.Combine("data", "archive")); // создаст вложенные сразу

foreach (string file in Directory.EnumerateFiles("data", "*.txt", SearchOption.AllDirectories))
{
    var info = new FileInfo(file);
    Console.WriteLine($"{info.Name}: {info.Length} байт, изменён {info.LastWriteTime}");
}

File.Copy("notes.txt", "data/notes_copy.txt", overwrite: true);
File.Move("data/notes_copy.txt", "data/archive/notes.txt", overwrite: true);
File.Delete("data/archive/notes.txt");

string[] folders = Directory.GetDirectories("data");
Directory.Delete("data/archive", recursive: true);

Классы FileInfo и DirectoryInfo дают тот же функционал в объектном виде и удобны, когда с одним файлом выполняют несколько действий: свойства Length, CreationTime, Extension читаются без повторной передачи пути. Диалог выбора файла в оконных программах строится на OpenFileDialog, а прочитанный текст можно вывести, например, в RichTextBox.

Асинхронное чтение и запись

В приложениях с интерфейсом долгое чтение блокирует окно, а в веб-сервисах занимает поток. Решение - асинхронные версии методов: ReadAllTextAsync, WriteAllTextAsync, ReadAllLinesAsync и ReadLineAsync. Они возвращают Task и применяются с ключевым словом await.

await File.WriteAllTextAsync("big.txt", "строка 1\nстрока 2\nстрока 3");

string all = await File.ReadAllTextAsync("big.txt");
string[] lines = await File.ReadAllLinesAsync("big.txt");

await using (var writer = new StreamWriter("out.txt"))
{
    for (int i = 0; i < 1000; i++)
        await writer.WriteLineAsync($"Запись {i}");
}

using var reader = new StreamReader("out.txt");
string? line;
int count = 0;
while ((line = await reader.ReadLineAsync()) != null)
    count++;
Console.WriteLine(count);

Для построчной асинхронной обработки в .NET есть ещё File.ReadLinesAsync, которая возвращает IAsyncEnumerable<string> и работает с await foreach. Если метод Main объявляется как async, убедитесь, что используете C# 7.1 или новее, подробности в статье про async в методе Main.

await foreach (string line in File.ReadLinesAsync("out.txt"))
{
    if (line.EndsWith("7")) Console.WriteLine(line);
}

Обработка ошибок при работе с файлами

Файловые операции зависят от внешних условий, поэтому исключения здесь обычное дело. Основные типы: FileNotFoundException (нет файла), DirectoryNotFoundException (нет папки), UnauthorizedAccessException (нет прав или путь указывает на каталог), IOException (файл занят или диск недоступен) и PathTooLongException. Ловите конкретные типы от частного к общему и сообщайте пользователю понятный текст.

try
{
    string text = File.ReadAllText(@"C:\data\input.txt");
    Console.WriteLine(text.Length);
}
catch (FileNotFoundException)
{
    Console.WriteLine("Файл не найден");
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("Нет прав на чтение");
}
catch (IOException ex)
{
    Console.WriteLine($"Ошибка ввода-вывода: {ex.Message}");
}

Ещё один приём: для надёжной записи важных данных сначала пишут во временный файл рядом с целевым, а затем заменяют оригинал методом File.Move с overwrite. Тогда при сбое посередине не остаётся наполовину записанного файла. Для сложных структур данные обычно сериализуют в JSON и сохраняют результат теми же методами.

Частые вопросы

Что лучше: File.ReadAllText или StreamReader?

ReadAllText проще и подходит для небольших файлов. StreamReader нужен, когда файл большой, его надо читать построчно или когда вы не хотите держать всё содержимое в памяти.

Как дописать строку в конец файла?

Используйте File.AppendAllText или File.AppendAllLines. Либо откройте StreamWriter с параметром append: true. Если просто вызвать WriteAllText, старое содержимое будет потеряно.

Почему в файле вместо русских букв знаки вопроса?

Файл записан или прочитан в неподходящей кодировке. Явно укажите Encoding.UTF8 при записи и чтении или зарегистрируйте CodePagesEncodingProvider, если работаете с windows-1251.

Как получить список всех файлов в папке?

Вызовите Directory.GetFiles(путь) или лениво перебирайте Directory.EnumerateFiles. Чтобы заглянуть во вложенные папки, передайте SearchOption.AllDirectories.

Как прочитать файл, который открыт в другой программе?

Откройте его через FileStream с режимом FileShare.ReadWrite. Тогда чтение не будет конфликтовать с процессом, который держит файл, если тот сам разрешил совместный доступ.

Как создать файл, если папки не существует?

Сначала вызовите Directory.CreateDirectory для нужной папки: метод создаёт все недостающие уровни и не падает, если каталог уже есть. После этого запись через File.WriteAllText пройдёт без ошибок.