概述
std::array是在c++11标准中增加的stl容器,它的设计目的是提供与原生数组类似的功能与性能。也正因此,使得std::array有很多与其他容器不同的特殊之处,比如:std::array的元素是直接存放在实例内部,而不是在堆上分配空间;std::array的大小必须在编译期确定;std::array的构造函数、析构函数和赋值操作符都是编译器隐式声明的……这让很多用惯了std::vector这类容器的程序员不习惯,觉得std::array不好用。但实际上,std::array的威力很可能被低估了。在这篇文章里,我会从各个角度介绍下std::array的用法,希望能带来一些启发。
c/c++开发分享详解C++语言中std::array的神奇用法的代码都在c++17环境下编译运行。当前主流的g++版本已经能支持c++17标准,但是很多版本(如gcc 7.3)的c++17特性不是默认打开的,需要手工添加编译选项-std=c++17。
自动推导数组大小
很多项目中都会有类似这样的全局数组作为配置参数:
uint32_t g_cfgpara[] = {1, 2, 5, 6, 7, 9, 3, 4};
当程序员想要使用std::array替换原生数组时,麻烦来了:
array<uint32_t, 8> g_cfgpara = {1, 2, 5, 6, 7, 9, 3, 4}; // 注意模板参数“8”
程序员不得不手工写出数组的大小,因为它是std::array的模板参数之一。如果这个数组很长,或者经常增删成员,对数组大小的维护工作恐怕不是那么愉快的。有人要抱怨了:std::array的声明用起来还没有原生数组方便,选它干啥?
但是,这个抱怨只该限于c++17之前,c++17带来了类模板参数推导特性,你不再需要手工指定类模板的参数:
array g_cfgpara = {1, 2, 5, 6, 7, 9, 3, 4}; // 数组大小与成员类型自动推导
看起来很美好,但很快就会有人发现不对头:数组元素的类型是什么?还是std::uint32_t吗?
有人开始尝试只提供元素类型参数,让编译器自动推导长度,遗憾的是,它不会奏效。
array<uint32_t> g_cfgpara = {1, 2, 5, 6, 7, 9, 3, 4}; // 编译错误
好吧,暂时看起来std::array是不能像原生数组那样声明。下面我们来解决这个问题。
用函数返回std::array
问题的解决思路是用函数模板来替代类模板——因为c++允许函数模板的部分参数自动推导——我们可以联想到std::make_pair、std::make_tuple这类辅助函数。巧的是,c++标准真的在ts v2试验版本中推出过std::make_array,然而因为类模板参数推导的问世,这个工具函数后来被删掉了。
但显然,用户的需求还是存在的。于是在c++20中,又新增了一个辅助函数std::to_array。
别被c++20给吓到了,这个函数的代码其实很简单,我们可以把它拿过来定义在自己的c++17代码中[1]。
template<typename r, typename p, size_t n, size_t... i> constexpr array<r, n> to_array_impl(p (&a)[n], std::index_sequence<i...>) noexcept { return { {a[i]...} }; } template<typename t, size_t n> constexpr auto to_array(t (&a)[n]) noexcept { return to_array_impl<std::remove_cv_t<t>, t, n>(a, std::make_index_sequence<n>{}); } template<typename r, typename p, size_t n, size_t... i> constexpr array<r, n> to_array_impl(p (&&a)[n], std::index_sequence<i...>) noexcept { return { {move(a[i])...} }; } template<typename t, size_t n> constexpr auto to_array(t (&&a)[n]) noexcept { return to_array_impl<std::remove_cv_t<t>, t, n>(move(a), std::make_index_sequence<n>{}); }
细心的朋友会注意到,上面这个定义与c++20的推荐实现有所差异,这是有目的的。稍后我会解释这么干的原因。
现在让我们尝试下用新方法解决老问题:
auto g_cfgpara = to_array<int>({1, 2, 5, 6, 7, 9, 3, 4}); // 类型不是uint32_t?
不对啊,为什么元素类型不是原来的std::uint32_t?
这是因为模板参数推导对std::initializer_list的元素拒绝隐式转换,如果你把to_array的模板参数从int改为uint32_t,会得到如下编译错误:
d:worksource_codesmyprogramvscodemain.cpp:51:61: error: no matching function for call to 'to_array<uint32_t>(<brace-enclosed initializer list>)' auto g_cfgpara = to_array<uint32_t>({1, 2, 5, 6, 7, 9, 3, 4}); d:worksource_codesmyprogramvscodemain.cpp:34:16: note: candidate: 'template<class t, long long unsigned int n> constexpr auto to_array(t (&)[n])' constexpr auto to_array(t (&a)[n]) noexcept ^~~~~~~~ d:worksource_codesmyprogramvscodemain.cpp:34:16: note: template argument deduction/substitution failed: d:worksource_codesmyprogramvscodemain.cpp:51:61: note: mismatched types 'unsigned int' and 'int' auto g_cfgpara = to_array<uint32_t>({1, 2, 5, 6, 7, 9, 3, 4}); d:worksource_codesmyprogramvscodemain.cpp:46:16: note: candidate: 'template<class t, long long unsigned int n> constexpr auto to_array(t (&&)[n])' constexpr auto to_array(t (&&a)[n]) noexcept ^~~~~~~~ d:worksource_codesmyprogramvscodemain.cpp:46:16: note: template argument deduction/substitution failed: d:worksource_codesmyprogramvscodemain.cpp:51:61: note: mismatched types 'unsigned int' and 'int'
hoho,有点惨是不,绕了一圈回到原点,还是不能强制指定类型。
这个时候,之前针对std::array做的修改派上用场了:我给to_array_impl增加了一个模板参数,让输入数组的元素和返回std::array的元素用不同的类型参数表示,这样就给类型转换带来了可能。为了实现转换到指定的类型,我们还需要添加两个工具函数:
template<typename r, typename p, size_t n> constexpr auto to_typed_array(p (&a)[n]) noexcept { return to_array_impl<r, p, n>(a, std::make_index_sequence<n>{}); } template<typename r, typename p, size_t n> constexpr auto to_typed_array(p (&&a)[n]) noexcept { return to_array_impl<r, p, n>(move(a), std::make_index_sequence<n>{}); }
这两个函数和to_array的区别是:它带有3个模板参数:第一个是要返回的std::array的元素类型,后两个和to_array一样。这样我们就可以通过指定第一个参数来实现定制std::array元素类型了。
auto g_cfgpara = to_typed_array<uint32_t>({1, 2, 5, 6, 7, 9, 3, 4}); // 自动把元素转换成uint32_t
这段代码可以编译通过和运行,但是却有类型转换的编译告警。当然,如果你胆子够大,可以在to_array_impl函数中放一个static_cast来消除告警。但是编译告警提示了我们一个不能忽视的问题:如果万一输入的数值溢出了怎么办?
auto g_a = to_typed_array<uint8_t>({256, -1}); // 数字超出uint8_t范围
编译器还是一样的会让你编译通过和运行,g_a中的两个元素的值将分别为0和255。如果你不明白为什么这两个值和入参不一样,你该复习下整型溢出与回绕的知识了。
显然,这个方案还不完美。但我们可以继续改进。
编译期字面量数值合法性校验
首先能想到的做法是在to_array_impl函数中放入一个if判断之类的语句,对于超出目标数值范围的输入抛出异常或者做其他处理。这当然可行,但要注意的是这些工具函数是可以在运行期调用的,对于这种常用的基础函数来说,性能至关重要。一旦在里面加入了错误判断,意味着运行时的每一次调用性能都会下降。
理想的设计是:只有在编译期生成的数组才进行校验,并且报编译错误。但运行时调用函数时不要加入任何校验。
可惜的是,至少在c++20之前,没有办法指定函数只允许在编译期执行[2]。那有没有其他手段呢?
熟悉c++的人知道:c++的编译期处理大多可以用模板的trick来完成——因为模板参数一定是编译期常量。因此我们可以用模板参数来完成编译期处理——只要把数组元素全部作为模板的非类型参数就可以了。当然,这里有个问题:模板的非类型参数的类型怎么确定?正好c++17提供了auto模板参数的功能,可以派上用场:
template<typename t> constexpr void checkintranges() noexcept {} // 用于终结递归 template<typename t, auto m, auto... n> constexpr void checkintranges() noexcept { // 防止无符号与有符号比较 static_assert(!((std::numeric_limits<t>::min() >= 0) && (m < 0))); // 范围校验 static_assert((m >= std::numeric_limits<t>::min()) && (m <= std::numeric_limits<t>::max())); checkintranges<t, n...>(); } template<typename t, auto... n> constexpr auto declarearray() noexcept { checkintranges<t, n...>(); array<t, sizeof...(n)> a{{static_cast<t>(n)...}}; return a; };
注意这个函数中,所有的校验都通过static_assert完成。这就保证了校验一定只会发生在编译期,不会带来任何运行时开销。
declarearray的使用方法如下:
constexpr auto a1 = declarearray<uint8_t, 1, 2, 3, 4, 255>(); // 声明一个std::array<uint8_t, 5>,元素分别为1, 2, 3, 4, 255 static_assert(a1.size() == 5); static_assert(a1[3] == 4); auto a2 = declarearray<uint8_t, 1, 2, 3, -1>(); // 编译错误,-1超出uint8_t范围 auto a3 = declarearray<uint16_t, 1, 2, 3, 65536>(); // 编译错误,65536超出uint16_t范围
这里有一个误区需要说明:有些人可能会把declarearray声明成这样:
template<typename t, t... n> // 注意n的类型为t constexpr auto declarearray() noexcept
这么做的话,会发现对数值的校验总是能通过——因为模板参数在进入校验之前就已经被转换为t类型了。如果你的编译器不支持c++17的auto模板参数,那么可以通过使用std::uint64_t、std::int64_t这些“最大”的类型来间接达到目的。
另一点要说明的是,c++对于非类型模板参数的允许类型存在限制,declarearray的方法只能用于数组元素为基本类型的场景(至少在c++20以前如此)。但是这也足够了。如果数组的元素是自定义类型,就可以通过自定义的构造函数等方法来控制类型转换。
如果你看到这里觉得有点意思了,那就对了,后面还有更过瘾的。
编译期生成数组
c++11中新增的constexpr修饰符可以在编译期完成很多计算工作。但是一般constexpr函数只能返回单个值,一旦你想用它返回一串对象的集合,就会遇到麻烦:stl容器都有动态内存申请功能,不能作为编译期常量(至少在c++20之前如此);而原生数组作为返回值会退化为指针,导致返回悬空的指针。即使是返回数组的引用也是不行的,会产生悬空的引用。
constexpr int* func() noexcept { int a[] = {1, 2, 3, 4}; return a; // 严重错误!返回局部对象的地址 }
直到std::array的出现,这个问题才得到较好解决。std::array既可以作为编译期常量,又可以作为函数返回值。于是,它成为了编译期返回集合数据的首选。
在上面to_array等工具函数的实现中,我们已经见过了编译期返回数组是怎么做的。这里我们再大胆一点,写一个编译期冒泡排序:
template<typename t, size_t n> constexpr std::array<t, n> sort(const std::array<t, n>& numbers) noexcept { std::array<t, n> sorted(numbers); for (int i = 0; i < n; ++i) { for (int j = n - 1; j > i; --j) { if (sorted[j] < sorted[j - 1]) { t t = sorted[j]; sorted[j] = sorted[j - 1]; sorted[j - 1] = t; } } } return sorted; } int main() { constexpr std::array<int, 4> before{4, 2, 3, 1}; constexpr std::array<int, 4> after = sort(before); static_assert(after[0] == 1); static_assert(after[1] == 2); static_assert(after[2] == 3); static_assert(after[3] == 4); return 0; }
因为整个排序算法都是在编译期完成,所以我们没有必要太关注冒泡排序的效率问题。当然,只要你愿意,完全可以写出一个编译期快速排序——毕竟constexpr函数也可以在运行期使用,不好说会不会有哪个憨憨在运行时调用它。
在编写constexpr函数时,有两点需要注意:
1.constexpr函数中不能调用非constexpr函数。因此在交换元素时不能用std::swap,排序也不能直接调用std::sort。
2. 传入的数组是constexpr的,因此参数类型必须加上const,也不能对数据进行就地排序,必须返回一个新的数组。
虽然限制很多,但编译期算法的好处也是巨大的:如果运算中有数组越界等未定义行为,编译将会失败。相比起运行时的测试,编译期测试constexpr函数能有效的提前拦截问题。而且只要编译通过就意味着测试通过,比起额外跑白盒测试用例方便多了。
上面的一大串static_assert语句让人看了不舒服。这么写的原因是std::array的operator==函数并非constexpr(至少在c++20前如此)。但是我们也可以自己定义一个模板函数用于判断两个数组是否相等:
template<typename t, typename u, size_t m, size_t n> constexpr bool equalsimpl(const t& lhs, const u& rhs) { static_assert(m == n); for (size_t i = 0; i < m; ++i) { if (lhs[i] != rhs[i]) { return false; } } return true; } template<typename t, typename u> constexpr bool equals(const t& lhs, const u& rhs) { return equalsimpl<t, u, size(lhs), size(rhs)>(lhs, rhs); } template<typename t, typename u, size_t n> constexpr bool equals(const t& lhs, const u (&rhs)[n]) { return equalsimpl<t, const u (&)[n], size(lhs), n>(lhs, rhs); } int main() { constexpr std::array<int, 4> before{4, 2, 3, 1}; constexpr std::array<int, 4> after = sort(before); static_assert(equals(after, {1, 2, 3, 4})); // 比较std::array和原生数组 static_assert(!equals(before, after)); // 比较两个std::array return 0; }
我们定义的equals比std::array的比较运算符更强大,甚至可以在std::array和原生数组之间进行比较。
对于equals有两点需要说明:
1.std::size是c++17提供的工具函数,对各种容器和数组都能返回其大小。当然,这里的equals只会允许编译期确定大小的容器传入,否则触发编译失败。
2.equals定义了两个版本,这是被c++的一个限制所逼的迫不得已:c++禁止{…}这种std::initializer_list字面量被推导为模板参数类型,因此我们必须提供一个版本声明参数类型为数组,以便{1, 2, 3, 4}这种表达式能作为参数传进去。
编译期排序是一个启发性的尝试,我们可以用类似的方法生成其他的编译期集合常量,比如指定长度的自然数序列:
template<typename t, size_t n> constexpr auto naturalnumbers() noexcept { array<t, n> arr{0}; // 显式初始化不能省 for (size_t i = 0; i < n; ++i) { arr[i] = i + 1; } return arr; } int main() { constexpr auto arr = naturalnumbers<uint32_t, 5>(); static_assert(equals(arr, {1, 2, 3, 4, 5})); return 0; }
这段代码的编译运行都没有问题,但它并不是推荐的做法。原因是在naturalnumbers函数中,先定义了一个内容全0的局部数组,然后再挨个修改它的值,这样没有直接返回指定值的数组效率高。有人会想能不能把arr的初始化给去掉,但这样会导致编译错误——constexpr函数中不允许定义没有初始化的局部变量。
可能有人觉得这些计算都是编译期完成的,对运行效率没影响——但是不要忘了constexpr函数也可以在运行时调用。更好的做法可以参见前面to_array函数的实现,让数组的初始化一气呵成,省掉挨个赋值的步骤。
我们用这个新思路,写一个通用的数组生成器,它可以接受一个函数对象作为参数,通过调用这个函数对象来生成数组每个元素的值。下面的代码还演示了下如何用这个生成器在编译期生成奇数序列和斐波那契数列。
template<typename t> constexpr t oddnumber(size_t i) noexcept { return i * 2 + 1; } template<typename t> constexpr t fibonacci(size_t i) noexcept { if (i <= 1) { return 1; } return fibonacci<t>(i - 1) + fibonacci<t>(i - 2); } template<typename t, size_t n, typename f, size_t... i> constexpr array<std::remove_cv_t<t>, n> generatearrayimpl(f f, std::index_sequence<i...>) noexcept { return { {f(i)...} }; } template<size_t n, typename f, typename t = invoke_result_t<f, size_t>> constexpr array<t, n> generatearray(f f) noexcept { return generatearrayimpl<t, n>(f, std::make_index_sequence<n>{}); } int main() { constexpr auto oddnumbers = generatearray<5>(oddnumber<uint8_t>); static_assert(equals(oddnumbers, {1, 3, 5, 7, 9})); constexpr auto fibonumbers = generatearray<5>(fibonacci<uint32_t>); static_assert(equals(fibonumbers, {1, 1, 2, 3, 5})); // 甚至可以传入lambda来定制要生成的数字序列(限定c++17) constexpr auto specified = generatearray<3>([](size_t i) { return i + 10; }); static_assert(equals(specified, {10, 11, 12})); return 0; }
最后那个传入lambda来定制数组的做法存在一个疑问:lambda是constexpr函数吗?答案为:可以是,但需要c++17支持。
generatearray这个数组生成器将会在后面发挥重大作用,继续往下看。
截取子数组
std::array并未提供输入一个指定区间来建立新容器的构造函数,但是借助上面的数组生成器,我们可以写个辅助函数来实现子数组生成操作(这里再次用上了lambda函数作为生成算法)。
template<size_t n, typename t> constexpr auto subarray(t&& t, size_t base) noexcept { return generatearray<n>([base, t = forward<t>(t)](size_t i) { return t[base + i]; }); } template<size_t n, typename t, size_t m> constexpr auto subarray(const t (&t)[m], size_t base) noexcept { return generatearray<n>([base, &t](size_t i) { return t[base + i]; }); } int main() { // 以std::initializer_list字面量为原始数据 constexpr auto x = subarray<3>({1, 2, 3, 4, 5, 6}, 2); // 下标为2开始,取3个元素 static_assert(equals(x, {3, 4, 5})); // 以std::array为原始数据 constexpr auto x1 = subarray<2>(x, 1); // 下标为1开始,取2个元素 static_assert(equals(x1, {4, 5})); // 以原生数组为原始数据 constexpr uint8_t a[] = {9, 8, 7, 6, 5}; constexpr auto y = subarray<2>(a, 3); static_assert(equals(y, {6, 5})); // 下标为3开始,取2个元素 // 以字符串为原始数据,注意生成的数组不会自动加上' ' constexpr const char* str = "hello world!"; constexpr auto z = subarray<5>(str, 6); static_assert(equals(z, {'w', 'o', 'r', 'l', 'd'})); // 下标为6开始,取5个元素 // 以std::vector为原始数据,非编译期计算 vector<int32_t> v{10, 11, 12, 13, 14}; size_t n = 2; auto d = subarray<3>(v, n); // 运行时生成数组 assert(equals(d, {12, 13, 14})); // 注意不能用static_assert,不是编译期常量 return 0; }
使用subarray时,模板参数n是要截取的子数组大小,入参t是任意能支持下标操作的类型,入参base是截取元素的起始位置。由于std::array的大小在编译期是确定的,因此n必须是编译期常量,但参数base可以是运行时变量。
当所有入参都是编译期常量时,生成的子数组也是编译期常量。
subarray提供了两个版本,目的也是为了让std::initializer_list字面量可以作为参数传入。
拼接多个数组
采用类似的方式可以做多个数组的拼接,这里同样用了lambda作为生成函数。
template<typename t> constexpr auto totallength(const t& arr) noexcept { return size(arr); } template<typename p, typename... t> constexpr auto totallength(const p& p, const t&... arr) noexcept { return size(p) + totallength(arr...); } template<typename t> constexpr auto pickelement(size_t i, const t& arr) noexcept { return arr[i]; } template<typename p, typename... t> constexpr auto pickelement(size_t i, const p& p, const t&... arr) noexcept { if (i < size(p)) { return p[i]; } return pickelement(i - size(p), arr...); } template<typename... t> constexpr auto concatarrays(const t&... arr) noexcept { return generatearray<totallength(arr...)>([&arr...](size_t i) { return pickelement(i, arr...); }); } int main() { constexpr int32_t a[] = {1, 2, 3}; // 原生数组 constexpr auto b = to_typed_array<int32_t>({4, 5, 6}); // std::array constexpr auto c = declarearray<int32_t, 7, 8>(); // std::array constexpr auto x = concatarrays(a, b, c); // 把3个数组拼接在一起 static_assert(equals(x, {1, 2, 3, 4, 5, 6, 7, 8})); return 0; }
和之前一样,concatarrays使用了模板参数来同时兼容原生数组和std::array,它甚至可以接受任何编译期确定长度的自定义类型参与拼接。
concatarrays函数因为可变参数的语法限制,没有再对std::initializer_list字面量进行适配,这导致std::initializer_list字面量不能再直接作为参数:
constexpr auto x = concatarrays(a, {4, 5, 6}); // 编译错误
但是我们有办法规避这个问题:利用前面介绍过的工具把std::initializer_list先转成std::array就可以了:
constexpr auto x = concatarrays(a, to_array({4, 5, 6})); // ok
编译期拼接字符串
std::array适合用来表示字符串么?回答这个问题前,我们先看看原生数组是否适合表示字符串:
char str[] = "abc"; // str数组大小为4,包括结尾的' '
上面是很常见的写法。由于数组名可退化为指针,str可用于各种需要字符串的场合,如传给cout打印输出。
std::array作为对原生数组的替代,自然也适合用来表示字符串。有人可能会觉得std::array没法直接作为字符串类型使用,不太方便。但实际上只要调用data方法,std::array就会返回能作为字符串使用的指针:
constexpr auto str = to_array("abc"); // to_array可以将字符串转换为std::array static_assert(str.size() == 4); static_assert(equals(str, "abc")); // equals也可以接受字符串字面量 cout << str.data(); // 打印字符串内容
由于字符串字面量是char[]类型,因此前面所编写的工具函数,都可以将字符串作为输入参数。上面的equals只是其中一个例子。
那之前写的数组拼接函数concatarrays能用于拼接字符串么?能,但结果和我们想的有差异:
constexpr auto str = concatarrays("abc", "def"); static_assert(str.size() == 8); // 长度不是7? static_assert(equals(str, {'a', 'b', 'c', ' ', 'd', 'e', 'f', ' '}));
因为每个字符串结尾都有’