Привет, Хабр!
В C++ долго не было нормального стандартизованного способа адресовать многомерные данные без самодельных обвязок на указателях, индексации по формуле и пачек typedef с макросами. В C++20 появился std::span для одномерных непрерывных диапазонов. Следующий логичный шаг — многомерный view с настраиваемым отображением индексов в адреса памяти. Этим шагом в C++23 стал std::mdspan в заголовке <mdspan>. Это не контейнер и не владеет памятью, это слой адресации поверх уже существующего буфера. Формально идею закрепили в P0009, а в стандарт попали mdspan, extents и политики layout; отдельная функция submdspan пошла в следующую версию стандарта C++26.
Что такое mdspan и зачем он нужен
std::mdspan<T, Extents, LayoutPolicy, AccessorPolicy> — это способ сказать компилятору: у меня есть буфер из T, а обращаться я хочу по многомерным индексам (i, j, k, ...), причём правило преобразования индексов в линейный offset настраивается. Базовые вещи:
-
extentsописывает размерность, где каждая размерность может быть статической константой или динамической величиной. -
layout_rightиlayout_leftзадают row‑major и column‑major порядок размещения соответственно. Есть и гибкийlayout_strideдля произвольных шагов по каждой оси. -
accessorуправляет тем, как по одномерному offset»у достать ссылку на элемент. По дефолту‑ прямой доступ по указателю, но интерфейс расширяемый.
Важно помнить две вещи. Первое: mdspan не проверяет границы и не следит за сроком жизни буфера. Второе: время выполнения индексации — чистая арифметика по strides, одинаковая для любой конфигурации при оптимизациях. Конструкторы требуют, чтобы размер буфера покрывал mapping.required_span_size() — иначе поведение не определено, т.е проверяйте сами входные данные.
Базовая инициализация
Начнём с обычной матрицы rows × cols поверх std::vector<double> в памяти row‑major. Валидируем размеры, создаём view, аккуратно работаем с константностью и не плодим лишних копий.
#include <vector> #include <mdspan> #include <cassert> #include <cstddef> using index_t = std::size_t; using dyn2d = std::extents<index_t, std::dynamic_extent, std::dynamic_extent>; using layout = std::layout_right; // row-major // Невладеющий view матрицы double[rows][cols] using md_matrix = std::mdspan<double, dyn2d, layout>; using cmd_matrix = std::mdspan<const double, dyn2d, layout>; inline md_matrix make_matrix(double* data, index_t rows, index_t cols) { assert(data != nullptr); md_matrix m{data, rows, cols}; // Проверка объёма буфера: для row-major required_span_size == rows*cols assert(m.mapping().required_span_size() == rows * cols); return m; } inline cmd_matrix make_matrix(const double* data, index_t rows, index_t cols) { assert(data != nullptr); cmd_matrix m{data, rows, cols}; assert(m.mapping().required_span_size() == rows * cols); return m; } int main() { std::vector<double> buf(6); auto M = make_matrix(buf.data(), 2, 3); M(0,0) = 1.0; M(1,2) = 42.0; }
Индексация всегда в математическом порядке (row, col), а реальный порядок в памяти решает layout. Поменяете на layout_left — внешний код с (i, j) останется прежним, изменится только формула offset.
Статические и динамические extents
Если какие‑то размерности известны на этапе компиляции, имеет смысл зафиксировать их в типе.
// 4x4 матрица со статическими размерностями using mat4x4 = std::mdspan<float, std::extents<index_t, 4, 4>, std::layout_right>; void mul_add(mat4x4 A, mat4x4 B, mat4x4 C) { // Простой i-j-k, компилятор любит разворачивать такие циклы for (index_t i = 0; i < 4; ++i) for (index_t k = 0; k < 4; ++k) { float s = 0.f; for (index_t j = 0; j < 4; ++j) s += A(i, j) * B(j, k); C(i, k) += s; } }
extents статичны, значит часть вычислений по stride доступны компилятору в виде констант.
layout_stride: вью на подматрицу, pitch и транспонирование без копий
layout_stride позволяет создать view с произвольными шагами по осям. Типичный кейс — изображение с шагом строки pitch или подматрица в большом буфере. Другой кейс — транспонированный вид поверх той же памяти.
Первое — ROI с фиксированным шагом строки. Пусть есть буфер uint8_t с высотой H, шириной W и шагом строки pitch в элементах. Хотим адресовать прямоугольник h × w, начиная с (row0, col0).
#include <mdspan> using index_t = std::size_t; using dyn2d = std::extents<index_t, std::dynamic_extent, std::dynamic_extent>; using stride_map = std::layout_stride::mapping<dyn2d>; template<class T> std::mdspan<T, dyn2d, std::layout_stride> make_roi(T* base, index_t pitch, index_t row0, index_t col0, index_t h, index_t w) { // Смещение в линейном буфере const index_t offset = row0 * pitch + col0; // Страйды для 2D: шаг по строке — pitch, по столбцу — 1 stride_map m{ dyn2d{h, w}, std::array<index_t,2>{pitch, 1} }; // required_span_size учитывает самую дальнюю точку ROI auto span_size = m.required_span_size(); // проверьте, что буфер действительно покрывает [offset, offset + span_size) return { base + offset, m }; }
Второе — транспонирование без копирования. Для row‑major буфера транспонированный вид — это перестановка extents и соответствующих stride. Делается на тех же примитивах:
template<class T> auto transpose_view(std::mdspan<T, dyn2d, std::layout_right> a) { // Оригинальные параметры const index_t r = a.extent(0); const index_t c = a.extent(1); // Страйды для row-major: stride_row = c, stride_col = 1 const index_t stride_row = a.mapping().stride(0); const index_t stride_col = a.mapping().stride(1); // Меняем роли осей: новая "строка" шагает как старый столбец и наоборот stride_map mt{ dyn2d{c, r}, std::array<index_t,2>{stride_col, stride_row} }; return std::mdspan<T, dyn2d, std::layout_stride>{ a.data_handle(), mt }; }
Через layout_stride удобно выражать вырожденные срезы и виды на кусок памяти, где нет непрерывности по строке.
Где взять срезы сейчас и что с submdspan
Отдельная свободная функция submdspan(x, slices...) позволяет выдать view на поддиапазон любого ранга: фиксировать индекс, задавать полуинтервалы, шаги. По идее она шла вместе с mdspan в ранних ревизиях P0009, но в финал C++23 не вошла. Консенсусный вариант попал в C++26, поверх него в 2024 году дорабатывали специфику pair‑like типов и детали совместимости с padded‑layout. На C++23 сегодня либо пишут тонкие адаптеры через layout_stride, как выше, либо используют промежуточные реализации из библиотек
Компиляторная и библиотечная поддержка эволюционирует: libstdc++ и libc++ несут <mdspan> в стандартной библиотеке под C++23, а submdspan появляется вместе с C++26. Отдельные вендоры предоставляли экспериментальные реализации в пространствах имён experimental, а референсная реализация лежит в репозитории Kokkos mdspan.
Универсальные сигнатуры: принимать mdspan как параметр
Удобно принимать mdspan по значению с максимально общими параметрами. Это не копия данных, а несколько слов метаданных. Ниже пример безопасной масштабирующей операции над матрицей любого layout, любой константности и с проверкой размеров.
template<class Element, class Extents, class Layout, class Accessor> void scale_matrix(std::mdspan<Element, Extents, Layout, Accessor> a, Element alpha) { static_assert(Extents::rank() == 2); const auto r = a.extent(0), c = a.extent(1); for (std::size_t i = 0; i < r; ++i) for (std::size_t j = 0; j < c; ++j) a(i, j) *= alpha; } // Пример использования void demo(std::vector<float>& buf, std::size_t rows, std::size_t cols) { std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right> M{buf.data(), rows, cols}; // Проверка буфера if (M.mapping().required_span_size() != rows * cols) throw std::runtime_error("size mismatch"); scale_matrix(M, 0.5f); }
Такой стиль дружит с inlining и не заставляет выносить логику в шаблон параметров layout»а. При этом вы свободно комбинируете row‑major, column‑major и strided виды.
Собственная политика доступа: встраиваем проверки
По дефолту accessor это просто доступ к T*. Но интерфейс AccessorPolicy позволяет внедрить проверку доступа и затем использовать её на любом layout. Требования к AccessorPolicy прописаны в стандарте: нужны element_type, data_handle_type, reference, offset_policy, а также методы access и offset. Ниже показан минимальный безопасный аксессор, который хранит длину доступного диапазона и проверяет выход за пределы.
#include <mdspan> #include <cassert> template<class T> struct checked_accessor { using element_type = T; using reference = T&; struct data_handle_type { T* p{}; std::size_t n{}; // доступный диапазон [0, n) }; using offset_policy = checked_accessor; constexpr reference access(const data_handle_type& dh, std::size_t i) const noexcept { assert(i < dh.n && "mdspan out of bounds"); return dh.p[i]; } constexpr data_handle_type offset(const data_handle_type& dh, std::size_t i) const noexcept { assert(i <= dh.n); return { dh.p + i, dh.n - i }; } }; template<class Extents, class Layout> auto make_checked_mdspan(typename checked_accessor<double>::data_handle_type dh, const Layout& map) { using md_t = std::mdspan<double, Extents, Layout, checked_accessor<double>>; // Предусловие: [0, map.required_span_size()) ⊆ [0, dh.n) assert(map.required_span_size() <= dh.n); return md_t{ dh, map, checked_accessor<double>{} }; }
С таким аксессором можно создавать как обычные, так и strided виды, а проверки будут централизованы и легко отключаемы при сборке без assert»ов.
Производительные штучки
Во‑первых, выбирайте layout осознанно под порядок обхода. Если внешний цикл идёт по строкам, row‑major (layout_right) уменьшит количество cache‑miss; если критично проходить по столбцам — ровно наоборот, layout_left.
Во‑вторых, для фиксированных размеров отдавайте приоритет статическим extents: тип становится конкретнее, компилятор снимает часть арифметики по stride.
В‑третьих, используйте layout_stride вместо ручной индексации при любом нестандартном memory pitch, а затем придерживайтесь линейного обхода во внутреннем цикле.
В‑четвёртых, аккуратно проверяйте размеры при создании view. Для непрерывных раскладок проверяйте rows * cols == required_span_size; для strided — что буфер покрывает [base_offset, base_offset + required_span_size). Не полагайтесь на sizeof(buf) или подобные эвристики.
Сравнение с «самодельными» view и почему стоит перейти
Раньше код выглядел так: «у меня есть T* base, есть stride0 и stride1, а дальше (base + istride0 + j*stride1)». Проблемы очевидны: отсутствует единый тип, не проверяются размеры, неявная зависимость от соглашения о раскладке, всякие constexpr‑возможности не используются.
mdspan стандартизует этот контракт: единый тип view, единая семантика вызова operator(), политики layout и accessors, понятные preconditions, возможность статически зафиксировать размерности, кросс‑платформенное поведение. Появляется возможность писать функции высшего уровня по многомерным данным без привязки к внутренней формуле offset»а. Это и была исходная мотивация авторов mdspan.
Минимальный каркас
Завершим материал компактной утилитой, которой можно пользоваться в проекте. Она инкапсулирует создание матричных view, проверки и несколько удобных представлений без копий.
#include <mdspan> #include <vector> #include <stdexcept> namespace mds { using idx = std::size_t; using dyn1 = std::extents<idx, std::dynamic_extent>; using dyn2 = std::extents<idx, std::dynamic_extent, std::dynamic_extent>; template<class T> using matrix = std::mdspan<T, dyn2, std::layout_right>; template<class T> using vector = std::mdspan<T, dyn1>; template<class T> matrix<T> as_matrix(T* data, idx rows, idx cols, idx capacity) { matrix<T> m{data, rows, cols}; const auto need = m.mapping().required_span_size(); if (need > capacity) throw std::out_of_range("as_matrix: buffer too small"); return m; } template<class T> vector<T> as_vector(T* data, idx n, idx capacity) { vector<T> v{data, n}; const auto need = v.mapping().required_span_size(); if (need > capacity) throw std::out_of_range("as_vector: buffer too small"); return v; } template<class T> auto roi(matrix<T> base, idx row0, idx col0, idx h, idx w) { using map_t = std::layout_stride::mapping<dyn2>; // stride_row и stride_col для row-major: (cols, 1) const idx sr = base.mapping().stride(0); const idx sc = base.mapping().stride(1); const idx offset = row0 * sr + col0 * sc; map_t m{ dyn2{h, w}, std::array<idx,2>{sr, sc} }; // Проверка, что ROI помещается в исходный буфер if (offset + m.required_span_size() > base.mapping().required_span_size()) throw std::out_of_range("roi: out of range"); return std::mdspan<T, dyn2, std::layout_stride>{ base.data_handle() + offset, m }; } template<class T> auto transposed(matrix<T> a) { using map_t = std::layout_stride::mapping<dyn2>; const idx sr = a.mapping().stride(0); const idx sc = a.mapping().stride(1); map_t mt{ dyn2{ a.extent(1), a.extent(0) }, std::array<idx,2>{ sc, sr } }; return std::mdspan<T, dyn2, std::layout_stride>{ a.data_handle(), mt }; } } // namespace mds
std::mdspan закрывает проблему с адресацией многомерных данных — единый контракт поверх буфера, явные extents, предсказуемые layout_right и layout_left, гибкий layout_stride, настраиваемые аксессоры и понятный путь к будущему <linalg>. Если уже внедряли, поделитесь опытом: где упростили код, какие паттерны заходят, как повели себя бенчмарки на разных раскладках, и что с миграцией на submdspan в C++26.
Если вы следили за тем, как в C++23 появился
std::mdspanдля аккуратной и безопасной работы с многомерными данными, вы наверняка оцените важность системного подхода к ресурсам и корректной индексации. Те же принципы лежат в основе базовых механизмов языка — управление памятью, обработка ошибок, копирование и перемещение объектов.В рамках курса C++ Developer. Basic мы проводим три бесплатных открытых урока, которые помогут закрепить эти фундаментальные знания:
28 августа в 19:00 — «Обработка ошибок в C++: исключения, ожидания и исключения из правил»
На занятии разберём стандартные механизмы обработки ошибок, исключения иstd::expected, а также ситуации, когда стандартные подходы не подходят.11 сентября в 20:00 — «Поддержка идиомы RAII средствами стандартной библиотеки C++»
RAII — ключевой паттерн для безопасного управления ресурсами. Рассмотрим стандартные классы и контейнеры, которые помогают автоматически освобождать память и другие ресурсы.22 сентября в 20:00 — «Чем перемещение отличается от копирования в C++?»
Поговорим о семантике перемещения и копирования объектов, почему перемещение экономит ресурсы и как правильно проектировать типы, поддерживающие move.Если вы хотите ознакомиться с множеством отзывов о курсе «C++ Developer. Basic», вы можете посетить страницу с отзывами.
ссылка на оригинал статьи https://habr.com/ru/articles/938014/
Добавить комментарий