What Are Multidimensional std::array?

A multidimensional std::array is created by nesting one std::array inside another, providing a safer alternative to multidimensional C-style arrays while retaining compile-time size information.

In the prior lesson, we discussed C-style multidimensional arrays.

    // C-style 2d array
    int grid[4][5] {
        { 10, 20, 30, 40, 50 },
        { 60, 70, 80, 90, 100 },
        { 110, 120, 130, 140, 150 },
        { 160, 170, 180, 190, 200 }};

But as you're aware, we generally want to avoid C-style arrays (unless they are being used to store global data).

In this lesson, we'll take a look at how multidimensional arrays work with std::array.

There is no standard library multidimensional array class

Note that std::array is implemented as a single-dimensional array. So the first question you should ask is, "is there a standard library class for multidimensional arrays?" And the answer is... no. Too bad.

A two-dimensional std::array

The canonical way to create a two-dimensional array of std::array is to create a std::array where the template type argument is another std::array. That leads to something like this:

    std::array<std::array<int, 5>, 4> grid {{  // note double braces
        { 10, 20, 30, 40, 50 },
        { 60, 70, 80, 90, 100 },
        { 110, 120, 130, 140, 150 },
        { 160, 170, 180, 190, 200 }}};

There are a number of "interesting" things to note about this:

  • When initializing a multidimensional std::array, we need to use double-braces (discussed in a future lesson).
  • The syntax is verbose and hard to read.
  • Because of the way templates get nested, the array dimensions are switched. We want an array with 4 rows of 5 elements, so grid[4][5] is natural. std::array<std::array<int, 5>, 4> is backwards.

Indexing a two-dimensional std::array element works just like indexing a two-dimensional C-style array:

    std::cout << grid[2][3]; // print the element in row 2, column 3

We can also pass a two-dimensional std::array to a function just like a one-dimensional std::array:

#include <array>
#include <iostream>

template <typename T, std::size_t Rows, std::size_t Cols>
void displayGrid(const std::array<std::array<T, Cols>, Rows> &grid)
{
    for (const auto& rowArray: grid)   // get each array row
    {
        for (const auto& element: rowArray) // get each element of the row
            std::cout << element << ' ';

        std::cout << '\n';
    }
}

int main()
{
    std::array<std::array<int, 5>, 4> grid {{
        { 10, 20, 30, 40, 50 },
        { 60, 70, 80, 90, 100 },
        { 110, 120, 130, 140, 150 },
        { 160, 170, 180, 190, 200 }}};

    displayGrid(grid);

    return 0;
}

Yuck. And this is for a two-dimensional std::array. A three-dimensional or higher std::array is even more verbose!

Making two-dimensional std::array easier using an alias template

In the Type aliases lesson, we noted that one of the uses of type aliases is to make complex types simpler to use. However, with a normal type alias, we must explicitly specify all template arguments. e.g.

using Grid2dint45 = std::array<std::array<int, 5>, 4>;

This allows us to use Grid2dint45 wherever we want a 4×5 two-dimensional std::array of int. But note we'd need one such alias for every combination of element type and dimensions we want to use!

This is a perfect place to use an alias template, which will let us specify the element type, row length, and column length for a type alias as template arguments!

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Rows, std::size_t Cols>
using Grid2d = std::array<std::array<T, Cols>, Rows>;

We can then use Grid2d<int, 4, 5> anywhere we want a 4×5 two-dimensional std::array of int. That's much better!

Here's a full example:

#include <array>
#include <iostream>

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Rows, std::size_t Cols>
using Grid2d = std::array<std::array<T, Cols>, Rows>;

// When using Grid2d as a function parameter, we need to respecify the template parameters
template <typename T, std::size_t Rows, std::size_t Cols>
void displayGrid(const Grid2d<T, Rows, Cols> &grid)
{
    for (const auto& rowArray: grid)   // get each array row
    {
        for (const auto& element: rowArray) // get each element of the row
            std::cout << element << ' ';

        std::cout << '\n';
    }
}

int main()
{
    // Define a two-dimensional array of int with 4 rows and 5 columns
    Grid2d<int, 4, 5> grid {{
        { 10, 20, 30, 40, 50 },
        { 60, 70, 80, 90, 100 },
        { 110, 120, 130, 140, 150 },
        { 160, 170, 180, 190, 200 }}};

    displayGrid(grid);

    return 0;
}

