Что такое динамический полиморфизм в контексте C++
Динамический полиморфизм позволяет объектам разных типов реагировать на один и тот же вызов функции, определяя собственную реализацию поведения во время выполнения. В традиционном C++ полиморфизм реализуется через виртуальные методы базовых классов. Этот подход удобен, но часто сопровождается накладными расходами на хранение v‑таблиц и необходимость наследования, что не всегда оправдано в проектах, где важна лёгкость компоновки и отсутствие жёсткой иерархии.
Полиморфизм через свободные функции
Свободные функции (не являющиеся членами классов) могут стать альтернативой виртуальных методов, если их вызов абстрагировать с помощью уровня indirection. Вместо того чтобы хранить указатель на объект с виртуальными методами, хранится «объект‑обёртка», содержащий указатель на функцию, реализующую нужный интерфейс. Такой подход известен как type erasure (стирание типа) и позволяет объединять любые вызываемые сущности — функции, лямбда‑выражения, functor‑ы — под единым интерфейсом без необходимости наследования.
Типовое стирание (type erasure) в C++
Базовая схема type erasure выглядит так:
class Callable {
struct Concept {
virtual ~Concept() = default;
virtual void invoke(float* out, const float* in, std::size_t n) const = 0;
};
template<class F>
struct Model : Concept {
F func;
explicit Model(F&& f) : func(std::forward<F>(f)) {}
void invoke(float* out, const float* in, std::size_t n) const override {
func(out, in, n);
}
};
std::unique_ptr<Concept> self;
public:
template<class F>
Callable(F&& f) : self(std::make_unique<Model<std::decay_t<F>>>(std::forward<F>(f))) {}
void operator()(float* out, const float* in, std::size_t n) const {
self->invoke(out, in, n);
}
};
Callable хранит указатель на абстрактный базовый класс Concept, а конкретный тип функции упаковывается в шаблонный Model. При вызове operator() происходит динамический диспетчинг к нужной реализации. При этом пользователь видит только один тип — Callable, а любые свободные функции, удовлетворяющие сигнатуре, могут быть переданы без создания отдельного наследника.
Практический пример в машинном обучении
В задачах машинного обучения часто требуется переключать разные реализации ядра операции (например, умножение матрицы на вектор) в зависимости от доступного оборудования: CPU, GPU, SIMD‑расширения. Ниже показан упрощённый пример, где ядра реализованы в виде свободных функций:
void matvec_cpu(float* out, const float* mat, const float* vec, std::size_t rows, std::size_t cols) {
for (std::size_t i = 0; i < rows; ++i) {
float sum = 0.0f;
for (std::size_t j = 0; j < cols; ++j)
sum += mat[i * cols + j] * vec[j];
out[i] = sum;
}
}
void matvec_simd(float* out, const float* mat, const float* vec, std::size_t rows, std::size_t cols) {
// SIMD‑оптимизированный вариант (упрощённо)
// ...
}
С помощью Callable можно собрать пул доступных реализаций и выбирать нужную в рантайме:
using Kernel = Callable;
std::vector<Kernel> kernels;
kernels.emplace_back([](float* out, const float* in, std::size_t n) {
matvec_cpu(out, in, /*vec=*/nullptr, n, /*cols=*/128);
});
kernels.emplace_back([](float* out, const float* in, std::size_t n) {
matvec_simd(out, in, /*vec=*/nullptr, n, /*cols=*/128);
});
auto selected = (has_simd_support()) ? kernels[1] : kernels[0];
selected(result.data(), matrix.data(), rows);
Плюс в том, что каждый элемент Kernel полностью инкапсулирует свою логику, а выбор происходит без привязки к конкретному типу. Это упрощает тестирование: можно подменять ядра мок-объектами, передавая лямбды, которые фиксируют входные данные и проверяют их.
Плюсы и ограничения подхода
| Плюсы | Ограничения |
|---|---|
| Отсутствие наследования – типы не обязаны находиться в одной иерархии. | Накладные расходы – каждый вызов проходит через виртуальную таблицу и динамический аллокатор (unique_ptr). |
| Гибкость ввода – любой callable объект (функция, лямбда, functor) подходит без адаптеров. | Отсутствие compile‑time оптимизаций – компилятор не может инлайнить вызов, как в шаблонных статических полиморфизмах. |
| Лёгкая замена реализации – можно подменять ядра в рантайме, не перекомпилируя основной код. | Увеличение кода – требуется писать небольшую обёртку (Concept/Model), что добавляет шаблонный шум. |
| Универсальный интерфейс – одинаковый тип для всех операций упрощает хранение в контейнерах. | Отсутствие контроля над жизненным циклом – пользователь обязан следить за тем, чтобы передаваемые функции имели корректный срок жизни (особенно при захвате внешних ресурсов). |
В большинстве сценариев, где количество переключаемых реализаций невелико и важна модульность, преимущества перекрывают затраты. Если же критична максимальная производительность и количество вызовов огромно, лучше рассмотреть статический полиморфизм (CRTP) или std::function с предвыделенным буфером (small buffer optimization).
Практические рекомендации по внедрению
- Определите минимальный набор параметров функции‑интерфейса. Чем компактнее сигнатура, тем проще будет реализовать типовое стирание.
- Избегайте захвата дорогих ресурсов в лямбдах, если они могут жить дольше, чем объект‑обёртка. Вместо этого передавайте указатели или
std::shared_ptr. - Используйте
small buffer optimization(например,boost::static_vectorвнутри обёртки) для уменьшения количества аллокаций при небольших callable‑объектах. - Тестируйте переключаемость через unit‑тесты, проверяя, что каждый ядровой callable корректно обрабатывает граничные случаи (нулевые размеры, NaN, выравнивание).
- Профилируйте критические участки: измеряйте время вызова через обёртку и сравнивайте с прямым вызовом функции, чтобы убедиться, что динамический диспетчинг не является узким местом.
Применяя динамический полиморфизм для свободных функций, разработчики машинного обучения получают гибкую инфраструктуру, позволяющую быстро адаптировать вычислительные ядра под новые архитектуры и экспериментировать с алгоритмами без громоздкой иерархии классов. Такой подход легко интегрировать в существующие C++‑библиотеки и сохраняет совместимость с современными инструментами оптимизации компиляторов.