четверг, 15 сентября 2022 г.

[prog.c++] Любопытная задачка с RSDN про гарантии вызова реализации виртуального метода из базового класса

На RSDN-е задали любопытный вопрос. Я этот вопрос понял так:

У нас есть базовый класс A с неким виртуальным методом f. Пользователь создает наследников класса A, в которых он переопределяет f. Но нужно гарантировать, что в переопределенном f обязательно вызывается f из A.

Грубо говоря, вот так нормально:

class A {
public:
   virtual void f() { std::cout << "A::f()" << std::endl; }
};

class B : public A {
public:
   void f() override {
      A::f();
      std::cout << "B::f()" << std::endl;
   }
};

а вот так уже нет, за такое нужно бить по рукам:

class A {
public:
   virtual void f() { std::cout << "A::f()" << std::endl; }
};

class C : public A {
public:
   void f() override {
      std::cout << "C::f()" << std::endl;
   }
};

В обсуждении на RSDN описано несколько решений, я же здесь остановлюсь на одном, которое лично мне кажется наиболее простым, понятным, работающим очевидным образом (и очевидно, что работающим), и гибким плюс ко всему.

Идея состоит в том, чтобы заставить f() возвращать экземпляр типа, который может создать только базовый класс. Соответственно, если f() переопределяется в производном классе, то этот экземпляр нужно где-то взять. А взять его можно только вызвав f() из базового класса.

Поэтому решение выглядит так (только публичный f() теперь не виртуальный, чтобы спрятать от пользователя всю эту внутреннюю машинерию, виртуальным будет f_impl()):

class A {
protected:
   class F_completed {
      friend class A;
      F_completed() = default;
   public:
   };

   [[nodiscard]]
   virtual F_completed f_impl() {
      std::cout << "A::f()" << std::endl;
      return {};
   }

public:
   void f() { std::ignore = f_impl(); }
};

class B : public A {
   F_completed f_impl() override {
      auto r = A::f_impl();
      std::cout << "B::f()" << std::endl;
      return r;
   }

public:
};

цынк

В таком подходе мы уже не можем забыть вызвать f_impl() из базового класса, т.к. из своего f_impl() нам нужно что-то вернуть, но это что-то взять неоткуда, кроме как из f_impl() базового класса.

Гибкость здесь в том, что в своем f_impl() мы вольны вызывать унаследованный f_impl() когда вздумается:

class B : public A {
   F_completed f_impl() override {
      auto r = A::f_impl();
      std::cout << "B::f()" << std::endl;
      return r;
   }
};

class C : public A {
   F_completed f_impl() override {
      std::cout << "C::f()" << std::endl;
      return A::f_impl();
   }
};

class D : public A {
   F_completed f_impl() override {
      std::cout << "D::f() 1" << std::endl;
      auto r = A::f_impl();
      std::cout << "D::f() 2" << std::endl;
      return r;
   }
};

Пока все было достаточно просто, т.к. A::f() возвращает void. А что делать, если A::f() должен возвращать, скажем, int?

Мне в голову приходит такой подход:

class A {
protected:
   class G_completed {
      friend class A;

      int result_;

      G_completed(int result) : result_{result} {}
   public:
      [[nodiscard]] int value() const noexcept { return result_; }

      [[nodiscard]] G_completed
      replace_by(int value) const { return {value}; }
   };

   [[nodiscard]]
   virtual G_completed g_impl() {
      std::cout << "A::g()" << std::endl;
      return {42};
   }

public:
   [[nodiscard]]
   int g() { return g_impl().value(); }
};

class B : public A {
   G_completed g_impl() override {
      auto r = A::g_impl();
      std::cout << "B::g()" << std::endl;
      return r;
   }

public:
};

class C : public A {
   G_completed g_impl() override {
      std::cout << "C::g()" << std::endl;
      auto r = A::g_impl();
      return r.replace_by(r.value() + 1);
   }

public:
};

цынк


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

Как по мне, так от случая "в наследнике забыли вызывать реализацию из базового класса" нужно защищаться другими средствами. В первую очередь тестами. Но никак не упражнениями в том, насколько хитрый C++ный код можно написать, чтобы компилятор ударил по рукам нерадивого программиста.

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

PS. Еще мне кажется, что обилие совершенно разных решений, предложенных в RSDN-овском обсуждении, указывает на то, что C++ всегда был, есть и будет языком велосипедостроителей для велосипедостроителей. И, пока эти самые велосипедостроители не перевелись, и C++ будет востребованным. Местами ;)

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