Note how much more concise and easy to use this is!

One neat thing about our alias template is that we can define our template parameters in whatever order we like. Since a std::array specifies element type first and then dimension, we stick with that convention. But we have the flexibility to define either Rows or Cols first. Since C-style array definitions are defined row-first, we define our alias template with Rows before Cols.

This method also scales up nicely to higher-dimensional std::array:

// An alias template for a three-dimensional std::array
template <typename T, std::size_t Rows, std::size_t Cols, std::size_t Depth>
using Grid3d = std::array<std::array<std::array<T, Depth>, Cols>, Rows>;

Getting the dimensional lengths of a two-dimensional array

With a one-dimensional std::array, we can use the size() member function (or std::size()) to get the length of the array. But what do we do when we have a two-dimensional std::array? In this case, size() will only return the length of the first dimension.

One seemingly appealing (but potentially dangerous) option is to get an element of the desired dimension, and then call size() on that element:

#include <array>
#include <iostream>

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Rows, std::size_t Cols>
using Grid2d = std::array<std::array<T, Cols>, Rows>;

int main()
{
    // Define a two-dimensional array of int with 4 rows and 5 columns
    Grid2d<int, 4, 5> grid {{
        { 10, 20, 30, 40, 50 },
        { 60, 70, 80, 90, 100 },
        { 110, 120, 130, 140, 150 },
        { 160, 170, 180, 190, 200 }}};

    std::cout << "Rows: " << grid.size() << '\n';    // get length of first dimension (rows)
    std::cout << "Cols: " << grid[0].size() << '\n'; // get length of second dimension (cols), undefined behavior if length of first dimension is zero!

    return 0;
}

In order to get the length of the first dimension, we call size() on the array. To get the length of the second dimension, we first call grid[0] to get the first element, and then call size() on that. To get the length of the third dimension of a 3-dimensional array, we would call grid[0][0].size().

However, the above code is flawed, as it will produce undefined behavior if any dimension other than the last has a length of 0!

A better option is to use a function template to return the length of the dimension directly from the associated non-type template parameter:

#include <array>
#include <iostream>

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Rows, std::size_t Cols>
using Grid2d = std::array<std::array<T, Cols>, Rows>;

// Fetch the number of rows from the Rows non-type template parameter
template <typename T, std::size_t Rows, std::size_t Cols>
constexpr int rowCount(const Grid2d<T, Rows, Cols>&) // you can return std::size_t if you prefer
{
    return Rows;
}

// Fetch the number of cols from the Cols non-type template parameter
template <typename T, std::size_t Rows, std::size_t Cols>
constexpr int colCount(const Grid2d<T, Rows, Cols>&) // you can return std::size_t if you prefer
{
    return Cols;
}

int main()
{
    // Define a two-dimensional array of int with 4 rows and 5 columns
    Grid2d<int, 4, 5> grid {{
        { 10, 20, 30, 40, 50 },
        { 60, 70, 80, 90, 100 },
        { 110, 120, 130, 140, 150 },
        { 160, 170, 180, 190, 200 }}};

    std::cout << "Rows: " << rowCount(grid) << '\n'; // get length of first dimension (rows)
    std::cout << "Cols: " << colCount(grid) << '\n'; // get length of second dimension (cols)

    return 0;
}

This avoids any undefined behavior if the length of any dimension is zero, as it only uses the type information of the array, rather than the actual data of the array. This also allows us to easily return the length as an int if we desire (no static_cast is required, as converting from a constexpr std::size_t to constexpr int is non-narrowing, so an implicit conversion is fine).

Flattening a two-dimensional array

Arrays with two or more dimensions have some challenges:

  • They are more verbose to define and work with.
  • It is awkward to get the length of dimensions greater than the first.
  • They are increasingly hard to iterate over (requiring one more loop for each dimension).

One approach to make multidimensional arrays easier to work with is to flatten them. Flattening an array is a process of reducing the dimensionality of an array (often down to a single dimension).

For example, instead of creating a two-dimensional array with Rows rows and Cols columns, we can create a one-dimensional array with Rows * Cols elements. This gives us the same amount of storage using a single dimension.

