多维stdarray详解

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

// C 风格二维数组  
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 {{  // 注意双层花括号  
    { 1, 2, 3, 4 },  
    { 5, 6, 7, 8 },  
    { 9, 10, 11, 12 } }};

关于上述写法,有几点值得注意:

  • 初始化多维 std::array 时必须使用双层花括号(原因见本章—— std::array 与类类型,以及花括号省略)。
  • 语法冗长且可读性差。
  • 由于模板嵌套方式,数组维度顺序被颠倒。我们期望的是“3 行 4 列”,即 arr[3][4],然而 std::array<std::array<int, 4>, 3> 的顺序恰恰相反。

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

std::cout << arr[1][2]; // 输出第 1 行第 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)       // 获取每一行  
    {  
        for (const auto& e : arow)     // 获取行内每个元素  
            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;  
}

然而,这仅是二维 std::array;三维或更高维的 std::array 语法更加冗长!

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

在 10.7 课 —— typedef 与类型别名中,我们介绍了类型别名,并指出其用途之一便是简化复杂类型。然而,普通类型别名必须显式指定所有模板实参,例如:

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

如此,我们便可在任何需要 3×4 int 二维 std::array 的地方使用 Array2dint34。但需注意:每换一种元素类型或维度组合,就需要再写一个新的别名。

这正是别名模板的用武之地。别名模板允许我们将元素类型、行数、列数作为模板形参:

// 二维 std::array 的别名模板  
template <typename T, std::size_t Row, std::size_t Col>  
using Array2d = std::array<std::array<T, Col>, Row>;

随后,凡需 3×4 int 二维 std::array 之处,皆可写 Array2d<int, 3, 4>,简洁多了!

完整示例如下:

#include <array>  
#include <iostream>  

// 二维 std::array 的别名模板  
template <typename T, std::size_t Row, std::size_t Col>  
using Array2d = std::array<std::array<T, Col>, Row>;  

// 以 Array2d 为形参的函数模板,需重新声明模板形参  
template <typename T, std::size_t Row, std::size_t Col>  
void printArray(const Array2d<T, Row, Col>& arr)  
{  
    for (const auto& arow : arr)  
    {  
        for (const auto& e : arow)  
            std::cout << e << ' ';  

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

int main()  
{  
    // 定义 3 行 4 列的二维 int 数组  
    Array2d<int, 3, 4> arr {{  
        { 1, 2, 3, 4 },  
        { 5, 6, 7, 8 },  
        { 9, 10, 11, 12 } }};  

    printArray(arr);  

    return 0;  
}

注意:别名模板的模板形参顺序可自由设定。std::array 先指定元素类型再指定维度,我们沿用此惯例;但由于 C 风格数组定义以行为先,我们也把 Row 放在 Col 之前。

该方法可平滑扩展到更高维度的 std::array:

// 三维 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>  

template <typename T, std::size_t Row, std::size_t Col>  
using Array2d = std::array<std::array<T, Col>, Row>;  

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

    std::cout << "行数: " << arr.size() << '\n';      // 取第一维长度(行)  
    std::cout << "列数: " << arr[0].size() << '\n';  // 取第二维长度(列),若第一维长度为 0 则产生未定义行为!  

    return 0;  
}

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

更稳妥的做法是通过函数模板,直接从非类型模板形参中获取维度长度:

#include <array>  
#include <iostream>  

template <typename T, std::size_t Row, std::size_t Col>  
using Array2d = std::array<std::array<T, Col>, Row>;  

// 从 Row 非类型模板形参取得行数  
template <typename T, std::size_t Row, std::size_t Col>  
constexpr int rowLength(const Array2d<T, Row, Col>&) // 如需可返回 std::size_t  
{  
    return Row;  
}  

// 从 Col 非类型模板形参取得列数  
template <typename T, std::size_t Row, std::size_t Col>  
constexpr int colLength(const Array2d<T, Row, Col>&) // 如需可返回 std::size_t  
{  
    return Col;  
}  

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

    std::cout << "行数: " << rowLength(arr) << '\n';  
    std::cout << "列数: " << colLength(arr) << '\n';  

    return 0;  
}

此方案不会因任何维度为 0 而触发未定义行为,因为它仅使用数组的类型信息而非实际数据;若需要,亦可轻松返回 int(从 constexpr std::size_t 到 constexpr int 的转换不会窄化,故隐式转换安全)。

将二维数组扁平化

二维及以上数组面临以下挑战:

  • 定义与使用语法冗长。
  • 获取除第一维外的维度长度较为繁琐。
  • 遍历困难(每多一维便需多一层循环)。

