пятница, 27 февраля 2015 г.

[prog.c++] Про наследование, виртуальные методы вообще и деструкторы в частности

Решил написать этот пост по следам двух недавних LOR-овских дискуссий. В первой поучаствовал сам, но там авторитетные анонимные эксперты дали разгромную оценку моим знаниями и способностям, на чем тема и затихла :) Вторая развивается прямо сейчас и по накалу идиотии может со временем стать достойным образчиком LOR-овских срачей, бессмысленных и беспощадных :)

Тем не менее, тема наследования, виртуальных методов и, особенно, виртуальных деструкторов (спасибо RSDN-у за этот мем), которая была там затронута, как я вижу, для многих является terra incognita, поэтому имеет смысл уделить ей некоторое внимание.

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

Так же бытует мнение, что если в классе нет виртуальных методов, то наследование от этого класса бессмысленно.

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

Прежде всего нужно вспомнить, что C++ -- это не объектно-ориентированный язык. Это мультипарадигменный язык, в котором ООП является всего лишь одной из возможностей. Может быть ключевой, но не это главное. Важнее то, что ООП в C++ несколько отличается от ООП в Eiffel, очень сильно отличается от ООП в Java. Не говоря уже о SmallTalk/Ruby или, прости Господи, Self или JavaScript :)

Это сказано потому, что нельзя в C++ мыслить категориями, привычными для других языков программирования. Например, в Java есть какая сущность, как интерфейс. В C++ нет. При этом интерфейсы на C++ вполне себе можно описывать, но это будет лишь частным случаем класса, а не отдельной сущностью, как в Java. Поэтому, естественные для C++ вещи нужно и воспринимать это естественными и нормальными. А не с позиций "чистого ООП", "классического ООП в стиле SmallTalk" или "ООП из Java".

Так вот, если отталкиваться от нормальных для C++ вещей, то наследование в C++ можно условно разделить на наследование интерфейса и на наследование реализации.

Когда мы говорим о наследовании интерфейса, то мы говорим об одном из столбов ООП -- о полиморфизме. Т.е. базовый класс определяет набор методов, которые наследники обязаны реализовывать тем или иным способом.

Например, у нас может быть базовый класс desktop, который определяет набор виртуальных методов:

class desktop
{
public :
   virtual dimension height() = 0;
   virtual dimension width() = 0;
   ...
   virtual void set_wallpaper(const picture &) = 0;
};

Это не что иное, как интерфейс в терминологии Java. В приложении должны быть экземпляры классов-наследников desktop-а, которые реализуют определенные в desktop методы. Например, в десктопном приложении для Windows-платформы это может быть класс windows_desktop, который будет в методах width() и height() возвращать актуальные размеры десктопа на главном мониторе. В приложении для Android-а это может быть класс android_multipage_desktop, который в методе width() возвратит суммарную ширину всех видимых пользователю страничек десктопа (или как оно там называется). А для Web-приложения, где высота десктопа определяется длиной генерируемой Web-страницы, может потребоваться какой-нибудь web_page_desktop.

Так вот, когда речь идет о наследовании интерфейса, то без виртуальных методов, обычно, не обойтись. Но нужно ли при наследовании интерфейса иметь виртуальный деструктор?

Вообще говоря, нет :)

Виртуальный деструктор нужен для того, чтобы иметь возможность удалять объекты производных классов по указателю на базовый класс. Т.е. для ситуаций, когда пользователю отдают указатель на кем-то созданный экземпляр desktop и говорят, что пользователь должен удалить этот объект сам. Пользователь не знает, что это за объект, он знает только, что объект принадлежит к классу-наследнику desktop-а и все. Соответственно, пользователь будет удалять объект по указателю на desktop. А run-time должен понять, что там скрывается какой-нибудь web_page_desktop, занимающий в памяти столько-то места, имеющий такие-то атрибуты, нуждающиеся в вызове собственных деструкторов, и т.д.

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

Взять тот же desktop. Это пример такой сущности, которая вряд ли будет создаваться во множественном числе и вряд ли будет отдаваться под контроль пользователя. Скорее всего desktop-ы будут создаваться и уничтожаться где-то в дебрях GUI-библиотеки и пользователь об этом не будет даже знать. Если самой GUI-библиотеке потребуется размещать desktop-объекты в динамической памяти и удалять их через указатель на базу, то тогда у desktop-а будет виртуальный деструктор. Если не нужно -- не будет.

