17-13 多维 std::array

在上节课(17.12 多维C风格数组)中,我们讨论了C风格的多维数组。

// C-style 2d array
int arr[3][4] {
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 10, 11, 12 }};

但正如你所知,我们通常希望避免使用C风格数组(除非它们用于存储全局数据)。

在本节课中,我们将探讨多维数组如何与std::array配合使用。


标准库中不存在多维数组类

请注意,std::array 实现为一维数组。因此你首先应该问的是:“标准库中有多维数组类吗?”答案是...没有。真遗憾。唉。


二维 std::array

创建二维 std::array 的标准方法是创建一个模板类型参数为另一个 std::array 的 std::array。这将导致类似以下形式:

std::array<std::array<int, 4>, 3> arr {{  // note double braces
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 10, 11, 12 }}};

关于这一点有几个值得注意的“有趣”之处:

  • 初始化多维 std::array 时需要使用双花括号(具体原因将在第 17.4 节讨论——类型的 std::array 与花括号省略)。
  • 该语法冗长且不易阅读。
  • 由于模板嵌套机制,数组维度顺序被颠倒。我们期望创建一个3行4列的数组,因此arr[3][4]才是自然写法。而std::array<std::array<int, 4>, 3>的维度顺序恰好相反。

对二维std::array元素的索引操作,与对二维C风格数组的索引方式完全一致:

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

我们也可以像传递一维 std::array 那样,将二维 std::array 传递给函数:

#include <array>
#include <iostream>

