Основы Rust: объясняем модули, крейты и Cargo

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

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

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

Введение к части

В этой части мы обсуждает способы структурирования программ на Rust, а именно: модули, крейты, а также Cargo. И прежде чем мы нырнем в детали, давайте дадим стартовые определения.

  • Модули — это уже привычный способ декомпоновки общей структуры программы на логические составляющие (для удобства понимания и сопровождения). Кстати говоря, ООП обеспечивает предельно высокую степень модульности, хотя вполне можно ограничиться более простыми (я бы сказал еще точнее — более естественными) формами модульного программирования, как это делается в Rust.
  • Cargo загружает зависимости вашего проекта и компилирует ваш проект, то есть это пакетный менеджер для Rust. Кроме того, что Cargo создает распространяемые пакеты, он также загружает их на crates.io, реестр пакетов сообщества Rust.
  • Крейт — это исполняемый файл или библиотека. Выделяют два типа крейтов: библиотечный и исполняемый. Библиотечные крейты можно подключать в другие крейты, но нельзя исполнять самостоятельно. Исполняемые же крейты — полная противоположность библиотечным — могут исполняться, но их нельзя подключить в другие крейты.

Модули

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

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

В Rust полное имя будет выглядеть как primitive::display::set_width, и после того, как вы скажете use primitive::display, сможете ссылаться на него как на display::set_width. Вы даже можете сказать use primitive::display::set_width и затем просто сказать set_width, но не стоит этим увлекаться. Нет, rustc не запутается, но вы можете сами запутаться позже. Чтобы это работало идеально, имена файлов должны следовать некоторым простым правилам.

Новое ключевое слово mod используется для определения модуля как блока, в котором могут быть записаны типы или функции Rust:

mod foo {
#[derive(Debug)]
struct Foo {
s: &'static str
}
}

fn main() {
let f = foo::Foo{s: "hello"};
println!("{:?}", f);
}

Но это все еще не совсем правильно — здесь мы получаем ошибку 'struct Foo is private'. Чтобы решить эту проблему, нам нужно ключевое слово pub для экспорта Foo. Тогда ошибка изменится на «поле s из struct foo::Foo является приватным», поэтому поставьте pub перед полем s, чтобы экспортировать Foo::s.

После этого все будет работать:

pub struct Foo {
pub s: &'static str
}

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

Обычно лучше скрыть внутренности структуры и разрешить доступ только через методы, вот так:

mod foo {
#[derive(Debug)]
pub struct Foo {
s: &'static str
}

impl Foo {
pub fn new(s: &'static str) -> Foo {
Foo{s: s}
}
}
}

fn main() {
let f = foo::Foo::new("hello");
println!("{:?}", f);
}

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

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

Когда не стоит прятаться? Как говорит Страуструп, когда интерфейс является реализацией, например, struct Point{x: f32, y: f32}. Внутри модуля все элементы видны всем остальным элементам. Это уютное место, где все могут дружить и знать интимные подробности друг о друге.

Каждый доходит до того момента, когда ему хочется разбить программу на отдельные файлы, в зависимости от вкуса. Я начинаю чувствовать себя неуютно примерно после 500 строк, но мы все согласны, что более 2000 строк — это уже перебор.

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

Как же разбить эту программу на отдельные файлы?

Мы помещаем код foo в файл foo.rs:

/ foo.rs
#[derive(Debug)]
pub struct Foo {
s: &'static str
}

impl Foo {
pub fn new(s: &'static str) -> Foo {
Foo{s: s}
}
}

Далее используйте оператор mod foo без блока в основной программе:

// mod3.rs
mod foo;

fn main() {
let f = foo::Foo::new("hello");
println!("{:?}", f);
}

Теперь rustc mod3.rs приведет к тому, что foo.rs также будет скомпилирован. Нет необходимости возиться с make-файлами!

Компилятор также будет смотреть на MODNAME/mod.rs, так что это будет работать, если я создам каталог boo, содержащий файл mod.rs:

// boo/mod.rs
pub fn answer()->u32 {
42
}

И теперь основная программа может использовать оба модуля как отдельные файлы:

// mod3.rs
mod foo;
mod boo;

fn main() {
let f = foo::Foo::new("hello");
let res = boo::answer();
println!("{:?} {}", f,res);
}

