Xtensor и его вспомогательные проекты позволяют легко реализовать функцию один раз на C ++ и предоставить ее для основных языков науки о данных, таких как Python, Julia и R, с небольшими дополнительными усилиями. Хотя, если это в принципе звучит просто, при определении API библиотеки C ++ могут возникнуть трудности. Давайте проиллюстрируем различные варианты, которые у нас есть, на примере одной функции compute, которая должна быть вызвана из всех языков.
Вариант 1. Общий API
Поскольку привязки xtensor предоставляют разные типы контейнеров для хранения тензоров (pytensor, rtensor и jltensor), если мы хотим, чтобы наша функция вызывалась со всех языков, она должна принимать общий аргумент:
template <class E> void compute(E&& e);
Однако это слишком общий вариант, и мы можем потребовать, чтобы эта функция принимала только аргументы xtensor. Поскольку все контейнеры xtensor наследуются от базового класса CRTP «xexpression», мы можем легко выразить это ограничение с помощью следующей сигнатуры:
template <class E> void compute(const xexpression<E>& e) { // Now the implementation must use e() instead of e }
Обратите внимание, что с этим изменением мы теряем возможность вызывать функцию с непостоянными ссылками или ссылками на rvalue. Если мы хотим их вернуть, нам нужно добавить следующие перегрузки:
template <class E> void compute(xexpression<E>& e); template <class E> void compute(xexpression<E>&& e);
В остальной части статьи я предполагаю, что постоянной справочной перегрузки достаточно. Теперь мы можем предоставить функцию вычисления другим языкам, давайте проиллюстрируем это с помощью привязок Python:
PYBIND11_MODULE(pymod, m) { xt::import_numpy(); m.def("compute", &compute<pytensor<double, 2>>); }
Вариант 2: Полностью квалифицированный API
Принятие любого выражения по-прежнему может быть слишком снисходительным; Предположим, мы хотим ограничить эту функцию только 2-мерными тензорными контейнерами. В этом случае решение состоит в том, чтобы предоставить функцию API, которая перенаправляет вызов общей универсальной реализации:
namespace detail { template <class E> void compute_impl(E&&); } template <class T> void compute(const xtensor<T, 2>& t) { detail::compute_impl(t); }
Открыть его для Python так же просто:
template <class T> void compute(const pytensor<T, 2>& t) { detail::compute_impl(t); } PYBIND11_MODULE(pymod, m) { xt::import_numpy(); m.def("compute", &compute<double>); }
Хотя это решение действительно простое, оно требует написания четырех дополнительных функций для API. Кроме того, если позже вы решите поддерживать контейнеры-массивы, вам нужно будет добавить еще четыре функции. Поэтому это решение следует рассматривать для библиотек с небольшим количеством предоставляемых функций и чьи API-интерфейсы вряд ли изменятся в будущем.
Вариант 3: выбор контейнера
Способ сохранить ограничение на тип параметра при ограничении необходимого количества ввода в привязках — это полагаться на дополнительные структуры, которые выберут для нас правильный тип. Большое спасибо Бенуа Бови за его предложение, исходный пост можно найти на G itHub.
Идея состоит в том, чтобы определить структуру для выбора типа контейнеров (тензор, массив) и структуру для выбора библиотечной реализации этого контейнера (xtensor, pytensor в случае тензорного контейнера):
// library container selector struct xtensor_c { }; // container selector, must be specialized for each // library container selector template <class C, class T, std::size_t N> struct tensor_container; // Specialization for xtensor library (or C++) template <class T, std::size_t N> struct tensor_container<xtensor_c, T, N> { using type = xt::xtensor<T, N>; }; template <class C, class T, std::size_t N> using tensor_container_t = typename tensor_container<C, T, N>::type;
Сигнатура функции становится
template <class T, class C = xtensor_c> void compute(const tensor_container_t<C, T, 2>& t);
Привязки Python требуют только специализации структуры «tensor_container».
struct pytensor_c { }; template <class T, std::size_t N> struct tensor_container<pytensor_c, T, N> { using type = pytensor<T, N>; }; PYBIND11_MODULE(pymod, m) { xt::import_numpy(); m.def("compute", &compute<double, pytensor_c>); }
Даже если нам нужно специализировать структуру «tensor_container» для каждого языка, эту специализацию можно повторно использовать для других функций и, таким образом, сократить объем требуемого набора текста. Однако за это приходится расплачиваться: мы потеряли вывод типа на стороне C ++.
xt::xtensor<double, 2> t {{1., 2., 3.}, {4., 5., 6.}}; compute<double>(t); // works compute(t); // error (couldn't infer template argument 'T')
Кроме того, если позже мы захотим поддерживать массивы, нам нужно добавить структуру «array_container» и ее специализации, а также перегрузку функции вычисления:
template <class C, class T> struct array_container; template <class C, class T> struct array_container<xtensor_c, T> { using type = xt::xarray<T>; }; template <class C, class T> using array_container_t = typename array_container<C, T>::type; template <class T, class C = xtensor_c> void compute(const array_container_t<C, T>& t);
Вариант 4: ограничение типа с помощью SFINAE
Главный недостаток предыдущего варианта — это потеря вывода типа в C ++. Единственный способ вернуть его — повторно ввести универсальный тип параметра. Однако мы можем заставить компилятор генерировать недопустимый тип, чтобы функция была удалена из набора разрешения перегрузки, когда фактический тип аргумента не удовлетворяет некоторому ограничению. Этот принцип известен как SFINAE (отказ замены не является ошибкой). Современный C ++ предоставляет метафункции, которые помогают нам использовать SFINAE:
template <class C> struct is_tensor : std::false_type { }; template <class T, std::size_t N, layout_type L, class Tag> struct is_tensor<xtensor<T, N, L, Tag>> : std::true_type { }; template <class T, template <class> class C = is_tensor, std::enable_if_t<C<T>::value, bool> = true> void compute(const T& t);
Здесь, когда «C ‹T› :: value» истинно, вызов «enable_if_t» генерирует тип bool. В противном случае он ничего не генерирует, что приводит к недопустимому объявлению функции. Компилятор удаляет это объявление из набора разрешений перегрузки, и никаких ошибок не происходит, если другая «вычислительная» перегрузка подходит для вызова. В противном случае компилятор выдаст ошибку.
Значение по умолчанию здесь, чтобы избежать необходимости передавать логическое значение при вызове функции «вычислить»; это значение бесполезно, мы полагаемся только на уловку SFINAE.
У этого объявления есть небольшая проблема: добавление «enable_if_t» к сигнатуре каждой функции, которую мы хотим предоставить, является громоздким. Давайте сделаем эту часть более выразительной:
template <template<class> class C, class T> using check_constraints = std::enable_if_t<C<T>::value, bool>; template <class T, template <class> class C = is_tensor, check_constraints<C, T> = true> void compute(const T& t);
Хорошо, у нас есть вывод типов и выразительный синтаксис для объявления нашей функции. Кроме того, если мы хотим ослабить ограничение, чтобы функция могла принимать как тензоры, так и массивы, все, что нам нужно сделать, это заменить значение по умолчанию для C:
// Equivalent to is_tensor<T>::value || is_array<T>::value template <class T> sturct is_container : xtl::disjunction<is_tensor<T>, is_array<T>> { }; template <class T, template <class> class C = is_container, check_constraints<C, T> = true> void compute(const T& t);
Это гораздо более гибкий вариант, чем предыдущий. За такую гибкость приходится платить: раскрытие функции Python немного более подробное:
template <class T, std::size_t N, layout_type L> struct is_tensor<pytensor<T, N, L>> : std::true_type { }; PYBIND11_MODULE(pymod, m) { xt::import_numpy(); m.def("compute", &compute<pytensor<double, 2>>); }
Заключение
У каждого решения есть свои плюсы и минусы, и выбор одного из них должен производиться в зависимости от гибкости, которую вы хотите придать своему API, и ограничений, налагаемых реализацией. Например, метод, требующий большого количества наборов текста в привязках, может не подходить для библиотек с огромным количеством функций, которые нужно раскрыть, в то время как полный общий API может быть проблематичным, если реализация ожидает только контейнеры. Ниже приводится сводка преимуществ и недостатков различных вариантов:
- Общий API: полная универсальность, в привязках не требуется дополнительного ввода текста, но, возможно, слишком разрешительный.
- Полноценный API: простой, принимает только указанный тип параметра, но требует много ввода для привязок.
- Выбор контейнера: довольно простой, требует меньше ввода, чем предыдущий метод, но теряет вывод типа на стороне C ++ и не обладает некоторой гибкостью.
- Ограничение типа с помощью SFINAE: более гибкое, чем предыдущий вариант, возвращает вывод типа, но немного сложнее для реализации.
Если вы хотите обсудить эти решения или поделиться с нами новым, не стесняйтесь посещать нашу чат-комнату Gitter и общаться с нами над xtensor и связанными с ним проектами!