在上一节(多维 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 合二为一,提供可拥有的多维数组!