LINUX.ORG.RU

Rust и наследование

 ,


1

4

Допустим, у нас есть структура игрового движка:

struct GameEngine {
    ... тут всякие кроссплатформенные поля вроде id OpenGL программ и т. д. ...
}

impl GameEngine {
    fn new() -> GameEngine {
        GameEngine {
            ...
        }
    }

    fn render(&mut self) {
        ...
    }

    ... какие-то ещё методы для обработки всяких событий ...
}

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

struct SDLGameEngine {
    GameEngine engine;
    ... Всякие платформозависимые переменные ...
    window: sdl2::video::Window
}

impl SDLGameEngine {
    fn new() -> SDLGameEngine {
        SDLGameEngine {
            engine: GameEngine::new(),
            ...
        }
    }

    fn main_loop(&mut self) {
        loop {
            self.handle_events();
            self.engine.render();
        }
    }

    ...
}

Всё хорошо до тех пор, пока SDLGameEngine полностью рулит GameEngine - вызывает всякие разные методы в ответ на всякие разные события (рендер, нажатие клавиш, движения мыши), а GameEngine меняет только своё внутреннее состояние и дёргает вызовы OpenGL.

Однако в один прекрасный момент нам захотелось показывать FPS в заголовке окна. При этом логика рассчёта FPS, очевидно, не зависит от платформы, а вот процедура смены заголовка окна явно зависит. Так что теперь GameEngine должен повлиять на SDLGameEngine, а не наоборот.

Окей, делаем трейт:

trait GameEnginePlatform {
    fn set_title(&mut self, title: &str);
}

А теперь у нас есть три варианта:

1. Реализуем трейт для SDLGameEngine (ведь в нём лежит реальное окно). Но не можем передать его в конструктор GameEngine, потому что в момент создания GameEngine SDLGameEngine ещё не существует. В свою очередь SDLGameEngine не может быть создан без экземпляра GameEngine. Начинать использовать указатели как-то глупо с учётом того, что структура очень простая (кто кем владеет) и у нас нет проблем с управлением памятью.

2. Реализуем трейт для SDLGameEngine (ведь в нём лежит реальное окно). Не будем передавать его в конструктор, добавим аргумент к функции render:

fn render(&mut self, platform: &mut dyn GameEnginePlatform) {
    ...
    platform.set_title(...);
}

...

fn main_loop(&mut self) {
    loop {
        self.handle_events();
        self.engine.render(self);
    }
}

Получаем ошибку заимствования:

   |         self.engine.render(self);
   |         ^^^^^^^^^^^^^^^^^^^----^
   |         |                  |
   |         |                  first mutable borrow occurs here
   |         second mutable borrow occurs here
   |         first borrow later captured here by trait object

В целом логично.

3. Дробим SDLGameEngine на две части. Одна основная, а другая управляет окном и реализует трейт для смены заголовка. Передаём её в конструктор GameEngine. И... Лишаемся возможности управлять окном из SDLGameEngine. А нам это хочется, ведь часть операций над окном платформозависимая.

4. Дробим SDLGameEngine на две части. Одна основная, а другая управляет окном и реализует трейт для смены заголовка. Храним её внутри SDLGameEngine, передаём ссылку в метод render. В свободное от вызова этого метода время можем управлять окном сами.

Получается, что в Rust единственный способ связи родителя класса с потомком (в терминах ООП) это вариант 4. Или есть альтернативы? Мне не очень нравится, что GameEngine теперь может обратиться к SDLGameEngine исключительно в специальных методах, которые принимают специальные параметры, а SDLGameEngine пришлось распилить на две части. В данном примере всё очень примитивно, однако в более сложной программе, мне кажется, это может стать проблемой.

Бонусный вопрос: я правильно понимаю, что trait это фактически vtable отделённый от самого объекта и в Rust каждая структура может иметь множество vtable? Как это вообще реализовано на низком уровне?

★★★★★

Последнее исправление: KivApple (всего исправлений: 1)

Натягивать OOP на раст довольно бессмысленно.

Бонусный вопрос: я правильно понимаю, что trait это фактически vtable отделённый от самого объекта и в Rust каждая структура может иметь множество vtable?

vtable это trait object. Сам трейт это немного другое.

quantum-troll ★★★★★
()

Я не очень понял верхнюю часть вопроса но могу ответить вот на эту:

Бонусный вопрос: я правильно понимаю, что trait это фактически vtable отделённый от самого объекта и в Rust каждая структура может иметь множество vtable? Как это вообще реализовано на низком уровне?