However, because our one-dimensional array only has a single dimension, we cannot work with it as a multidimensional array. To address this, we can provide an interface that mimics a multidimensional array. This interface will accept two-dimensional coordinates, and then map them to a unique position in the one-dimensional array.

Here's an example of that approach that works in C++11 or newer:

#include <array>
#include <iostream>
#include <functional>

// An alias template to allow us to define a one-dimensional std::array using two dimensions
template <typename T, std::size_t Rows, std::size_t Cols>
using GridFlat2d = std::array<T, Rows * Cols>;

// A modifiable view that allows us to work with a GridFlat2d using two dimensions
// This is a view, so the GridFlat2d being viewed must stay in scope
template <typename T, std::size_t Rows, std::size_t Cols>
class GridView2d
{
private:
    // You might be tempted to make m_grid a reference to a GridFlat2d,
    // but this makes the view non-copy-assignable since references can't be reseated.
    // Using std::reference_wrapper gives us reference semantics and copy assignability.
    std::reference_wrapper<GridFlat2d<T, Rows, Cols>> m_grid {};

public:
    GridView2d(GridFlat2d<T, Rows, Cols> &grid)
        : m_grid { grid }
    {}

    // Get element via single subscript (using operator[])
    T& operator[](int index) { return m_grid.get()[static_cast<std::size_t>(index)]; }
    const T& operator[](int index) const { return m_grid.get()[static_cast<std::size_t>(index)]; }

    // Get element via 2d subscript (using operator(), since operator[] doesn't support multiple dimensions prior to C++23)
    T& operator()(int row, int col) { return m_grid.get()[static_cast<std::size_t>(row * cols() + col)]; }
    const T& operator()(int row, int col) const { return m_grid.get()[static_cast<std::size_t>(row * cols() + col)]; }

    // in C++23, you can uncomment these since multidimensional operator[] is supported
//    T& operator[](int row, int col) { return m_grid.get()[static_cast<std::size_t>(row * cols() + col)]; }
//    const T& operator[](int row, int col) const { return m_grid.get()[static_cast<std::size_t>(row * cols() + col)]; }

    int rows() const { return static_cast<int>(Rows); }
    int cols() const { return static_cast<int>(Cols); }
    int length() const { return static_cast<int>(Rows * Cols); }
};

int main()
{
    // Define a one-dimensional std::array of int (with 4 rows and 5 columns)
    GridFlat2d<int, 4, 5> grid {
        10, 20, 30, 40, 50,
        60, 70, 80, 90, 100,
        110, 120, 130, 140, 150,
        160, 170, 180, 190, 200 };

    // Define a two-dimensional view into our one-dimensional array
    GridView2d<int, 4, 5> gridView { grid };

    // print array dimensions
    std::cout << "Rows: " << gridView.rows() << '\n';
    std::cout << "Cols: " << gridView.cols() << '\n';

    // print array using a single dimension
    for (int index=0; index < gridView.length(); ++index)
        std::cout << gridView[index] << ' ';

    std::cout << '\n';

    // print array using two dimensions
    for (int row=0; row < gridView.rows(); ++row)
    {
        for (int col=0; col < gridView.cols(); ++col)
            std::cout << gridView(row, col) << ' ';
        std::cout << '\n';
    }

    std::cout << '\n';

    return 0;
}

This prints:

Rows: 4
Cols: 5
10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190 200
10 20 30 40 50
60 70 80 90 100
110 120 130 140 150
160 170 180 190 200

Because operator[] can only accept a single subscript prior to C++23, there are two alternate approaches:

  • Use operator() instead, which can accept multiple subscripts. This lets you use [] for single index indexing and () for multiple-dimension indexing. We've opted for this approach above.
  • Have operator[] return a subview that also overloads operator[] so that you can chain operator[]. This is more complex and doesn't scale well to higher dimensions.

In C++23, operator[] was extended to accept multiple subscripts, so you can overload it to handle both single and multiple subscripts (instead of using operator() for multiple subscripts).

std::mdspan C++23

Introduced in C++23, std::mdspan is a modifiable view that provides a multidimensional array interface for a contiguous sequence of elements. By modifiable view, we mean that a std::mdspan is not just a read-only view (like std::string_view) - if the underlying sequence of elements is non-const, those elements can be modified.

