Привет, Хабр!
В 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», вы можете посетить страницу с отзывами.