C++ std::span
C++17 标准库引入了一个新的容器 std::span
,它最初在 C++ Core Guidelines 2015 版出现,实现于微软的 Guideline support library(GSL),目的是简化同类型数组的使用,解决以往 C 风格数组退化为指针并对数组类型参数的函数进行了统一。
如果让我找出一个 C 语言过度设计导致的缺陷,数组退化指针肯定当仁不让,他看似简化了代码书写但是却带来了无尽的 Bug 和安全漏洞。
由于 C 语言没有动态数组,于是 C 语言的开发者们便想到用指针代替数组,而为了普通数组能和指针式数组混用,又发明了数组的退化。然后设计出了可以计入编程史最丑陋设计 —— C 风格字符串。
为什么说数组的主动退化是丑陋的:
由于数组在定义时已经包括长度信息了,换句话说数组的长度信息不用引入额外的变量维护,但是 C 的大部分操作数组的函数不是在操作数组,而是在操作指针。它们的函数签名大多是如同这样:void oper(type* t, size_t n);
,使用时传递数组的标识符,标识符自动转换为对数组首元素取址。
这看似带来了通用性,但实际上将导致编译器无法检查 n 是否跨过了数组边界。
如果从一开始就是使用 malloc(sizeof(type)*n)
申请的内存,然后传递给 void oper(type* t, size_t n);
,这倒是无可厚非,因为 malloc
本身就返回 type*
。但是为什么对于数组类型也要使用 type*
的指针来传递?
而这本身是可以避免的:你只需要将函数签名定义为 void oper(type (t*)[n])
即可,编译器会帮助你检查数组是否存在越界(这种方法我在之前的文章 C/C++ 指针,数组与退化 中介绍过,它同时也兼容 malloc
)。
C 风格字符串就更加搞笑了,C 风格字符串使用 '\0'
作为字符串结束标志,并且广泛实现为裸指针,这不仅会导致判断字符串长度的时间复杂度是 O(n),更严重的是如果字符串处理过程出现了错误导致结尾没有 '\0'
,将导致调试异常困难(因为错误很可能不会直接表现出来,坏的字符串会和普通字符串一样被继续“正常”使用),并且会导致很多安全漏洞(如溢出)。
那么 C 风格字符串有没有好的解决方法呢?当然有,可以使用两种方法实现字符串:
- 使用结构体的第一个元素储存字符串长度,第二个元素储存字符类型的指针:这是一种不算太坏的妥协,最起码判断字符串长度的时间复杂度降为 O(1),而且相对来说更安全。
- 使用固定长度的数组来代替指针:字符串在大部分情况下都是短小的,所以你有非常合适的理由给字符串分配比实际长度更大的空间,比如说文件名,http 链接,各种 Name。我敢肯定 95% 以上的字符串都小于 1kb,很多标准也都规范了一些字符串的长度不超过 255byte。所以用固定长度的数组代替 不定长、结束标志飘渺 的字符指针是很合理的。
上述两种方法都可以做到兼容 C 的字符串库的使用,但是相对来说安全太多了(实际上 C++ 的 std::string 和其他所有兼容 C 风格字符串的实现都是上述两种方法的结合)。
正是由于数组和指针的混乱导致 C 语言所写的库造成了多少安全漏洞:如果一个 C 语言所写的库没有出现由于结束标记或者边界检查错误导致的安全漏洞,只能说明这个库用的人太少或者代码太少。另外一个事实就是,微软长期以来在使用 printf
时都会给出警告,C 的标准库后来也加入了额外的带有边界检查的字符串函数 sprintf
等。
说了这么多,本文主要是为了介绍 std::span
,一种兼容 C 风格数组的函数参数包装容器,使用 std::span
能够在一定程度上缓解数组和指针的混乱使用,保证安全性的同时保持通用性,这一切的前提是 C++ 的模板。
std::span
本质是对不同类型(要求目标对象是一个固定大小的连续内存的集合)的自动/手动特化,使得不同类型的同类操作都可以使用 std::span
来包装,达到抽象容器的目的,类似于 std::remove_reference
。
下面借用 Bjarne Stroustrup 在 CppCoreGuidelines 所写的例子来说明 std::span
的好处:
-
运行时错误转换为编译时检查
// bad void read(int* p, int n); // read max n integers into *p int a[100]; read(a, 1000); // better,调用 read 的时候可以编译期检查是否越界 void read(span<int> r) // read into the range of integers r { for (int& x : r){} // 可以非常方便的使用范围for遍历元素,也可以使用 r.size() } int a[100]; read(a); // let the compiler figure out the number of elements // bad void increment1(int* p, size_t n) // 容易出错 { for (size_t i = 0; i < n; ++i) ++p[i]; } void use1(size_t m) { const size_t n = 10; int a[n] = {}; // ... increment1(a, m); // 不慎将 n 错打为 m,或者设计者希望 m <= n // 如果在 m > n 的情况下调用increment1呢? // ... } // better void increment2(span<int> p) // 改用span { for (int& x : p) ++x; }
-
将数组作为整体打包出去而不是零散的元素
std::unique_ptr
不支持对数组进行包装,只能通过单个元素,而使用span
进行包装可以方便的传递变量所有权:// bad extern void f3(unique_ptr<int[]>, int n); void g3(int n) { f3(make_unique<int[]>(n), m); // pass ownership and size separately } // better extern void f4(vector<int>&); // separately compiled, possibly dynamically loaded extern void f4(span<int>); // separately compiled, possibly dynamically loaded void g3(int n) { vector<int> v(n); f4(v); // pass a reference, retain ownership f4(span<int>{v}); // pass a view, retain ownership }
-
复制时的边界检查
// bad,无法确定边界 void copy_n(const T* p, T* q, int n); // copy from [p:p+n) to [q:q+n) // better,边界可以自由控制 void copy(span<const T> r, span<T> r2);
-
阻止不正确的类型转换
void draw2(span<Circle>); Circle arr[10]; // ... draw2(span<Circle>(arr)); // deduce the number of elements draw2(arr); // deduce the element type and array size void draw3(span<Shape>); draw3(arr); // error: cannot convert Circle[10] to span<Shape>
编译器会拒绝隐式的类型转换,以防止出现类型错误。