Глава 12: Работа с файлами и вводом-выводом в Rust

Содержание

Добро пожаловать в одиннадцатую лекцию нашего курса по Rust! Сегодня мы погрузимся в увлекательный мир работы с файлами и вводом-выводом (I/O). Этот раздел критически важен для большинства реальных приложений, будь то чтение конфигурационных файлов, обработка данных или запись логов. Мы начнем с основ, постепенно углубляясь в детали, и закончим практическим упражнением. Лекция рассчитана как на новичков, так и на тех, кто хочет освоить тонкости работы с I/O в Rust.


Основы ввода-вывода: std::fs::File и std::io

В Rust ввод-вывод построен вокруг модуля стандартной библиотеки std::io, который предоставляет инструменты для работы с потоками данных, а также модуля std::fs, предназначенного для операций с файловой системой.

Открытие файла: std::fs::File

Для работы с файлом его нужно сначала открыть. В Rust это делается с помощью структуры File. Давайте разберем простой пример:

use std::fs::File;
use std::io::Read;

fn main() -> std::io::Result<()> {
    // Открываем файл для чтения
    let mut file = File::open("example.txt")?;
    // Создаем буфер для хранения содержимого
    let mut contents = String::new();
    // Читаем весь файл в строку
    file.read_to_string(&mut contents)?;
    println!("Содержимое файла:\n{}", contents);
    Ok(())
}

Что здесь происходит?

  1. File::open — пытается открыть файл с именем example.txt. Возвращает Result<File, std::io::Error>. Если файл не существует или нет прав доступа, программа завершится с ошибкой.
  2. ? — оператор обработки ошибок. Если Result содержит Err, функция вернет ошибку, иначе извлечет значение Ok.
  3. read_to_string — метод структуры File, который читает весь файл в строку. Требует изменяемую ссылку на String.

Создание файла

Если нужно создать файл или перезаписать существующий:

use std::fs::File;
use std::io::Write;

fn main() -> std::io::Result<()> {
    let mut file = File::create("output.txt")?;
    file.write_all(b"Hello, Rust!")?;
    Ok(())
}

Модуль std::io

Модуль std::io предоставляет трейты и структуры для работы с потоками ввода-вывода:

Эти трейты используются не только с файлами, но и с сетевыми сокетами, стандартным вводом (stdin) и выводом (stdout).


Буферизация: BufReader и BufWriter

Работа с файлами напрямую через File может быть неэффективной, особенно при частых операциях чтения или записи. Для оптимизации используются буферизированные обертки: BufReader и BufWriter.

BufReader

BufReader минимизирует прямые системные вызовы, читая данные в буфер:

use std::fs::File;
use std::io::{BufReader, Read};

fn main() -> std::io::Result<()> {
    let file = File::open("example.txt")?;
    let mut reader = BufReader::new(file);
    let mut contents = String::new();
    reader.read_to_string(&mut contents)?;
    println!("Содержимое:\n{}", contents);
    Ok(())
}

BufWriter

Аналогично, BufWriter буферизирует запись:

use std::fs::File;
use std::io::{BufWriter, Write};

fn main() -> std::io::Result<()> {
    let file = File::create("output.txt")?;
    let mut writer = BufWriter::new(file);
    writer.write_all(b"Hello, Rust!")?;
    writer.flush()?; // Сбрасываем буфер в файл
    Ok(())
}

Совет: Всегда используйте буферизацию для больших файлов или частых операций I/O.


Работа с путями: std::path::Path и PathBuf

Пути в Rust обрабатываются через структуры Path и PathBuf.

Path

Path — это неизменяемый срез пути, аналогичный str:

use std::path::Path;

fn main() {
    let path = Path::new("/home/user/example.txt");
    println!("Расширение: {:?}", path.extension()); // Some("txt")
    println!("Имя файла: {:?}", path.file_name()); // Some("example.txt")
    println!("Существует? {}", path.exists());
}

PathBuf

PathBuf — это изменяемая версия пути, аналогичная String:

use std::path::PathBuf;

fn main() {
    let mut path = PathBuf::from("/home/user");
    path.push("example.txt");
    println!("Путь: {:?}", path); // "/home/user/example.txt"
}

Практический совет: Используйте Path для проверки существующих путей, а PathBuf — для построения новых.


