Мої перші враження від Rust

18 хв. читання
1 тиждень тому

У попередній публікації, я описував свій перший досвід переходу з PHP на C++, в рамках створення браузеру для протоколу Gemini. Не зважаючи на те, що у цьому напрямку було виконано багато роботи, все таки, виникли певні труднощі з ручним контролем пам'яті, а саме - в рамках відсутності досвіду, просто не розумів де саме я міг допустити помилку, як довго живуть мої об'єкти і про які з них міг забути. Іншою проблемою стала збірка програми на різних пристроях, і врешті останнім фактором стало встановлення додаткових залежностей, адже в C++ немає єдиної екосистеми пакунків, накшталт npm, Composer і т.д. З іншого боку, можна поставити Conan, але мені просто не захотілось в тому розбиратись, оскільки ця система вимагає використання Python, що якось трохи дисонує в плані подальших перспектив такого симбіозу.

Орієнтовно через місяць роботи з плюсами, вирішив не витрачати час, та спробувати сучасну і давно розхайповану мову Rust, від якої, чесно сказати, довго морозився. Від самого початку, всі дороги вели сюди і врешті перевів реалізацію браузеру в окрему гілку, почавши розробку спочатку.

Синтаксис

Синтаксис Rust, мені здається якимось мультяшним, мовою, яка пишеться з права на ліво, своєю екзотичністю чимось нагадує Ruby, якою бувши розробником PHP, в принципі цікавився але в так і не дійшли руки. З іншого боку, нагадує JS, який особисто мені чомусь ніколи не подобався (як і Python). Тобто я був таким собі консерватором і варився в C-подібній екосистемі, нічого не хотів змінювати, але тут сталось те, що описано вище - тепер мені не хотілось розбиратись в додаткових інструментах контролю пам'яті а не синтаксисі.

Стилістика

Стиль коду горизонтально-орієнтований, у той час як мені подобається писати і читати короткі рядки, віддаючи пріоритет скролу. Якщо використовувати стандартну конфігурацію rustfmt, то написаний в ручному форматуванні код перетворюється в таку собі склеєну горизонтально кашу, яку реально важко читати, але вирішив не відмовлятись від загально-прийнятого оформлення і адаптуватись, максимально розділяючи об'ємні структури коду на окремі моди, про що згодом.

Відсутність NULL

Що мене постійно манило в Rust - це сучасні підходи програмування, які виключають застарілі конструкції через їх спірне застосування. NULL - це тип даних, який по суті може означати і нічого і все водночас, цим часто зловживають розробники, використовуючи її як false або як тип, що має бути визначений батьківським елементом. Особисто в мене проблем з цим не було, оскільки й не такого бачив в * кодах PHP. З іншого боку, в C++ постійно доводилось працювати з nullptr що в принципі знову натякає на давню потребу модернізації цього типу даних.

Rust від самого початку вимагає певного стилю програмування з коробки, таким чином ведучи за руку "праведною стезею" сучасних підходів, виключаючи помилки та власну хвору фантазію. Більше інформації про спірність використання NULL можна знайти в мережі, окремо описувати подробиці тут не буду.

Option, Result, match

Продовжуючи тему сучасних конструкцій, на заміну NULL в Rust присутні такі типи нумерованих списків як Option та Result а також новий оператор match для роботи з ними і не тільки.

Option, на прикладі двох змінних, перша з яких повертає "рядок" а друга - "нічого":

let some_string: Option<String> = Some(String::from("Hello, Rust!"));
let none_string: Option<String> = None;

match some_string {
        Some(string) => println!("деяке значення: {string}"),
        None => println!("значення відсутнє"),
}
  • по аналогії можна додати необмежену кількість варіантів

Якщо певній функції потрібно повернути статус успішної операції або помилки, у таких випадках доречніше використовувати тип Result:

// Функція повертає i32 суми або String з описом помилки
fn sum(a: i32, b: i32, max: i32) -> Result<i32, String> {
    let sum = a + b;
    if sum > max {
        return Err(format!("sum > max"));
    }
    Ok(sum)
}

// якщо 1 + 2 менше максимального значення - друкуємо результат, 
// якщо більше - викликаємо паніку або делегуємо обробку помилки
match sum(1, 2, 10) {
    Ok(value) => println!("sum: {value}"),
    Err(reason) => panic!("{reason}"),
}

Це звісно дуже спрощений приклад, для наочної демонстрації обробки результатів без NULL - такий підхід забезпечує коректність виконання програми вже на етапі її написання.

Оператор match дозволяє замінити собою класичні конструкції if та switch, і подібно до останнього, створювати вкладене розгортання умов.

Лаконічність

