Перейти к содержимому

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

Чтение и запись файлов — одно из первых мест, где всё, что ты узнал о Result и операторе ?, перестаёт быть теорией. Почти любая операция с файлом может не получиться (файла нет, нет прав доступа), поэтому стандартная библиотека заставляет разбираться с этим сразу.

Самый простой способ прочитать содержимое файла в String:

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("Не удалось прочитать файл: {e}"),
}
}

fs::read_to_string возвращает Result<String, std::io::Error>: Ok с текстом, если всё получилось, и Err с причиной, если нет (файла нет, нет прав и так далее).

Писать match для каждой операции с файлом быстро надоедает. Если ты внутри функции, которая сама возвращает Result, оператор ? (со страницы Result) сильно всё упрощает:

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // при ошибке сразу возвращает её
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Ошибка: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "Привет, файл!");
match result {
Ok(()) => println!("Запись прошла успешно"),
Err(e) => println!("Не удалось записать: {e}"),
}
}

fs::write создаёт файл, если его нет, а если он есть — полностью перезаписывает его. Помни об этом, если нужно дописать в файл, а не заменить его.

Чтобы дописать в существующий файл, а не перезаписать его, используй OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "Новая строка в журнале")?;
Ok(())
}

Заметь, что здесь сама main возвращает Result. Этот приём стоит знать: когда main возвращает Result, внутри неё можно сразу писать ?, а не заворачивать всё в match.

Большой файл не всегда стоит целиком загружать в память. Вместо этого его можно читать строка за строкой:

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

До сих пор все ошибки обрабатывались одинаково: мы их выводили. Но часто нужно реагировать по-разному в зависимости от того, что пошло не так. Отсутствующий файл — обычно не беда (может, это первый запуск, и можно начать с пустого списка). А «нет доступа» (permission denied) — настоящая проблема, о которой стоит сообщить.

io::Error может назвать свою категорию через .kind(). Этот метод возвращает перечисление ErrorKind (на английском), по которому можно сделать match:

use std::fs;
use std::io::ErrorKind;
fn main() {
// файл во временной папке системы, которого не существует
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("Загружено строк: {}", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("Заметок пока нет, начинаем с чистого листа"),
ErrorKind::PermissionDenied => println!("Нет прав на чтение файла с заметками"),
_ => println!("Что-то другое пошло не так: {e}"),
},
}
}

Вывод:

Заметок пока нет, начинаем с чистого листа
▶ Попробуй сам

std::env::temp_dir() возвращает путь к временной папке системы (например, /tmp в Linux), а .join(...) добавляет к нему имя файла. Функции для работы с файлами принимают такой путь так же охотно, как &str.

Всегда оставляй ветку _ в конце: у ErrorKind много вариантов, и со временем добавляются новые. Ветка _ ловит всё, что ты не перечислил.

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

Запиши в файл три дела через fs::write, по одному на строку. Затем прочитай файл обратно через fs::read_to_string и выведи каждую строку с номером, а в конце — общее количество.

Подсказка: поставь \n между делами, чтобы получились отдельные строки. text.lines() выдаёт строки по одной. Пусть main возвращает std::io::Result<()>, чтобы можно было пользоваться ?.

Показать решение
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "разобраться с владением\nнаписать модуль\nпрочитать файл\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("Всего дел: {count}");
Ok(())
}

Вывод:

1. разобраться с владением
2. написать модуль
3. прочитать файл
Всего дел: 3
▶ Попробуй сам

fs::write заменяет файл целиком, поэтому сколько ни запускай, получишь те же три строки. Каждый ? возвращает ошибку из main, если запись или чтение не удались.

Создай файл журнала заново со строкой-заголовком, а потом допиши в него ещё две строки через OpenOptions и writeln!. В конце прочитай весь файл и выведи его.

Подсказка: для заголовка используй fs::write (так каждый запуск начинается с чистого файла), а для остальных строк — OpenOptions::new().append(true).open(&path)?. print! (а не println!) не добавит лишнюю пустую строку в конце, ведь файл и так заканчивается переводом строки.

Показать решение
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== журнал ===\n")?; // начинаем с чистого файла
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "запущено")?;
writeln!(file, "завершено")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Вывод:

=== журнал ===
запущено
завершено
▶ Попробуй сам

use std::fs::{self, OpenOptions}; подключает и сам модуль fs, и OpenOptions. Без первого fs::write каждый запуск продолжал бы дописывать в старый файл, и журнал рос бы бесконечно. Для настоящего журнала это именно то, что нужно, а для упражнения с фиксированным выводом — нет.

  • fs::read_to_string(path) читает весь файл в String и возвращает Result.
  • fs::write(path, contents) записывает файл и перезаписывает его, если он уже есть.
  • OpenOptions::new().append(true) позволяет дописать в файл, а не заменить его.
  • main() -> std::io::Result<()> позволяет писать ? прямо в main, без match для каждого вызова.
  • e.kind() говорит, какая ошибка случилась, например ErrorKind::NotFound, — и каждую можно обработать по-своему.
  • Примеры с файлами в папке проекта лучше пробовать локально; std::env::temp_dir() даёт папку, которая работает где угодно.

Проверь себя

1. Что будет, если вызвать fs::write для пути, где файл уже есть?

2. Как дописать в файл, а не перезаписать его?

3. Что даёт то, что main сама возвращает std::io::Result<()>?

4. Что делает оператор ?, если fs::read_to_string(path)? завершился ошибкой?

5. Зачем читать большой файл по строкам через BufReader, а не через fs::read_to_string?

Результат: 0 / 5