Когда ты делаешь let x: &dyn T = &object или там let x: Box<dyn T> = Box::new(object) вот в этот момент к указателю на объект привязывается виртуальная таблица трейта T для типа объекта. Т.е. если у тебя тип имплементирует два разных трейта, то когда ты делаешь вот такой dyn ..., то у тебя привязывается виртуальная только того трейта, который в dyn указан.

Aswed ★★★★★
()

Однако в один прекрасный момент нам захотелось показывать FPS в заголовке окна. При этом логика рассчёта FPS, очевидно, не зависит от платформы, а вот процедура смены заголовка окна явно зависит. Так что теперь GameEngine должен повлиять на SDLGameEngine, а не наоборот.

И это будет происходить для 100500 параметров? Почему не рассматривается вариант, что окно подпишется на информацию об FPS, или будет смотреть это из соответствующего поля, или т.д. и т.п.?

я правильно понимаю, что trait это фактически vtable отделённый от самого объекта

Как-то ограничено. Это больше.

AlexVR ★★★★★
()
Ответ на: комментарий от Aswed

Типа dyn ссылка под капотом представляет собой два указателя? На сам объект и на vtable? А в C++ указатель один, а vtable лежит как поле структуры в самой структуре.

KivApple ★★★★★
() автор топика
Ответ на: комментарий от AlexVR

будет смотреть это из соответствующего поля

На самом деле render обновляет FPS раз в секунду, так что в таком случае SDLGameEngine придётся ещё и проверять, изменилось ли значение. К тому же мне нравится вариант, если заголовок будет полностью задаваться из GameEngine. Вдруг я захочу выводить в заголовок окна что-нибудь другое, так не придётся менять все платформозависимые реализации.

окно подпишется на информацию об FPS

Что вы имеете ввиду под этими словами? Как это конкретно будет выглядеть в Rust?

KivApple ★★★★★
() автор топика
Ответ на: комментарий от KivApple

Ну учитывая, что ссылка на dyn в памяти занимает столько же сколько и обычная, предположу, что она все таки одна. Энивей, а на что это влиет в твоем коде? Ты хочешь вручную по vtable скакать?

Aswed ★★★★★
()
Ответ на: комментарий от Aswed

Просто интересно какой оверхед, практического интереса нет

KivApple ★★★★★
() автор топика
Ответ на: комментарий от KivApple

Что вы имеете ввиду под этими словами? Как это конкретно будет выглядеть в Rust?

При чём тут Rust? Для начала https://martalex.gitbooks.io/gameprogrammingpatterns/content/ как раз половина книги посвящена избавлению от сильной связности.

Вдруг я захочу выводить в заголовок окна что-нибудь другое, так не придётся менять все платформозависимые реализации.

А если говорим о полноэкранном режиме? А если пользователь захочет отключить?

Можно конечно запихать всё в одну кучу:

trait GameEngine {
    fn set_title(&mut self, title: &str);

    fn render(&mut self) {
        let title = format!("FPS {}", 50.0);
        self.set_title(&title);
    }
}

struct ConsoleGameEngine {}

impl GameEngine for ConsoleGameEngine {
    fn set_title(&mut self, title: &str) {
        println!("Title: {}", title)
    }
}

impl ConsoleGameEngine {
    fn new() -> Self {
        Self{}
    }

    fn main_loop(&mut self) {
        self.render();
        self.render();
        self.render();
    }
}

fn main() {
    let mut e = ConsoleGameEngine::new();
    e.main_loop();    
}

Заметь без всяких наследований.

Но ещё раз, это слишком связный код.

AlexVR ★★★★★
()
Ответ на: комментарий от AlexVR

У вашего варианта есть проблема - GameEngine не может иметь состояние. А оно ему нужно.

KivApple ★★★★★
() автор топика

tl;dr

trait Platform { 
    fn set_title(&mut self, title: &str);
    fn poll_events(&mut self) -> EventsIter;
    fn make_context_current(&mut self);
    /* ... */ 
}

struct GameEngine<P: Platform> { /* ... */ }

struct SdlPlatform { /* ... */ }

impl Platform for SdlPlatform { /* ... */ }

А то что trait-object - два указателя - уже сказали.

anonymous-angler ★☆
()

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

anonymous
()
Ответ на: комментарий от pathfinder

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

https://www.tedinski.com/2018/02/13/inheritance-modularity.html

Int64 ★★★
()
Последнее исправление: Int64 (всего исправлений: 1)
Ответ на: комментарий от Int64