Як і в Ruby, для Rust характерна лаконічність коду, про це, зокрема, натякають максимально скорочені ключові слова функцій (fn) та інших елементів. Кажучи безпосередньо про опис логіки, в нагоді початківцям стане такий інструмент як Clippy, який окрім пошуку помилкових конструкцій (типу зайвих референсів) запропонує замінити такий код:

match result.get(0) {
    Some(value) => Some(value.row),
    None => None,
}

на такий:

result.first().map(|value| value.row)

Скорочення зустрічаються тут всюди, наприклад замість if/else або його цукру

a = b > c ? true : false

можна зустріти мабуть найкоротший варіант умови "?", яка поверне стандартну відповідь дочірнього компонента батьківському при невідповідності результату:

// зупинка циклу з select return при помилці
for record in select(&tx)? {
    // зупинка циклу з update return при помилці
    update(&tx, record.id, false, record.time, record.name)?;
}
  • тут варто зазначити, що тип, який повертається повинен відповідати патерну return батька

Не мутабельність за замовченням

Про синтаксис Rust можна багато чого розповісти. Наприклад, усі змінні (variables) є незмінними за замовченням. Якщо дані, що зберігаються в змінній повинні бути мутабельними, вона повинна бути від початку оголошена ключовим словом mut:

let var_1: bool = true;
let mut var_2: bool = true;

var_1 = false; // помилка, оскільки змінна var_1 оголошена як не мутабельна
var_2 = false // ok

Це може здаватись дивним, але на практиці дані у пам'яті для оголошених змінних доволі рідко змінюються. Наприклад, в плюсах я майже всюди оголошував константи, щоб у великій програмі не забути переписати значення випадково там, де це не було заплановано.

Не мутабельність насправді здебільшого потрібна для коректної роботи компілятора та контролера посилань (borrow-checker), які забезпечують безпечну роботу з пам'яттю за рахунок виконання однієї простої умови: може бути або одне мутабельне посилання або не обмежена кількість не мутабельних. Тут немає збірника сміття та рантайм-середовища, але є ось такі принципи.

Вказівники

В Rust є і вказівники (pointers) і посилання (references). Перші в сирому вигляді тут зустрічаються доволі рідко, можливо просто саме я не зустрічав їх в екосистемі gtk-rs. Пояснити це можна безпекою роботи з пам'яттю. Для роботи з вказівниками, в Rust використовуються обгортки (wrappers) типу Box, Rc, Arc, та інші, - по суті вони є аналогами таких класів як std::shared_ptr в C++. Різняться сферою застосування: одні гарантують унікальність копії, інші - безпечну роботу в асинхронних потоках, або реалізують внутрішній лічильник посилань, або прапор блокування, тощо.

Зупинятись не буду, це просто величезна тема, вказівники тут є, але в режимі safe (окрім типів, що копіюються) майже всі вони обгорнені класом, формуючи такі "страшні" для незнайомців конструкції як Rc<Mutex<Option<i32>>> - де Rc просто надає можливість клонування вказівника на ту саму область пам'яті, Mutex забезпечує мутабельність, а про Option ви вже знаєте ;)

Організація файлів

В Rust файли проекту організовані у вигляді модів (mods) та крейтів (crates).

Моди - це по суті окремі файли, або модулі, з яких складається крейт, тобто фінальний пакунок програми. В проекті також може бути декілька крейтів а також локальні їх форки, оголошені наприклад через наступну конструкцію в Cargo.toml:

[dependencies.crate_1]
package = "crate_1"
version = "0.1.0"

[patch.crates-io]
crate_1 = { path = "crate_1" } // локальний форк crate_1

Кастомізація Cargo.toml - також окрема тема, тут не будемо зупинятись, скажу тільки що це головний файл маніфесту кожного для крейту, в той час як моди - просто файли, які підключаються до основного контролеру main.rs.

Окрім цього, в екосистемі присутнє поняття бібліотек (lib.rs) - це по суті бібліотечні файли, які не можуть бути виконані без батьківського main.rs. Кожен крейт може включати в себе бібліотеку або тільки реалізовувати її, тоді такий крейт буде вважатись бібліотекою.

На відміну від C/C++, в Rust немає заголовкових файлів (headers), файли підключаються через ключове слово use засобами неймспейсів. Не потрібно руками резольвити файлові залежності, що найменше такий підхід є опціональним (з використанням анотацій). При стандартному підході, є два способи організації файлів:

  1. Старий (2015) - це коли файл моду має зарезервовану назву mod.rs та розміщується в теці з назвою модуля
  2. Новий (2018) - файл моду має довільну назву, але такий підхід вимагає розміщення дочірніх модів у теці з однойменною назвою.

