$ sudo teach IT
Модуль 7 · Проект целиком · Урок 7.2

Чтение и запись файлов

Вы научите «Копилку» сохранять траты в текстовый файл и читать их обратно, не падая от ошибок ввода-вывода.

Теория + практика~30 минутСредний

В прошлом уроке мы разложили «Копилку» по файлам, и код стал аккуратным. Но у программы осталась неприятная привычка: стоит набрать выход, как все траты исчезают, потому что вектор Vec<Expense> живёт только в оперативной памяти. Сегодня исправим это: научим программу записывать траты в файл на диске и читать их оттуда при следующем запуске. Заодно разберёмся, что делать, когда файловая операция не получилась, и почему это не повод для паники.

Формат файла: одна трата — одна строка

Представьте бумажную тетрадь расходов. Вы записываете трату на новой строке, а завтра открываете тетрадь и читаете всё сверху вниз. Нам нужно то же самое, только тетрадью будет обычный текстовый файл. Его можно открыть в любом редакторе и проверить глазами, поэтому для первого проекта лучше формата не придумать.

Официально такой формат называют текстовым файлом с разделителями: каждая запись лежит в своей строке, а поля внутри строки отделены одним и тем же символом. Назовём наш файл kopilka.txt, а строку соберём из трёх полей: сумма, категория, заметка.

Текст · kopilka.txt
350;еда;обед
120;транспорт;метро
90;дом;лампа

Почему разделитель именно ;? Пробел не подходит: в заметке пробелы встречаются постоянно («обед в столовой»), и строку нельзя было бы честно разрезать. Запятая тоже плоха, потому что люди пишут её в заметках сами. А точка с запятой в обычной заметке почти не встречается.

«Почти» не значит «никогда», поэтому вводим правило: если в заметке всё же оказалась точка с запятой, при записи заменяем её на запятую. Пара запятых вместо точки с запятой ничего важного не ломает, зато каждая строка файла всегда делится ровно на три поля.

ПолеПримерЧто это
сумма350целое число рублей, u32
категорияедаслово, которое знает Category::from_word
заметкаобедлюбой текст без ;, может быть пустой

Запись: fs::write и метод to_line

Запись в файл похожа на переписывание тетради начисто: вы берёте чистый лист и целиком переносите на него все траты. Rust делает это одной функцией. Официально она называется std::fs::write; fs — сокращение от file system, «файловая система». Функции даются путь к файлу и содержимое. Если файла нет, она его создаст, а если есть — полностью заменит старое содержимое новым. Попробуем отдельно, в маленькой программе:

Rust · main.rs
use std::fs;

fn main() {
    let result = fs::write("hello.txt", "привет\n");
    println!("{:?}", result);
}
Вывод
Ok(())

Разбор по строкам

  • use std::fs; — делаем ярлык на модуль fs из стандартной библиотеки, как use из прошлого урока.
  • fs::write("hello.txt", "привет\n") — создаёт файл hello.txt в текущей папке и кладёт в него слово и символ перевода строки \n. Если файл был, его старое содержимое пропадает.
  • result — то, что вернула функция. Запись могла не получиться (нет прав, диск переполнен), поэтому ответ — это Result, знакомый вам по модулю про ошибки.
  • Ok(()) — запись удалась. В скобках стоит () — пустой кортеж, то есть «ничего интересного, просто получилось».

Тип этого результата называется io::Result<()>. Это короткая запись для Result<(), io::Error>: ошибка всегда одного вида, io::Error, «ошибка ввода-вывода» (input/output, ввод-вывод). Чтобы писать io::, наверху файла нужен use std::io;.

Запись затирает старое

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

Одна трата → одна строка

Прежде чем записывать, нужно уметь превращать трату в строку файла. Это работа самой траты, поэтому добавим метод в impl Expense в файле expense.rs. Он собирает строку знакомым вам format!:

Rust · expense.rs
pub fn to_line(&self) -> String {
    let note = self.note.replace(';', ",");
    format!("{};{};{}", self.amount, self.category.title(), note)
}