Чтение и запись текстовых данных

Чтение построчно

Для больших файлов полезно читать их построчно с помощью BufReader:

use std::fs::File;
use std::io::{BufReader, BufRead};

fn main() -> std::io::Result<()> {
    let file = File::open("example.txt")?;
    let reader = BufReader::new(file);

    for line in reader.lines() {
        println!("{}", line?);
    }
    Ok(())
}

Запись текста

Пример записи строк в файл:

use std::fs::File;
use std::io::{BufWriter, Write};

fn main() -> std::io::Result<()> {
    let file = File::create("output.txt")?;
    let mut writer = BufWriter::new(file);
    writeln!(writer, "Первая строка")?;
    writeln!(writer, "Вторая строка")?;
    writer.flush()?;
    Ok(())
}

Дозапись в конец файла

Чтобы дописать данные в конец файла, нужно использовать OpenOptions вместо File::create
и указать режим append:

use std::fs::OpenOptions;
use std::io::{BufWriter, Write};

fn main() -> std::io::Result<()> {
    let file = OpenOptions::new()
        .write(true)  // Разрешаем запись
        .append(true) // Устанавливаем режим дозаписи (append)
        .create(true) // Создаем файл, если он не существует
        .open("output.txt")?; // Открываем файл
    let mut writer = BufWriter::new(file);
    writeln!(writer, "Первая строка")?;
    writeln!(writer, "Вторая строка")?;
    writer.flush()?;
    Ok(())
}

Копирование файлов (std::fs::copy)

В Rust используется функция std::fs::copy, которая принимает исходный и целевой пути и возвращает Result<u64, std::io::Error>, где u64 — количество скопированных байтов.

Пример:

use std::fs;

fn main() {
    match fs::copy("source.txt", "destination.txt") {
        Ok(bytes) => println!("Скопировано {} байт", bytes),
        Err(e) => println!("Ошибка при копировании: {}", e),
    }
}

Упрощённый вариант с unwrap

use std::fs;

fn main() {
    let bytes = fs::copy("source.txt", "destination.txt").unwrap();
    println!("Скопировано {} байт", bytes);
}
Предупреждение: Если исходного файла нет или возникнет ошибка (например, нет прав), программа завершится с паникой.

Проверка перед копированием

Чтобы избежать ошибок, можно проверить существование исходного файла:

use std::fs;
use std::path::Path;

fn main() {
    let source = "source.txt";
    let destination = "destination.txt";
    if Path::new(source).exists() {
        match fs::copy(source, destination) {
            Ok(bytes) => println!("Скопировано {} байт", bytes),
            Err(e) => println!("Ошибка: {}", e),
        }
    } else {
        println!("Исходный файл не существует");
    }
}

Перемещение файлов (std::fs::rename)

В Rust используется функция std::fs::rename, которая принимает исходный и целевой пути и возвращает Result<(), std::io::Error>.

Пример:

use std::fs;

fn main() {
    match fs::rename("oldname.txt", "newname.txt") {
        Ok(()) => println!("Файл успешно перемещён"),
        Err(e) => println!("Ошибка при перемещении: {}", e),
    }
}

Упрощённый вариант с unwrap

use std::fs;

fn main() {
    fs::rename("oldname.txt", "newname.txt").unwrap();
    println!("Файл перемещён");
}

Перемещение между файловыми системами

std::fs::rename работает только в пределах одной файловой системы (как rename в PHP). Если нужно переместить файл между разными дисками или разделами, придётся сначала скопировать, а затем удалить исходный файл вручную:
use std::fs;

fn main() {
    let source = "source.txt";
    let destination = "/mnt/other_disk/destination.txt";

    // Копируем файл
    match fs::copy(source, destination) {
        Ok(_) => {
            // Удаляем исходный файл после успешного копирования
            match fs::remove_file(source) {
                Ok(()) => println!("Файл успешно перемещён"),
                Err(e) => println!("Ошибка при удалении исходного файла: {}", e),
            }
        }
        Err(e) => println!("Ошибка при копировании: {}", e),
    }
}

rename в POSIX (и в Rust) использует системный вызов, который не работает между разными файловыми системами. В таких случаях копирование + удаление — стандартный подход.

Пример: Копирование и перемещение

