Чому я відмовився від Rust


Коли я дізнався, що з'явився новий мову програмування системного рівня, з продуктивністю як у З++ і без збирача сміття, я відразу зацікавився. Мені подобається вирішувати завдання за допомогою мов зі збирачами сміття, начебто C# або JavaScript, але мене постійно мучила думка про сирої і грубої сили++. Але в З++ так багато способів вистрілити собі в ногу та інших добре відомих проблем, що я зазвичай не наважувався.
Так я вліз у Rust. І, блін, заліз глибоко.
Мова Rust все ще досить молодий, тому його екосистема поки знаходиться в стадії початкового розвитку. В деяких випадках, наприклад, у випадку з вебсокетами або серіалізацією є хороші і популярних рішення. В інших областях у Rust не все так добре. Одна з таких областей це OpenGL GUI, на зразок CEGUI або nanogui. Я хотів допомогти спільноті і мови, тому взявся за портування nanogui на Rust, з кодом на чистому Rust, без зв'язок з С/C++. Проект можна знайти тут.
Звичайно, знайомство з Rust починається з боротьби з ідеєю borrow-checker. Як і в інших програмістів, у мене теж був період, коли я не міг зрозуміти, як вирішити ту чи іншу проблему. На щастя, є класне співтовариство #rust-beginners. Його мешканці допомагали мені відповідали на мої дурні питання. Мені знадобилося кілька тижнів на те, щоб відчути себе більш-менш комфортно в Rust.
Але я не підозрював, що коли стикаєшся з проблемою, пошук рішення схожий на орієнтацію в джунглях. Часто знаходиться декілька відповідей, які схожі на вирішення твоєї проблеми, але не підходять за крихітній деталі.
Ось приклад: уявіть, що у вас є базовий клас Widget, і ви хочете, щоб у самих віджетів (Label, Button, Checkbox) були деякі спільні, легкодоступні функції. У мовах зразок C++ або C# це легко. Потрібно зробити абстрактний клас або базовий клас, в залежності від мови, і успадковувати свої класи від нього.
public abstract class Widget {
private Theme _theme { get; set; }
private int _fontSize { get; set; }
public int GetFontSize() {
return (_fontSize < 0) ? _theme.GetStandardFontSize() : _fontSize;
}
}

У Rust для цього потрібно використовувати типажі (traits). Однак, типаж нічого не знає про внутрішню реалізації. Типаж може визначити абстрактну функцію, але у нього немає доступу до внутрішніх полів.
trait Widget {
fn font_size(&self) -> i32 {
if self.font_size < 0 { //compiler error
return self.theme.get_standard_font_size(); //compiler error
} else {
return self.font_size; //compiler error
}
}
}

Запустити в інтерактивній пісочниці
Подумайте про це. Моя перша реакція була "Ем, що?!". Звичайно, існує справедлива критика ООП, але таке рішення — це просто смішно.
На щастя, виявилося, що мова змінюється і поліпшується з допомогою Requests For Change, і цей процес добре налагоджений. Я не єдиний, хто вважає, що така реалізація сильно обмежує мову, і зараз є відкритий RFC, покликаний поліпшити цю дурість. Але процес йде з березня 2016. Концепція типажів вже багато років існує у багатьох мовах. Зараз — вересень 2016. Чому така важлива і необхідна частина мови все ще в жалюгідному стані?
У деяких випадках можна обійти це обмеження, додавши функцію в типаж, яка реалізована не в типаж, а в самому об'єкті, а потім використовувати її для звернення до реальної функції.
trait Widget {
fn get_theme(&self) -> Theme;
fn get_internal_font_size(&self) -> i32;
fn get_actual_font_size(&self) -> i32 {
if self.get_internal_font_size() < 0 {
return self.get_theme().get_standard_font_size();
} else {
return self.get_internal_font_size();
}
}
}

Запустити в інтерактивній пісочниці
Але тепер у вас є публічна функція (функції типажу ведуть себе як інтерфейс, і зараз немає можливості відзначити функцію типажу як mod-only), яку все ще потрібно реалізувати у всіх конкретних типах. Так що ви або не використовуєте абстрактні функції і дублюєте купу коду, або використовуєте підхід вище і дублюєте трохи менше, але все ще занадто багато коду І отримуєте дірявий API. Обидва результату неприйнятні. І такого немає ні в одному з усталених мов, як C++, C# і, блін, навіть у Go є нормальне рішення.
Інший приклад. У nanogui (в CEGUI така концепція теж використовується) кожен віджет має вказівник на батька і вектор покажчиків на своїх нащадків. Як це реалізується у Rust? Є кілька відповідей:
  1. Використовувати реалізацію
    Vec<T>
  2. Vec<*mut T>
  3. Vec<Rc<RefCell<T>>>
  4. C bindings