The following example prints the same output as the prior example, but uses std::mdspan instead of our own custom view:

#include <array>
#include <iostream>
#include <mdspan>

// An alias template to allow us to define a one-dimensional std::array using two dimensions
template <typename T, std::size_t Rows, std::size_t Cols>
using GridFlat2d = std::array<T, Rows * Cols>;

int main()
{
    // Define a one-dimensional std::array of int (with 4 rows and 5 columns)
    GridFlat2d<int, 4, 5> grid {
        10, 20, 30, 40, 50,
        60, 70, 80, 90, 100,
        110, 120, 130, 140, 150,
        160, 170, 180, 190, 200 };

    // Define a two-dimensional span into our one-dimensional array
    // We must pass std::mdspan a pointer to the sequence of elements
    // which we can do via the data() member function of std::array or std::vector
    std::mdspan mdView { grid.data(), 4, 5 };

    // print array dimensions
    // std::mdspan calls these extents
    std::size_t rows { mdView.extents().extent(0) };
    std::size_t cols { mdView.extents().extent(1) };
    std::cout << "Rows: " << rows << '\n';
    std::cout << "Cols: " << cols << '\n';

    // print array in 1d
    // The data_handle() member gives us a pointer to the sequence of elements
    // which we can then index
    for (std::size_t index=0; index < mdView.size(); ++index)
        std::cout << mdView.data_handle()[index] << ' ';
    std::cout << '\n';

    // print array in 2d
    // We use multidimensional [] to access elements
    for (std::size_t row=0; row < rows; ++row)
    {
        for (std::size_t col=0; col < cols; ++col)
            std::cout << mdView[row, col] << ' ';
        std::cout << '\n';
    }
    std::cout << '\n';

    return 0;
}

This should be fairly straightforward, but there are a few things worth noting:

  • std::mdspan lets us define a view with as many dimensions as we want.
  • The first parameter to the constructor of std::mdspan should be a pointer to the array data. This can be a decayed C-style array, or we can use the data() member function of std::array or std::vector to get this data.
  • To index a std::mdspan in one-dimension, we must fetch the pointer to the array data, which we can do using the data_handle() member function. We can then subscript that.
  • In C++23, operator[] accepts multiple indices, so we use [row, col] as our index instead of [row][col].

C++26 will include std::mdarray, which essentially combines std::array and std::mdspan into an owning multidimensional array!

Summary

No standard multidimensional array class: The C++ standard library doesn't provide a dedicated multidimensional array class. std::array itself is single-dimensional.

Two-dimensional std::array: Created by using a std::array as the element type of another std::array, resulting in syntax like std::array<std::array<int, Cols>, Rows>. Note that dimensions appear in reverse order compared to C-style arrays.

Double-brace initialization: Multidimensional std::array initialization requires double braces: one for the outer array and one for the inner arrays.

Alias templates for convenience: Type aliases using templates (alias templates) simplify the verbose syntax of multidimensional std::array. For example, template <typename T, std::size_t Rows, std::size_t Cols> using Grid2d = std::array<std::array<T, Cols>, Rows> allows using Grid2d<int, 4, 5> instead of the full nested template syntax.

Getting dimensional lengths: The size() member function only returns the length of the first dimension. For other dimensions, use helper functions that extract values from template parameters to avoid undefined behavior with zero-length dimensions.

Flattening multidimensional arrays: Convert a multidimensional array into a one-dimensional array (with Rows * Cols elements) and provide an interface that maps multi-dimensional coordinates to single-dimensional indices. This simplifies working with the array while maintaining multi-dimensional access patterns.

std::mdspan (C++23): A modifiable view that provides a multidimensional interface for contiguous sequences. It works with one-dimensional containers like std::array and std::vector by accepting a pointer to the data and dimension information. In C++23, operator[] supports multiple indices using comma syntax: mdView[row, col].

C++26 std::mdarray: Will provide an owning multidimensional array combining the features of std::array and std::mdspan.

While std::array can be nested to create multidimensional arrays, the syntax is verbose and dimensions are backwards. Using alias templates improves usability, and flattening or using std::mdspan provides cleaner alternatives for working with multidimensional data.