前言
std::vector<int>和std::vector<int*>看起来只差一个星号,实际是两种完全不同的数据结构。前者在连续内存里存放整数本身;后者在连续内存里存放指针,指针指向的整数在别处。这个差别影响内存布局、拷贝语义、生命周期、排序行为和性能。
常见误解有三个。第一,认为vector<int*>一定比vector<int>省内存。它省的是「移动元素时的拷贝成本」,但每个元素本身要占一个指针,被指向的对象还要单独分配,总内存通常更多。第二,认为拷贝vector<int*>会复制它指向的整数。vector 的拷贝构造只复制指针值,两个 vector 指向同一批对象,这就是浅拷贝。第三,认为对vector<int*>调用std::sort会按整数值排序。默认比较的是指针地址,不是被指向的值。
本文从内存布局讲起,依次说明用法、拷贝与所有权、迭代器失效、排序与性能,最后给出一个完整示例。示例以 C++17 为准,指针大小按 64 位平台描述,具体数值由实现决定。
一、内存布局
vector<int>内部是一块连续的动态数组,每个元素就是一个int,元素之间紧挨着。v[i]直接得到整数值,取地址&v[i]得到指向该元素的指针。
vector<int*>内部的连续数组存放的是指针,每个元素占一个指针大小(64 位平台上通常是 8 字节)。指针指向的int可以在栈上、在另一块堆内存里、在另一个容器里,彼此不一定相邻。访问*v[i]需要先读出指针,再按地址访问目标,多了一次间接寻址。
| 维度 | vector<int> | vector<int*> |
|---|
| 连续存储的内容 | int值 | 指针值 |
| 元素大小 | sizeof(int) | sizeof(int*) |
| 目标整数位置 | 就在 vector 内 | vector 外,由指针指向 |
| 访问方式 | v[i] | *v[i],需先判空 |
| 内存局部性 | 好,缓存友好 | 差,目标分散 |
局部性差异在遍历时最明显。vector<int>的顺序访问几乎每次都命中缓存行;vector<int*>每访问一个元素就可能跳到另一个内存页,缓存未命中的概率高得多。对性能敏感的热点循环,这个差别往往比算法本身的影响还大。
二、基本用法
vector<int>的用法就是普通的数值容器:push_back存值,下标读写值,排序按值比较。元素之间互相独立,修改一个不影响另一个。
vector<int*>存放的是地址,因此必须先有可以取地址的对象。常见来源有三种:指向局部或全局变量的地址、指向堆上new出来的整数、指向另一个容器中的元素。存入nullptr在语法上合法,但使用前必须判空。
// 适用于 C++17
int a = 10;
int b = 20;
std::vector<int*> ptrs;
ptrs.push_back(&a);
ptrs.push_back(&b);
for (int* p : ptrs) {
if (p != nullptr) {
std::cout << *p << ' ';
}
}这段代码没有所有权转移:ptrs只是保存了a和b的地址,a、b的生命周期由它们所在的作用域决定。如果ptrs活过了a、b,里面就是悬垂指针,解引用是未定义行为。
如果确实需要 vector 拥有整数,正确的类型是std::vector<std::unique_ptr<int>>,而不是std::vector<int*>。unique_ptr会在析构时释放所指对象,拷贝被禁止,移动转移所有权,语义清晰。裸指针的vector<int*>默认不表达任何所有权,删除与否完全靠约定。
三、拷贝语义:值拷贝与浅拷贝
vector<int>的拷贝是深拷贝:新 vector 有自己的一块内存,每个整数被复制一份。修改副本不影响原对象,原对象销毁也不影响副本。这是值语义。
vector<int*>的拷贝是浅拷贝:新 vector 复制的是指针值,两个 vector 的元素指向同一批整数。通过任一 vector 修改*p,另一边的观察结果同步变化。如果其中一个 vector 负责delete这些指针,另一个 vector 里的指针立刻悬垂;如果两个都尝试delete,就是重复释放。
std::vector<int*> a;
a.push_back(new int(5));
std::vector<int*> b = a; // 浅拷贝,两个 vector 指向同一个 int
*b[0] = 6;
// 此时 *a[0] 也是 6
// 只能 delete 一次,且必须保证另一个 vector 不再使用这种共享关系不是错误,但必须有明确的约定:谁拥有、谁释放、谁只观察。工程中更推荐std::vector<std::unique_ptr<int>>(独占)或std::vector<std::shared_ptr<int>>(共享),让所有权由类型系统表达,而不是靠注释。
四、迭代器与引用的失效规则
vector 扩容时,内部缓冲区会重新分配,所有指向元素的指针、引用、迭代器都会失效。这条规则对两种 vector 都成立,但「元素」的含义不同。
对vector<int>,元素就是整数本身。扩容后,之前保存的&v[0]或v.begin()都不能再用。
对vector<int*>,元素是指针槽位。扩容后,指向槽位的指针(例如int**)和迭代器失效,但槽位里保存的指针值被复制到新缓冲区,它们指向的整数没有移动。也就是说,之前从 vector 里取出的int* p = v[0];在扩容后仍然有效,只要被指向的整数还活着。这个区别经常被混淆:失效的是「装指针的容器」,不是「指针指向的对象」。
// 适用于 C++17
std::vector<int*> v;
int x = 1;
v.push_back(&x);
int* p = v[0]; // 指向 x
v.push_back(nullptr); // 可能扩容
// p 仍然指向 x,有效;但 v.begin() 等迭代器已失效反过来,如果int*指向的是另一个vector<int>的元素,那么那个 vector 扩容时,被指向的整数会搬家,指针同样失效。判断失效时,要分清是哪一层容器发生了重新分配。
五、排序与比较
对vector<int>调用std::sort,默认的operator<比较整数值,结果符合直觉。
对vector<int*>调用std::sort,默认比较的是指针地址。地址大小与整数值毫无关系,排序结果看起来是乱的。要按被指向的值排序,必须传入自定义比较器:
std::sort(ptrs.begin(), ptrs.end(),
[](int* lhs, int* rhs) { return *lhs < *rhs; });这个比较器要求所有指针非空,否则解引用空指针是未定义行为。如果容器里可能有nullptr,要先定义空指针排在前还是后,并在比较器里显式处理。
另一个细节是:直接用内置的<比较两个不相干对象的指针,结果未指定(unspecified),并不保证构成严格全序。标准库的std::less<int*>对指针提供了全序保证,std::sort的默认比较用的正是std::less,所以默认排序本身不会违反严格弱序,只是比较的是地址而非值。自定义比较器按*lhs < *rhs比较时,如果有两个指针指向相等的值,比较器对二者都返回false,这是合法的严格弱序,排序仍可正常进行。若要求相同值保持原顺序,应使用std::stable_sort。
六、完整示例
// 适用于 C++17
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
// 值容器:排序按值,修改互不影响
std::vector<int> values = {3, 1, 4, 1, 5};
std::sort(values.begin(), values.end());
for (int v : values) {
std::cout << v << ' ';
}
std::cout << '\n'; // 1 1 3 4 5
// 指针容器:元素指向三个独立变量
int a = 30;
int b = 10;
int c = 20;
std::vector<int*> ptrs = {&a, &b, &c};
// 默认排序比较地址,结果与值无关,这里显式按值排序
std::sort(ptrs.begin(), ptrs.end(),
[](int* lhs, int* rhs) { return *lhs < *rhs; });
for (int* p : ptrs) {
std::cout << *p << ' ';
}
std::cout << '\n'; // 10 20 30
// 通过指针修改,原变量同步变化
*ptrs.front() = 99;
std::cout << a << ' ' << b << ' ' << c << '\n'; // 30 99 20
// 浅拷贝:两个 vector 指向同一批变量
std::vector<int*> copy = ptrs;
*copy.back() = 77; // copy.back() 指向 a
std::cout << a << ' ' << b << ' ' << c << '\n'; // 77 99 20
return 0;
}推演输出。values排序后是 1、1、3、4、5。ptrs初值为 30、10、20 的地址,按值排序后顺序变成 10、20、30,对应&b、&c、&a。ptrs.front()现在指向b,写入 99 后b变为 99,a仍为 30,c仍为 20,输出30 99 20。
copy与ptrs共享指针,copy.back()也指向a。写入 77 后,a变成 77,三个变量依次为 77、99、20。这验证了浅拷贝的行为:修改通过副本做,原变量同样变化。同时也说明,指针容器排序后元素顺序与变量声明顺序无关,必须按实际指向判断哪个变量会受影响。
这段示例没有堆分配,因此不需要delete。如果指针来自new,必须在合适的时机逐个释放,并确保释放后没有其他 vector 还持有同一地址。
七、如何选择
优先使用vector<int>。它内存连续、缓存友好、拷贝语义直观、不需要手动管理生命周期。只有在以下场景才考虑指针容器:元素是大型对象且需要多态、需要共享同一份数据、需要容器只保存引用而不复制对象。即使如此,也应该优先用智能指针而不是裸指针。
| 场景 | 推荐类型 |
|---|
| 保存一组整数 | std::vector<int> |
| 观察外部对象,不拥有 | std::vector<int*>或std::vector<std::reference_wrapper<int>> |
| 独占一组堆对象 | std::vector<std::unique_ptr<int>> |
| 共享一组堆对象 | std::vector<std::shared_ptr<int>> |
| 需要多态 | std::vector<std::unique_ptr<Base>> |
常见坑点
坑点一:对vector<int*>默认排序。
❌std::sort(ptrs.begin(), ptrs.end());,按地址排序,结果与整数值无关。
✅ 传入按*lhs < *rhs比较的 lambda,并处理空指针。
坑点二:保存局部变量的地址后越界使用。
❌ 函数返回vector<int*>,里面存的是函数内局部变量的地址。
✅ 保证被指向对象的生命周期覆盖 vector 的使用期,或改为按值存储。
坑点三:拷贝vector<int*>后重复释放。
❌ 两个 vector 持有同一批new出来的指针,各自delete一遍。
✅ 明确单一所有者,或改用unique_ptr/shared_ptr。
坑点四:解引用空指针。
❌ 容器里存了nullptr,遍历时直接*p。
✅ 使用前判断p != nullptr,或在比较器中定义空指针顺序。
坑点五:以为 vector 扩容会让被指向的整数失效。
❌int* p = v[0]; v.push_back(...);之后认为p一定失效。
✅ 区分「指针槽位失效」与「指向对象失效」;只有后者搬家时才悬垂。
坑点六:以为vector<int*>更省内存。
❌ 每个元素一个指针,目标还要单独分配,总内存通常更大。
✅ 小对象直接用vector<int>;只有大对象或需共享时才用指针。
坑点七:把vector<int>元素的地址长期保存。
❌int* p = &v[0]; v.push_back(1);之后继续用p,扩容后悬垂。
✅ 在容器稳定后再取地址,或改用下标/迭代器重新获取。
坑点八:把vector<int*>当成拥有资源的容器。
❌ 默认析构不会delete任何指针,堆对象全部泄漏。
✅ 用智能指针容器表达所有权,或显式编写释放逻辑。
总结
| 维度 | vector<int> | vector<int*> |
|---|
| 存储内容 | 整数值 | 指针 |
| 拷贝 | 深拷贝 | 浅拷贝 |
| 生命周期 | 由容器管理 | 由外部管理,易悬垂 |
| 排序默认行为 | 按值 | 按地址 |
| 扩容影响 | 元素搬家 | 槽位搬家,目标不动 |
| 缓存友好度 | 高 | 低 |
| 适用场景 | 通用数值序列 | 观察、共享、多态 |
两者的选择本质上是在回答「容器是否拥有数据」。拥有数据时,值容器最简单;需要引用外部对象时,指针容器可以做到,但必须同时解决生命周期和所有权问题。把这两个问题想在前面,比记住语法差异更重要。