Я спробував способи 1, 2 і 3, в кожному знайшлися мінуси, які зробили їх використання неприйнятним. Зараз я розглядаю варіант 4, це мій останній шанс. Давайте поглянемо на всі варіанти:
Варіант 1
Цей варіант вибере будь новачок Rust. Я так і зробив, і відразу зіткнувся з проблемами з borrow checker. У цьому варіанті Widget повинен бути власником (owner) своїх нащадків І батьків. Це неможливо, тому що батько і нащадок будуть мати циклічні посилання володіння один одним.
Варіант 2
Це був мій другий вибір. Його плюс в тому, що він похід на стиль C++, використаний у nanogui. Є кілька мінусів, наприклад, використання небезпечних блоків скрізь, всередині і зовні бібліотеки. До того ж, borrow checker перевіряє покажчики на валідність. Але головний мінус в тому, що неможливо створити об'єкт-лічильник. Я не маю на увазі еквівалент "розумного покажчика" З с++, або типу Rc з Rust. Я маю на увазі об'єкт, який вважає, скільки разів на нього вказували, і видаляє сам себе коли лічильник досягає нуля. Ось приклад на C++ з реалізації nanogui.
Щоб ця штука працювала, потрібно сказати компілятору, що видаляти себе можна тільки зсередини об'єкта. Погляньте на приклад:
struct WidgetObj {
pub parent: Option<*mut WidgetObj>,
pub font_size: i32
}

impl WidgetObj {
fn new(font_size: i32) -> WidgetObj {
WidgetObj {
parent: None,
font_size: font_size
}
}
}

impl Drop for WidgetObj {
fn drop(&mut self) {
println!("widget font_size {} dropped", self.font_size);
}
}

fn main() {
let mut w1 = WidgetObj::new(1);
{
let mut w2 = WidgetObj::new(2);
w1.parent = Some(&mut w2);
}

unsafe { println!("parent font_size: {}", (*w1.parent.unwrap()).font_size) };
}

Запустити в інтерактивній пісочниці
Висновок буде таким:
widget font_size 2 dropped
parent font_size: 2
widget font_size 1 dropped

Це потрібно, щоб не з'явилася помилка after use free error, бо пам'ять не обнуляється після видалення.
Так що для коректної реалізації такого лічильника потрібно резервувати пам'ять глобально. Просто немає простого способу вказати компілятору не видаляти змінну автоматично, коли вона виходить з області видимості.
Ну, добре. Роби як знаєш, Rust. Який же спосіб реалізації циклічного спрямованого графа є идиоматическим у Rust?
Варіант 3
У підсумку я знайшов гарну бібліотеку для створення дерев, яка називається rust-forest. Вона дає можливість створювати сайти, вказувати на вузли розумними покажчиками і вставляти та видаляти вузли. Однак, реалізація не дозволяє додавати вузли різного типу T в один граф, і це важлива вимога бібліотеки начебто nanogui.
Погляньте на цей інтерактивний приклад. Він трохи задовге, тому я не додав повний лістинг прямо в статтю. Проблема в цій функції:
// Widget is a trait
// focused_widgets is a Vec<Rc<RefCell<Widget>>>
fn update_focus(&self, w: &Widget) {
self.focused_widgets.clear();
self.focused_widgets.push_child(w); // This will never work, we don't have the reference counted version of the widget here.
}

Запустити в інтерактивній пісочниці
До речі, цю дивну штуку можна обійти, але я все одно не розумію, чому це взагалі проблема.
let refObj = Rc::new(RefCell::new(WidgetObj::new(1)));
&refObj as &Rc<RefCell<Widget>>; // non-scalar cast

Запустити в інтерактивній пісочниці
Висновок
Проблеми, з якими я зіткнувся при реалізації способів 1, 2 і 3, наштовхують мене на думку, що четвертий варіант зі зв'язкою з С — це єдиний підходящий для мого завдання спосіб. І тепер я думаю — навіщо робити зв'язку з, коли можна просто написати все на З? Або С++?
У мови програмування Rust є позитивні риси. Мені подобається, як працює Match. Мені подобається загальна ідея типажів, як і інтерфейсів в Go. Мені подобається пакетний менеджер cargo. Але коли справа доходить до реалізації деталей типажів, підрахунку посилань і неможливості змінити поведінку компілятора, я змушений сказати «ні». Мені це не підходить.
Я щиро сподіваюся, що люди продовжать покращувати Rust. Але я хочу писати гри. А не намагатися перемогти компілятор або писати RFC, щоб зробити мову більш відповідні моїм завданням.
Примітка перекладача
Я не зрозумів, що має на увазі автор, коли говорить «для коректної реалізації такого лічильника потрібно резервувати пам'ять глобально», як якщо б це поведінка було нетиповим для інших мов, зокрема З і С++. У них теж потрібно класти змінну в динамічну пам'ять якщо хочеш зберегти її після завершення функції, вірно?
До того ж, «немає простого способу вказати компілятору не видаляти змінну автоматично, коли вона виходить з області видимості» — схоже, просто невірне твердження, тому що функція std::mem::forget створена спеціально для цього (з обговорення на реддите.
Добрі обговорення статті:
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.