Пока что есть mod3.rs, содержащий main, модуль foo.rs и каталог boo, содержащий mod.rs. Обычное соглашение заключается в том, что файл, содержащий main, называется просто main.rs.

Почему есть два способа сделать одно и то же? Потому что boo/mod.rs может ссылаться на другие модули, определенные в boo, Обновите boo/mod.rs и добавьте новый модуль — обратите внимание, что он явно экспортирован. Без pub, bar можно увидеть только внутри модуля boo.

// boo/mod.rs
pub fn answer()->u32 {
42
}

pub mod bar {
pub fn question() -> &'static str {
"the meaning of everything"
}
}

и затем у нас появляется соответствующий ответ ранее заданному вопросу (модуль bar находится внутри boo):

let q = boo::bar::question();

Этот блок модуля можно вытащить как boo/bar.rs:

// boo/bar.rs
pub fn question() -> &'static str {
"the meaning of everything"
}

А boo/mod.rs становится:

// boo/mod.rs
pub fn answer()->u32 {
42
}

pub mod bar;

В общем, модули — это организация и видимость, и это может включать или не включать отдельные файлы.

Обратите внимание, что use не имеет ничего общего с импортом, а просто определяет видимость имен модулей. Например:

{
use boo::bar;
let q = bar::question();
...
}
{
use boo::bar::question();
let q = question();
...
}

Важно отметить, что здесь нет отдельной компиляции. Основная программа и ее файлы модулей будут каждый раз перекомпилироваться. Сборка больших программ займет достаточно много времени, хотя rustc все лучше справляется с инкрементальной компиляцией.

Крейты (Crates)

Единицей компиляции в Rust является крейт, который представляет собой либо исполняемый файл, либо библиотеку.

Чтобы отдельно скомпилировать файлы из последнего раздела, сначала соберите foo.rs как статический библиотечный крейт Rust:

src$ rustc foo.rs --crate-type=lib
src$ ls -l libfoo.rlib
-rw-rw-r-- 1 steve steve 7888 Jan 5 13:35 libfoo.rlib

Теперь мы можем подключить его к нашей основной программе:

src$ rustc mod4.rs --extern foo=libfoo.rlib

Но теперь основная программа должна выглядеть следующим образом, где имя extern такое же, как и при линковке. Существует неявный модуль верхнего уровня foo, связанный с библиотекой крейт:

// mod4.rs
extern crate foo;

fn main() {
let f = foo::Foo::new("hello");
println!("{:?}", f);
}

Прежде чем люди начнут скандировать «Cargo! Cargo!», позвольте мне оправдать этот низкоуровневый взгляд на создание Rust. Я очень верю в лозунг «Know Thy Toolchain», и это уменьшит количество новой магии, которую вам придется изучать, когда мы будем рассматривать управление проектами с помощью Cargo.

Модули — это базовые возможности языка, которые можно использовать вне проектов Cargo.

Пришло время понять, почему двоичные файлы Rust такие большие:

src$ ls -lh mod4
-rwxrwxr-x 1 steve steve 3,4M Jan 5 13:39 mod4

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

Но в большинстве случаев это не нужно, поэтому давайте удалим эту отладочную информацию и посмотрим, что получится:

src$ strip mod4
src$ ls -lh mod4
-rwxrwxr-x 1 steve steve 300K Jan 5 13:49 mod4

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

Мы можем динамически компоновать Rust со средой выполнения и получать действительно крошечные «экзы»:

src$ rustc -C prefer-dynamic mod4.rs --extern foo=libfoo.rlib
src$ ls -lh mod4
-rwxrwxr-x 1 steve steve 14K Jan 5 13:53 mod4
src$ ldd mod4
linux-vdso.so.1 => (0x00007fffa8746000)
libstd-b4054fae3db32020.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3cd47aa000)
/lib64/ld-linux-x86-64.so.2 (0x00007f3cd4d72000)

Это «не найдено» потому, что rustup не устанавливает динамические библиотеки глобально. Мы можем взломать наш путь к счастью, по крайней мере на Unix (да, я знаю, что лучшее решение — это симлинк).

src$ export LD_LIBRARY_PATH=~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib
src$ ./mod4
Foo { s: "hello" }

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

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

Cargo

