Pull to refresh

Почему я отказался от Rust

Reading time 6 min
Views 65K
Original author: Michael de Lang


Когда я узнал, что появился новый язык программирования системного уровня, с производительностью как у С++ и без сборщика мусора, я сразу заинтересовался. Мне нравится решать задачи с помощью языков со сборщиками мусора, вроде 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

Это нужно, чтобы не появилась ошибка use after 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 создана специально для этого (из обсуждения на реддите).


Хорошие обсуждения статьи:


Tags:
Hubs:
+35
Comments 119
Comments Comments 119

Articles