Разбор по строкам

  • pub fn to_line(&self) -> String — метод читает трату и возвращает новую строку. pub нужен, потому что вызывать его будет другой файл, storage.rs.
  • self.note.replace(';', ",") — метод replace делает копию строки, где каждая точка с запятой заменена на запятую. Первый аргумент в одинарных кавычках — один символ (char), второй в двойных — строка. Оригинальная заметка в трате не меняется.
  • format!("{};{};{}", ...) — шаблон с тремя местами. Точки с запятой между {} — это обычные символы шаблона, они и станут разделителями.
  • self.category.title() — уже знакомое слово категории: еда, дом и так далее. Именно его умеет разбирать from_word.

Сборка текста и сама функция save

Теперь функция, которая записывает все траты. Она лежит в новом файле storage.rs (storage — «хранилище»): собирает текст из строк и отдаёт его fs::write.

Rust · storage.rs
pub fn save(path: &str, expenses: &[Expense]) -> io::Result<()> {
    let mut text = String::new();
    for expense in expenses {
        text.push_str(&expense.to_line());
        text.push('\n');
    }
    fs::write(path, text)
}

Разбор по строкам

  • path: &str — путь к файлу. Передаём его параметром, чтобы в тестах можно было писать в другой файл.
  • expenses: &[Expense] — траты только для чтения: сохранять не значит менять.
  • let mut text = String::new(); — пустая строка-владелец, в которую будем дописывать, поэтому mut.
  • text.push_str(&expense.to_line()); — приклеивает к тексту строку траты. push_str принимает &str, поэтому перед результатом to_line стоит &.
  • text.push('\n'); — добавляет один символ — перевод строки. Без него все траты слиплись бы в одну строку.
  • fs::write(path, text) — последнее выражение без точки с запятой: результат записи сразу становится результатом save. Ошибку мы не обрабатываем, а честно отдаём вызывающему.

Чтение: read_to_string, lines и from_line

Чтение — обратное действие: открыли тетрадь, прочитали строку, разобрали её на сумму, категорию и заметку, перешли к следующей. В Rust на это есть три инструмента. fs::read_to_string(path) читает весь файл в одну строку String и тоже возвращает io::Result, потому что файла может не оказаться. Метод lines() режет текст на строки, причём символы перевода строки в них уже не входят. А splitn(3, ';') режет одну строку по точке с запятой на не больше трёх кусков: если в файле, отредактированном вручную, окажется лишний разделитель, всё лишнее попадёт в заметку, а не потеряется.

Разбирать строку будет метод from_line, парный к to_line. Возвращать он будет Option<Expense>: нормальная строка даст Some(трата), а битая — None. Почему не Result? Потому что битая строка для нас не авария. Если кто-то ради шутки вписал в файл слово «мусор», остальные траты надо загрузить, а эту строку просто пропустить.

Rust · expense.rs
pub fn from_line(line: &str) -> Option<Expense> {
    let mut parts = line.splitn(3, ';');
    let amount = parts.next()?.trim().parse::<u32>().ok()?;
    let category = Category::from_word(parts.next()?.trim())?;
    let note = parts.next()?.trim();
    Some(Expense::new(amount, category, note))
}

Разбор по строкам

  • line.splitn(3, ';') — делит строку на не более чем три куска и возвращает итератор по ним (итераторы вы встречали в модуле про коллекции).
  • let mut parts — mut нужен, потому что каждый вызов next() двигает итератор вперёд.
  • parts.next()? — next() достаёт следующий кусок как Option<&str>. Знак ? работает и с Option, как с Result: если внутри None, функция сразу возвращает None, а если Some — достаёт значение.
  • .trim().parse::<u32>().ok()? — убираем пробелы, пробуем получить число. parse возвращает Result, а .ok() превращает его в Option: Ok(n) становится Some(n), а Err(...) — None. Причину ошибки мы выбрасываем, нам хватает факта «строка битая».
  • Category::from_word(parts.next()?.trim())? — слово категории превращаем в Category. Неизвестное слово даст None, и ? тут же выйдет из функции.
  • let note = parts.next()?.trim(); — третий кусок — заметка. Если куска нет (строка вида 350;еда), снова None.
  • Some(Expense::new(amount, category, note)) — все три поля разобраны, собираем трату и заворачиваем в Some.