Стандартная библиотека Rust не очень велика, по сравнению с Java или Python. Хотя нужно признать, что она гораздо более полнофункциональна, чем C или C++, которые в значительной степени опираются на библиотеки, предоставляемые операционной системой.

Но получить доступ к библиотекам, предоставленным сообществом в crates.io, очень просто с помощью Cargo. Cargo найдет нужную версию и загрузит исходный текст для вас, а также обеспечит загрузку всех других необходимых библиотек. Мы уже упоминали Cargo выше, когда говорили про крейты.

Давайте создадим простую программу, которой нужно читать JSON. Этот формат данных очень широко используется, но он слишком специализирован для включения в стандартную библиотеку. Поэтому мы инициализируем проект Cargo, используя ‘--bin‘, поскольку по умолчанию создается библиотечный проект.

test$ cargo init --bin test-json
Created binary (application) project
test$ cd test-json
test$ cat Cargo.toml
[package]
name = "test-json"
version = "0.1.0"
authors = ["Your Name <you@example.org>"]

[dependencies]

Чтобы сделать проект зависимым от JSON crate, отредактируйте файл ‘Cargo.toml‘ так:

[dependencies]
json="0.11.4"

Затем выполните первую сборку с помощью Cargo:

test-json$ cargo build
Updating registry `https://github.com/rust-lang/crates.io-index`
Downloading json v0.11.4
Compiling json v0.11.4
Compiling test-json v0.1.0 (file:///home/steve/c/rust/test/test-json)
Finished debug [unoptimized + debuginfo] target(s) in 1.75 secs

Главный файл этого проекта уже создан — это ‘main.rs‘ в каталоге ‘src'. Он начинается как приложение ‘hello world’, поэтому давайте отредактируем его так, чтобы он стал настоящей тестовой программой.

Обратите внимание на очень удобный строковый литерал ‘raw‘ — иначе нам пришлось бы убирать двойные кавычки и в итоге получилось бы уродство:

// test-json/src/main.rs
extern crate json;

fn main() {
let doc = json::parse(r#"
{
"code": 200,
"success": true,
"payload": {
"features": [
"awesome",
"easyAPI",
"lowLearningCurve"
]
}
}
"#).expect("parse failed");

println!("debug {:?}", doc);
println!("display {}", doc);
}

Теперь вы можете собрать и запустить этот проект — изменился только main.rs:

test-json$ cargo run
Compiling test-json v0.1.0 (file:///home/steve/c/rust/test/test-json)
Finished debug [unoptimized + debuginfo] target(s) in 0.21 secs

Running `target/debug/test-json`

debug Object(Object { store: [("code", Number(Number { category: 1, exponent: 0, mantissa: 200 }),
0, 1), ("success", Boolean(true), 0, 2), ("payload", Object(Object { store: [("features",
Array([Short("awesome"), Short("easyAPI"), Short("lowLearningCurve")]), 0, 0)] }), 0, 0)] })
display {"code":200,"success":true,"payload":{"features":["awesome","easyAPI","lowLearningCurve"]}}

Отладочный вывод показывает некоторые внутренние детали документа JSON, но простое '{}', используя признак Display, регенерирует JSON из разобранного документа.

Давайте немного изучим API JSON. Он не был бы полезен, если бы мы не могли извлекать значения. Методы as_TYPE возвращают Option<TYPE>, поскольку мы не можем быть уверены, что поле существует или имеет правильный тип (см. документацию по JsonValue):

let code = doc["code"].as_u32().unwrap_or(0);
let success = doc["success"].as_bool().unwrap_or(false);

assert_eq!(code, 200);
assert_eq!(success, true);

let features = &doc["payload"]["features"];
for v in features.members() {
println!("{}", v.as_str().unwrap()); // MIGHT explode
}
// awesome
// easyAPI
// lowLearningCurve

Здесь есть ссылка на JsonValue — это должна быть ссылка, потому что иначе мы бы пытались переместить значение из документа JSON. Здесь мы знаем, что это массив, поэтому members() вернет непустой итератор по &JsonValue.

Что если у объекта ‘payload‘ не было ключа ‘features‘? Тогда features будет установлен в Null. Взрыва не произойдет. Это удобство очень хорошо выражает свободную, произвольную природу JSON. Вы сами должны исследовать структуру любого полученного документа и создавать свои собственные ошибки, если структура не соответствует.

Вы можете изменять эти структуры. Если бы у нас было let mut doc, то это сделало бы то, что вы и ожидаете:

let features = &mut doc["payload"]["features"];
features.push("cargo!").expect("couldn't push")

Если features не был массивом, то push завершится неудачей, поэтому возвращается Result<()>.

Вот красивое использование макросов для генерации литералов JSON:

let data = object!{
"name" => "John Doe",
"age" => 30,
"numbers" => array![10,53,553]
};
assert_eq!(
data.dump(),
r#"{"name":"John Doe","age":30,"numbers":[10,53,553]}"#
);

Чтобы это работало, нужно явно импортировать макросы из JSON crate таким образом:

#[macro_use]
extern crate json;

У использования этого крейта есть недостаток, связанный с несоответствием между аморфной, динамически-типизированной природой JSON и структурированной, статической природой Rust (в readme прямо говорится о «трении» этих двоих сущностей).

Поэтому если вы захотите отобразить JSON на структуры данных Rust, то придется делать много проверок, потому что вы не можете считать, что полученная структура соответствует вашим структурам!

Для этого лучшим решением является serde_json, где вы сериализуете структуры данных Rust в JSON и десериализуете JSON в Rust.

Для этого создайте другой бинарный проект Cargo с помощью cargo new --bin test-serde-json, потом перейдите в каталог test-serde-json и отредактируйте Cargo.toml. Отредактируйте его следующим образом:

[dependencies]
serde="0.9"
serde_derive="0.9"
serde_json="0.9"

И отредактируйте src/main.rs таким образом:

#[macro_use]
extern crate serde_derive;
extern crate serde_json;

#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u8,
address: Address,
phones: Vec<String>,
}

#[derive(Serialize, Deserialize, Debug)]
struct Address {
street: String,
city: String,
}

fn main() {
let data = r#" {
"name": "John Doe", "age": 43,
"address": {"street": "main", "city":"Downtown"},
"phones":["27726550023"]
} "#;
let p: Person = serde_json::from_str(data).expect("deserialize error");
println!("Please call {} at the number {}", p.name, p.phones[0]);

println!("{:#?}",p);
}

Вы уже видели атрибут derive, но крейт serde_derive определяет пользовательские derive для специальных признаков Serialize и Deserialize.

Результат показывает сгенерированную структуру Rust:

Please call John Doe at the number 27726550023
Person {
name: "John Doe",
age: 43,
address: Address {
street: "main",
city: "Downtown"
},
phones: [
"27726550023"
]
}

Если бы вы делали это сами с помощью json crate, то потребовалось бы несколько сотен строк пользовательского кода преобразования, в основном для обработки ошибок. Это утомительно, легко все сломать, да и в любом случае зачем тратить усилия.

Это лучшее решение, если вы обрабатываете хорошо структурированный JSON из внешних источников (при необходимости можно изменить имена полей). Это решение обеспечивает надежный способ для программ Rust, который позволяет обмениваться данными с другими программами по сети (поскольку в наши дни все понимают JSON).

Крутая вещь в serde (для SERialization DEserialization) в том, что поддерживаются и другие форматы файлов, такие как toml — популярный формат, удобный для конфигурации и активно используемый в Cargo. Таким образом, ваша программа может читать файлы .toml в структуры и записывать эти структуры далее в .json.

Сериализация — это важная техника, и подобные решения существуют для Java и Go — но с большой разницей. В этих языках структура данных определяется во время выполнения с помощью отражения, но в данном случае код сериализации генерируется во время компиляции — что гораздо эффективнее!

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

Это мучительно делать для проектов на C++, и было бы почти так же мучительно для проектов на Rust, если бы Cargo не существовало. Но C++ несколько уникален в своей безнадежной отсталости, поэтому лучше сравнить Cargo с пакетными менеджерами других языков.

npm (для JavaScript) и pip (для Python) управляют для вас зависимостями и загрузками, но механизм дистрибуции здесь сложнее, поскольку пользователю вашей программы нужно сначала установить Node.js или Python. Но программы Rust статически связаны со своими зависимостями, поэтому их можно раздавать своим приятелям без внешних зависимостей.

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

 

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

Обучение 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