Сообщений 4    Оценка 376 [+0/-2]         Оценить  
Система Orphus

Небольшой нюанс

Автор: Александр Насонов
Перевод: Алифанов Андрей
Источник: RSDN Magazine #3-2004
Опубликовано: 28.11.2004
Исправлено: 10.12.2016
Версия текста: 1.0
Немного сложнее
Заключение
Ссылки

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

Вот проблемный код:

      template<class T>
struct Mixin : T
{
  ~Mixin();
};

Я догадываюсь, о чем вы подумали: «Да этот класс выглядит, как пример из книги по C++». Вы наверняка встречались с подобным кодом. Эти чувства, скорей всего, основаны на бесспорном знании простых конструкций C++. Тем не менее, несмотря на простоту, в этом коде есть одна маленькая проблема.

Почему этот код не вызывает подозрений на первый взгляд? Если бы это был обычный класс, можно было бы просто скомпилировать его и увидеть, что все замечательно. Но это не пройдет в случае с шаблонами. Написание кода – это только половина работы. Другая половина – конкретизация (этот термин, возможно, не вполне удачен в качестве перевода для instantiating, но лучшего найти пока не удается. - прим.ред.) шаблона. Это придется делать пользователю класса, если только вы не протестируете свой шаблон сами, параметризуя его всеми возможными аргументами.

Здесь нужно другое мышление. Когда имеешь дело с шаблонами, нужно представлять, как они будут компилироваться, будучи параметризованы различными аргументами. Вы можете заявить: «Ну и в чем проблема? Я могу написать тесты и конкретизировать шаблоны в них». Да, можете. Но сперва нужно найти правильные классы для конкретизации. В качестве примера: можете ли вы найти такую реализацию Mixin<X>, которая нарушит код, приведенный выше?

Не утруждайте себя. У меня уже есть ответ. Вот он:

      struct X
{
  virtual ~X() throw();
};

Итак, нужный класс найден, можно попробовать скомпилировать его. Мой компилятор (g++ 3.2.2) при этом выдает следующее:

1.cpp:    In instantiation of 'Mixin<X>':
1.cpp:12: instantiated from here
1.cpp:3:  looser throw specifier for 'void Mixin<T>::Mixin() [with T = X]'
1.cpp:7:  overriding 'virtual X::~X() throw ()'

Что же это означает? Смотрим стандарт C++, параграф 15.4, абзац 3:

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

В деструкторе базового класса X не разрешены никакие исключения. Поэтому их не должно быть и в классе-наследнике Mixin<X>:

      template<class T>
struct Mixin : T
{
  ~Mixin() throw();
};

Итак, мы быстро нашли решение проблемы. Имеет ли оно изъяны? Может ли оно помешать параметризации данного шаблона другими аргументами? Например, что произойдет, если деструктор класса T неожиданно выкинет исключение? Mixin<X> имеет пустой список разрешенных исключений, следовательно, будет вызвана функция std::unexpected. Она, в свою очередь, вызовет std::terminate и выполнение программы будет прервано. Определенно, это совсем не то поведение, которое ожидает пользователь.

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

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

Мне не нравится в деструкторе с пустой спецификацией исключений то, что компилятор может поместить его код в блок try-catch. Это предохраняет код от «утечек исключений». Этот блок можно исключить только в том случае, если тело деструктора доступно и компилятор может сделать вывод, что деструктор никогда не выдаст исключение. В противном случае блоки try-catch приводят к раздуванию кода и замедлению его выполнения.

Другой недостаток вышеприведенного кода заметил Фил Басс, когда просматривал мою статью. Его наблюдение относится скорее к дизайну, чем к деталям реализации. Фил заметил, что если шаблон Mixin является частью библиотеки широкого назначения, было бы здорово, если бы он следовал правилам генерации исключений, определяемым проектом, в котором библиотека используется.

Существуют две стратегии использования спецификаций исключений в деструкторах:

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

Не стоит и говорить, что деструктор Mixin<T>, нейтральный к стратегии спецификации исключений, намного предпочтительней заставляющего нас выбирать ту или иную стратегию.

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

Даже при столь небольшом выборе вариантов решение может показаться неожиданным: не определять деструктор вообще, то есть положиться на компилятор.

      template<class T>
struct Mixin : T
{
};

Почему это решение лучше? Чтобы объяснить это, снова сошлюсь на стандарт C++, параграф 15.4, абзац 13. Кроме объяснения приведенного решения, там содержится еще и пример с множественным наследованием, который будет рассмотрен позже. В моей вольной интерпретации: неявно определенный деструктор «наследует» спецификацию исключений от деструктора базового класса. Таким образом, какую бы спецификацию не задавал деструктор класса T, такую же точно будет иметь и деструктор шаблона Mixin<T>. Замечательно, это именно то, что нам нужно!

Вы можете спросить: как добиться того, чтобы явные деструкторы были не нужны в реальных шаблонах? Я рекомендую использовать обертки в стиле RAII, «умные» указатели, строки и контейнеры стандартной библиотеки C++ везде, где только можно. Это сводит потребность явно задавать деструкторы к исключительным случаям.

Немного сложнее

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

      struct Base
{
  // ...
};

template<class T>
struct Mixin : Base, T
{
  // ...
};

Ясно, что всегда можно использовать деструктор с пустой спецификацией в Mixin. Я хотел бы, чтобы вы проанализировали случай с неявным деструктором. Помните только, что, с одной стороны, неявный деструктор наследует спецификацию исключений от всех баз, а с другой стороны, если какой-либо из базовых деструкторов является виртуальным, ~Mixin() не может иметь менее строгую спецификацию исключений.

Первый случай: класс Base имеет невиртуальный деструктор. Анализ показывает, что деструктор класса Base должен иметь пустой список исключений, чтобы деструктор ~Mixin() можно было определить неявно.

      struct Base
{
  ~Base() throw();
};

template<class T>
struct Mixin : Base, T
{
};

Хотя это решение навязывает стратегию выбора спецификаций исключений базового класса, оно все равно не лишено интереса, так как получившийся шаблон Mixin нейтрален к используемой пользователем стратегии спецификаций исключений.

Второй случай не имеет решения. Если деструктор Base виртуальный, мы всегда сможем найти такой тип T, который нарушит компиляцию, несмотря на спецификацию исключений деструктора ~Base().

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

      struct Base
{
  virtual ~Base();
};

template<class T>
struct Mixin : Base, T
{
  ~Mixin() throw();
};

Заключение

В заключение я хотел бы сделать два вывода. Во-первых, подвести итог проделанной работе.

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

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

Ссылки

[ISO] ISO/IEC 14882


Эта статья опубликована в журнале RSDN Magazine #3-2004. Информацию о журнале можно найти здесь
    Сообщений 4    Оценка 376 [+0/-2]         Оценить