Основы Rust: и снова работа с файлами

Игорь Грегорченко

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

Этот пост — вольный перевод на русский вот этой оригинальной статьи (с нашими дополнениями в местах, где это показалось нужным), которую написал Стив Донован. Начало можно найти вот здесь, а оглавление всей серии — вот тут.

На этот раз мы основательно обсудим работу с файлами в Rust, а также операции I/O с файловой системой.

Еще один взгляд на чтение файлов

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

fs::File реализует io::Read, который является трейтом. Эта фича определяет метод read, который будет заполнять фрагмент u8 байтами — это единственный обязательный метод, а некоторые другие предоставляемые методы вы получаете автоматически, как и в случае с Iterator. Вы можете использовать read_to_end для заполнения вектора байтов содержимым из читаемого файла, и read_to_string для заполнения строки — она должна быть закодирована в UTF-8.

Это «сырое» чтение, без буферизации. Для буферизованного чтения существует свойство io::BufRead, которое предоставляет нам read_line и итератор строк. io::BufReader в свою очередь предоставит реализацию io::BufRead для любой читаемой таблицы.

fs::File также реализует io::Write.

Самый простой способ убедиться, что все эти фичи доступны, — использовать std::io::prelude::*.

use std::fs::File;
use std::io;
use std::io::prelude::*;

fn read_all_lines(filename: &str) -> io::Result<()> {
let file = File::open(&filename)?;

let reader = io::BufReader::new(file);

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

Функция let line = line? может выглядеть немного странно. Строка, возвращаемая итератором, на самом деле является io::Result<String>, который мы разворачиваем с помощью ?. Это сделано из-за того, что во время этой итерации все может пойти не так — ошибки ввода-вывода, проглатывание куска байтов не UTF-8 и так далее.

Строки — это итератор, поэтому очень просто считать файл в вектор строк с помощью collect или распечатать строку с номерами строк с помощью итератора enumerate.

Однако это не самый эффективный способ чтения всех строк, поскольку для каждой строки выделяется новая строка. Более эффективно использовать read_line, хотя это и менее удобно. Обратите внимание, что возвращаемая строка включает в себя перевод строки, который можно удалить с помощью trim_right.

let mut reader = io::BufReader::new(file);
    let mut buf = String::new();
    while reader.read_line(&mut buf)? > 0 {
        {
            let line = buf.trim_right();
            println!("{}", line);
        }
        buf.clear();
    }

 

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

Это один из тех случаев, когда мы используем блок для управления заимствованием. Line заимствуется из buf, и это заимствование должно завершиться до того, как мы изменим buf.

Опять же, Rust пытается остановить нас от глупого поступка, который заключается в том, чтобы получить доступ к строке после того, как мы очистили буфер. Проверка заимствований иногда может быть ограничительной. Rust должен получить «нелексическое время жизни», когда он проанализирует код и увидит, что строка не используется после buf.clear()).

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

Итак, сначала определите общую структуру. Это параметр типа R — «любой тип, реализующий Read». Эта структура содержит читателя и буфер, который мы собираемся заимствовать.

/ file5.rs
use std::fs::File;
use std::io;
use std::io::prelude::*;

struct Lines<R> {
    reader: io::BufReader<R>,
    buf: String
}

impl <R: Read> Lines<R> {
    fn new(r: R) -> Lines<R> {
        Lines{reader: io::BufReader::new(r), buf: String::new()}
    }
    ...
}

 

Затем идет следующий метод. Он возвращает Option — как и итератор. И когда он возвращает None, итератор завершается. Возвращаемый тип — Result, потому что read_line может потерпеть неудачу, а мы никогда не выбрасываем ошибки. Поэтому в случае неудачи мы упаковываем ошибку в Some <Result>. В противном случае, возможно, было прочитано ноль байт, что является естественным концом файла — это не ошибка, а просто None.