Новий підхід з'явився по причині того, що при відкритті багатьох файлів в IDE, всі таби будуть мати назву mod.rs, що погодьтесь, не дуже зручно в плані навігації, коли мізки зайняті кодом а не пошуком файлу в дереві.

Детальніше про тему йменування файлів, можна почитати тут.

У цілому, мені знадобився певний час, щоб розібратись в темі. Тут немає такої файлової "анархії", яка є в інших мовах. Це на мою думку і добре, оскільки інші розробники зможуть розібратись у вашому коді (якщо ви не реорганізуєте проект анотаціями чи специфічними оголошеннями залежностей в Cargo.toml)

Cargo

Що дійсно круто в Rust - це система пакунків Cargo та її репозиторій crates.io. Основна система пакунків тут одна, вона офіційна і є стандартною. Більше немає солянки з різних компіляторів, пакункових менеджерів, аналізаторів та іншого. Все зібрано довкола Cargo.

Легко ставити, легко майнтейнити. Єдине, якщо залежності програми вимагають бібліотек C, тим паче, як в моєму випадку - інтеграція з робочим столом GNOME, будьте готові змінювати дистрибутив на Fedora чи компілювати самому пів системи, ну або щось думати з контейнерами. Чудес не буває. А ось руками резольвити дикі бібліотеки під різні платформи не треба, Cargo все зробить сам - перевірить залежності, оновить код, перевірить помилки, скомпілює та опублікує ваш пакунок.

До речі, опублікований пакунок на crates.io видалити не вийде, оскільки такий підхід гарантує доступність версії для залежних пакунків, що встигли її включити в свій склад. Є опція Yank але будьте готові що вона просто позначить версію як відхилену, а не видалить її з репозиторію. Оновити пакунок можна тільки додавши нову його версію.

Варто окремо згадати про дані, які вивантажує команда cargo publish. Спочатку я забув ознайомитись з документацією, але пощастило, що Cargo вже про все подбав і заігнорив сенситивні дані типу .vscode, .git та інші теки в корені проекту. Перевірити, чи це дійсно так, можна командою в корені проекту до його публікації:

cargo package --list

Оптимальним способом встановити Rust і Cargo останніх версій, є утиліта rustup.

Інше

Наслідування

Як відомо, Rust не реалізує класичну систему наслідування класів, оскільки така парадигма вважається застарілою. В той час, як мій браузер вже було написано з використанням обгортки gtkmm, тут мені довелось знову проектувати скелет програми з нуля. Хоча я довго намагався натягувати логіку наслідування на ненатягуване.

По нормальному, програму все таки краще писати тією мовою і парадигмою, на якій і засобами якої реалізовано обраний фреймворк. Тоді, мій вибір на плюси пав саме тому, що не хотів руками реалізовувати всі ті низькорівневі обгортки для C, які є в плюсовому gtkmm і так вийшло що з Rust я отримав подвійний трабл - це і новий підхід і новий синтаксис як бонус :)

Загадавши процедурний підхід, в останній раз користувався ним років 15 тому, коли тільки починав програмувати. Потім купив книгу з патернами, перейшов на ООП і повністю забув що це таке, не знаю взагалі які реалізації зараз. Тобто для мене ієрархічні конструкції та функції - це дрімучий ліс, всі нейрони побудовані під об'єктно орієнтоване мислення, з наслідуванням та іншими плюхами, деякі з яких, до речі, сьогодні критикують через їх надмірні залежності від батьківських класів.

Поки що вивчаю цю тему на практиці, думаю розібратись з патернами на основі enum, тому додати нічого. Якщо вирішили йти шляхом Rust - це не про ООП та розширення батьківських класів. Іноді, мені доводиться наслідувати певні класи GObject в GTK обхідними шляхами, але нативно, майже все обертається довкола трейтів (trait) та їх імплементацій (impl-ementation).

Лайфтайми

Тут хочу згадати тільки про один, не зручний особисто для мене момент: якщо ви працюєте з C-ішними обгортками типу gkt-rs, можуть виникнути певні непорозуміння з часом життя об'єктів, оскільки в Rust, автоматично відбувається зменшення лічильника посилань (або знищення), при виході об'єкта з області видимості.

Наприклад, в GTK є такий клас як SocketConnection, а також асинхронні методи як наприклад SocketClient::connect_async, який виконується на стороні фреймворку, але й живе в основному потоці програми Rust. Якщо ви НЕ передасте клон SocketConnection в цей метод, то компілятор автоматично зменшить для нього лічильник посилань, при виході за область видимості, а GTK, у свою чергу - видалить, коли його значення сягне нуля. Таким чином, отримаємо закрите з'єднання ще до того, як воно буде оброблене через асинхронний метод. Саме ця проблема, вирішується передачею клону поінтера SocketConnection в тред, який триматиме кількість посилань до свого завершення, або передаватиме його володіння дочірнім структурам. На практиці це не завжди зручно і часто стає причиною непорозумінь в пошуках багів, коли сторона C очікує від вас ручного видалення посилання, а Rust робить це автоматично.

