среда, 23 августа 2023 г.

[prog.c++] Пример того, как NRVO(RVO) оптимизация может к вам в код UB подсадить

В процессе одного из споров на RSDN-е родился пример того, как компилятор, применив NRVO, приводит к неожиданному поведению.

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

Есть простой класс entity, который содержит внутри себя целочисленное значение. Изменить это целочисленное значение можно только вызывав метод entity::update. Но это не const-метод, т.е. для константного entity его вызывать нельзя.

Фокус с entity в том, что есть еще и entity_registry, т.е. реестр объектов entity. Если в конструктор entity передать ссылку на entity_registry, то entity себя в реестре зарегистрирует. А в деструкторе -- вычеркнет.

Пока объект находится в реестре, его можно изменить через реестр: вызываем метод entity_registry::update и реестр вызывает update для всех зарегистрированных в нем объектов.

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

void check() {
   entity_registry registry;
   entity e1{registry};

   entity e2; // Не в реестре.

   entity e3{e1}; // Не в реестре.

   registry.update(25);

   std::cout << e1.value() << ", " << e2.value() << ", " << e3.value() << std::endl;
}

Мы получим вполне ожидаемое:

add to registry: 0x7ffd7b567c18
25, 0, 0
removed from registry: 0x7ffd7b567c18

Т.е. объект e1 добавил себя в реестр, потом мы получили три предсказуемых значения (два нулевых, т.к. никто не менял e2 и e3), затем e1 изъял себя из реестра.

А теперь внимание, вопрос: что будет во в таком случае?

entity make_entity(entity_registry & registry) {
   entity ent{registry};
   ent.update(42);
   return ent;
}

void demo() {
   entity_registry registry;
   const entity ent = make_entity(registry);

   registry.update(100);

   std::cout << ent.value() << std::endl;
}

Можем ли мы изменть значение константного ent внутри функции demo через вызов update у реестра?

Да. Сможем.

Все дело в том, что компилятор применит для возвращенного из make_entity значения оптимизацию NRVO и, фактически, в demo под видом константного ent продолжит жить неконстантный ent из make_entity, который все еще зарегистрирован в реестре.

Хотя, если бы NRVO здесь не применялась бы, то объект ent из make_entity должен был бы разрушится и вычеркнуть себя из реестра, а объект ent из demo был бы самой настоящей константой, про которую реестр не знает.

Но т.к. NRVO (RVO) плевать на побочные эффекты выбрасываемых конструкторов и деструкторов, то имеем что имеем.

Да, а UB здесь в том, что мы умудряемся менять константный ent в demo, а снимать константность с константного объекта в C++ -- это UB.

Таки да, пример синтетический. Хотя это как посмотреть. Помещение объекта в какой-то реестр не такая уж и редкая ситуация.


Попутно можно сказать еще и о том, что во время работы конструкторов-деструкторов у объекта константности нет (т.е. там есть неконстантный указатель this). Что позволяет нам сделать так:

void stupid() {
   entity_registry registry;
   const entity immutable_ent{registry};
   ...
}

Т.е. константный объект окажется зарегистрирован в реестре и в этом реестре окажется неконстантный указатель на константный объект. Со всеми вытекающими...

Но в этом-то случае мы сами себе злобные буратино. Тогда как в показанном ниже коде в make_entity и demo в этом плане все нормально. Мы своими руками константные объекты в реестр не засовывали. Здесь нам компилятор "подсобил"...


#include <iostream>
#include <set>

class entity;
class entity_registry;

class entity {
   entity_registry * registry_{};
   int value_{};

public:
   entity() = default;
   entity(entity_registry & registry);
   entity(const entity & source);
   entity(entity && source);
   ~entity();

   entity &
   operator=(entity &&) = delete;

   entity &
   operator=(const entity &) = delete;

   int value() const noexcept;

   void update(int delta);
};

class entity_registry {
   std::set<entity *> entities_;

public:
   void add(entity & ent) {
      entities_.insert(&ent);
      std::cout << "add to registry: " << &ent << std::endl;
   }
   void remove(entity & ent) {
      entities_.erase(&ent);
      std::cout << "removed from registry: " << &ent << std::endl;
   }

   void update(int delta) {
      for(auto * ent : entities_) ent->update(delta);
   }
};

entity::entity(entity_registry & registry) : registry_{&registry} {
   registry_->add(*this);
}

entity::entity(const entity & source) : value_{source.value_} {
}

entity::entity(entity && source) : value_{source.value_} {
}

entity::~entity() {
   if(registry_) registry_->remove(*this);
}

int entity::value() const noexcept {
   return value_;
}

void entity::update(int delta) {
   value_ += delta;
}

void consume_one(const entity & ent) {
   std::cout << "consume_one: " << ent.value() << std::endl;
}

void consume_two(const entity & ent) {
   std::cout << "consume_two: " << ent.value() << std::endl;
}

entity make_entity(entity_registry & registry) {
   entity ent{registry};
   ent.update(42);
   return ent;
}

void demo() {
   entity_registry registry;
   const entity ent = make_entity(registry);

   std::cout << "value: " << ent.value() << std::endl;
   consume_one(ent);
   registry.update(100);
   consume_two(ent);
}

int main() {
   demo();
}

Результат работы (GCC-11, Clang-16):

add to registry: 0x7ffd51ed71f0
value: 42
consume_one: 42
consume_two: 142
removed from registry: 0x7ffd51ed71f0

Комментариев нет: