понедельник, 15 июня 2020 г.

[prog.flame] Тут опять поднимается волна хайпа вокруг Rust-а, а мне интересно, насколько просто будет на Rust-е...

...делать какие-то вещи из привычной мне окружающей реальности.

Данный пост навеян двумя факторами:

  • после поверхностного знакомства с Rust-ом при написании кода на C++ я зачастую задумываюсь о том, а как такие же вещи можно было бы сделать на Rust-е и насколько бы сложно было бы объяснить Rust-овому компилятору, что я сам понимаю, что делаю;
  • прозвучавшей на днях в этих наших Интернетиках статьей в которой кто-то выступил от имени всего Microsoft-а: "Microsoft: Rust Is the Industry’s ‘Best Chance’ at Safe Systems Programming".

Так вот, в свете очередного обещания неизбежного пришествия Rust-а в индустрию с неизбежной заменой C++, мне захотелось узнать, а насколько идиоматично будет выглядеть на Rust-е решение, которое пару недель назад в текущем проекте довелось сделать на C++.

Вкратце суть такова: есть класс-родитель, который владеет неким объектом child. При этом у класса-родителя есть метод replace_child:

class child;
using child_unique_ptr = std::unique_ptr<child>;

class parent
{
   child_unique_ptr m_child;

public:
   void replace_child(child_unique_ptr new_child)
   {
      m_child = new_child;
   }
   ...
};

Класс child -- это интерфейс, который имеет несколько реализаций. Что-то вроде:

class child
{
   parent & m_parent;

public:
   ... // some pure virtual methods.
};

class first_stage : public child {...};
class second_stage : public child {...};
class third_stage : public child {...};
...

А фокус в том, что объекты-дети заменяют у родителя сами себя. Т.е. есть код вида:

class first_stage : public child
{
   ...
   void on_some_event() override
   {
      ...
      m_parent.replace_child(std::make_unique<second_stage>(...));
   }
};

Обратить внимание нужно на то, что только parent владеет указателем на child. И вызов replace_child синхронный. Поэтому, когда child вызывает replace_child у своего parent-а, то parent внутри replace_child-а уничтожает того child-а, который и сделал вызов replace_child.

Это ведет к тому, что значение this после возврата из replace_child будет невалидным. И, если в first_stage::on_some_event после возврата из replace_child будут происходить какие-то обращения к this (например, вызовы каких-то нестатических методов и/или чтение/изменение полей объекта), то возникнет UB.

В общем-то, примененное мной решение явно опасное и неоднозначное. Да и вообще программист из меня так себе, поэтому пишу код как умею. Отсюда и такие спорные решения.

Однако, если уж мне не удалось найти более безопасного решения в C++, то программируй я на Rust-е, то и на Rust-е бы я пришел бы к чему-либо подобному. И вот тут как раз и возникает вопрос, который мне любопытен:

А можно было бы в Rust-е применить такой же прием, не прибегая к unsafe?

Мои поверхностные знания Rust-е не позволяют дать точный ответ. Есть смутные подозрения, что без unsafe не обойтись.

Ну и добавлю сюда еще и то, что нормального ООП в Rust-е нет, поэтому иерархию из child-ов и их реализаций пришлось бы костылить без возможности просто-так переиспользовать функциональность, которая нужна всем (или значительной части) child-ов.

24 комментария:

  1. Почему не использовать shared_ptr вместо unique_ptr-а?

    ОтветитьУдалить
  2. А принципиально это вряд ли что-то меняет.

    ОтветитьУдалить
  3. Прекрасно помню "лихие девяностые", когда манпуляции с this были делом обычным...)))
    И легко могу себе представить, что заставило бы меня сделать что-нибудь подобное Вашему жульничеству, Евгений. Но при всем уважении, это не слишком правильно, особено в свете последних стандартов, чай не старый добрый C! Я бы постарался извернуться и выполнить обработку события и замену вложенного обработчика в контексте родителя.
    Касательно реализации чего-то подобного на Rust, поскольку не эксперт в этом языке, то чисто умозрительно. Первая и очевидная трудность - наличие циклических мутабельных ссылок родитель <-> ребенок. Rust такого не одобряет, поскольку неясно, кто кем владеет. Извернуться можно, в том числе и через unsafe, но не знаю, стоит ли, лучше избежать.
    Что касается замены обработчика, то есть следующие соображения:
    - в Rust аналог this - явная переменная,и, если она мутабельная - то меняй не хочу. Насколько я знаю, естественно.
    - если вложенный обработчик определен, как вариация dyn Trait, то, опять же, заменить толстый указатель на другой должно быть возможно. А старое значение "забыть" до поры, до времени. Вот как сделать, чтобы оно не утекло - не знаю, разве что переместить в локальную переменную, а она уже drop'нется в конце области видимости.
    - если вложенный обработчик определен, как enum или еще как-то статически, то,боюсь,без переноса обработки+замены в контекст метода родителя не получится.
    - ну, естественно, можно попытаться как-то изглумиться над здравым смыслом с помощью unsafe. Как говориться, добавляй звездочки и приведения типа, пока не скомпилируется.
    В целом, мне непонятно предубеждение перед unsafe в Rust, равно как и галдеж по-поводу "безопасности" Rust. Да, в Rust есть подмножество, позволяющее за счет ограничения выразительности исключить некоторые виды UB. Но и только! Приятный бонус, освобождающий "мыслительный ресурс". Unsafe - органичая и необходимая часть языка, без которой ничто нетривиальное невозможно! И "растоман" не умеющий в unsafe и боящийся его представляется мне калекой. Это как когда-то было можно говорить о managed языках, что они позволяют не следить за выделением и освобождением памяти, хотя GC, на самом деле о другом и для другого.
    Как-то так.

    ОтветитьУдалить
  4. @Сергей Скороходов

    Приемы с delete this активно использовались, например, при работе с библиотекой ACE. Да, у нее ноги растут из 90-х, но мы ей активно пользовались где-то с 2005-го по 2014-й и вполне себе нормально.

    В принципе, вызов метода replace_child можно было бы сделать несколько безопаснее, например, за счет того, чтобы replace_child возвращал child_unique_ptr, котором бы лежал указатель на старого child-а.

    Но здесь так же не все так просто, поскольку такой подход не очень устойчив к выбросу исключений. Т.е. нужно делать replace_child noexcept.

    Надежнее всего было бы child-ов наследовать от enable_shared_from_this и parent-у отдавать shared_ptr. А перед вызовом replace_child сперва вызывать shared_from_this, сохраняя дополнительный shared_ptr в локальной переменной.

    Но даже здесь надежность определяется (не)забывчивостью разработчика.

    ОтветитьУдалить
  5. В 90-х delete this жульничество не ограничивалось, хотя подробности уже не вспомню.))
    Боюсь, Вы не совсем верно меня поняли - я не осуждаю подобные хитрости, хотя ясно отдаю себе отчет в их опасности. Последнее время используя чистый C в системах, где вся память распределена статически... Да кто мне чего запретит, нафик! Объект в C - это просто данные, именно неявный this и неявное расположение vtbl делают из него что-то нетривиальное. И хотя кода-то this казался мне гениальной идеей, сейчас я сторонник "толстых" указателей и считаю this скорее стратегической ошибкой, слишком много проблем с временем жизни объектов возникает ради уменьшения числа нажатий на клавиши.
    Далее по предложенным Вами вариантам более безопасной реализацией трюка с подменой объекта "на скаку". Все можно, хотя (чисто умозрительно) тут лучше бы подошла какая-нибудь схема отложенного освобождения памяти из lock-free типа hazard_ptr или как-то так. Корень проблемы, имхо, не в том, что вы подменяете объект внутри функции-члена (хотя это дело сомнительное). Корень в том, что а)с this нельзя обращаться так, как с любым обычным указателем, бо компилятор с ним может обойтись совсем не так, как предполагаете и б)отсутствием в языке простого и понятного способа прекратить жизнь одного объекта и начать использовать другой вместо него. Может быть, как-то с laundry поиграться? Мне понимания не хватает.

    ОтветитьУдалить
  6. > отсутствием в языке простого и понятного способа прекратить жизнь одного объекта и начать использовать другой вместо него.

    А вот этого как раз и нет. Поскольку parent владеет child-ом, то во время выполнения replace_child вполне себе понятно где один динамически созданный объект уничтожается, а второй динамически созданный объект начинает использоваться вместо первого.

    Да и трюков с this нет. Его значение вообще вручную никак не меняется. Главная опасность в том, что this перестает быть валидным прямо внутри метода. И вот это как раз средствами C++ никак не выражается. Поэтому есть вероятность, что кто-то когда-то со временем после вызова replace_child может сделать еще какое-то действие внутри child-а.

    ОтветитьУдалить
  7. >> отсутствием в языке простого и понятного способа прекратить жизнь одного объекта и начать использовать другой вместо него.

    > А вот этого как раз и нет. Поскольку parent владеет child-ом, то во время выполнения replace_child вполне себе понятно где один динамически созданный объект уничтожается, а второй динамически созданный объект начинает использоваться вместо первого.

    Уверены? А я вот не уверен: с этми правилами времени жизни объектов в новых стандартах черт ного сломит: решит компилятор как-то закершировать значение или нет - да кто ж подскажет. У меня голова - не дом советов.

    > Главная опасность в том, что this перестает быть валидным прямо внутри метода. И вот это как раз средствами C++ никак не выражается.

    Об чем и речь...

    ОтветитьУдалить
  8. > А я вот не уверен: с этми правилами времени жизни объектов в новых стандартах черт ного сломит: решит компилятор как-то закершировать значение или нет - да кто ж подскажет.

    Если вдруг при выполнении unique_ptr::reset() вдруг перестанет уничтожаться объект, указатель на который хранился в unique_ptr, то, грубо говоря, вся библия нафиг.

    Так что как раз здесь никаких подвохов от нормальной реализации копилятора/stdlib я лично не жду.

    ОтветитьУдалить
  9. > Так что как раз здесь никаких подвохов от нормальной реализации копилятора/stdlib я лично не жду.

    Так-то оно так, мне это тоже кажется мало вероятным. Но я утратил возможность понять последние телодвижения Стандарта в части жизненого цикла объектов. Так что опасаюсь...

    По факту я бы попробовал то, что Вы предлагали: возвращать из replace_child прежний unique_ptr. И завершил бы тело метода длинным печальным комментарием...

    ОтветитьУдалить
  10. Понимаете:
    A B::f1();

    B b;
    ...
    A a = b.f1(); // 1
    ...
    b.f2(); // 2

    Какая будет актуальная сигнатура вызова // 1 с учетом ABI, this, RVO/NRVO, флагов компиляции и положения спутнков Сириуса?
    Что думает компилятор о значении this в вызове // 2?

    ОтветитьУдалить
  11. void on_some_event() override
    {
    ...
    m_parent.replace_child(std::make_unique(...));
    } // <- использует ли компилятор this в этом месте?

    ОтветитьУдалить
  12. > использует ли компилятор this в этом месте?

    А зачем ему там this использовать?

    Вот просто с точки зрения здравого смысла.

    ОтветитьУдалить
  13. Где компилятор, а где здравый смысл. vtbl, даные-члены и пр. машинерия вычисляется относительно this, эпилог функции не обязан быть пустым. Мой неквалифицированный ответ - я не знаю ответа и не знаю, где в стандарте и в доке компилятора могу посмотреть однозначный ответ. Остается ассемблерный листинг, который переменчив, как девичьи грезы. Последний пример из наболевшего: добавили массив в SDRAM на микроконтроллере, и другой массив перестал читаться. Поменяли местами - все заработало. Какая связь - а черт его знает, всякое бывает.

    ОтветитьУдалить
  14. Ну так в стандарте же фиксируют вполне себе разумные и логичные вещи. Делать delete this внутри метода не запрещено, если объект был создан динамически. Ну и как-то сложно придумать, зачем компилятору this для реализации возврата из метода.

    ОтветитьУдалить
  15. Вы ж можете вернуть старый указатель из replace_child и захватить его снаружи до выхода из метода

    Если еще и добавить каких-нить прагм или аналога, что результат нельзя игнорировать - то будет и ворнинг от компилятора

    ОтветитьУдалить
  16. > Если еще и добавить каких-нить прагм или аналога, что результат нельзя игнорировать - то будет и ворнинг от компилятора

    И получится Rust))))
    Самое трудное - к this не обращаться после этого, не представляю, как это запретить.

    ОтветитьУдалить
  17. так можно обращаться - возвращенный указатель будет держать объект
    правда его надо допередавать до возврата из всего стека вызовов внутри объекта

    ОтветитьУдалить
  18. @Eugeniy

    Про возврат указателя было выше в комментариях. Там не все так хорошо, если вмешаются исключения.

    ОтветитьУдалить
  19. А количество вариантов стадий

    class first_stage : public child {...};
    class second_stage : public child {...};
    class third_stage : public child {...};

    Оно заранее известно и определено статически?

    ОтветитьУдалить
  20. Хорошо, child-ом владеет parent, но кто владеет parent-ом? Если parent переместить, то ссылка на него внутри child будет инвалидирована. Это - самоссылающаяся структура, и чтобы такое сделать в Rust нужно использовать Pin API. Другой вариант - начать городить огород с Rc, Weak и RefCell. В обоих случаях код будет не ахти. Так что в Rust лучше вообще так не делать, а делать как-то так:

    struct FirstStage;
    struct SecondStage;
    struct ThirdStage;

    enum Stage {
    First(FirstStage),
    Second(SecondStage),
    Third(ThirdStage),
    }

    impl Stage {
    fn on_some_event(&mut self) {
    match self {
    Self::First(_) => *self = Self::Second(SecondStage),
    Self::Second(_) => *self = Self::Third(ThirdStage),
    Self::Third(_) => *self = Self::First(FirstStage),
    }
    }
    }

    struct Parent {
    child: Stage,
    }

    https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=693b1da30d799e199d9abf0d6c7db4e4

    ОтветитьУдалить
  21. @XX

    > Оно заранее известно и определено статически?

    В конечном итоге будет ограниченное количество *_stage. Но тут есть два нюанса. Во-первых, сколько и каких именно *_stage потребуется определяется в процессе разработки. Т.е. начали делать, казалось, что потребуется 3 stage. Сделали, выяснилось, что нужен еще и 4-й. И это только первую часть функциональности реализовали. Следующим этапом будет еще одна большая часть, сколько там *_stage еще потребуется пока непонятно.

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

    > Хорошо, child-ом владеет parent, но кто владеет parent-ом?

    Parent живет сам по себе, по своим правилам. И он определяет, когда ему завершить свою жизнь. При этом parent, естественно, убивает, предварительно child-а.

    ОтветитьУдалить
  22. Самоссылающие структуры и Pin/Unpin тут совершенно ни при чем - тут циклическая ссылка, а владение должно быть ациклическим графом. Цикл можно разорвать известным способом, с помощью слабых ссылок и пр. обернутого unsafe, или просто с помощью unsafe. Но это и не нужно в данном случае.
    Дело в том, что ссылка на родителя нужна "ребенку" для того, чтобы изменить указатель на "ребенка" в родителе, т.е. сделать *this = ... Не уверен, что это можно делать в C++, но вот в Rust сделать это с self возможно, если при вызове будет передан &mut self.
    Что же касается неопределенного числа стадий, то вопрос сводится к тому, можно ли задать интерфейс всех вариантов одним трейтом. Если да, то это решит проблему. Родитель будет просто хранить умный указатель на dyn Trate, в точности, как и в C++.
    И даже если задавать стадии через enum, то добавление новой стадии "сломает" диспетчеризацию вызовов через pattern matching и придется перекомпилировать код.

    ОтветитьУдалить
  23. Вот еще вариант - сделать отдельную функцию

    void process(child *ch) {
    ch->process();
    auto *parent = ch->getParent();
    parent->removeChild();
    }

    ОтветитьУдалить