Допустим, у нас есть структура игрового движка:
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? Как это вообще реализовано на низком уровне?