Слышал от многих коллег и сам сталкивался большими проблемами из-за наследования. Намного лучше работает интерфейсы и композиция.
https://www.tedinski.com/2018/02/13/inheritance-modularity.html

Где ты откопал этого чела? Признавайся.

byko3y ★★★★
()
Ответ на: комментарий от meliafaro

Клоун, при чем тут «моя» методичка? Тебя кто-то про нее спросил?

Siborgium ★★★★★
()

Просто пиши на JavaScript

anonymous
()
Ответ на: комментарий от Lrrr

До тех пор пока не перестает, и тогда появляются интерфейсы типа такого https://doc.rust-lang.org/stable/std/task/struct.RawWakerVTable.html

Уф-ф-ф, я это прочитал:

https://doc.rust-lang.org/stable/src/core/task/wake.rs.html

Эталонный пример того, как растянуть на 300 строк (с доками) код, который по сути делает:

pub struct RawWaker {
    data: *const (),
    vtable: &static RawWakerVTable,
}
pub struct RawWakerVTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ()),
}

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

byko3y ★★★★
()
Ответ на: комментарий от Lrrr

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

red75prim ★★★
()
Последнее исправление: red75prim (всего исправлений: 1)
Ответ на: комментарий от red75prim

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

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

byko3y ★★★★
()
Ответ на: комментарий от byko3y

Как это делать три года обсуждали. И я за этим обсуждением не следил. Так что сейчас не возьмусь всё перечитывать и разбираться.

red75prim ★★★
()
Ответ на: комментарий от red75prim

Как это делать три года обсуждали. И я за этим обсуждением не следил. Так что сейчас не возьмусь всё перечитывать и разбираться

При чем тут обсуждения «как написать реализацию не расплескав смузи?»? Язык есть, инструменты в нем конкретные, можно конкретно говорить, как бы каждый из нас это сделал, реализуя конкретную видимую ему задачу (которую он нам поведает, чтобы мы вообще понимали, что он там у себя в голове решил). В данном конкретном примере делается классическая динамическая диспетчеризация по виртуальным таблицам аля классы C++/Object Pascal/Java/etc.

byko3y ★★★★
()
Ответ на: комментарий от byko3y

как бы каждый из нас это сделал

«Это» - это интерфейс к рантайму асинка. Он не должен сильно ограничивать различные способы реализации рантайма, и в то же время не должен быть слишком сложен в реализации или требовать оверхеда для реализации. То есть нужно будет подумать какими способами можно реализовать рантайм на разных ОС (и без ОС) и какими способами можно реализовать интерфейс к рантайму. Рассмотреть все комбинации реализации рантайма и интерфейса к нему, выяснить сложность/оверхед реализации конкретного интерфейса для конкретного способа реализации рантайма и найти наилучший (или компромиссный) интерфейс.

При этом легко упустить из виду какие-то платформы и оптимальные способы реализации рантайма на них.

Ничего хорошего из попытки решить это между делом на форуме не выйдет.

red75prim ★★★
()
Ответ на: комментарий от byko3y

Как я понимаю, в Rust нет интерфейсов/протоколов

Весь ЛОР засрал своими растостраданиями и даже не знает про трейты.

anonymous
()

Используй композицию и забей на наследование. Движки именно что лучше ложатся в компонентную модель («подсистемы»), чем в какие-то развесистые иерархии наследников.

slackwarrior ★★★★★
()
Ответ на: комментарий от anonymous

Весь ЛОР засрал своими растостраданиями и даже не знает про трейты

Да, и почему же авторы стандартной библиотеки про трейты не знают?

byko3y ★★★★
()
Ответ на: комментарий от byko3y

Waker может быть statically allocated и тогда будет лишний оверхед.

vlad9486
()
Ответ на: комментарий от Int64

Слышал от многих коллег и сам сталкивался большими проблемами из-за наследования. Намного лучше работает интерфейсы и композиция.
https://www.tedinski.com/2018/02/13/inheritance-modularity.html

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

byko3y ★★★★
()
Ответ на: комментарий от byko3y

vtable: &static RawWakerVTable,

Это выглядит как реализация руками того, что компилятор C++ делает автоматом.

X512 ★★★★★
()
Ответ на: комментарий от X512

Это выглядит как реализация руками того, что компилятор C++ делает автоматом

Да, об этом и был разговор — C++ оптимизирует статику, убирая ее по необходимости из динамической диспетчеризации во время выполнения, а Rust, наоборот, пляшет от отсутствия динамической диспетчеризации, которую необходимо реализовывать самому руками.

byko3y ★★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.