Создадим файл, скопируем его, а затем переместим копию:

use std::fs;

fn main() {
    // Создаём исходный файл
    fs::write("original.txt", "Привет, мир!").unwrap();
    println!("Создан original.txt");

    // Копируем файл
    match fs::copy("original.txt", "copy.txt") {
        Ok(bytes) => println!("Скопировано {} байт в copy.txt", bytes),
        Err(e) => println!("Ошибка копирования: {}", e),
    }

    // Перемещаем копию
    match fs::rename("copy.txt", "moved.txt") {
        Ok(()) => println!("copy.txt перемещён в moved.txt"),
        Err(e) => println!("Ошибка перемещения: {}", e),
    }
}

Вывод:

  Создан original.txt
  Скопировано 21 байт в copy.txt
  copy.txt перемещён в moved.txt

Удаление файла (std::fs::remove_file)

В Rust удаление файла осуществляется с помощью функции std::fs::remove_file. Она принимает путь к файлу и возвращает результат типа Result<(), std::io::Error>, что позволяет обработать возможные ошибки (например, если файла не существует).

Пример:

use std::fs;

fn main() {
    let result = fs::remove_file("example.txt");
    match result {
        Ok(()) => println!("Файл успешно удалён"),
        Err(e) => println!("Ошибка при удалении файла: {}", e),
    }
}

Упрощённый вариант с unwrap

Если вы уверены, что файл существует, и не хотите обрабатывать ошибки вручную, можно использовать .unwrap() (но это не рекомендуется в реальных проектах):

use std::fs;

fn main() {
    fs::remove_file("example.txt").unwrap();
    println!("Файл удалён");
}
Предупреждение: Если файла нет или возникнет ошибка, программа завершится с паникой.

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

Часто проверяют существование файла перед удалением, для этого в Rust можно использовать std::path::Path:

use std::fs;
use std::path::Path;

fn main() {
    let path = "example.txt";
    if Path::new(path).exists() {
        match fs::remove_file(path) {
            Ok(()) => println!("Файл успешно удалён"),
            Err(e) => println!("Ошибка: {}", e),
        }
    } else {
        println!("Файл не существует");
    }
}

Удаление директорий

Пример:

   use std::fs;

   fn main() {
       fs::remove_dir_all("folder").unwrap(); // Удаляет папку и всё внутри
       println!("Папка удалена");
   }

Работа с путями

Используйте std::path::PathBuf для динамических путей:

   use std::fs;
   use std::path::PathBuf;

   fn main() {
       let path = PathBuf::from("example.txt");
       fs::remove_file(&path).unwrap();
       println!("Файл удалён");
   }

Пример

Создадим файл, а затем удалим его:

use std::fs;

fn main() {
    // Создаём файл
    fs::write("temp.txt", "Временный файл").unwrap();
    println!("Файл создан");

    // Удаляем файл
    match fs::remove_file("temp.txt") {
        Ok(()) => println!("Файл temp.txt удалён"),
        Err(e) => println!("Ошибка: {}", e),
    }
}

Вывод:

  Файл создан
  Файл temp.txt удалён

Выполнение системных команд в Rust

В Rust основной инструмент для запуска внешних программ — это модуль std::process, в частности структуры Command и Output. Вот основные варианты:

Простой запуск команды

В Rust для простого запуска используется std::process::Command с методом .output():

use std::process::Command;

fn main() {
    let output = Command::new("ls")
        .arg("-l")
        .output()
        .expect("Ошибка выполнения команды");

    // Вывод в stdout как строка
    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Вывод: {}", stdout);

    // Код возврата
    println!("Код возврата: {}", output.status.code().unwrap_or(-1));
}
Примечание: На Windows замените ls -l на, например, dir.

Раздельный захват stdout и stderr

В Rust для этого используют Command:

use std::process::Command;

fn main() {
    let output = Command::new("ls")
        .arg("-l")
        .arg("nonexistent") // добавим несуществующий файл для ошибки
        .output()
        .expect("Ошибка выполнения команды");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    println!("stdout: {}", stdout);
    println!("stderr: {}", stderr);
    println!("Код возврата: {}", output.status.code().unwrap_or(-1));
}

Вывод (пример на Linux):

  stdout: 
  stderr: ls: cannot access 'nonexistent': No such file or directory
  Код возврата: 2

