Какова мотивация статического полиморфизма в С++?
Я понимаю механику статического полиморфизма с помощью любопытно повторяющегося шаблона шаблона. Я просто не понимаю, для чего это полезно.
Заявленная мотивация такова:
Мы жертвуем некоторой гибкостью динамического полиморфизма для скорости.
Но зачем беспокоиться о чем-то таком сложном, как:
template <class Derived>
class Base
{
public:
void interface()
{
// ...
static_cast<Derived*>(this)->implementation();
// ...
}
};
class Derived : Base<Derived>
{
private:
void implementation();
};
Когда вы можете просто сделать:
class Base
{
public:
void interface();
}
class Derived : public Base
{
public:
void interface();
}
Моя лучшая догадка заключается в том, что в коде нет семантической разницы и что это всего лишь хороший стиль С++.
Херб Саттер написал в Exceptional C++ style: Chapter 18
, что:
Предпочитают делать частные виртуальные функции.
Сопровождается, конечно, с подробным объяснением, почему это хороший стиль.
В контексте этого руководства первый пример хороший, потому что:
Функция void implementation()
в примере может претендовать на виртуальность, так как здесь требуется выполнить настройку класса. Поэтому он должен быть закрытым.
И второй пример bad, так как:
Мы не должны вмешиваться в открытый интерфейс для выполнения настройки.
Мой вопрос:
- Что мне не хватает в статическом полиморфизме? Это все о хорошем стиле С++?
- Когда он должен использоваться? Каковы некоторые рекомендации?
Ответы
Ответ 1
Что мне не хватает в статическом полиморфизме? Это все о хорошем стиле С++?
Статический полиморфизм и полиморфизм времени выполнения - разные вещи и достигают разных целей. Они оба являются технически полиморфизмом, поскольку они решают, какой фрагмент кода выполнять по типу чего-либо. Полиморфизм времени выполнения откладывает привязку типа чего-либо (и, следовательно, кода, который выполняется) до времени выполнения, тогда как статический полиморфизм полностью разрешен во время компиляции.
Это приводит к плюсам и минусам для каждого. Например, статический полиморфизм может проверять допущения во время компиляции или выбирать среди вариантов, которые иначе не компилировались. Он также предоставляет тонны информации компилятору и оптимизатору, который может встроить, зная целиком вызовы и другую информацию. Но статический полиморфизм требует, чтобы реализации были доступны для компилятора для проверки в каждой единицы перевода, может привести к раздуванию размера двоичного кода (шаблоны представляют собой причудливые скопированные копии штанов) и не позволяют этим определениям встречаться во время выполнения.
Например, рассмотрим что-то вроде std::advance
:
template<typename Iterator>
void advance(Iterator& it, ptrdiff_t offset)
{
// If it is a random access iterator:
// it += offset;
// If it is a bidirectional iterator:
// for (; offset < 0; ++offset) --it;
// for (; offset > 0; --offset) ++it;
// Otherwise:
// for (; offset > 0; --offset) ++it;
}
Невозможно получить это для компиляции с использованием полиморфизма времени выполнения. Вы должны принять решение во время компиляции. (Обычно вы делаете это с отправкой тегов, например.)
template<typename Iterator>
void advance_impl(Iterator& it, ptrdiff_t offset, random_access_iterator_tag)
{
// Won't compile for bidirectional iterators!
it += offset;
}
template<typename Iterator>
void advance_impl(Iterator& it, ptrdiff_t offset, bidirectional_iterator_tag)
{
// Works for random access, but slow
for (; offset < 0; ++offset) --it; // Won't compile for forward iterators
for (; offset > 0; --offset) ++it;
}
template<typename Iterator>
void advance_impl(Iterator& it, ptrdiff_t offset, forward_iterator_tag)
{
// Doesn't allow negative indices! But works for forward iterators...
for (; offset > 0; --offset) ++it;
}
template<typename Iterator>
void advance(Iterator& it, ptrdiff_t offset)
{
// Use overloading to select the right one!
advance_impl(it, offset, typename iterator_traits<Iterator>::iterator_category());
}
Аналогичным образом бывают случаи, когда вы действительно не знаете тип во время компиляции. Рассмотрим:
void DoAndLog(std::ostream& out, int parameter)
{
out << "Logging!";
}
Здесь DoAndLog
ничего не знает о фактической реализации ostream
, которую он получает, и может быть невозможно статически определить, какой тип будет передан. Конечно, это можно превратить в шаблон:
template<typename StreamT>
void DoAndLog(StreamT& out, int parameter)
{
out << "Logging!";
}
Но это заставляет DoAndLog
внедряться в файл заголовка, что может оказаться непрактичным. Это также требует, чтобы все возможные реализации StreamT были видны во время компиляции, что может быть неверным - полиморфизм времени выполнения может работать (хотя это не рекомендуется) через границы DLL или SO.
Когда он должен использоваться? Каковы некоторые рекомендации?
Это похоже на то, что кто-то приходит к вам и говорит: "Когда я пишу предложение, я должен использовать сложные предложения или простые предложения"? Или, может быть, художник говорит: "Должен ли я всегда использовать красную краску или синюю краску?" Правильного ответа нет, и нет никаких правил, которые можно слепо следовать за ними. Вы должны взглянуть на плюсы и минусы каждого подхода и решить, какие карты лучше всего подходят для вашего конкретного проблемного домена.
Что касается CRTP, большинство случаев использования - это позволить базовому классу предоставить что-то в терминах производного класса; например Boost iterator_facade
. Базовый класс должен иметь такие вещи, как DerivedClass operator++() { /* Increment and return *this */ }
внутри - указан в терминах производных в подписях функций-членов.
Он может использоваться для полиморфных целей, но я не видел слишком много таких.
Ответ 2
Ссылка, которую вы указываете, повышает итераторы как пример статического полиморфизма. Итераторы STL также демонстрируют эту схему. Давайте рассмотрим пример и рассмотрим, почему авторы этих типов решили, что этот шаблон подходит:
#include <vector>
#include <iostream>
using namespace std;
void print_ints( vector<int> const& some_ints )
{
for( vector<int>::const_iterator i = some_ints.begin(), end = some_ints.end(); i != end; ++i )
{
cout << *i;
}
}
Теперь, как бы мы реализовали int vector<int>::const_iterator::operator*() const;
Можем ли мы использовать для этого полипром? Ну нет. Какова была бы подпись нашей виртуальной функции? void const* operator*() const
? Это бесполезно! Тип стирается (деградирует от int до void *). Вместо этого возникает любопытно повторяющийся шаблон шаблона, чтобы помочь нам генерировать тип итератора. Вот приблизительное приближение класса итератора, которое нам нужно было бы реализовать выше:
template<typename T>
class const_iterator_base
{
public:
const_iterator_base():{}
T::contained_type const& operator*() const { return Ptr(); }
T::contained_type const& operator->() const { return Ptr(); }
// increment, decrement, etc, can be implemented and forwarded to T
// ....
private:
T::contained_type const* Ptr() const { return static_cast<T>(this)->Ptr(); }
};
Традиционный динамический полиморфизм не мог обеспечить вышеуказанную реализацию!
Связанным и важным термином является параметрический полиморфизм. Это позволяет реализовать аналогичные API-интерфейсы, например, в python, с помощью которого вы можете использовать любопытно повторяющийся шаблон шаблона на С++. Надеюсь, это будет полезно!
Я думаю, что стоит взять удар в источник всей этой сложности, и почему такие языки, как Java и С#, в основном стараются избегать этого: type erasure! В С++ нет полезной информации, содержащей тип Object
с полезной информацией. Вместо этого у нас есть void*
, и как только у вас есть void*
, у вас действительно ничего нет! Если у вас есть интерфейс, который распадается на void*
, единственный способ восстановить - это сделать опасные предположения или сохранить дополнительную информацию о типе.
Ответ 3
Хотя могут быть случаи, когда статический полиморфизм полезен (другие ответы перечислены несколькими), я обычно рассматривал бы это как плохое. Зачем? Поскольку вы больше не можете использовать указатель на базовый класс, вам всегда нужно предоставить аргумент шаблона, предоставляющий точный производный тип. И в этом случае вы можете точно использовать производный тип напрямую. И, если честно, статический полиморфизм - это не ориентация объекта.
Разница во времени между статическим и динамическим полиморфизмом - это, в точности, два указателя-указателя (если компилятор действительно вводит метод отправки в базовый класс, если по какой-то причине статический полиморфизм медленнее). Это не очень дорого, тем более, что второй поиск практически всегда попадает в кеш. В общем, эти запросы обычно дешевле самого вызова функции и, безусловно, стоят того, чтобы получить реальную гибкость, обеспечиваемую динамическим полиморфизмом.