引言

vector应该是c++标准库中最常用的容器了,但我此前对它的认识基本停留在“可变长数组”这个层面,初始化方式的细节、size与capacity的区别、各个修改函数的语义,其实都没有认真梳理过。

于是我写了一个vector方法的示例实验,把初始化、访问、修改三块内容分别放进三个函数里逐个验证。

在此篇文章中,我将记录下自己对vector的理解,以供思考,如有不足之处欢迎指正。

正文

初始化的多种方式

为了观察,先写了两个模板打印函数:

template<typename T>
void print(const vector<T> &vec)//T!=bool,vector<bool>被认为不属于vector容器
{
    cout << vec.size() << ':';
    for (const T &i: vec) { cout << i << " "; }
    cout << endl;
}

然后是各种初始化方式的对决:

vector<int> empty{};        //默认构造,空vector
vector<double> list{1, 2, 3, 4};//列表构造
vector ctad{'a', 'b', 'c'}; //CTAD(c++17)自动推导元素类型为char
vector<int> v1(6);          //直接初始化显式构造,6个元素默认初始化为0
vector<int> v2{6};          //列表初始化优先于直接初始化,是1个元素6

这里有一个很容易踩的坑:vector<int> v1(6)和vector<int> v2{6}外形只差一个括号,结果却是一个6元素的零值vector和一个单元素vector。 原因是列表初始化的优先级高于直接初始化,只要花括号能匹配列表构造,就不会走单参数构造函数。

实验里还验证了vector<int> v = 10;是编译不过的,10既不匹配列表构造,而单参数构造函数本身是explicit的,也不能匹配复制初始化。

类的成员默认初始化器不支持直接初始化,只能写成vector<int> v5 = {vector<int>(7)};这种复制列表初始化的形式,算是一个冷门但真实的限制。

元素访问

访问方式里,最重要的是[]与at()的区别:

cout << vec[2] << endl;//无边界检查,越界是未定义行为
cout << vec.at(3) << ' ';//有安全检查,越界抛出std::out_of_range

at()的代价是一次检查,换来的是确定的行为,我认为大多数场景下这点开销是值得的。

另外几个取大小的方法也有讲究:

cout << vec.size() << endl;//成员方法,返回无符号的size_type
cout << std::size(vec) << endl;//c++17,还能接受c风格数组
cout << std::ssize(vec) << endl;//c++20,返回有符号整型std::ptrdiff_t

size()返回无符号数这一点,在做vec.size() - 1之类的运算时很容易因为下溢出问题,所以c++20补了ssize()。

索引类型最好用std::size_t,其他整数类型在传给at()时会发生类型窄化警告。此外data()能拿到底层c风格数组的裸指针,front()和back()取首尾元素,这些和string是共通的。

大小与容量

这是vector作为动态数组最核心的机制:

vec.resize(1000);//运行时调整vector大小
vec.resize(5);   //缩小回来,但容量不一定缩
cout << vec.capacity() << endl;//容量,已分配的内存,不会小于size
vec.shrink_to_fit();//请求去除未使用的容量(非强制)
vec.reserve(10);   //重新分配容量,不改变size

我的理解是,size是装了多少东西,capacity是预留了多少空间。 vector扩容时需要重新分配内存并搬移元素,代价不小,所以提前reserve()避免反复扩容是常见的优化手段。

从之前写生命周期实验的经验看,reserve()导致的重新分配还会使所有迭代器失效,这一点和下面要说的erase类似。

增删与交换

修改操作里,最值得对比的是push_back()与emplace_back():

vec.push_back(4);//构造好元素再放入
vec.emplace_back(5);//在容器内直接构造,适合临时对象或explicit构造
vec.pop_back();//模拟出栈
vec.clear();  //清空
vec.assign(6, {1});//重新赋值为6个1

insert()和erase()都以迭代器为参数,且erase()返回被删元素的下一个位置的迭代器:

auto it = vec.insert(vec.begin(), 2);//返回指向新元素的迭代器
vec.erase(it);
vec.swap(vec2);//交换两个vector,指针级别的交换,开销很小

swap()只交换内部的指针和元信息,我认为这也是vector很多操作(包括前面的copy and swap惯用法)能高效实现的基础。

遍历与ranges

除了普通for循环,实验里还试了c++20的ranges:

template<typename T>
void print_reverse(const vector<T> &vec)
{
    for (const T &i: std::views::reverse(vec)) { cout << i << " "; }//反向视图
}

std::views::reverse()生成一个反向视图, range-based for直接就能倒着遍历,不需要手动拿reverse_iterator,写起来确实舒服。

补充要点

实验注释里记了几条容易忽略的限制:vector的元素类型不能是const(即vector<const int>不行),只能把vector本身声明为const;vector<bool>在标准库中被特殊对待,不被认为属于vector容器,应该尽量避免使用。

小结

vector的方法本身并不难记,难的是背后那一层语义:花括号与圆括号决定了列表构造还是元素构造,size与capacity决定了内存行为,insert与erase决定了迭代器的生死。把这些理顺之后,vector就从“能用的可变长数组”变成了一个行为完全可预期的工具。

标准库容器的学习方法大概都是如此,逐个方法敲一遍,比只看文档记得牢得多。

本文所用的测试代码收录于Learncpp,如有不妥之处欢迎指正。