Прямой вывод в консоль

В Rust для вывода результата в консоль используйте .status() вместо .output():

use std::process::Command;

fn main() {
    let status = Command::new("ls")
        .arg("-l")
        .status()
        .expect("Ошибка выполнения команды");

    println!("Код возврата: {}", status.code().unwrap_or(-1));
}

Вывод автоматически идёт в текущий терминал, но вы не можете захватить его как строку. Используйте .output(), если нужен захват.

Работа с потоками в реальном времени

Если нужно читать вывод по мере его появления можно настроить Stdio и использовать spawn:

use std::process::{Command, Stdio};
use std::io::{BufRead, BufReader};

fn main() {
    let mut child = Command::new("ping")
        .arg("google.com")
        .arg("-c")
        .arg("5")
        .stdout(Stdio::piped()) // перенаправляем stdout в канал
        .stderr(Stdio::piped()) // перенаправляем stderr в канал
        .spawn()
        .expect("Ошибка запуска команды");

    // Читаем stdout
    if let Some(stdout) = child.stdout.take() {
        let reader = BufReader::new(stdout);
        for line in reader.lines() {
            println!("stdout: {}", line.unwrap());
        }
    }

    // Читаем stderr
    if let Some(stderr) = child.stderr.take() {
        let reader = BufReader::new(stderr);
        for line in reader.lines() {
            println!("stderr: {}", line.unwrap());
        }
    }

    let status = child.wait().expect("Ошибка ожидания завершения");
    println!("Код возврата: {}", status.code().unwrap_or(-1));
}

Вывод (пример):

  stdout: PING google.com (142.250.190.78) 56(84) bytes of data.
  stdout: 64 bytes from 142.250.190.78: icmp_seq=1 ttl=117 time=15.2 ms
  ...
  Код возврата: 0

Пример

Запустим команду echo и выведем результат:

use std::process::Command;

fn main() {
    let output = Command::new("echo")
        .arg("Hello from Rust!")
        .output()
        .expect("Ошибка выполнения команды");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Вывод: {}", stdout.trim()); // trim убирает перенос строки
}

Вывод:

  Вывод: Hello from Rust!

Итог


Конфигурационные файлы в Rust: INI и TOML

В Rust для работы с конфигурационными файлами в форматах INI и TOML можно использовать популярные библиотеки из экосистемы crates.io. Я опишу реализацию для обоих форматов с примерами.

Формат INI в Rust

Для работы с INI-файлами в Rust можно использовать библиотеку rust-ini. Она проста в использовании и поддерживает основные возможности формата INI.

Установка

Добавьте в ваш Cargo.toml:

[dependencies]
rust-ini = "0.19"
    

Пример реализации

1. Создание и запись INI-файла:

use ini::Ini;
use std::fs;

fn main() -> Result<(), Box> {
    // Создаем новый объект для работы с INI
    let mut config = Ini::new();

    // Добавляем глобальные параметры (без секции)
    config.with_section(None::)
        .set("ServerAliveInterval", "45")  // Устанавливаем значение для ключа
        .set("Compression", "yes");        // Еще один глобальный параметр
    // Добавляем секцию "Database" с параметрами
    config.with_section(Some("Database"))
        .set("host", "localhost")          // Хост базы данных
        .set("port", "5432")               // Порт как строка
        .set("name", "myapp_db");          // Имя базы данных
    // Добавляем секцию "App"
    config.with_section(Some("App"))
        .set("debug", "true")              // Режим отладки
        .set("log_level", "info");         // Уровень логирования

    // Записываем конфигурацию в файл config.ini
    config.write_to_file("config.ini")?;
    println!("INI-файл успешно создан!");
    Ok(())
}
    

2. Чтение INI-файла:

use ini::Ini;

fn main() -> Result<(), Box> {
    // Загружаем INI-файл из диска
    let config = Ini::load_from_file("config.ini")?;

    // Извлекаем значения с указанием секции и ключа, с дефолтными значениями на случай отсутствия
    let db_host = config.get_from(Some("Database"), "host").unwrap_or("default_host");  // Хост базы данных
    let debug = config.get_from(Some("App"), "debug").unwrap_or("false");               // Режим отладки
    let log_level = config.get_from(Some("App"), "log_level").unwrap_or("default");     // Уровень логирования

    println!("Database host: {}, Debug: {}, Log level: {}", db_host, debug, log_level);
    Ok(())
}
    