В этот момент буфер содержит строку с добавленной строчной запятой (`\n'). Обрежем ее и красиво упакуем кусочек строки.

fn next<'a>(&'a mut self) -> Option<io::Result<&'a str>>{
        self.buf.clear();
        match self.reader.read_line(&mut self.buf) {
            Ok(nbytes) => if nbytes == 0 {
                None // no more lines!
            } else {
                let line = self.buf.trim_right();
                Some(Ok(line))
            },
            Err(e) => Some(Err(e))
        }
    }

 

Теперь обратите внимание, как работает время жизни переменной. Нам нужно явное время жизни, потому что Rust никогда не позволит нам передавать заимствованные фрагменты строк, не зная их времени жизни. А здесь мы говорим, что время жизни этой заимствованной строки находится в пределах времени жизни self.

И эта сигнатура со временем жизни несовместима с интерфейсом Iterator. Но легко увидеть проблемы, даже если бы она была совместима. Для примера рассмотрим попытку создать вектор из этих кусочков строки. Это не может работать в принципе, поскольку все они заимствованы из одной и той же изменяемой строки!

Если бы вы прочитали весь файл в строку, то итератор строк этой строки мог бы возвращать фрагменты строк, поскольку все они заимствованы из разных частей исходной строки.

Получившийся цикл выглядит намного чище, а буферизация файла незаметна для пользователя.

fn read_all_lines(filename: &str) -> io::Result<()> {
    let file = File::open(&filename)?;

    let mut lines = Lines::new(file);
    while let Some(line) = lines.next() {
        let line = line?;
        println!("{}", line);
    }

    Ok(())
}

 

Можно даже переписать цикл следующим образом, поскольку явное совпадение может вытащить фрагмент строки:

while let Some(Ok(line)) = lines.next() {
        println!("{}", line)?;
    }

Это заманчиво, но здесь вы отбрасываете возможную ошибку — этот цикл будет беззвучно останавливаться всякий раз, когда возникнет ошибка. В частности, он остановится в первом месте, где Rust не сможет преобразовать строку в UTF-8. Хорошо для повседневного тестового кода новичка, но плохо для продакшен-кода!

Запись в файлы

Мы познакомились с макросом write! при реализации Debug — он также работает со всем, что реализует Write. Так что вот еще один способ сказать «печатай!»:

let mut stdout = io::stdout();
    ...
    write!(stdout,"answer is {}\n", 42).expect("write failed");

Если ошибка потенциально возможна, вы должны ее обработать. Это может быть не очень вероятно, но это может произойти в самый неподходящий момент. Обычно это нормально, потому что если вы выполняете операции ввода-вывода файлов, вы должны быть в контексте, где работает ?.

Но есть разница: print! блокирует stdout для каждой записи. Обычно это то, что вам нужно для вывода, потому что без такой блокировки многопоточные программы могут перемешать вывод непредсказуемыми способами. Но если вы выводите много текста, то write! будет, конечно, быстрее.

Для произвольных файлов нам нужен write! Файл закрывается при завершении write_out, что и удобно, и важно.

// file6.rs
use std::fs::File;
use std::io;
use std::io::prelude::*;

fn write_out(f: &str) -> io::Result<()> {
let mut out = File::create(f)?;
write!(out,"answer is {}\n", 42)?;
Ok(())
}

fn main() {
write_out("test.txt").expect("write failed");
}

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

Это значение по умолчанию отличается от других языков программирования и может привести к шокирующему открытию, что Rust может значительно уступить в скорости даже скриптовым языкам! Как и в случае с Read и io::BufReader, существует io::BufWriter для буферизации любой записи.

Файлы, пути и каталоги

Вот небольшая программа для распечатки каталога Cargo на машине. В простейшем случае это '~/.cargo'. Это расширение оболочки Unix, поэтому мы используем env::home_dir, поскольку он кросс-платформенный. Это может не сработать, но компьютер без домашнего каталога в любом случае не будет размещать инструменты Rust.

Затем мы создаем PathBuf и используем его метод push для построения полного пути к файлу из его компонентов. Это гораздо проще, чем возиться с '/','' или еще чем-нибудь, в зависимости от системы.

// file7.rs
use std::env;
use std::path::PathBuf;

fn main() {
let home = env::home_dir().expect("no home!");
let mut path = PathBuf::new();
path.push(home);
path.push(".cargo");

if path.is_dir() {
println!("{}", path.display());
}
}

PathBuf подобен String — ему принадлежит расширяемый набор символов, но с методами, специализированными для построения путей. Однако большая часть его функциональности происходит от заимствованной версии Path, которая похожа на &str. Так, например, is_dir — это метод Path.

Это может показаться подозрительно похожим на форму наследования, но волшебный признак Deref работает иначе. Он работает так же, как и в случае со String/&str — ссылка на PathBuf может быть принудительно превращена в ссылку на Path. «Принудительно» — сильное слово, но это действительно одно из немногих мест, где Rust делает автоматические преобразования за вас.

fn foo(p: &Path) {...}
...
let path = PathBuf::from(home);
foo(&path);

PathBuf имеет тесную связь с OsString, который представляет строки, которые мы получаем непосредственно из системы.

Такие строки не гарантированно могут быть представлены как UTF-8. В реальной жизни все сложнее. Подведем итог: во-первых, есть горы унаследованного кода в кодировке ASCII, а также есть множество специальных кодировок для других языков. Во-вторых, человеческие языки сложны. Например, «noël» — это пять кодовых точек Юникода! Готов поспорить, что ваш код споткнется об это слово, если вы заранее не предусмотрите подобные аномалии.

Это правда, что в большинстве случаев в современных операционных системах имена файлов будут иметь кодировку Unicode (UTF-8 на стороне Unix, UTF-16 для Windows). И Rust должен строго учитывать эту возможность. Например, у Path есть метод as_os_str, который возвращает &OsStr, но метод to_str возвращает Option<&str>. Это не всегда возможно для Unicode!

У людей возникают проблемы на этом этапе, потому что они слишком привязались к «строке» и «символу» как к единственным необходимым абстракциям. Как мог бы сказать Эйнштейн, язык программирования должен быть настолько простым, насколько это возможно, но не проще.

Системному языку необходимо различие между String/&str, и если он хочет стандартизировать строки Unicode, то ему нужен другой тип для работы с текстом, который не является действительным Unicode — отсюда OsString/&OsStr. Обратите внимание, что для этих типов нет никаких интересных строкоподобных методов — именно потому, что мы не знаем кодировку.

Но люди привыкли обрабатывать имена файлов как строки, поэтому Rust упрощает работу с путями файлов с помощью методов PathBuf.

Можно последовательно удалять компоненты пути. Здесь мы начинаем с текущего каталога программы:

// file8.rs
use std::env;

fn main() {
let mut path = env::current_dir().expect("can't access current dir");
loop {
println!("{}", path.display());
if ! path.pop() {
break;
}
}
}
// /home/steve/rust/gentle-intro/code
// /home/steve/rust/gentle-intro
// /home/steve/rust
// /home/steve
// /home
// /

У меня есть программа, которая ищет файл конфигурации, и правило заключается в том, что он может находиться в любом подкаталоге текущего каталога. Поэтому я создаю /home/steve/rust/config.txt и запускаю эту программу в /home/steve/rust/gentle-intro/code:

// file9.rs
use std::env;

fn main() {
let mut path = env::current_dir().expect("can't access current dir");
loop {
path.push("config.txt");
if path.is_file() {
println!("gotcha {}", path.display());
break;
} else {
path.pop();
}
if ! path.pop() {
break;
}
}
}
// gotcha /home/steve/rust/config.txt

Примерно так работает git, когда хочет узнать текущее repo.

Подробная информация о файле (его размер, тип и так далее) называется его метаданными. Как всегда, может возникнуть ошибка — не только «не найден», но и в случае, если у нас нет разрешения на чтение этого файла.

// file10.rs
use std::env;
use std::path::Path;

fn main() {
    let file = env::args().skip(1).next().unwrap_or("file10.rs".to_string());
    let path = Path::new(&file);
    match path.metadata() {
        Ok(data) => {
            println!("type {:?}", data.file_type());
            println!("len {}", data.len());
            println!("perm {:?}", data.permissions());
            println!("modified {:?}", data.modified());
        },
        Err(e) => println!("error {:?}", e)
    }
}
// type FileType(FileType { mode: 33204 })
// len 488
// perm Permissions(FilePermissions { mode: 436 })
// modified Ok(SystemTime { tv_sec: 1483866529, tv_nsec: 600495644 })

Длина файла (в байтах) и время модификации легко интерпретируются. Обратите внимание, что мы можем не получить это время! Тип файла имеет методы is_dir, is_file и is_symlink.

permissions — отдельный интересный вопрос. Rust стремится быть кроссплатформенным, и поэтому это случай «наименьшего общего знаменателя». В общем, все, что вы можете запросить, это — является ли файл доступным только для чтения: концепция «разрешений» расширена в Unix и кодирует чтение/запись/исполнение для пользователя/группы/других.

Но если вас не интересует Windows, то введение специфичного для платформы трейта даст нам по крайней мере биты режима разрешений. Затем применение программы к собственному исполняемому файлу дает:

use std::os::unix::fs::PermissionsExt;
...
println!("perm {:o}",data.permissions().mode());
// perm 755

 

(обратите внимание на '{:o}' для печати в восьмеричном формате).

Является ли файл исполняемым в Windows, определяется по его расширению. Расширения исполняемых файлов находятся в переменной среды PATHEXT'.exe','.bat' и так далее.

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

Чтобы найти содержимое каталога, std::fs::read_dir предоставляет итератор. Здесь находятся все файлы с расширением '.rs' и размером более 1024 байт:

fn dump_dir(dir: &str) -> io::Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let data = entry.metadata()?;
        let path = entry.path();
        if data.is_file() {
            if let Some(ex) = path.extension() {
                if ex == "rs" && data.len() > 1024 {
                    println!("{} length {}", path.display(),data.len());
                }
            }
        }
    }
    Ok(())
}
// ./enum4.rs length 2401
// ./struct7.rs length 1151
// ./sexpr.rs length 7483
// ./struct6.rs length 1359
// ./new-sexpr.rs length 7719

 

Очевидно, что read_dir может завершиться неудачей (обычно «не найден» или «нет разрешения»), но и получение каждой новой записи может закончиться неудачей. Кроме того, мы можем не получить метаданные, соответствующие записи. У файла может не быть расширения, поэтому мы должны проверить и это.

Почему бы просто не сделать итератор по путям? В Unix так работает системный вызов opendir, но в Windows нельзя итерировать содержимое каталога без получения метаданных. Таким образом, это достаточно элегантный компромисс, который позволяет кросс-платформенному коду быть максимально эффективным.

На этом этапе вы можете почувствовать «усталость от ошибок». Но обратите внимание, что ошибки существовали всегда — не то чтобы Rust изобретал новые, это не его какая-то эксклюзивная специфика. Просто он изо всех сил старается сделать так, чтобы вы не могли их игнорировать, из-за чего это впечатление «продирания сквозь чащу». Любой вызов операционной системы может завершиться неудачей.

Языки вроде Java и Python бросают исключения, а языки вроде Go и Lua возвращают два значения, где первое — результат, а второе — ошибка. Как и в Rust, там считается дурным тоном, когда библиотечные функции вызывают ошибки. Поэтому в Rust обычно много проверок ошибок и ранних возвратов функций.

В Rust используется Result, потому что это или-или: вы не можете получить ни результат, ни ошибку. А оператор вопросительного знака делает обработку ошибок намного чище и понятней.

Продолжение следует…

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023