Так что интерфейс (базовый класс) с виртуальными методами, но без виртуального деструктора, вполне себе имеет и смысл, и право на существование.

Тот же пример с desktop-ом. GUI-библиотека предоставляет пользователю возможность работы с этим понятием. Что позволяет пользователю рисовать на десктопе какие-то замысловатые картинки, отображать последние новости, свежие курсы валют и т.д. Т.е. пользователь посредством интерфейса может решать свои прикладные задачи. Но при этом не несет ответственности за время жизни реального объекта, стоящего за интерфейсом desktop.

Откуда же берется правило делать деструкторы виртуальными, если у класса появляется виртуальный метод?

От того, что лучше перебдеть, чем перебз... :) Сделать деструктор виртуальным сразу проще, чем потом объяснять пользователям, что класс не предназначался для того, чтобы через указатель на него кто-то удалял экземпляры наследников.

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

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

Тут нужно вспомнить о еще одном виде наследования, которое доступно нам в C++. Это наследование реализации. Т.е. базовый класс содержит в себе базовую функциональность, которая через наследование становится доступной всем наследникам. В ряде случаев наследование реализации может быть заменено агрегацией, но тут уже нужно рассматривать каждую ситуацию отдельно. На LOR-е я приводил следующий пример:

class synchronized_container {
public :
  virtual void lock() { l_.lock(); }
  virtual void unlock() { l_.unlock(); }

  class locker {
    synchronized_container * what_;
  public :
    locker( synchronized_container * what ) : what_(what) {
      what_->lock();
    }
    ~locker() {
      what_->unlock();
    }
  };

private :
  std::mutex l_;
};

templateclass T >
class thread_safe_list : private synchronized_container {
public :
  void push_back(T&& o) {
    locker l{ this };
    ...
  }
  void pop_front() {
    locker l{ this };
    ...
  }
  ...
};

class my_ultra_fast_thread_safe_list : public thread_safe_list< some_my_type > {
...
private :
  virtual void lock() override { spin_.lock(); }
  virtual void unlock() override { spin_.unlock(); }

  super_puper_spinlock spin_;
};

Здесь база synchronized_container служит поставщиком общей функциональности для группы классов-контейнеров, которые не имеют общего формального интерфейса. Например, synchronized_container может использоваться в качестве базы для реализации vector, deque, list, map, hash_table и т.д.

Нужен ли здесь synchronized_container-у виртуальный деструктор? Нет, не нужен. Т.к. если пользователь классов-наследников (вроде thread_safe_list или thread_safe_map) захочет почему-то оперировать ими через указатели на synchronized_container, то он собирается делает что-то, что разработчики класса synchronized_container не предполагали. И что, скорее всего, делать не следует.

Ну а теперь более интересный вопрос. А есть ли смысл в наследовании без виртуальных методов вообще?

И таки да :) Можно придумать и такие случаи.

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

Вполне можно реализовать основные методы класса-арены без использования виртуальных методов. Может быть что-то вроде:

class memory_arena
{
public :
   void * allocate(size_t bytes)
   {
      auto n = current_ + bytes;
      if( n > end_ )
         throw bad_alloc(...);
      auto r = current_;
      current_ = n;

      return r;
   }

   size_t free_bytes() const
   {
      return static_castsize_t >(end_ - current_);
   }

   size_t used_bytes() const
   {
      return static_castsize_t >(current_ - begin_);
   }

   ...
private :
   char * begin_;
   char * end_;
   char * current_;
};

На что указывают атрибуты memory_arena::begin_, end_ и current_?

А вот об этом должен позаботится производный класс ;)

Раз уж нам потребовалась собственная арена, а не штатный аллокатор динамической памяти, значит эффективность распределения памяти очень важна. И мы можем создать наследников, которые будут размещать арены в автоматической или статической памяти. Да еще и сделать так, чтобы наследник выделял столько памяти, сколько нам покажется достаточным. Так, если нам нужна арена для десятка мелких объектов по 20 байт каждый, то нам нужен блок на 200 байт. А если пару тысяч объектов по килобайту каждый, то несколько мегабайт. Что запросто делается, например, посредством шаблона:

templatesize_t capacity >
class preallocated_memory_arena : public memory_arena
{
public :
   preallocated_memory_arena()
   {
      setup_arena( memory_, memory_ + capacity );
   }

private :
   char memory_[ capacity ];
};

Где метод setup_arena -- это protected-метод из memory_arena, который предназначен для вот такого использования производными классами.