Проследим две строки. Для 350;еда;обед получим куски 350, еда, обед, все три шага удадутся, и на выходе Some(...). Для строки мусор первый кусок — это сам мусор, parse не превратит его в число, сработает .ok()? и функция вернёт None уже на первой строчке. Пустая строка отсеется так же, потому что пустой текст тоже не число.

io::Result и оператор ?: функция load

Теперь соберём всё в функцию load. Тут надо решить одну важную вещь. При самом первом запуске файла kopilka.txt ещё нет, и read_to_string вернёт ошибку. Но это не авария: просто трат пока не было, как у человека, который ещё не завёл тетрадь. Значит, «файла нет» нужно отличать от настоящих бед вроде «нет прав на чтение».

Для этого у io::Error есть метод kind(), он возвращает вид ошибки — значение перечисления io::ErrorKind. Нам нужен вид NotFound, «не найдено». Запрос «файла нет» превращается в пустой список, все остальные ошибки отдаются наверх:

Rust · storage.rs
pub fn load(path: &str) -> io::Result<Vec<Expense>> {
    let text = match fs::read_to_string(path) {
        Ok(text) => text,
        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(err),
    };
    let mut expenses = Vec::new();
    for line in text.lines() {
        if let Some(expense) = Expense::from_line(line) {
            expenses.push(expense);
        }
    }
    Ok(expenses)
}

Разбор по строкам

  • pub fn load(path: &str) -> io::Result<Vec<Expense>> — на выходе либо вектор трат, либо ошибка ввода-вывода.
  • match fs::read_to_string(path) — читаем файл целиком и смотрим на результат, как на любой Result.
  • Ok(text) => text — прочитали успешно, значение match — сам текст, он попадает в переменную text.
  • Err(err) if err.kind() == io::ErrorKind::NotFound — ветка с условием (в Rust оно называется match guard, «страж»): ветка подходит, только когда образец совпал и условие после if верно. Тогда возвращаем Ok(Vec::new()): пустой список.
  • Err(err) => return Err(err) — любая другая ошибка уходит вызывающему без изменений.
  • for line in text.lines() — идём по строкам файла.
  • if let Some(expense) = Expense::from_line(line) — если строка разобралась, кладём трату в вектор, если нет — молча идём дальше.
  • Ok(expenses) — всё прочитано, отдаём результат.

Здесь легко было бы написать fs::read_to_string(path).unwrap() и успокоиться. Но тогда при первом запуске программа упала бы с паникой, а пользователь увидел бы страшное сообщение вместо пустой «Копилки». В модуле про ошибки вы уже видели, что unwrap годится, когда ошибка означает баг в программе. А «файла нет» — нормальная жизнь, её нужно обработать.

Откуда берётся путь

Путь kopilka.txt без папок — относительный. Он отсчитывается от папки, откуда запущена программа, а не от папки с исходниками. Если запускать cargo run в корне проекта (рядом с Cargo.toml), файл появится там же. Запустите команду из другой папки, и программа заведёт новую пустую тетрадь.

Подключаем к программе

Остаётся пришить хранилище к main.rs. Нужны три изменения. Первое: подключить модуль (mod storage;), сделать ярлыки на load и save и завести константу с именем файла, чтобы строка "kopilka.txt" не повторялась по коду. Второе: при старте вместо пустого вектора вызывать load. Третье: после каждой добавленной траты вызывать save.

Главное правило этого урока: ошибки сохранения и загрузки печатаем, но не паникуем. Если запись не удалась, трата всё равно лежит в памяти, программа может работать дальше, а пользователю достаточно честно сказать, что файл не обновился. Для этого хватит if let Err(err) = ... и обычного match.

Практика: «Копилка» запоминает траты

Работаем в проекте kopilka из прошлого урока. Файлы command.rs и report.rs трогать не нужно.

Шаг 1. Новый файл storage.rs

Создайте рядом с main.rs файл src/storage.rs и вставьте в него обе функции целиком:

Rust · storage.rs
use std::fs;
use std::io;

use crate::expense::Expense;

pub fn save(path: &str, expenses: &[Expense]) -> io::Result<()> {
    let mut text = String::new();
    for expense in expenses {
        text.push_str(&expense.to_line());
        text.push('\n');
    }
    fs::write(path, text)
}

pub fn load(path: &str) -> io::Result<Vec<Expense>> {
    let text = match fs::read_to_string(path) {
        Ok(text) => text,
        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(err),
    };
    let mut expenses = Vec::new();
    for line in text.lines() {
        if let Some(expense) = Expense::from_line(line) {
            expenses.push(expense);
        }
    }
    Ok(expenses)
}

Шаг 2. Два метода в expense.rs

Откройте src/expense.rs и вставьте оба метода в impl Expense, сразу после describe, перед закрывающей скобкой блока:

Rust · expense.rs
    pub fn to_line(&self) -> String {
        let note = self.note.replace(';', ",");
        format!("{};{};{}", self.amount, self.category.title(), note)
    }

    pub fn from_line(line: &str) -> Option<Expense> {
        let mut parts = line.splitn(3, ';');
        let amount = parts.next()?.trim().parse::<u32>().ok()?;
        let category = Category::from_word(parts.next()?.trim())?;
        let note = parts.next()?.trim();
        Some(Expense::new(amount, category, note))
    }

Шаг 3. main.rs

Замените src/main.rs целиком. Изменились четыре места: строка mod storage; и ярлык use storage::{load, save}; наверху, константа DATA_FILE, вызов save в ветке Command::Add и загрузка трат в начале main.

Rust · main.rs
mod command;
mod expense;
mod report;
mod storage;

use std::io;

use command::{Command, parse_command};
use expense::Expense;
use report::{print_list, print_report, print_status, total};
use storage::{load, save};

pub const MONTH_LIMIT: u32 = 30_000;
const DATA_FILE: &str = "kopilka.txt";

fn print_help() {
    println!("Команды:");
    println!("  добавить <сумма> <категория> [заметка] - записать трату");
    println!("  список - показать все траты");
    println!("  отчёт - итоги по категориям");
    println!("  помощь - показать эту подсказку");
    println!("  выход - закончить работу");
    println!("Категории: еда, транспорт, дом, отдых, другое");
}

fn run_command(command: Command, expenses: &mut Vec<Expense>) -> bool {
    match command {
        Command::Add(expense) => {
            println!("Записано: {}", expense.describe());
            expenses.push(expense);
            if let Err(err) = save(DATA_FILE, expenses) {
                println!("Не удалось сохранить: {err}");
            }
            let spent = total(expenses);
            println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
            print_status(spent, MONTH_LIMIT);
        }
        Command::List => print_list(expenses),
        Command::Report => print_report(expenses),
        Command::Help => print_help(),
        Command::Quit => return false,
    }
    true
}

fn main() {
    println!("Копилка 0.7");
    println!("Наберите помощь, чтобы увидеть команды.");

    let mut expenses = match load(DATA_FILE) {
        Ok(loaded) => loaded,
        Err(err) => {
            println!("Не удалось прочитать {DATA_FILE}: {err}");
            Vec::new()
        }
    };
    if !expenses.is_empty() {
        println!("Загружено трат: {}", expenses.len());
    }
    let mut line = String::new();

    loop {
        line.clear();
        let bytes = io::stdin()
            .read_line(&mut line)
            .expect("не удалось прочитать строку");
        if bytes == 0 {
            break;
        }

        match parse_command(&line) {
            Ok(command) => {
                if !run_command(command, &mut expenses) {
                    break;
                }
            }
            Err(err) => println!("Ошибка: {err}"),
        }
    }

    println!(
        "Итого потрачено: {} руб. из {MONTH_LIMIT} руб.",
        total(&expenses)
    );
}

Шаг 4. Запуск

Запустите программу из корня проекта и добавьте две траты. Заметку в первой намеренно сделайте с точкой с запятой:

Терминал
cargo run
Вывод
Копилка 0.7
Наберите помощь, чтобы увидеть команды.
> добавить 350 еда обед; с кофе
Записано: 350 руб., еда, «обед; с кофе»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
> добавить 120 транспорт метро
Записано: 120 руб., транспорт, «метро»
Потрачено: 470 руб. из 30000 руб.
Статус: в норме
> выход
Итого потрачено: 470 руб. из 30000 руб.

В корне проекта появился файл kopilka.txt. Откройте его в редакторе или выведите на экран (cat kopilka.txt в macOS и Linux, type kopilka.txt в Windows). Обратите внимание: вместо точки с запятой в заметке стоит запятая.