Тему керування пам'яттю в GTK засобами C та C++, описував окремо у попередніх публікаціях:

Асинхронність

Асинхронність тут є, така опція була не від самого початку (як наприклад в Go) але все таки її згодом впровадили. Як писав вище, я особисто не користуюсь штатними засобами Rust, оскільки API мого фреймворку надає зручні методи роботи в основному потоці, за рахунок вбудованої системи подій.

Багато хто сварить штатну реалізацію за використання await, тут не скажу. Особисто для себе, зрозумів що асинхронні методи працюють з асинхронними методами, тобто якщо програма вимагає асинхронних потоків, така функціональність повинна бути закладена в її архітектуру на етапі проектування. Звісно тут з'являється додатковий thread-safe інструментарій та відповідні розумні вказівники такі як Arc, блокувальники Mutex, RwLock та інше.

Особисто на практиці, намагався тільки швидкоруч натягнути інтеграційні тести (оскільки вже встиг написати зовнішню бібліотеку, щоб розвантажити основний код програми), але поки відклав це заняття, оскільки схоже, що для моєї задачі потрібна інтеграція Tokio.

Спільнота

Спільнота Rust - супер жива та активна. Наразі є два форуми - безпосередньо для розробників мови та для користувачів, де вам не тільки оперативно допоможуть з будь яким питанням, а також безкоштовно проведуть аудит якості коду у відповідній гілці.

Є також чати, але мені вони не цікаві, тому у будь якому разі ком'юніті є і воно дружнє до новачків.

Щодо соціальних мереж, на YouTube мені подобається один канал Bitωise. Якщо ви початківець в програмуванні, можу порекомендувати також хоч не про Rust, але якісний україномовний канал Blogan, зокрема курс теорії C++ який стане в нагоді для розуміння основ організації пам'яті для різних типів даних.

Існує проект української локалізації Rust Book на GitHub та колективним перекладом на Crowdin.

Висновки

В цілому, я задоволений своїм новим вибором. Чи готовий повертатись на плюси чи скажімо на C - не знаю, на плюси точно ні, а до C в мене якісь перманентно теплі почуття, не знаю чому. Можливо ця мова є найпростішою, можливо тому що програмував на Arduino та рахував там доступну пам'ять. Тим не менше, я не можу довіряти собі настільки, щоб впевнено писати чистий код без витоків пам'яті у програмах більших за контролер датчика температури. Враховуючи кількість підказок компілятора та аналізатора Clippy, ця впевненість остаточно зникла а ставити додатковий софт чи витрачати час на вивчення такого, коли є все готове - не хочу.

Звісно, чутки про те, що швидкість Rust дорівнює C не зовсім однозначні. Тут можуть використовуватись зовсім інші підходи для однакових конструкцій. Без розуміння що діється під капотом компілятора, написати такий само швидкий код навряд чи можливо, але на першому місці тут саме безпека роботи з пам'яттю, швидкість для мене і не є пріоритетною. Впевнений, що копнувши тему оптимізацій, дізнаюсь ще багато нового.

Кажучи про сфери застосування і комерс, тут поки що далі свого аматорського проекту діло не зайшло. Просто не знаю, де шукати ті замовлення окрім декількох контор на доу, які щось ляпають там англійською по лекалу. До того ж саму мову знати мало, тут більше відіграє роль екосистеми під яку ви пишете. У моєму випадку це малопопулярні GTK, Glib, Gio та інші, цих бібліотек мені вистачає як для роботи з сокетами, сертифікатами TLS, мультимедійними даними, так і для реалізації графічного інтерфейсу, але всі вони написані у свою чергу на C, на Rust я поки що не знайшов такого фреймворку, який би мені сподобався (egui і tauri це трохи не те що мені підходить)

Чи замінить ця мова C - не знаю, як писав на початку, мені вона здається якоюсь мультяшною та іграшковою, хоча цією іграшкою можна бити горіхи. У той час як C - це скло, класика, на якій написане мабуть все сучасне програмне забезпечення. Як мені відповіли на одному з форумів GTK, ніхто не буде просто так переписувати мільйони строк відтестованого коду, тому я думаю що нинішній тренд пройде і залишить Rust на тому ж рівні та в ніші, у яких сьогодні знаходиться Ruby.

Ось мабуть і все, чим хотів поділитись, можливо згадаю ще щось - доповню.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
p.s. 626
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід