понедельник, 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 комментария:

Unknown комментирует...

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

eao197 комментирует...

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

Сергей Скороходов комментирует...

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

eao197 комментирует...

@Сергей Скороходов

Приемы с 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 в локальной переменной.

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

Сергей Скороходов комментирует...

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

Сергей Скороходов комментирует...

https://docs.rs/replace_with/0.1.5/replace_with/

eao197 комментирует...

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

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

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

Сергей Скороходов комментирует...

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

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

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

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

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

eao197 комментирует...

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

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

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

Сергей Скороходов комментирует...

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

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

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

Сергей Скороходов комментирует...

Понимаете:
A B::f1();

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

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

Сергей Скороходов комментирует...

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

eao197 комментирует...

> использует ли компилятор this в этом месте?

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

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

Сергей Скороходов комментирует...

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

eao197 комментирует...

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

Eugeniy комментирует...

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

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

Сергей Скороходов комментирует...

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

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

Eugeniy комментирует...

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

eao197 комментирует...

@Eugeniy

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

XX комментирует...

А количество вариантов стадий

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

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

XX комментирует...

Хорошо, 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

eao197 комментирует...

@XX

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

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

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

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

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

Сергей Скороходов комментирует...

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

Unknown комментирует...

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

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