В тех местах прикладного кода, где требуется абстрагирование от деталей реального размещения арены памяти, будет использоваться memory_arena. А там, где эти детали важны -- конкретные классы наследники:

void handle_request_one(const request_one & r, memory_arena & arena)
{
   ...
}

void handle_request_two(const request_two & r, memory_arena & arena)
{
   ...
}

void request_handling_dispatcher(...)
{
   const request & next = get_next_request();
   if( is_one(next) )
   {
      preallocated_memory_arena< 128 > arena;
      handle_request_one( dynamic_castconst request_one & >(next), arena );
   }
   else( is_two(next) )
   {
      preallocated_memory_arena< 20480 > arena;
      handle_request_two( dynamic_castconst request_two & >(next), arena );
   }
   ...
}

Однако, фокус в том, что последний пример с memory_arena можно так же использовать в качестве демонстрации того, что виртуальный деструктор может потребоваться и при наследовании, когда нет виртуальных методов, кроме самого виртуального деструктора! :)

Вполне можно представить себе ситуацию, когда требуется большая арена, которая будет использоваться длительное время. Такую проще создать не на стеке или в статической памяти, а выделить динамически. После чего передать указатель на нее длительному обработчику запроса. Он сам удалит арену, когда она перестанет быть нужной:

void initiate_session_handling_thread(std::shared_ptr<session> s)
{
   std::shared_ptr< memory_arena > arena{
      new preallocated_memory_arena< 24*1024*1024 > };

   start_new_worker_thread(
      [session, arena] { handle_session( session, arena ); } );
}

В этом случае в memory_arena никаких прикладных виртуальных методов не будет. А вот виртуальный деструктор потребуется.

Вот как-то так оно, в C++ :)

Хотя на самом деле это еще не все :) Т.к. в C++ есть множественное наследование, то некоторые классы могут специально разрабатываться для того, чтобы быть подмешанными куда-то еще посредством наследования (т.н. mixin-классы). Поскольку эти mixin-классы могут использоваться и для наследования интерфейса, и для наследования реализации, и даже для наследования и того и другого сразу, то ситуация с виртуальностью деструкторов mixin-классов становится вообще веселой :) При том, что отдельного понятия mixin-а в C++ нет (в отличии, скажем, от trait-ов в Scala), и mixin-класс -- это обычный C++ный класс или интерфейс (которых в C++ так же нет, в отличии от Java)...

Так что выбирай, но осторожно. Но выбирай. Но осторожно :)

PS. Комментаторы, которые захотят мне объяснить, что приведенные выше примеры -- это говнокод, что так никто не пишет, что я не разбираюсь в ООП, в С++ и в программировании вообще, пусть сразу идут на LOR. Какой я разработчик, я и сам прекрасно знаю. Тем более, что уже года три, как не считаю себя программистом, хотя код пишу время от времени. Но главное не это. А то, что многие, к сожалению, пишут гораздо хуже :(

PPS. В качестве небольшого "бонуса" для дочитавших до этого места. Маленький этюд на тему memory_arena, который показывает, как посредством множественного наследования сделать так, чтобы у memory_arena не нужно было вручную вызывать setup_arena. Как результат, объект memory_arena сразу конструируется в корректном состоянии:

class memory_arena
{
   memory_arena( const memory_arena & ) = delete;
   memory_arena & operator=( const memory_arena & ) = delete;

protected :
   memory_arena( char * begin, char * end )
      :  begin_(begin), end_(end), current_(begin)
   {}

public :
   void * allocate( size_t bytes )
   {
      auto n = current_ + bytes;
      if( n > end_ )
         throw bad_alloc();
      auto r = current_;
      current_ = n;

      return r;
   }

   size_t free_bytes() const
   {
      return static_castsize_t >(end_ - current_);
   }

   size_t used_bytes() const
   {
      return static_castsize_t >(current_ - begin_);
   }

private :
   char * begin_;
   char * end_;
   char * current_;
};

templatesize_t capacity >
class buffer_holder
{
   char memory_[ capacity ];

public :
   char * begin() { return memory_; }
   char * end() { return memory_ + capacity; }
};

templatesize_t capacity >
class preallocated_memory_arena
   :  private buffer_holder< capacity >
   ,  public memory_arena
{
public :
   preallocated_memory_arena()
      :  memory_arena(
            buffer_holder< capacity >::begin(),
            buffer_holder< capacity >::end() )
   {}
};

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