Текст · kopilka.txt
350;еда;обед, с кофе
120;транспорт;метро

Шаг 5. Второй запуск

Запустите cargo run снова. Теперь программа должна сама рассказать, что нашла старые траты:

Вывод
Копилка 0.7
Наберите помощь, чтобы увидеть команды.
Загружено трат: 2
> список
1. 350 руб., еда, «обед, с кофе»
2. 120 руб., транспорт, «метро»
> добавить 90 дом лампа
Записано: 90 руб., дом, «лампа»
Потрачено: 560 руб. из 30000 руб.
Статус: в норме
> выход
Итого потрачено: 560 руб. из 30000 руб.

Шаг 6. Испортим файл

Откройте kopilka.txt, допишите внизу строку мусор и пустую строку, сохраните и запустите программу ещё раз. Команда список покажет три нормальные траты: битые строки молча пропущены, а после следующей записи они исчезнут из файла вовсе, потому что save перезаписывает его заново из памяти.

Проверьте себя

  • Файл kopilka.txt появляется в корне проекта после первой же траты.
  • После перезапуска программа пишет Загружено трат: N и список показывает старые траты.
  • Заметка с ; попала в файл с запятой на месте точки с запятой.
  • Удалите kopilka.txt и запустите программу: она стартует с пустым списком без паники.
  • Сборка идёт без предупреждений.

Частые ошибки

1. «Файл не найден» или траты «пропали»

Запустили программу из другой папки или кликом по бинарнику: путь kopilka.txt отсчитывается от рабочей папки, и программа завела пустой файл рядом с запуском. Исправление: запускайте cargo run из корня проекта, а старые траты ищите в файле, который лежит рядом с Cargo.toml.

2. Паника при первом запуске

Если написать fs::read_to_string(path).unwrap(), то без файла программа упадёт:

Вывод
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Перед этой строкой в настоящем сообщении есть ещё одна, с файлом и номером строки, где случилась паника. Почему так: файла ещё нет, а unwrap на любой ошибке паникует. Как исправить: проверить err.kind() == io::ErrorKind::NotFound и вернуть пустой список, как в load.

3. Забыли mod storage;

Ошибка компилятора
error[E0432]: unresolved import `storage`
 --> src/main.rs:11:5
   |
11 | use storage::{load, save};
   |     ^^^^^^^ use of unresolved module or unlinked crate `storage`
   |
help: to make use of source file src/storage.rs, use `mod storage` in this file to declare the module

Почему: файл storage.rs лежит в src, но компилятор о нём не знает, пока в main.rs нет mod storage;. Как исправить: добавьте строку наверху; компилятор в подсказке help сам называет её.

4. Пустые и битые строки в файле

В файле лишняя пустая строка, а программа ничего не сообщает. Так и задумано: from_line вернёт None, и load пропустит строку. Если вам важно знать о таких строках, считайте пропущенные и печатайте число в конце загрузки (это хорошая тренировка). А вот ловить пустую последнюю строку отдельным if не нужно: lines() и так не возвращает пустой хвост после последнего \n.

5. Сохранили только новую трату

Если передать в fs::write текст одной новой траты, весь остальной файл будет затёрт. Поэтому save получает весь срез expenses и пишет всё заново.

Что важно запомнить

  • Файл формата «одна трата — одна строка» удобен тем, что его можно прочитать глазами; разделитель ;, а лишние ; в заметке при записи заменяются на запятые.
  • fs::write(path, text) создаёт файл или полностью заменяет старый; возвращает io::Result<()>.
  • fs::read_to_string(path) читает весь файл в String; lines() режет на строки, splitn(3, ';') — на не более чем три поля.
  • ? работает и с Option: на None функция сразу возвращает None; .ok() превращает Result в Option.
  • «Файла нет» (ErrorKind::NotFound) — это пустой список, а не ошибка; остальные ошибки возвращаем наверх.
  • Ошибки загрузки и сохранения печатаем, не паникуем; путь относительный, отсчитывается от папки запуска.

Данные теперь живут между запусками, но пока вы проверяли программу только руками. В следующем уроке научимся проверять разбор команд и отчёт автоматическими тестами.

На следующий урок