В прошлом уроке мы разложили «Копилку» по файлам, и код стал аккуратным. Но у программы осталась неприятная привычка: стоит набрать выход, как все траты исчезают, потому что вектор Vec<Expense> живёт только в оперативной памяти. Сегодня исправим это: научим программу записывать траты в файл на диске и читать их оттуда при следующем запуске. Заодно разберёмся, что делать, когда файловая операция не получилась, и почему это не повод для паники.
Формат файла: одна трата — одна строка
Представьте бумажную тетрадь расходов. Вы записываете трату на новой строке, а завтра открываете тетрадь и читаете всё сверху вниз. Нам нужно то же самое, только тетрадью будет обычный текстовый файл. Его можно открыть в любом редакторе и проверить глазами, поэтому для первого проекта лучше формата не придумать.
Официально такой формат называют текстовым файлом с разделителями: каждая запись лежит в своей строке, а поля внутри строки отделены одним и тем же символом. Назовём наш файл kopilka.txt, а строку соберём из трёх полей: сумма, категория, заметка.
350;еда;обед
120;транспорт;метро
90;дом;лампа
Почему разделитель именно ;? Пробел не подходит: в заметке пробелы встречаются постоянно («обед в столовой»), и строку нельзя было бы честно разрезать. Запятая тоже плоха, потому что люди пишут её в заметках сами. А точка с запятой в обычной заметке почти не встречается.
«Почти» не значит «никогда», поэтому вводим правило: если в заметке всё же оказалась точка с запятой, при записи заменяем её на запятую. Пара запятых вместо точки с запятой ничего важного не ломает, зато каждая строка файла всегда делится ровно на три поля.
| Поле | Пример | Что это |
|---|---|---|
| сумма | 350 | целое число рублей, u32 |
| категория | еда | слово, которое знает Category::from_word |
| заметка | обед | любой текст без ;, может быть пустой |
Запись: fs::write и метод to_line
Запись в файл похожа на переписывание тетради начисто: вы берёте чистый лист и целиком переносите на него все траты. Rust делает это одной функцией. Официально она называется std::fs::write; fs — сокращение от file system, «файловая система». Функции даются путь к файлу и содержимое. Если файла нет, она его создаст, а если есть — полностью заменит старое содержимое новым. Попробуем отдельно, в маленькой программе:
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!:
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.
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? Потому что битая строка для нас не авария. Если кто-то ради шутки вписал в файл слово «мусор», остальные траты надо загрузить, а эту строку просто пропустить.
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, «не найдено». Запрос «файла нет» превращается в пустой список, все остальные ошибки отдаются наверх:
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 и вставьте в него обе функции целиком:
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, перед закрывающей скобкой блока:
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.
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). Обратите внимание: вместо точки с запятой в заметке стоит запятая.
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) — это пустой список, а не ошибка; остальные ошибки возвращаем наверх. - Ошибки загрузки и сохранения печатаем, не паникуем; путь относительный, отсчитывается от папки запуска.
Данные теперь живут между запусками, но пока вы проверяли программу только руками. В следующем уроке научимся проверять разбор команд и отчёт автоматическими тестами.