суббота, 20 июня 2020 г.

[prog.thoughts] Применимость вложенных классов в C++ и серьезность проблемы с forward declaration для них

На RSDN-е угораздило пообщаться с персонажем, насколько уверенном в собственном непогрешимом мнении, что просто караул. К счастью, в Интернетах такие на 100% правые люди встречаются гораздо чаще, чем в реальной жизни. А то работать было бы тяжело.

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

Итак, речь пойдет о применимости вложенных классов в C++. И, немного, о том, насколько в реальности серьезна проблема невозможности forward declaration для вложенных классов. Все нижеизложенное является моим имхо, сформулированным после небольшого обдумывания данной темы. Наверняка я в чем-то не прав, поэтому все написанное ниже стоит воспринимать просто как мнение одного конкретного старого C++ника, а не истину в последней инстанции. Если же я забыл описать какой-то важный и интересный сценарий использования вложенных классов, то об этом можно (и даже нужно) написать в комментариях.


Начнем с того, что вложенные классы в C++ -- это не что-то особенное, связанное какими-то специфическими узами со своим объемлющим классом (как это было, емнип, в Java). Вложенный класс является обычным классом. Который живет в своем собственном пространстве имен, роль которого играет объемлющий класс. Т.е., по большому счету вот это:

struct Outer
{
   struct Inner
   {
      ...
   };
   ...
};

мало чем отличается от:

namespace Outer
{
   struct Inner
   {
      ...
   };
}

Тут уместно будет вспомнить, что пространства имен появились в C++ не сразу. Еще более не сразу в C++ компиляторах появилась поддержка пространств имен. Поэтому в конце 1980-х и в 1990-х вложенные классы широко использовались как раз из-за отсутствия пространств имен (или невозможности использовать пространства имен из-за убогости компиляторов). Чем и я сам неоднократно занимался в прошлом.

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


Упрятывание деталей реализации объемлющего класса

Вложенный класс может быть объявлен в объемлющем классе не только как public, но и как private или protected класс:

class DataHolder
{
   // Это private вложенный класс. Который не может быть
   // просто так использован вне DataHolder-а.
   class Cache
   {
      ...
   };

   Cache cache_;
   
public:
   ...
};

В этом случае DataHolder::Cache защищен на уровне компилятора от того, чтобы Cache не использовал кто попало и как попало.

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

// Это типа отдельный "домик" для DataHolder, в который никто не
// должен залезать со своими грязными лапами.
namespace data_holder_impl
{
   class Cache
   {
      ...
   };
}

class DataHolder
{
   data_holder_impl::Cache cache_;
   
public:
   ...
};

То мы никак не можем воспрепятствовать тому, что кто-то решит использовать data_holder_impl::Cache где-то у себя. Ну потому что если нельзя, но очень нужно, то...

Кстати говоря, гарантии со стороны компилятора -- это хорошо, но надобность пожмякать Cache своими похотливыми ручонками может возникнуть гораздо раньше, чем об этом можно было бы подумать. В частности, если Cache нужно будет покрыть какими-то тестами. Если Cache будет private вложенным классом в DataHolder, то нам нужно будет в DataHolder как-то описывать friend-ов, которые смогут использовать Cache. Что, во-первых, неудобно. И, во-вторых, может затруднить написание новых тестов для Cache, особенно если эти тесты пишет не автор Cache и DataHolder. Поэтому и подход с отдельным impl-пространством имен так же имеет право на жизнь, хоть он и не имеет гарантий со стороны компилятора.

Вложенный класс завязан на параметры объемлющего класса

Достаточно распространенный случай в обобщенном программировании:

templatetypename A, typename B, std::size_t Capacity >
class Outer
{
   using T = typename some_complex_metafunction<A, B>::type;

   struct Inner
   {
      std::array<T, Capacity> data_;
      ...
   };
   ...
};

Тут вполне логично иметь Inner вложенным типом для Outer, потому что содержимое Inner напрямую зависит от параметров и содержимого Outer-а.

В принципе, здесь так же можно Inner вынести в отдельное пространство имен, а внутри Outer использовать только using:

namespace outer_impl
{
   template<typename T, std::size_t Capacity>
   struct Inner
   {
      std::array<T, Capacity> data_;
      ...
   };
}

templatetypename A, typename B, std::size_t Capacity >
class Outer
{
   using T = typename some_complex_metafunction<A, B>::type;

   using Inner = outer_impl::Inner<T, Capacity>;
   ...
};

Но при таком подходе может оказаться, что придется делать слишком уж большой список параметров для outer_impl::Inner. Что приведет к хрупкому коду, который будет ломаться на раз-два при небольших изменениях в Outer.


Traits типы

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

struct ThreadSafeTraits
{
   using LockType = std::mutex;

   static constexpr bool need_actual_locking = true;

   ...
};

struct ThreadUnsafeTraits
{
   using LockType = NullMutex;

   static constexpr bool need_actual_locking = false;

   ...
};

templatetypename Traits >
class EventQueue
{
   using LockType = typename Traits::LockType;

   LockType lock_;
   ...
};

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

struct TrivialQueueTraits
{
   templatetypename T >
   class Queue
   {
      std::queue<T> queue_;
      ...
   public:
      void push(T && v) { queue_.push(std::move(v)); }
      ...
   };
};

template< std::size_t Capacity >
struct TrivialFixedCapacityQueueTraits
{
   templatetypename T >
   class Queue
   {
      std::array<T, Capacity> queue_;
      ...
   public:
      void push(T && v) { ... }
      ...
   };
};

templatetemplate Traits >
class MessagingSystem
{
   using Queue = typename Traits::Queue;
   ...
};

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


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

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

Т.е. если вложенный класс является private или protected частью объемлющего класса, тогда в его forward declaration нет смысла, т.к. извне класса про него вообще ничего знать не нужно.

А если вложенный класс предназначен для использования в шаблонах, то от его forward declaration мало толку, т.к. ничего полезного с таким объявлением сделать нельзя.

К сожалению, тов.Kluev на RSDN так и не смог привести пример реальной проблемы из-за отсутствия forward declaration. Полагаю, тов.Kluev вынужден активно использовать идиому opaque pointers, которая проникла в C++ из чистого Си. И которая, как мне кажется, мало оправдана в C++. Возможно, так же, тов.Kluev вынужден сопровождать древний C++ный код, который начали писать еще до появления пространств имен. И поэтому люди страдают с вещами типа:

class Storage
{
public:
   class Blob
   {
      ...
   };
   ...
};

Тогда как большого смысла в публичном вложенном классе Blob в классе Storage нет, и аналогичного результата можно было бы достичь посредством пространств имен:

namespace storage
{
   class Blob;
   class Database;
};

И здесь бы никаких проблем с forward declaration не было бы от слова совсем.


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

Если вы видите еще какой-то смысл во вложенных классах, то напишите об этом в комментариях. Я с удовольствием расширю свой кругозор. Как и другие читатели данной заметки.

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