3. Пример config.ini:

ServerAliveInterval=45
Compression=yes
[Database]
host=localhost
port=5432
name=myapp_db
[App]
debug=true
log_level=info
    

Плюсы:

Минусы:

Формат TOML в Rust

Для работы с TOML в Rust чаще всего используется библиотека serde вместе с toml. TOML особенно популярен в Rust, так как это "родной" формат для Cargo.toml.

Установка

Добавьте в ваш Cargo.toml:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
    

Пример реализации

1. Создание структуры и запись TOML-файла:

use serde::{Serialize, Deserialize};
use std::fs;
use toml;

#[derive(Serialize, Deserialize)]
struct Config {
    app: AppConfig,         // Вложенная структура для секции app
    database: DatabaseConfig, // Вложенная структура для секции database
}

#[derive(Serialize, Deserialize)]
struct AppConfig {
    debug: bool,            // Булево значение для режима отладки
    log_level: String,      // Строковое значение для уровня логирования
}

#[derive(Serialize, Deserialize)]
struct DatabaseConfig {
    host: String,           // Хост базы данных
    port: u16,              // Порт как 16-битное число
    name: String,           // Имя базы данных
}

fn main() -> Result<(), Box> {
    // Создаем экземпляр структуры с данными
    let config = Config {
        app: AppConfig {
            debug: true,
            log_level: "info".to_string(),
        },
        database: DatabaseConfig {
            host: "localhost".to_string(),
            port: 5432,
            name: "myapp_db".to_string(),
        },
    };

    // Преобразуем структуру в строку TOML
    let toml_string = toml::to_string(&config)?;
    // Записываем строку в файл
    fs::write("config.toml", toml_string)?;
    println!("TOML-файл успешно создан!");
    Ok(())
}
    

2. Чтение TOML-файла:

use serde::{Serialize, Deserialize};
use std::fs;
use toml;

#[derive(Serialize, Deserialize, Debug)]
struct Config {
    app: AppConfig,
    database: DatabaseConfig,
}

#[derive(Serialize, Deserialize, Debug)]
struct AppConfig {
    debug: bool,
    log_level: String,
}

#[derive(Serialize, Deserialize, Debug)]
struct DatabaseConfig {
    host: String,
    port: u16,
    name: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Читаем содержимое файла в строку
    let toml_string = fs::read_to_string("config.toml")?;
    // Десериализуем строку в структуру Config
    let config: Config = toml::from_str(&toml_string)?;

    // Выводим значения из структуры
    println!(
        "Database host: {}, Debug: {}, Log level: {}",
        config.database.host, config.app.debug, config.app.log_level
    );
    Ok(())
}
    

3. Пример config.toml:

[app]
debug = true
log_level = "info"

[database]
host = "localhost"
port = 5432
name = "myapp_db"
    

Плюсы:

Минусы:

Как выбрать между INI и TOML в Rust?


Парсинг файлов: CSV и JSON

Для работы с форматами данных нужны внешние библиотеки. Добавьте их в Cargo.toml:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
csv = "1.1"

Эти крейты часто используются вместе для обработки структурированных данных в различных форматах.

Парсинг CSV

Пример чтения CSV-файла:

use std::fs::File;
use csv::Reader;

fn main() -> std::io::Result<()> {
    let file = File::open("data.csv")?;       // Открываем файл "data.csv" и возвращаем Result, где "?" обрабатывает ошибку, если файл не удалось открыть
    let mut rdr = Reader::from_reader(file);  // Создаем экземпляр csv::Reader из открытого файла для чтения CSV-данных

    for result in rdr.records() {
        let record = result?;                 // Получаем очередную запись (строку) из CSV-файла как Result, где "?" обрабатывает ошибку, если запись не удалось прочитать
        println!("{:?}", record);
    }
    Ok(())
}

Парсинг JSON

Пример чтения JSON с использованием serde_json:

use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::BufReader;

#[derive(Debug, Serialize, Deserialize)] // Автоматически реализует трейты Debug, Serialize и Deserialize для структуры или перечисления
struct Config {
    name: String,
    value: i32,
}

