Rust: параметризуем мутабельность через маркеры и зависимые типы
Borrow-checker — отличный секюрити, который очень эффективен, если мы находимся в безопасном Rust. Его поведение отлично описано в RustBook, и, по крайней мере, я почти никогда не сталкиваюсь с придирками, которым я бы не был благодарен.
Но вот когда нужно написать семантически-безопасный API над функциями и данными, которые вообще не безопасны — у меня всё стало валиться из рук. Последние пару дней я потратил на то, чтобы придумать элегантный способ параметризации мутабельности. Над тем, чтобы на уровне API сохранялась семантика — зависимость изменяемости полей друг от друга. Даже если на самом деле они живут сами по себе.
На английском, с примерами — на GitHub pages.
Исходник тестов — на GitHub.
Проблема
Я собрал небольшой модуль monkey_ffi
, который имитирует какой-нибудь C API объектно-ориентированной GUI библиотеки. Там явно есть родительские отношения, разветвлённая структура и т.п. Но этот набор функций не гарантирует существование объектов, также как и их взаимосвязи. Например, если мы узнаем, что фрейм уже не существует — мы всё ещё не знаем, какие из кнопок тоже пора дропать.
Вот примерная структура того, что я набросал:
Root
----Window
----Frame
----|----FrameButton
----WindowButton
fn make_window() -> usize;
fn get_window(window_id: usize) -> usize;
fn make_frame(_window_id: usize) -> usize;
fn make_window_button(_window_id: usize) -> usize;
fn make_frame_button(_window_id: usize) -> usize;
fn window_button_is_clicked(_window_id: usize, _button_id: usize) -> bool;
fn window_button_click(window_id: usize, button_id: usize);
fn window_button_set_text(window_id: usize, button_id: usize, text: &String);
fn frame_button_is_clicked(_frame_id: usize, _button_id: usize) -> bool;
fn frame_button_click(frame_id: usize, button_id: usize);
fn frame_button_set_text(frame_id: usize, button_id: usize, text: &String);
В идеале бы обеспечить ситуацию, в которой мы можем изменять один объект за раз. И надо обеспечить гарантии того, что дети не переживут своих родителей.
Проблема в том, что, используя Rc
, или простые референсы на каждом «уровне вложенности», мы теряем зависимость от мутабельности родителя. Не получится просто сделать параметризованную структуру Window
, которая будет содержать в себе только либо &Root
, либо &mut Root
. Даже такая простая параметризация потребует дополнительной реализации трейта с зависимым типом, и с каждой итерацией сигнатура будет разрастаться. Типа такого: SecondChild<&mut Parent, &mut FirstChild<&mut Parent>>
.
Сделать две версии Window
? Тоже лишние телодвижения, а кроме того, бойлерплейт, наподобие повсеместных фнукций get()
и get_mut()
, только уже на уровне целой структуры.
На мысль об удобоваримой архитектуре меня натолкнул факт того, что Self
, &Self
и &mut Self
— не просто состояние структуры, а совершенно разные типы, которые реализуют разные трейты. А эта дискуссия ещё больше подтолкнула меня к решению.
Вообще-то, мутабельность в Rust не бинарная, а троичная: есть типы изменяемые, неизменяемые, и «те, которым плевать», собственно, так желаемые мной дженерики. Так что давайте начнём с объявления типов, характеризующих эти три состояния: один трейт и две структуры:
trait ProbablyMutable;
struct Mutable;
impl ProbablyMutable for Mutable {}
struct Immutable;
impl ProbablyMutable for Immutable {}
Дальше мы используем их как маркеры для последующей параметризации.
Едем дальше. Надо обеспечить времена жизни потомков, так что набросаем скелет библиотеки. PhantomData
будет использоваться как дженерик по мутабельности, чтобы не тащить на каждый новый уровень зоопарк generic переменных.
struct Root;
struct Window<'a, T: ProbablyMutable> {
id: usize,
name: String,
frames_amount: usize,
buttons_amount: usize,
root: &'a Root,
mutability: PhantomData,
}
struct Frame<'a, T: ProbablyMutable> {
window: &'a Window<'a, T>,
id: usize,
width_px: Option,
buttons_amount: usize,
}
struct WindowButton<'a, T: ProbablyMutable> {
id: usize,
text: String,
parent: &'a Window<'a, T>,
}
struct FrameButton<'a, T: ProbablyMutable> {
id: usize,
text: String,
parent: &'a Frame<'a, T>,
}
Поскольку, в FFI API два разных набора функций для кнопок фреймов и окон, я решил сделать два отдельных типа, которые реализуют один интерфейс (трейт) Button
. Теоретически, должно быть возможно сделать одну структуру, которая различает родительские и зависимые типы через enum
. Но на данном этапе мне это показалось уже совсем отходом в сторону от проблемы.
Для параметризации мутабельности, я пишу три реализации, как для трёх разных типов:
struct
для функций, которые должны быть параметризованы.struct
для тех функций, которым необходима мутабельностьstruct
для функций, гарантирующих иммутабельность
impl<'a, T: ProbablyMutable> Window<'a, T> {
fn new(root: &'a Root, id: usize) -> Option {todo!()}
fn get_id(&self) -> usize {todo!()}
fn get_name(&self) -> &String {todo!()}
fn get_width(&self) -> u16 {todo!()}
}
impl<'a> Window<'a, Immutable> {
fn get_frame(&self, id: usize) -> Option> {todo!()}
fn get_button(&self, id: usize) -> Option> {todo!()}
}
impl<'a> Window<'a, Mutable> {
fn set_name(&mut self, name: impl Into) {todo!()}
fn make_frame(&mut self) -> Frame {todo!()}
fn make_button(&mut self) -> WindowButton {todo!()}
}
Кнопки выглядят почти также, но для них есть два общих трейта с зависимыми типами родителя:
trait Button
where
Self: Sized,
{
type Parent;
fn new(parent: Self::Parent, id: usize) -> Option;
fn get_id(&self) -> usize;
fn is_clicked(&self) -> bool;
fn get_text(&self) -> &String;
}
trait ButtonMut
where
Self: Sized,
{
type Parent;
fn click(&mut self);
fn set_text(&mut self, text: impl Into);
}
struct WindowButton<'a, T: ProbablyMutable> {
id: usize,
text: String,
parent: &'a Window<'a, T>,
}
impl<'a, T: ProbablyMutable> Button for WindowButton<'a, T> {
type Parent = &'a Window<'a, T>;
fn new(parent: Self::Parent, id: usize) -> Option;
fn get_id(&self) -> usize;
fn is_clicked(&self) -> bool;
fn get_text(&self) -> &String;
}
impl<'a> ButtonMut for WindowButton<'a, Mutable> {
type Parent = Window<'a, Mutable>;
fn click(&mut self);
fn set_text(&mut self, text: impl Into);
}
struct FrameButton<'a, T: ProbablyMutable> {
id: usize,
text: String,
parent: &'a Frame<'a, T>,
}
impl<'a, T: ProbablyMutable> Button for FrameButton<'a, T> {
type Parent = &'a Frame<'a, T>;
fn new(parent: Self::Parent, id: usize) -> Option;
fn get_id(&self) -> usize;
fn is_clicked(&self) -> bool;
fn get_text(&self) -> &String;
}
impl<'a> ButtonMut for FrameButton<'a, Mutable> {
type Parent = Frame<'a, Mutable>;
fn click(&mut self) ;
fn set_text(&mut self, text: impl Into);
}
Всё остальное, в принципе — уже бойлерплейт. Можете посмотреть на реализацию позже.
Попробуем поиграться:
Для начала, удостоверимся, что будем видеть вывод, и у нас будет Root
. Для работы логгера надо установить переменную среды RUST_LOG=debug
:
env_logger::init();
let mut root = Root::new();
let window1: Window = root.make_child();
Выглядит неплохо: добавление окна изменяет root
. Так что window1
— тоже Mutable
. Добавим ещё одно!
let window2 = root.make_child();
Ай!: Err: cannot borrow root as mutable more than once at a time
. Но, вообще, так оно и должно выглядеть. Дропнем это окно, но сохраним id для дальнейшего использования.
let w1_id: usize = window1.get_id();
debug!("{}", w1_id);
drop(window1);
Теперь root
снова неизменный (точнее, не позаимствованный). Ну-ка, теперь сделаем два окна по-нормальному.
let id2: usize = root.make_child().get_id();
let window1: Window = root.get_child(w1_id).unwrap();
let _window2: Window = root.get_child(id2).unwrap(); // OK!
Так, они оба Immutable
, так что, если мы попробуем их изменять — должна выскочить ошибка:
window1.make_button();
Err: no method named `make_button` found for struct `Window<'_, test::Immutable>` in the current scope. The method was found for `Window<'a, test::Mutable>`
Продолжаем:
let mut window1: Window = root.get_child_mut(w1_id).unwrap();
let button: WindowButton = window1.make_button();
let b_id: usize = button.get_id();
// button is dropped.
let mut frame: Frame = window1.make_frame();
let fr_b_id: usize = frame.make_button().get_id();
let f_id: usize = frame.get_id();
// frame is dropped.
debug!("button text: {}", button.get_text());
//
Err: cannot borrow `window1` as mutable more than once at a time
Да, потому что button
была WindowButton
. Но, можно ли её позаимствовать иммутабельно?
let button: WindowButton = window1.get_button(b_id);
Err: no method named `get_button` found for struct `Window<'_, test::Mutable>` in the current scope the. Method was found for - `Window<'a, test::Immutable>`
Ну, напоследок проверим, что несколько иммутабельных референсов уживаются вместе:
let window1: Window = root.get_child(w1_id).unwrap();
let frame: Frame = window1.get_frame(f_id).unwrap();
let w_b: WindowButton = window1.get_button(b_id).unwrap();
let fr_b: FrameButton = frame.get_button(fr_b_id).unwrap();
debug!("is window button clicked: {}", w_b.is_clicked());
debug!("is frame button clicked: {}", fr_b.is_clicked());
Мда. Только начинаешь что-то изучать — сразу появляется жедание написать статью. Вот про Python мне писать ничего уже не хочется — там, вроде бы и так всё понятно. Но, по крайней мере, я себя извиняю тем, что действительно не смог найти хорошего готового решения этой проблемы. И тем, что экосистема rust вообще немножко грешит тем, что надо хранить блокнотик. А в нём записывать любимые крейты, которые называются почти неотличимо от нелюбимых, и реализации нетривиальных вещей, вроде std::sync::Once
, которые не подскажет автокомплит.
Пусть эта реализация лежит здесь и на GitHub) Буду рад критике.
P.S. Оставьте, пожалуйста, старый редактор.