КПДВ: borrow checker не даёт стрелять в ногу при работе с FFI


Borrow-checker — отличный секюрити, который очень эффективен, если мы находимся в безопасном Rust. Его поведение отлично описано в RustBook, и, по крайней мере, я почти никогда не сталкиваюсь с придирками, которым я бы не был благодарен.


Но вот когда нужно написать семантически-безопасный API над функциями и данными, которые вообще не безопасны — у меня всё стало валиться из рук. Последние пару дней я потратил на то, чтобы придумать элегантный способ параметризации мутабельности. Над тем, чтобы на уровне API сохранялась семантика — зависимость изменяемости полей друг от друга. Даже если на самом деле они живут сами по себе.


На английском, с примерами — на GitHub pages.


Исходник тестов — на GitHub.


Проблема


Я собрал небольшой модуль monkey_ffi, который имитирует какой-нибудь C API объектно-ориентированной GUI библиотеки. Там явно есть родительские отношения, разветвлённая структура и т.п. Но этот набор функций не гарантирует существование объектов, также как и их взаимосвязи. Например, если мы узнаем, что фрейм уже не существует — мы всё ещё не знаем, какие из кнопок тоже пора дропать.


Вот примерная структура того, что я набросал:


Root
----Window
----Frame
----|----FrameButton
----WindowButton

Модуль `monkey_ffi`
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<T>,
}

struct Frame<'a, T: ProbablyMutable> {
    window: &'a Window<'a, T>,
    id: usize,
    width_px: Option<u16>,
    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<T: ProbablyMutable> для функций, которые должны быть параметризованы.
  • struct<Mutable> для тех функций, которым необходима мутабельность
  • struct<Immutable> для функций, гарантирующих иммутабельность

impl<'a, T: ProbablyMutable> Window<'a, T> {
    fn new(root: &'a Root, id: usize) -> Option<Self> {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<Frame<Immutable>> {todo!()}
    fn get_button(&self, id: usize) -> Option<WindowButton<Immutable>> {todo!()}
}
impl<'a> Window<'a, Mutable> {
    fn set_name(&mut self, name: impl Into<String>) {todo!()}
    fn make_frame(&mut self) -> Frame<Mutable> {todo!()}
    fn make_button(&mut self) -> WindowButton<Mutable> {todo!()}
}

Кнопки выглядят почти также, но для них есть два общих трейта с зависимыми типами родителя:


trait Button<T: ProbablyMutable>
where
    Self: Sized,
{
    type Parent;
    fn new(parent: Self::Parent, id: usize) -> Option<Self>;
    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<String>);
}
struct WindowButton<'a, T: ProbablyMutable> {
    id: usize,
    text: String,
    parent: &'a Window<'a, T>,
}
impl<'a, T: ProbablyMutable> Button<T> for WindowButton<'a, T> {
    type Parent = &'a Window<'a, T>;
    fn new(parent: Self::Parent, id: usize) -> Option<Self>;
    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<String>);
}

struct FrameButton<'a, T: ProbablyMutable> {
    id: usize,
    text: String,
    parent: &'a Frame<'a, T>,
}
impl<'a, T: ProbablyMutable> Button<T> for FrameButton<'a, T> {
    type Parent = &'a Frame<'a, T>;
    fn new(parent: Self::Parent, id: usize) -> Option<Self>;
    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<String>);
}

Всё остальное, в принципе — уже бойлерплейт. Можете посмотреть на реализацию позже.


Попробуем поиграться:


Для начала, удостоверимся, что будем видеть вывод, и у нас будет Root. Для работы логгера надо установить переменную среды RUST_LOG=debug:


env_logger::init();
let mut root = Root::new();
let window1: Window<Mutable> = 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<Immutable> = root.get_child(w1_id).unwrap();
let _window2: Window<Immutable> = 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<Mutable> = root.get_child_mut(w1_id).unwrap();
let button: WindowButton<Mutable> = window1.make_button();
let b_id: usize = button.get_id();
// button is dropped.
let mut frame: Frame<Mutable> = 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<Mutable>. Но, можно ли её позаимствовать иммутабельно?


let button: WindowButton<Immutable> = 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<Immutable> = root.get_child(w1_id).unwrap();
let frame: Frame<Immutable> = window1.get_frame(f_id).unwrap();
let w_b: WindowButton<Immutable> = window1.get_button(b_id).unwrap();
let fr_b: FrameButton<Immutable> = 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. Оставьте, пожалуйста, старый редактор.

