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