Привет, Хабр!

В 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 мы проводим три бесплатных открытых урока, которые помогут закрепить эти фундаментальные знания:

Если вы хотите ознакомиться с множеством отзывов о курсе «C++ Developer. Basic», вы можете посетить страницу с отзывами.

Комментарии (0)