Комментарии (16)


  1. mayorovp
    02.12.2022 06:24
    +2

    Проблема в том, что id объекта в подобной библиотеке — аналог небезопасного указателя.


    Безопасная обёртка должна скрывать его, а не основывать на основе его получения все операции с API.


    1. Levitanus Автор
      02.12.2022 09:36
      +2

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

      Собственно, в этом отношении, работа с «указателями» в этом примере становится не более опасной, чем вызов элемента из вектора по индексу. Я добавляю как минимум гарантии:

      • того, что, мы сами не поломаем свои же объекты, потому что теперь можно менять один объект за раз

      • того, что объект умрёт вместе с «родителем» и я не смогу им пользоваться позже.

      • того, что объект из FFI слоя потребует себя проверить на Null (Option).

      Может быть, GUI — не самая лучшая аналогия, потому что редко бывают ситуации, когда у GUI несколько владельцев. И тут можно было бы попробовать хранить копию состояния в структуре, оставляя лишь те проверки, которые отвечают за вход изначальных данных. И общаться с высокоуровневой обёрткой в стиле imgui\egui. Мне просто хотелось что-то более близкое и понятное, чем то, с чем общаюсь сейчас сам.

      Но оригинальная среда, из которой я выцепил этот пример — это DAW, где есть треки, на них есть items, у них есть takes, в них есть source, в котором лежат сырые данные. И я физически не могу контролировать то, что происходит в проекте. Также, как мне кажется расточительным тратить на каждую операцию дополнительные O(n) на итерацию, особенно, если оно ещё будет в каждом вызове общаться с FFI: это и так несколько накладно, а про свой API я точно знаю, что можно нарваться на O(n²) за один обход, допустим, миди-событий в треке.

      Ну и в принципе, с одной стороны, хотелось проработать пример достаточно для того, чтобы он «крякал», когда monkey_ffi выполняет какую-то операцию. И мог «упасть». А с другой стороны, это пример контроля параметризации мутабельности. Поэтому, чем больше «обвеса» — тем сложнее выцепить основной механизм, который тестирую, и про который рассказываю.


      1. mayorovp
        02.12.2022 18:09

        Когда вы обращаетесь к вектору по индексу — вектор гарантирует, что будет либо паника, либо он вернёт корректный элемент.


        Когда вы передаёте id в стороннюю библиотеку на другом языке — библиотека может без всякой проверки взять неинициализированный элемент из своего внутреннего массива, и обратиться по указателю оттуда.


        1. Levitanus Автор
          02.12.2022 18:33

          Нет, когда я обращаюсь к вектору по индексу Vec::get(), он предлагает мне обработать Option. То же самое делает моя обёртка. Здесь механизм «проверки на валидность» лежит в самом модуле monkey_ffi, который возвращает None, если я прошу объект с id выше, чем у него. И, да, там даже unsafe код есть)

          При работе с указателем у нас есть несколько других механизмов. Самый «лобовой» — ptr::is_null(). У меня, допустим, API предоставляет способ валидации указателя: отправляешь ему указатель, а он говорит, можно ли им пользоваться.

          Я в этом случае сделал обёртку над обёрткой. Я получаю сырой указатель по методу get(), который внутри проверяет, живой ли указатель и паникует по необходимости. Если хочется проверить без паники — есть метод, который делает то же самое, но возвращает Result.

          А, поскольку, эта операция тоже не бесплатная — есть способ выключить эту проверку на время. Допустим, в начале функции валидировать указатель, потом выключить проверки и работать на механизмах rust.

          Вот тут можно посмотреть, как оно выглядит в проекте.


          1. mayorovp
            02.12.2022 19:35

            Здесь механизм «проверки на валидность» лежит в самом модуле monkey_ffi, который возвращает None, если я прошу объект с id выше, чем у него.

            Это совершенно неинтересный случай, который никак не подходит под ваше вступление:


            Но вот когда нужно написать семантически-безопасный API над функциями и данными, которые вообще не безопасны

            Почему библиотеку, которая проверяет любые переданные ей на вход параметры и никогда не падает, вы называете "вообще не безопасной"?


            1. Levitanus Автор
              02.12.2022 19:39
              +1

              Я не понимаю, почему мы обсуждаем одну проблему, когда статья про другую?
              И потрудитесь прочитать, пожалуйста: она не «никогда не падает» и «проверяет любые переданные на вход параметры». А предоставляет такой механизм.

              Ещё раз: проблема в том, чтобы на этапе компиляции (cargo check) знать, что ты не удалишь трек №2 во время того, как редактируешь что-то на треке №3. Я пытаюсь семантически связать те объекты, которые не связаны в оригинальном API.


  1. domix32
    02.12.2022 16:45
    +2

    тут в TWIR 468 после появления GATов один чувак решил в miri сделать что-то похожее.


  1. 0xd34df00d
    02.12.2022 22:13
    +3

    Открыл статью ради зависимых типов и что-то сходу их не нашёл (но я не Rust-программист). Тут у вас зависимые типы — это как в лямбда-кубе, или что-то другое?


    1. Levitanus Автор
      03.12.2022 12:17
      -2

      В Rust Зависимые типы (на данный момент) — это конкретные типы, которыми может оперировать trait (интерфейс). В общем, это способ замкнуть реализацию трейта на конкретном наборе структур. А зависимые они потому, что, в последствие, достаточно указать один параметр для трейта, чтобы инициализировать остальной набор типов.

      Здесь в примере, завтипы не раскрылись в полной мере, потому что для этого надо было бы добавить ещё 1-2 уровня потомков. Но присутствуют:

      trait Button<T: ProbablyMutable>
      where
          Self: Sized,
      {
          type Parent; // Зависимый тип
      }
      

      А потом мы его имплементируем для конкретных типов:

      impl<'a, T: ProbablyMutable> Button<T> for WindowButton<'a, T> {
          type Parent = &'a Window<'a, T>;
          fn new(parent: Self::Parent, id: usize) -> Option<Self>;
      }
      impl<'a> ButtonMut for WindowButton<'a, Mutable> {
          type Parent = Window<'a, Mutable>;
      }
      

      В принципе, если у нас появится целая пачка типов, которые нужны трейту — можно также их выводить из одного дженерик-параметра:

      impl<'a, T: ProbablyMutable> Button<T> for WindowButton<'a, T> {
          type Root = &'a Root<'a, T>;
          type Parent = &'a Window<'a, T>;
          type Child = WindowButtonText<T>;
          fn new(parent: Self::Parent, id: usize) -> Option<Self>;
          fn get_text(&self) -> Self::Child;
      }
      


      1. mayorovp
        03.12.2022 13:00
        +4

        Это называется не "зависимые типы", а "ассоциированные типы".


        1. Levitanus Автор
          03.12.2022 13:10

          Хм. Пожалуй.

          Я, когда для себя переводил «assotiated functions» и «assotiated types» решил, что, раз ассоциированные функции всё равно в 90% случаев легче называть методами (а в остальных 10%, хоть и не очень корректно — статическими методами).
          То и assotiated types, скорее всего всё-таки завтипы. Т.к. ассоциированные типы на русском я впервые читаю в вашем комментарии.

          Раз уж вы здесь — расскажите, пожалуйста, чем завтипы отличаются от ассоциированных?


          1. mayorovp
            03.12.2022 13:58
            +2

            Считайте завтип дженериком, параметризованным значением, известным только в рантайме.


            1. Levitanus Автор
              03.12.2022 14:23

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


              1. mayorovp
                03.12.2022 14:34
                +4

                Вот для этой уверенности они и нужны. Там фокус в том, что хотя точный тип известен только в рантайме — проверка типов всё равно выполняется на этапе компиляции. Но сложность в том, что для их использования компилятору надо научиться доказывать теоремы, иначе нормально пользоваться ими будет невозможно.


                Простейший пример с которого можно начинать понимать как они могут быть использованы — проверка границ массивов. Если в Rust есть два режима работы — с проверкой при каждом обращении (которую, может быть, уберёт оптимизатор если звёзды сойдутся правильно) и небезопасный, то завтипы позволяют описать тип "число от 0 до N-1, где N — длина массива" и безопасно избежать почти всех проверок.


                1. Levitanus Автор
                  03.12.2022 14:37

                  Да, это следующее, что мне б хотелось научиться делать без паники...


              1. Cerberuser
                04.12.2022 19:30
                +1

                Поимею небольшую наглость вкинуть собственную статью около темы - https://habr.com/ru/post/477330/ (реализация на Rust и расписана в целом достаточно подробно, так что проблем с пониманием быть вроде бы не должно).