Среди техник, существующих в C++, есть несколько таких, которые используются довольно часто и, соответственно, находятся на слуху. Одной из таких техник является CRTP, которую мы рассматривали ранее. Другой является сокрытие типа (type erasure), которой я и хочу посвятить данную статью. Почему именно ей? Потому, что в стандартной библиотеке уже достаточно сущностей, которые основаны на этой технике, а знание того, как твой инструментарий работает позволяет избавить его от магического ореола, что, в свою очередь, значительно облегчает разработку.
Что за сокрытие?
Сокрытие типа существует в C++ с самого зарождения языка. Действительно, что может быть проще чем это?
int intVar = 0;
void* unknownVar = &intVar;
unknwonVar содержит указатель на память, но что находится по этому указателю? Неизвестно, т.к. мы скрыли тип. Но есть ли польза в таком сокрытии? Давайте разбираться.
Итак, имея только указатель на unknwonVar, что мы можем с ним сделать? Мы можем привести его к любому типу, но что дальше? Что будет если мы не угадаем тип (кроме неопределённого поведения от нарушения strict aliasing, разумеется)? Ничего хорошего, мы интерпретируем память под тип, которого там в действительности нет. Вряд ли это можно применить где-то, кроме каких-то программистских забав. Поэтому очевидно, что помимо самого указателя, нам всё-таки нужно хранить тип содержимого. Для этого мы можем создать какую-то такую структуру:
enum class Kind {Unknown, Int, Float, Char};
struct Any
{
void* data;
Kind kind;
};
Очевидный минус подобного подхода заключается в том, что мы явно храним kind, который нужно задавать самостоятельно. Другим минусом является само наличие enum Kind, который нам нужно заполнять и поддерживать. Конечно, мы могли бы использовать обезличенный тип и магические константы, но мы ведь не такие, правда? Кроме того, что делать с signed/unsigned/const/volatile и прочими? Это всё тоже вписывать и задавать? Всё это очень сложно поддерживать и шанс сделать что-то неправильно неоправданно велик.
К счастью, в языке C++ существует функционал, который умеет определять тип за нас. Точнее, подобного функционала у нас 2. Первый использует typeid, а второй шаблоны. Правда, у typeid есть очевидные проблемы, одна из которых иллюстрируется этим кодом:
assert(typeid(float&) == typeid(float));
Т.е. мы не можем сохранить тип точно. Но если этого не требуется, а достаточно того, что может нам предложить typeid, тогда у нас может получиться такой вариант:
#include <typeindex>
using namespace std;
struct Any
{
void* data;
type_index type;
};
int main()
{
long long var = 156;
Any any{static_cast<void*>(&var), typeid(var)};
}
Какие у нас есть очевидные проблемы с этим кодом?
Нужно явно указывать typeid, пользователь не может просто передать нам объект для хранения.
Нужно явно преобразовывать к void*.
После того, как мы поместили объект в нашу структуру, как его оттуда извлечь?
Как сохранить копию объекта? Сохранять указатель на существующий объект это здорово, но не покрывают большую часть случаев, когда Any применяется.
Очевидно, что для решения первых двух проблем нам нужен конструктор. Но какой? Что он будет принимать? Чтобы написать удобный конструктор нам придётся воспользоваться другим функционалом языка — шаблонами. Ведь они подходят здесь лучше всего — в шаблоне функции тип известен.
struct Any
{
public:
template<typename T>
Any(T& var):
data{static_cast<void*>(&var)},
type{typeid(var)}
{}
public:
void* data;
type_index type;
};
int main()
{
long long var = 156;
Any any{var};
}
Итак, две проблемы позади. Переходим к третьей. Для её решения мы снова воспользуемся шаблонной функцией, которая будет довольно простой:
template<typename T>
T& AnyCast(const Any& any)
{
if(any.type == typeid(T))
return *(static_cast<T*>(any.data));
throw logic_error("Any doesn't hold object of specified type");
}
Если пользователь запрашивает именно тот тип, что ранее был сохранён, то мы возвращаем ссылку на него (мы не копию храним, поэтому копию возвращать и не будем). Если же нет, то преобразование невозможно, и мы просто выбрасываем исключение. Отлично, 3/4 позади, осталась всего одна проблема!
Правда, эта проблема является очень острой — копирование исходного объекта просто необходимо, т.к. ситуация, при которой время жизни исходного объекта превышает время жизни созданного из него объекта Any, является довольно редкой. В общем случае мы не можем этого гарантировать. Что делать? Из-за того, что мы никак не можем сохранить статически тип в структуре Any, становится очевидно, что мы обязаны оставить void* data; в классе. Но что можно сделать, чтобы и это сохранилось, и наш объект не зависел от исходного? Нужно выделить память на куче и пусть data указывает туда! Сказано, сделано:
template<typename T>
Any(const T& var):
data{new T{var}},
type{typeid(var)}
{}
Какие есть проблемы с этим кодом? Да в общем-то никаких, кроме того, что мы пишем на C++ и имеем new без delete. Значит нужно написать деструктор, куда мы и добавим delete! Но этот номер не пройдёт: delete должен знать тип, на который ссылается указатель, — он не может удалять указатели на void. Но ведь есть умные указатели, пусть они удаляют! Можно попробовать создать std::unique_ptr<void>, но у нас ничего не выйдет — ровно по той же самой причине. А что с std::shared_ptr<void>, его ведь можно создать?! Действительно, с его помощью мы можем переписать наш класс следующим образом:
struct Any
{
public:
template<typename T>
Any(const T& var):
data{std::make_shared<T>(var)},
type{typeid(var)}
{}
public:
std::shared_ptr<void> data;
type_index type;
};
template<typename T>
T AnyCast(const Any& any)
{
if(any.type == typeid(T))
return *(static_cast<T*>(any.data.get()));
throw logic_error("Any doesn't hold object of specified type");
}
Ну вот и всё, мы сокрыли тип и написали Any, можно расходиться! Или нет? Всё-таки нет. Нашей целью было исследовать сокрытие типа — как это можно сделать, а не реализовать Any. Да, мы скрыли тип в Any, но каким образом? Ответа на это нет, он скрыт в shared_ptr. Т.е. для сокрытия типа мы использовали готовые возможности его сокрытия. Так дело не пойдёт, т.к. нам важно докопаться до истины и понять, что же стоит за этой «магией».
Но прежде чем мы перейдём к «магии» shared_ptr, я хочу сказать, что я немного схитрил, и кто-то, возможно, заметил (и ринулись писать гневные комментарии!), что мы всё-таки можем реализовать требуемый деструктор в Any и без shared_ptr. Для этого нам понадобится ещё один член в структуре Any, который будет выглядеть вот так: void(*deleter)(void*);, а конструктор с деструктором вот так:
template<typename T>
Any(const T& var):
data{new T{var}},
type{typeid(var)},
deleter{[](void* ptr) { delete static_cast<T*>(ptr); }}
{}
~Any()
{
deleter(data);
}
Т.е. у нас получается, что мы имеем член структуры deleter, являющийся указателем на любую функцию, которая ничего не возвращает и принимает указатель на void в качестве аргумента. С помощью лямбда-выражения, мы создаём такую всеядную функцию, которая внутри себя помнит тип и, соответственно, может корректно удалить память по переданному указателю. Всё просто и никакой магии!
Теперь нам осталось реализовать копирование и перемещение Any, но первое делается ровно по тому же сценарию, что и удаление, а второе — тривиально, поэтому забивать этим текст статьи не стану. Код доступен в в этой фиксации, поэтому кому интересно — милости прошу в хранилище. Кстати, реализация с shared_ptr решала проблему копирования весьма своеобразным образом, т.к. сохраняло один объект на все копии Any. Было бы это проблемой? В большинстве случаев — нет, но какие-то проблемы могли бы вскрыться в дальнейшем. В любом случае, подобное поведение не интуитивно и должно быть документированной возможностью.
Итак, мы успешно реализовали сокрытие типа, реализовав класс Any. К сожалению, такое решение не является универсальным из-за того, что мы скрываем тип, и восстанавливаем его с помощью typeid. Я уже упоминал, что из-за этого мы теряем часть типа, его «обвязки», но это ещё не всё. Что если мы хотим скрыть реальный тип функции или функтора, а потом вызвать её? Сможем ли мы это сделать без явного указания типа функции после сокрытия?
Очевидно, что те методы, что мы рассмотрели здесь, нам не позволят этого сделать. Также напоминаю, что в стандарте есть такой замечательный объект как std::function, который как раз и занимается сокрытием типа объекта-функции. Чтобы нам написать что-то похожее, нам нужно рассмотреть ещё одну технику сокрытия типа.
Элегантное сокрытие
Итак, в прошлом разделе мы собрали Any из подручных материалов, в этом же мы снова напишем Any, но уже с использованием более продвинутой техники.
Техника эта довольно проста, как для понимания, так и для адаптирования под свои нужды. Для начала нам понадобится общий интерфейс, который будет предоставлять доступ ко всем возможностям скрытого типа, которые нам могут понадобиться. Для нашего Any он может выглядеть так:
class AnyBase
{
public:
virtual void* getData() = 0;
virtual std::unique_ptr<AnyBase> clone() const = 0;
virtual ~AnyBase() = default;
};
Назначение функций в нашем интерфейсе предельно прозрачно: getData возвращает указатель на данные,— у нас уже был такой указатель в предыдущей реализации — а clone это простая функция копирования, которая создаёт полную копию объекта; этакий полиморфный конструктор копирования.
Итак, интерфейс есть, теперь напишем реализацию, которая и является краеугольным камнем будущего Any:
template <typename T>
class AnyBaseImpl: public AnyBase
{
public:
AnyBaseImpl(const T& value):
m_Data{value}
{}
void* getData() override
{
return &m_Data;
}
std::unique_ptr<AnyBase> clone() const override
{
return std::make_unique<AnyBaseImpl<T>>(m_Data);
}
private:
T m_Data;
};
Как вы можете видеть, реализация получается довольно компактная, но сколько в ней замечательного! Этот класс всегда знает для какого типа он создан и, соответственно, всегда может оперировать этим знанием. Думаю, что пояснения к коду излишни — он очевиден. Но два вышеописанных класса это не класс Any, а лишь инструменты для его создания, поэтому они не будут видны пользователю. Наш пользователь будет видеть тот же интерфейс, что мы описали в предыдущем разделе. Поэтому приступим к написанию самого класса Any, который тоже будет весьма прост:
class Any
{
public:
template<typename T,
typename = std::enable_if_t<
!std::is_same_v<std::decay_t<T>, Any>>>
Any(T&& var):
m_AnyImpl{std::make_unique<
AnyBaseImpl<std::decay_t<T>>>(
std::forward<T>(var))}
{}
Any(const Any& rhs):
m_AnyImpl{rhs.m_AnyImpl->clone()}
{
}
Any(Any&& rhs) = default;
Any& operator=(const Any& rhs)
{
Any tmp{rhs};
swap(*this, tmp);
return *this;
}
Any& operator=(Any&& rhs) = default;
template <typename T>
friend T AnyCast(const Any& any);
friend void swap(Any& lhs, Any& rhs)
{
std::swap(lhs.m_AnyImpl, rhs.m_AnyImpl);
}
private:
std::unique_ptr<AnyBase> m_AnyImpl;
};
Класс довольно тривиальный получился, хотя «этажность» выражений может немного пугать, но, к сожалению, эти «этажи» необходимы. Для «всеядного» конструктора стоит SFINAE-проверка, чтобы исключить замещение конструктора копирования. Конструктор копирования использует ранее написанную функцию clone, в общем — всё просто и не представляет никакого интереса. Ах да, возможно кого-то смутит вот эта строчка: AnyBaseImpl<std::decay_t<T>>: здесь мы создаём потомка нашего класса AnyBase, который будет хранить исходный тип. Но т.к для Any хранение всяких ссылок, const, массивов и функций излишне, мы «разлагаем» исходный тип с помощью std::decay_t. Эта функция позволяет очистить тип от всего того, что нам не нужно в Any.
Остаётся только реализация AnyCast, к которой мы и переходим:
template <typename T>
T AnyCast(const Any& any)
{
if(dynamic_cast<AnyBaseImpl<T>*>(any.m_AnyImpl.get()))
return *(static_cast<T*>(any.m_AnyImpl->getData()));
throw std::logic_error("Any doesn't hold object of specified type");
}
Реализация получается довольно элегантная: если Any содержит требуемый тип, значит dynamic_cast вернёт ненулевой указатель, в противном случае он вернёт nullptr. Полный код этой реализации может быть найден в этой фиксации.
Если кому-то не нравится использование dynamic_cast, тогда можно рассмотреть альтернативную реализацию с использование typeid. Для этого в интерфейс AnyBase нужно добавить метод, который будет возвращать std::type_index, с которым в дальнейшем будет проводиться сравнение в AnyCast. Здесь я реализацию приводить не буду, но если интересно, её можно найти в этой фиксации.
Реализация через виртуальные функции является, наверное, наиболее известной техникой сокрытия типа. И это немудрено, ведь код получается довольно простой. Да, приходится платить за косвенный вызов через виртуальные функции, но без косвенного вызова всё равно не обойтись. А какая реализация получается проще, та, что у нас в первом разделе, или эта? На мой взгляд, преимущество подхода с виртуальными функциями (по крайней мере в части понятности кода) неоспоримо.
Тем не менее, если вы посмотрите реализацию std::any в стандартных библиотеках популярных компиляторов, то скорее всего обнаружите подход, который больше напоминает то, что мы использовали в первом разделе. Т.е. в сущности, они реализуют виртуальные функции вручную, а всё для того, чтобы иметь возможность сохранять данные прямо в any, если они помещаются в некоторый заданный буфер. Мы уже видели подобную оптимизацию для строк, которая называется оптимизацией коротких строк.
Суть подобной оптимизации заключается в уменьшении (насколько это возможно) зависимости от выделения памяти в куче, что может дать действительно серьёзный прирост в производительности. Но ничего не даётся бесплатно, наш размер Any будет 8 байтов, на 64-х разрядной системе, а вот std::any будет 64, 32 и 16 для MSVC 2017, clang 8.0 и gcc 9.0 соответственно. За всё приходится платить, поэтому только тестирование конкретного случая покажет, какой подход является наиболее подходящим для этого случая. Старая мантра в деле: сначала измеряй, потом меняй. Да и использование подхода с виртуальными функциями не исключает выделения памяти прямо внутри Any, поэтому подход, выбранный в стандартных библиотеках, мне кажется спорным.
Итак, рассмотрев новый метод сокрытия типа, предлагаю рассмотреть то, как с его помощью можно написать простой аналог std::function, чтобы завершить картину, которую мы начали писать в первом разделе.
Function
Код для этого раздела может быть найден в этой фиксации.
Когда мы разрабатывали Any у нас была задача что-то положить в объект, а потом это что-то извлечь, если мы «угадаем» тип того, что там лежит. Если же мы пишем обобщённый класс Function, то перед нами стоит следующая задача: положить в объект что-то, что можно вызвать с теми аргументами и с тем возвращаемым значением, с которым мы объект Function создали. Т.е. мы можем положить не всё, что угодно, а только ограниченный набор вещей, которые удовлетворяют вышеописанным ограничениям.
С другой стороны, нам не нужно ничего впоследствии извлекать, нам лишь нужна возможность вызова того, что мы положили и получение результата вызова. Назовём то, что мы будем класть в Function функтором, чтобы упростить дальнейший текст. Под функтором мы будем понимать любой объект, который можно вызвать с определённым набором аргументов и получить возвращаемое значение (если оно есть). Ещё для простоты условимся, что возвращаемое значение входит в понятие сигнатуры функции, хотя с точки зрения C++ это и не так.
Чтобы реализовать подобный функционал, нам понадобится следующий интерфейс:
template<typename R, typename... Args>
class FunctionBase
{
public:
virtual R operator()(Args... args) const = 0;
virtual std::unique_ptr<FunctionBase> clone() const = 0;
virtual ~FunctionBase() = default;
};
В отличии от Any, тут интерфейс также является шаблоном класса, т.к. мы заранее знаем сигнатуру функтора, который мы будем хранить. Точнее, мы знаем сигнатуру, на которую мы будем ориентироваться при использовании operator(). Это важное отличие, ведь мы можем хранить функтор с отличающейся сигнатурой. Главное, чтобы аргументы (args), переданные в operator(), могли быть неявно преобразованы в аргументы, которые ожидает функтор, а возвращаемое значение функтора конвертировалось в то, что указано у нашего шаблона (R).
Но вернёмся к интерфейсу: уже знакомая нам функция clone и вместо getData у нас operator() для вызова функтора, который мы будем прятать. Вот, собственно, и всё. Переходим к реализации:
template<typename Functor, typename R, typename... Args>
class FunctionImpl: public FunctionBase<R, Args...>
{
public:
FunctionImpl(const Functor& functor):
m_Functor{functor}
{
}
FunctionImpl(Functor&& functor):
m_Functor{std::move(functor)}
{
}
R operator()(Args... args) const override
{
return m_Functor(args...);
}
std::unique_ptr<FunctionBase<R, Args...>> clone() const override
{
return std::make_unique<FunctionImpl>(m_Functor);
}
private:
Functor m_Functor;
};
Тут тоже ничего интересного, т.к. реализация довольно банальна. Добавили ещё один параметр для шаблона, чтобы принимать любой функтор, и сохраняем его в недрах класса FunctionImpl. При вызове operator() просто делегируем исполнение сохранённому функтору. Итак, инструменты готовы, приступаем к реализации Function. Для начала нам понадобится вот такой общий шаблон:
template<typename>
class Function;
Зачем он нужен? Дело в том, что мы планируем создавать наш объект Function по примеру того, как это делается с std::function, т.е. как-то так: Function<int(long, double)>, но как нам написать шапку шаблона, чтобы можно было вычленить возвращаемый тип и аргументы? Никак, потому что в шапке шаблона каждый тип записывается отдельно, и никакого специального синтаксиса, который бы позволил нам в шапке написать что-то вроде template<typename R(Args...)>, из которого мы бы получили отдельные R и Args..., не существует. Но выход есть, и нам на выручку приходит частичная специализация, которая будет выглядеть так:
template<typename R, typename... Args>
class Function<R(Args...)>
{
...
};
Итак, раз нельзя использовать шапку, значит можно её переписать, разделив R и Args..., а затем создать частичную специализацию по типу, который является функцией, возвращающей R и принимающей Args...! Вот такая небольшая хитрость, которая позволяет обойти отсутствие прямого синтаксиса. Это, кстати, одно из немногих мест в языке, где использование типа функции (не указателя!) необходимо. Разобравшись с этим, привожу полный текст Function:
template<typename R, typename... Args>
class Function<R(Args...)>
{
public:
Function() = default;
template<typename Functor,
typename = std::enable_if_t<std::is_convertible_v<
std::invoke_result_t<Functor, Args...>, R>>,
typename = std::enable_if_t<
!std::is_same_v<std::decay_t<Functor>, Function>>>
Function(Functor&& functor):
m_Functor{std::make_unique<FunctionImpl<
Functor, R, Args...>>(std::forward<Functor>(functor))}
{
}
Function(const Function& rhs):
m_Functor{rhs.m_Functor->clone()}
{
}
Function(Function&& rhs) = default;
Function& operator=(const Function& rhs)
{
Function tmp{rhs};
swap(*this, tmp);
return *this;
}
Function& operator=(Function&& rhs) = default;
R operator()(Args... args) const
{
return (*m_Functor)(args...);
}
friend void swap(Function& lhs, Function& rhs)
{
std::swap(lhs.m_Functor, rhs.m_Functor);
}
private:
std::unique_ptr<FunctionBase<R, Args...>> m_Functor;
};
Очевидно, что самым сложным в этой реализации является наш хитрый трюк с вычленением R и Args..., всё остальное должно быть кристально ясно, т.к. это мало чем отличается от ранее разобранного Any. Правда, у нас добавилось ещё этажей в конструкторе, за счёт вот этой проверки:
std::is_convertible_v<std::invoke_result_t<Functor, Args...>, R>
Здесь мы используем метафункцию invoke_result_t из C++17, которая позволяет проверить, что переданный Functor может быть вызван с аргументами Args..., и метафункцию is_convertible_v чтобы удостовериться в возможности преобразования того, что возвращает вызов Functor в R. Эта проверка нам нужна для того, чтобы исключить попытки передать в наш объект функторы, которые наш Function обрабатывать не может, в силу непреодолимого различия сигнатур. К примеру, вот такой код даст ошибку компиляции:
Function<void(int)> func{[](int){return 0;}};
Этот код дал бы её и без этой проверки, но тогда источник проблемы найти было бы сложнее. С проверкой он даст ошибку создания Function, а без неё, что operator() не может быть вызван. Чем ближе сообщение об ошибке к её источнику, тем проще её устранить. Так звучит первое правило в священной книге метапрограммистов.
И вот, собственно, вся Function, можно заворачивать и нести использовать. Да, кода получилось не так уж и мало, но большая его часть это просто всякие проверки и прочий «обвес». Сама суть класса очень проста и занимает крайней мало места. Но весь ли функционал std::function мы покрыли в этом классе? Не совсем, поэтому предлагаю обсудить отличия.
А что у std::function?
Одним из ключевых отличий function от нашей поделки, является то, что function может принимать и вызывать функции-члены класса, а наш Function — нет. Т.е. вот такой код является вполне работоспособным:
std::function<size_t(const string&)> func = &string::size;
cout << func("Hey!"s);
Думаю, что не все знают о таком функционале function, но он, тем не менее, присутствует. К счастью, подобный функционал реализовать довольно легко. Нам просто нужно немного видоизменить наш вспомогательный класс FunctionImpl, переписав реализацию operator() на такую:
R operator()(Args... args) const override
{
return invokeImpl(m_Functor, args...);
}
Теперь осталось реализовать invokeImpl. Для её реализации нам понадобится код для двух вариантов: вызов функции-члена и простой функции. И каждый из этих вариантов нужно разложить ещё на 2: функции которые что-то возвращают и void-функции. Исходя из этого, получаются вот такие две функции:
template<typename Functor, typename... Args,
typename = std::enable_if_t<
!std::is_member_function_pointer_v<Functor>>>
auto invokeImpl(Functor&& functor, Args&&... args)
{
if constexpr(std::is_same_v<
std::invoke_result_t<Functor, Args...>, void>)
functor(std::forward<Args>(args)...);
else
return functor(std::forward<Args>(args)...);
}
template<typename MemberFun, typename ClassType,
typename... Args, typename = std::enable_if_t<
std::is_member_function_pointer_v<MemberFun>>>
auto invokeImpl(MemberFun function, ClassType&& object,
Args&&... args)
{
if constexpr(std::is_same_v<std::invoke_result_t<
MemberFun, ClassType, Args...>, void>)
(object.*function)(std::forward<Args>(args)...);
else
return (object.*function)(std::forward<Args>(args)...);
}
Снова страшные «шапки» и предельно простая реализация. Увы, такова цена SFINAE. Кстати, если вы были внимательны, и у вас не зарябило в глазах от обилия всей это метамагии, то вы могли заметить, что наша invokeImpl поддерживает только передачу объекта по ссылке, либо же копирование, тогда как function поддерживает и такое:
std::function<size_t(string* const)> func = &string::size;
auto str = "Hey!"s;
cout << func(&str);
Добавить поддержку этого случая очень просто, но это уже пусть останется в качестве домашнего задания, там действительно ничего сложного. А можно вообще не писать собственную реализацию invokeImpl, а просто использовать std::invoke, в которой всё реализовано за нас. Если кто-то хочет ознакомиться с полным кодом примера, могут найти его в этой фиксации. Мы же идём дальше.
Вторым отличием является то, что function может игнорировать возвращаемое значение функторов, а наша реализация — нет. Т.е. если мы создадим std::function<void()>, то мы можем помещать туда любой функтор, который не принимает аргументов, но при этом он может возвращать всё, что угодно — это будет просто проигнорировано. Я бы сказал, что это сомнительный функционал, который удобен тогда, когда ты понимаешь, что делаешь, но может выйти боком, когда не подозреваешь. Тем не менее, это всё тоже несложно реализовать, но я уже не буду.
Наконец, все известные мне реализации function используют ту же самую оптимизацию, что и any, размещая функтор прямо в «себе», если его размер невелик. Это позволяет снизить зависимость от кучи и для некоторых сценариев это может дать значительный прирост в производительности. Но за это мы платим тем, то function занимает на 64-х разрядной платформе 64, 48 и 32 байта для MSVC 2017, clang 8.0 и gcc 9.0 соответственно. Тогда как наш Function занимает всего 8 байтов, т.е. один указатель. И именно в таких отличиях и кроется то, почему я так подробно разбираю реализацию Any и Function. И function, и any являются замечательными классами, но они пытаются угодить всем, что не всегда выходит. Поэтому важно понимать, как написать свой вариант. Особенно это касается function.
Итог
Итак, ещё одну из широкоизвестных техник мы рассмотрели в этом блоге. Надеюсь, что теперь у вас не будет вопросов по тому, что такое сокрытие типа (type erasure). Я уверен, что рассмотренные здесь техники не являются единственными, и возможны другие вариации, просто они либо до меня не дошли, либо ещё ни до кого не дошли. Тем не менее, рассмотренных здесь вариантов достаточно, чтобы создавать свои типы, в которых нужно скрывать тип исходный. Однако, прежде чем вы будете создавать свои варианты для неучебного кода, предлагаю посмотреть на библиотеку Boost.TypeErasure, которая являет собой некий конструктор типов, которые скрывают исходный тип, но всё равно позволяют проводить различные манипуляции над сохранённым в недрах объектом. Довольно интересная библиотека.