template <typename T, std::size_t Row, std::size_t Col>
void printArray(const std::array<std::array<T, Col>, Row> &arr)
{
    for (const auto& arow: arr)   // get each array row
    {
        for (const auto& e: arow) // get each element of the row
            std::cout << e << ' ';

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

int main()
{
    std::array<std::array<int, 4>, 3>  arr {{
        { 1, 2, 3, 4 },
        { 5, 6, 7, 8 },
        { 9, 10, 11, 12 }}};

    printArray(arr);

    return 0;
}

image

呕。这还是二维的std::array。三维或更高维的std::array就更冗长了!


使用别名模板简化二维 std::array 的使用

在第 10.7 课——类型别名与类型别名中,我们介绍了类型别名,并指出其用途之一是简化复杂类型的使用。然而,使用常规类型别名时,必须显式指定所有模板参数。例如:

using Array2dint34 = std::array<std::array<int, 4>, 3>;

这使我们能在需要3×4二维std::array整型数组的任何地方使用Array2dint34。但请注意,我们需要为每种元素类型与维度的组合都定义一个别名!

此时正是使用别名模板alias template的绝佳时机——它允许我们将元素类型、行长度和列长度作为模板参数指定给类型别名!

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;

然后我们就可以在任何需要3×4二维整型std::array的地方使用Array2d<int, 3, 4>了。这样就完美多了!

以下是一个完整示例:

#include <array>
#include <iostream>

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;

// When using Array2d as a function parameter, we need to respecify the template parameters
template <typename T, std::size_t Row, std::size_t Col>
void printArray(const Array2d<T, Row, Col> &arr)
{
    for (const auto& arow: arr)   // get each array row
    {
        for (const auto& e: arow) // get each element of the row
            std::cout << e << ' ';

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

int main()
{
    // Define a two-dimensional array of int with 3 rows and 4 columns
    Array2d<int, 3, 4> arr {{
        { 1, 2, 3, 4 },
        { 5, 6, 7, 8 },
        { 9, 10, 11, 12 }}};

    printArray(arr);

    return 0;
}

image

注意这种写法多么简洁易用!

我们别名模板的一个巧妙之处在于,可以按任意顺序定义模板参数。由于 std::array 先指定元素类型再指定维度,我们遵循该约定。但实际可灵活定义行或列参数的顺序。鉴于 C 风格数组定义采用行优先原则,我们在此别名模板中将 Row 置于 Col 之前。

此方法同样适用于更高维度的 std::array:

// An alias template for a three-dimensional std::array
template <typename T, std::size_t Row, std::size_t Col, std::size_t Depth>
using Array3d = std::array<std::array<std::array<T, Depth>, Col>, Row>;

获取二维数组的维度长度

对于一维 std::array,我们可以使用 size() 成员函数(或 std::size())获取数组长度。但当遇到二维 std::array 时该怎么办?此时 size() 只会返回第一维的长度。

一种看似可行的(但存在潜在风险)方案是:先获取目标维度的某个元素,再对该元素调用size():

#include <array>
#include <iostream>

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;

int main()
{
    // Define a two-dimensional array of int with 3 rows and 4 columns
    Array2d<int, 3, 4> arr {{
        { 1, 2, 3, 4 },
        { 5, 6, 7, 8 },
        { 9, 10, 11, 12 }}};

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

    return 0;
}

image

要获取第一维度的长度,我们对数组调用 size() 函数。要获取第二维度的长度,我们先调用 arr[0] 获取第一个元素,再对该元素调用 size()。要获取三维数组的第三维度长度,则需调用 arr[0][0].size()。

然而上述代码存在缺陷:若除末维度外的任何维度长度为0,将导致未定义行为!

更优方案是使用函数模板,直接通过关联的非类型模板参数返回维度长度:

#include <array>
#include <iostream>

// An alias template for a two-dimensional std::array
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;

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

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

int main()
{
    // Define a two-dimensional array of int with 3 rows and 4 columns
    Array2d<int, 3, 4> arr {{
        { 1, 2, 3, 4 },
        { 5, 6, 7, 8 },
        { 9, 10, 11, 12 }}};

    std::cout << "Rows: " << rowLength(arr) << '\n'; // get length of first dimension (rows)
    std::cout << "Cols: " << colLength(arr) << '\n'; // get length of second dimension (cols)

    return 0;
}

image

这避免了任何维度长度为零时可能出现的未定义行为,因为它仅使用数组的类型信息而非实际数据。同时,若需将长度作为整数返回时,此方法也提供了便捷途径(无需静态转换,因为从 constexpr std::size_t 转换为 constexpr int 属于非窄化转换,故隐式转换完全可行)。


展平二维数组

二维及以上维度的数组存在若干挑战:

  • 定义和操作时更冗长
  • 获取首维之后的维度长度较为麻烦
  • 遍历难度逐渐增加(每个维度需额外增加一个循环)

为简化多维数组操作,可采用展平处理。展平Flattening即降低数组维度(通常降至单维)。

例如,可将包含Row行与Col列的二维数组,转换为包含Row * Col元素的单维数组。此举在单维存储下实现等效容量。

然而,由于一维数组仅有一维,我们无法将其作为多维数组操作。为解决此问题,可提供模拟多维数组的接口:该接口接受二维坐标,并将它们映射到一维数组中的唯一位置。

以下是在 C++11 或更高版本中实现该方法的示例:

#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 Row, std::size_t Col>
using ArrayFlat2d = std::array<T, Row * Col>;

// A modifiable view that allows us to work with an ArrayFlat2d using two dimensions
// This is a view, so the ArrayFlat2d being viewed must stay in scope
template <typename T, std::size_t Row, std::size_t Col>
class ArrayView2d
{
private:
    // You might be tempted to make m_arr a reference to an ArrayFlat2d,
    // 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<ArrayFlat2d<T, Row, Col>> m_arr {};

public:
    ArrayView2d(ArrayFlat2d<T, Row, Col> &arr)
        : m_arr { arr }
    {}

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

    // 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_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }
    const T& operator()(int row, int col) const { return m_arr.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_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }
//    const T& operator[](int row, int col) const { return m_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }

    int rows() const { return static_cast<int>(Row); }
    int cols() const { return static_cast<int>(Col); }
    int length() const { return static_cast<int>(Row * Col); }
};

int main()
{
    // Define a one-dimensional std::array of int (with 3 rows and 4 columns)
    ArrayFlat2d<int, 3, 4> arr {
        1, 2, 3, 4,
        5, 6, 7, 8,
        9, 10, 11, 12 };

    // Define a two-dimensional view into our one-dimensional array
    ArrayView2d<int, 3, 4> arrView { arr };

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

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

    std::cout << '\n';

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

    std::cout << '\n';

    return 0;
}

这将输出:

image

由于在C++23之前operator[]只能接受单个下标,因此存在两种替代方案:

使用operator()替代,它可接受多个下标。这允许使用[]进行单维索引,使用()进行多维索引。我们在上文选择了此方案。

让 operator[] 返回一个同样重载 operator[] 的子视图,从而实现链式操作。此方案更为复杂,且在高维度时扩展性较差。

在 C++23 中,operator[] 已扩展为支持多重下标,因此可通过重载同时处理单下标与多下标(无需再用 operator() 处理多重下标)。

相关内容:
第17.5课 通过std::reference_wrapper实现引用数组 将讲解std::reference_wrapper。


std::mdspan (C++23)

std::mdspan 是 C++23 中引入的可修改视图,为连续元素序列提供多维数组接口。所谓可修改视图,是指 std::mdspan 不仅是只读视图(如 std::string_view)——若底层元素序列为非 const 类型,则可修改其中元素。

以下示例与前例输出相同,但使用 std::mdspan 替代自定义视图:

#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 Row, std::size_t Col>
using ArrayFlat2d = std::array<T, Row * Col>;

int main()
{
    // Define a one-dimensional std::array of int (with 3 rows and 4 columns)
    ArrayFlat2d<int, 3, 4> arr {
        1, 2, 3, 4,
        5, 6, 7, 8,
        9, 10, 11, 12 };

    // 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 { arr.data(), 3, 4 };

    // 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 i=0; i < mdView.size(); ++i)
        std::cout << mdView.data_handle()[i] << ' ';
    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;
}

这应该相当简单,但有几点值得注意:

  • std::mdspan 允许我们定义任意维度的视图。
  • std::mdspan 构造函数的首个参数应为数组数据的指针。该指针可以是衰变的 C 风格数组,也可以通过 std::array 或 std::vector 的 data() 成员函数获取数据。
  • 要对 std::mdspan 进行一维索引,需先通过 data_handle() 成员函数获取数组数据指针,再进行下标操作。
  • 在C++23中,operator[]支持多重索引,因此我们使用[行, 列]而非[行][列]作为索引。

C++26将引入std::mdarray,该类型实质上将std::array与std::mdspan融合为具有所有权的多维数组!

posted @ 2026-01-13 19:14  游翔  阅读(19)  评论(0)    收藏  举报