Неявное приведение между родственными шаблонами С++

Written by elwood

Сегодня столкнулся с интересной проблемой и нашел не менее интересный способ решения. Как всем известно, если мы имеем иерархию классов class Derived : public Base, и шаблонный класс наподобие Template<typename T>, то классы, которые будут сгенерированы при инстанцировании шаблонов Template<Base> и Template<Derived> не имеют ничего общего. То есть мы не сможем привести тип Template<Derived> к типу Template<Base>, но ведь часто это просто необходимо ! В данном случае не работает ни один из способов приведения (даже reinterpret_cast). Приходится действовать обходным путем : брать адрес у объекта, приводить его к типу указателя на объект искомого типа, и выполнять разыменование. Выглядит это, мягко говоря, не слишком элегантно :

  Template<Derived> derived;
  Template<Base>& base = *static_cast<Template<Base>*> (static_cast<void*> (&derived));

В принципе, таким способом можно выполнить приведение Template<Derived> к шаблону с любым аргументом-типом, главное следить, чтобы приведенный экземпляр вел себя корректно (вызывал правильный деструктор, обладал той же бинарной структурой итд). Как можно усовершенствовать этот код ? Идею подсказал мне коллега, напомнив про имеющуюся в арсенале С++ возможность перегрузки оператора приведения типов. В самом деле, почему нет ? Ведь мы можем определить оператор преобразования типа, и к тому же сделать его шаблонной функцией, которая будет принимать тип, в который необходимо выполнить преобразование. С учетом того, что для шаблонных функций работает механизм автоматического вывода аргументов шаблона, это решение кажется все более изящным:

  template<typename T>
  class Template
  {
  public:
    template <typename U>
    operator Template<U>& ()
    {
      return (*static_cast<Template<U>*> (static_cast<void*> (this)));
    }
  // ...
  };

Работает ! И у нас даже нет необходимости явно указывать шаблонные параметры для оператора приведения, поскольку компилятор выводит их автоматически из контекста использования :

  Template<Base>& base = derived;

К сожалению, используя этот способ, мы теряем контроль над происходящим, поскольку приведение будет успешным для любого аргумента шаблона U. Было бы замечательно добавить некие ограничения на эту операцию.. Но – опять же – в чем проблема ? Ведь мы можем добавить в код шаблонной функции-оператора кусочек кода, который будет выполнять преобразование указателя T* в указатель U*. И если это приведение корректно, то шаблонная функция успешно скомпилируется, в противном случае инстанцирования произведено не будет. Т.е. компилятор будет решать – насколько корректно приведение внешних шаблонов-классов по корректности приведения их шаблонных аргументов. Заодно можно сделать этот код никогда не выполняющимся, поскольку помимо верификации типов он никакой смысловой нагрузки не несет.

  template <typename U>
  operator Template<U>& ()
  {
    if (0) {
      T* derivedTypePtr = NULL;
      U* baseTypePtr = derivedTypePtr;
      (void) baseTypePtr;  // А это может пригодиться для того, чтобы подавить
                           // предупреждения компилятора о неиспользуемой переменной
    }
    return (*static_cast<Template<U>*> (static_cast<void*> (this)));
  }

Таким образом – мы получаем безопасный по отношению к типам код, который полностью прозрачен для пользователя. Далее – вспоминаем про константность : следующий код не скомпилируется, даже если derived был изначально объявлен как const.

  const Template<Base>& base = derived;

Чтобы устранить это досадное недоразумение, достаточно написать константную версию оператора :

  template <typename U>
  operator const Template<U>& () const
  {
    if (0) {
      T* derivedTypePtr = NULL;
      U* baseTypePtr = derivedTypePtr;
      (void) baseTypePtr;
    }
    return (*static_cast<const Template<U>*> (static_cast<const void*> (this)));
  }

Все ! Осталось только разрешить пользователям все-таки применять небезопасное преобразование, если они точно уверены в том, что делают. Для этого напишем рядом специальную функцию, выполняющую такое же преобразование, но без дополнительных проверок (не забываем про const-версию) :

  template <typename U>
  Template<U>& do_unsafe_cast()
  {
    return (*static_cast<Template<U>*> (static_cast<void*> (this)));
  }
 
  template <typename U>
  const Template<U>& do_unsafe_cast() const
  {
    return (*static_cast<const Template<U>*> (static_cast<const void*> (this)));
  }

Заодно отметим, что в случае функции do_unsafe_cast() пользователю придется всегда явно указывать аргумент шаблона – и это очень хорошо, поскольку уточняет действия пользователя и к тому же дополнительно выделяется в коде :

  Template<Derived>& derived = base.do_unsafe_cast<Derived>();

Такой вот получился аналог reinterpret_cast. К сожалению, для dynamic_cast метода преобразования создать не получилось (поскольку шаблонный класс Template в общем случае не может быть уверен в том, что тип, задаваемый аргументом шаблона, содержит виртуальные методы), но, думаю, здесь тоже можно найти какой-нибудь хитрый способ заставить компилятор сделать эффектный реверанс в сторону разработчика.

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

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

UPD: Внимание ! Этот код некорректно работает с компилятором GCC, поскольку GCC неверно
обрабатывает ситуации, связанные с преобразованием ссылок. В Visual C++ и Comeau все в норме.
Для того, чтобы эта особенность компилятора GCC не приводила к некорректной работе приложения,
придется избавиться от удобств перегруженных операторов преобразования и вместо них написать
аналогичные safe_cast-методы. Объяснение проблемы в следующем посте.