fn main() -> std::io::Result<()> {
    let file = File::open("config.json")?;
    let reader = BufReader::new(file);
    let config: Config = serde_json::from_reader(reader)?;
    println!("Конфигурация: {:?}", config);
    Ok(())
}

В контексте программирования, особенно в языках, использующих атрибуты или аннотации (например, Rust или Python с библиотеками вроде serde для сериализации/десериализации), указание #[serde(default)] обычно применяется к структуре или полю в структуре. Оно говорит, что если значение для этого поля отсутствует при десериализации (например, в JSON или другом формате данных), то будет использовано значение по умолчанию.

В Rust, например, с библиотекой serde, это работает следующим образом:

use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct Example {
    #[serde(default)]
    name: String, // Если "name" не указано, будет использовано значение по умолчанию для String, т.е. ""
    #[serde(default)]
    age: i32, // Если "age" не указано, будет использовано значение по умолчанию для i32, т.е. 0
}

Здесь #[serde(default)] указывает, что для поля будет использовано значение по умолчанию (определяемое типом данных), если оно не предоставлено в входных данных. Если вы хотите задать собственное значение по умолчанию, можно использовать #[serde(default = "function_name")], где function_name — это функция, возвращающая нужное значение.

Пример с кастомным значением:

use serde::{Serialize, Deserialize};
fn default_age() -> i32 {
    18
}
#[derive(Serialize, Deserialize)]
struct Example {
    #[serde(default)]
    name: String,
    #[serde(default = "default_age")]
    age: i32, // Если "age" не указано, будет использовано 18
}

Примеры реальных задач

Чтение конфигурации

Допустим, у нас есть config.json:

{"name": "App", "value": 42}

Код для его чтения уже приведен выше.

Логирование

Простой логгер:

use std::fs::OpenOptions;
use std::io::{BufWriter, Write};

fn log_message(msg: &str) -> std::io::Result<()> {
    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open("log.txt")?;
    let mut writer = BufWriter::new(file);
    writeln!(writer, "{}", msg)?;
    writer.flush()?;
    Ok(())
}

fn main() -> std::io::Result<()> {
    log_message("Программа запущена")?;
    Ok(())
}

Копирование файла

Копирование с буферизацией:

use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};

fn copy_file(src: &str, dst: &str) -> std::io::Result<()> {
    let src_file = File::open(src)?;
    let dst_file = File::create(dst)?;
    
    let mut reader = BufReader::new(src_file);
    let mut writer = BufWriter::new(dst_file);
    
    let mut buffer = [0; 1024]; // Буфер 1 КБ
    loop {
        let bytes_read = reader.read(&mut buffer)?;
        if bytes_read == 0 { break; } // Конец файла
        writer.write_all(&buffer[..bytes_read])?;
    }
    writer.flush()?;
    Ok(())
}

fn main() -> std::io::Result<()> {
    copy_file("source.txt", "dest.txt")?;
    Ok(())
}

Упражнение: Анализ текстового файла (подсчет слов)

Задание

Напишите программу, которая:

  1. Читает текстовый файл.
  2. Подсчитывает количество слов (разделенных пробелами).
  3. Выводит результат.

Решение

use std::fs::File;
use std::io::{BufReader, BufRead};

fn count_words(filename: &str) -> std::io::Result<usize> {
    let file = File::open(filename)?;
    let reader = BufReader::new(file);
    let mut word_count = 0;

    for line in reader.lines() {
        let line = line?;
        let words = line.split_whitespace().count();
        word_count += words;
    }
    Ok(word_count)
}

fn main() -> std::io::Result<()> {
    let filename = "example.txt";
    match count_words(filename) {
        Ok(count) => println!("Количество слов в файле {}: {}", filename, count),
        Err(e) => eprintln!("Ошибка: {}", e),
    }
    Ok(())
}

Разбор

  1. split_whitespace — разбивает строку на слова, игнорируя лишние пробелы.
  2. count — подсчитывает элементы в итераторе.
  3. Ошибки обрабатываются через Result и выводятся пользователю.

Проверочное задание

  1. Создайте файл example.txt с произвольным текстом.
  2. Запустите программу и проверьте результат.
  3. Добавьте обработку случая, когда файл пустой или не существует.

Заключение

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