一种简化多维数组使用的策略是“扁平化”:降低数组维度(通常降至一维)。

例如,与其创建 Row 行 Col 列的二维数组,不如创建含 Row * Col 个元素的一维数组,总容量相同,却仅有一维。

然而,一维数组本身无法直接按多维方式操作。为此,可提供模拟多维接口的视图:接收二维坐标,并将其映射到一维数组的唯一位置。

下面给出一个 C++11 及以上版本均可工作的示例:

#include
#include
#include

// 别名模板:用二维尺寸定义一维 std::array
template <typename T, std::size_t Row, std::size_t Col>
using ArrayFlat2d = std::array<T, Row * Col>;

// 可修改的二维视图,使得 ArrayFlat2d 支持二维接口
// 此为视图,因此被视图的 ArrayFlat2d 必须在作用域内保持有效
template <typename T, std::size_t Row, std::size_t Col>
class ArrayView2d
{
private:
// 若将 m_arr 设为 ArrayFlat2d&,则视图不可拷贝赋值,因为引用不可重绑定。
// 使用 std::reference_wrapper 既具引用语义又支持拷贝赋值。
std::reference_wrapper<ArrayFlat2d<T, Row, Col» m_arr {};

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

// 单下标访问(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)]; }  

// 双下标访问(operator(),因 C++23 之前 operator[] 不支持多维)  
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)]; }  

// C++23 起可取消注释以下代码,使用多维 operator[]  
// 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()
{
// 定义 3 行 4 列的一维 std::array
ArrayFlat2d<int, 3, 4> arr {
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12 };

// 定义一维数组的二维视图  
ArrayView2d<int, 3, 4> arrView{ arr };  

// 打印数组维度  
std::cout << "行数: " << arrView.rows() << '\n';  
std::cout << "列数: " << arrView.cols() << '\n';  

// 一维方式打印  
for (int i = 0; i < arrView.length(); ++i)  
    std::cout << arrView[i] << ' ';  
std::cout << '\n';  

// 二维方式打印  
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;  

}

程序输出:

Rows: 3
Cols: 4
1 2 3 4 5 6 7 8 9 10 11 12
1 2 3 4
5 6 7 8
9 10 11 12

由于 C++23 之前 operator[] 仅支持单下标,有两种替代方案:

  • 使用 operator(),可接受多个下标,从而 [] 用于一维索引,() 用于多维索引。上文即采用此方案。
  • 让 operator[] 返回子视图,该子视图亦重载 operator[],以支持链式 [][]。此方案更复杂,且难以扩展到更高维度。

C++23 起,operator[] 已支持多下标,可重载以同时处理单下标与多下标(无需再用 operator())。

相关内容

std::reference_wrapper 相关内容见 17.5 课 —— 通过 std::reference_wrapper 实现引用数组。

C++23 的 std::mdspan

C++23 引入的 std::mdspan 是一个可修改的视图,为连续元素序列提供多维数组接口。所谓“可修改视图”是指:若底层元素序列本身非 const,则可修改元素(不同于只读视图 std::string_view)。

以下示例与前例输出一致,但改用 std::mdspan:

#include
#include
#include

// 别名模板:以二维尺寸定义一维 std::array
template <typename T, std::size_t Row, std::size_t Col>
using ArrayFlat2d = std::array<T, Row * Col>;

int main()
{
// 定义 3 行 4 列的一维 std::array
ArrayFlat2d<int, 3, 4> arr {
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12 };

// 定义二维 span 视图  
// 需向 std::mdspan 构造函数传入数组数据指针  
// 可通过 std::array 或 std::vector 的 data() 成员函数获得  
std::mdspan mdView{ arr.data(), 3, 4 };  

// 打印数组维度  
// std::mdspan 称之为 extents  
std::size_t rows{ mdView.extents().extent(0) };  
std::size_t cols{ mdView.extents().extent(1) };  
std::cout << "行数: " << rows << '\n';  
std::cout << "列数: " << cols << '\n';  

// 一维方式打印  
// data_handle() 成员返回序列指针,可直接索引  
for (std::size_t i = 0; i < mdView.size(); ++i)  
    std::cout << mdView.data_handle()[i] << ' ';  
std::cout << '\n';  

// 二维方式打印  
// C++23 起可使用多维 [] 访问元素  
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[] 支持多索引,故使用 [row, col] 而非 [row][col]。
  • C++26 将引入 std::mdarray,实质上将 std::array 与 std::mdspan 合二为一,提供可拥有的多维数组!

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"