C++ string 和 vector 基础:从常用接口到模拟实现
前言
学到 STL 的时候,我一开始最容易犯的错误是:把 string 和 vector 当成一堆接口来背。
比如 push_back 是尾插,size 是元素个数,begin 是起始迭代器。这些当然要记,但只背接口很快就会遇到两个问题:
- 写代码时知道函数名,却不知道什么时候用
reserve,什么时候用resize。 - 遍历、插入、删除一混起来,就容易踩迭代器失效这种坑。
- 看模拟实现时,才发现
string和vector背后其实都绕不开内存管理、拷贝控制和对象生命周期。
所以这篇文章不打算按课件接口表逐项复述,而是按我自己复习时更顺的一条线来整理:
text
先会用 string 和 vector。
再理解 size、capacity、iterator 这些概念。
最后通过简单模拟实现,看清楚它们为什么不能只当普通数组用。
内容范围主要围绕当前 08.string.pdf 和 09.vector.pdf 两份课件:auto、范围 for、string 常用接口、vector 常用接口、容量增长、迭代器失效、string/vector 的模拟实现、深浅拷贝、memcpy 拷贝问题和动态二维数组。
这篇先不展开 list、deque、map、空间配置器、完整标准库源码这些内容。它们当然重要,但现在更关键的是先把这两个最常用的顺序容器用明白。
文章目录
[1. 先把 string 和 vector 放到同一条线](#1. 先把 string 和 vector 放到同一条线)
[2. string 为什么比 C 字符串更适合日常使用](#2. string 为什么比 C 字符串更适合日常使用)
[3. auto 和范围 for:让遍历少写一点,也少错一点](#3. auto 和范围 for:让遍历少写一点,也少错一点)
[4. string 常用接口:构造、容量、访问和修改](#4. string 常用接口:构造、容量、访问和修改)
[5. find、substr、getline 和 c_str 怎么用](#5. find、substr、getline 和 c_str 怎么用)
[6. vector 可以先理解成会扩容的数组](#6. vector 可以先理解成会扩容的数组)
[7. vector 的构造、遍历和增删查改](#7. vector 的构造、遍历和增删查改)
[8. size、capacity、reserve、resize 分清楚](#8. size、capacity、reserve、resize 分清楚)
[9. 迭代器失效:看起来还能跑,不代表代码没问题](#9. 迭代器失效:看起来还能跑,不代表代码没问题)
[10. erase 删除元素的正确写法](#10. erase 删除元素的正确写法)
[11. 从模拟实现 string 看深浅拷贝](#11. 从模拟实现 string 看深浅拷贝)
[12. 从模拟实现 vector 看三指针模型](#12. 从模拟实现 vector 看三指针模型)
[13. 为什么 vector 的 reserve 不能随便用 memcpy](#13. 为什么 vector 的 reserve 不能随便用 memcpy)
[14. 动态二维数组怎么理解](#14. 动态二维数组怎么理解)
[15. 常见坑放一起看](#15. 常见坑放一起看)
[16. 复习时怎么串起来](#16. 复习时怎么串起来)
一、先把 string 和 vector 放到同一条线
string 和 vector 看起来一个处理字符串,一个处理数组,表面上不是一个东西。
但如果从使用方式看,它们有很多相似点:
| 对比点 | string |
vector |
|---|---|---|
| 管理的内容 | 一段字符序列 | 一段同类型元素序列 |
| 底层特点 | 通常会管理一段连续字符空间 | 管理一段连续元素空间 |
| 常见操作 | 构造、遍历、尾插、查找、截取 | 构造、遍历、尾插、插入、删除 |
| 容量相关 | size、capacity、reserve、resize |
size、capacity、reserve、resize |
| 需要注意 | 字符串结束、c_str、扩容 |
扩容、迭代器失效、元素搬移 |
| 模拟实现重点 | 深拷贝、析构、赋值 | 三指针、扩容、对象拷贝 |
我现在更愿意把它们放在同一条线上理解:
text
string 是管理字符序列的类。
vector 是管理连续元素序列的类。
它们都帮我们把"空间申请、空间扩容、元素访问、对象释放"这些细节包装起来。平时用的时候不用自己手动 new[],但如果完全不知道底层大概怎么做,一到插入、删除、拷贝这些场景,还是很容易写错。
这也是为什么学完类和对象、内存管理之后,再看 string 和 vector 会顺很多。前面的知识不是孤立的,到了 STL 容器这里正好串起来。
二、string 为什么比 C 字符串更适合日常使用
C 语言里的字符串本质上是一段以 '\0' 结尾的字符数组。
cpp
char name[] = "hello";
这种写法很直接,但也有几个麻烦点:
- 字符串长度需要自己判断。
- 空间大小需要自己控制。
- 拼接、查找、拷贝通常要调用一组
str系列函数。 - 如果边界没处理好,容易越界。
比如下面这种代码,写起来就比较累:
cpp
#include <cstring>
#include <iostream>
int main() {
char buffer[32] = "hello";
std::strcat(buffer, " cpp");
std::cout << buffer << '\n';
return 0;
}
这段代码不是不能用,而是调用者要一直记着 buffer 到底够不够放。字符串内容和字符串操作是分开的,空间也要自己盯着。
换成 std::string 后,代码会自然很多:
cpp
#include <iostream>
#include <string>
int main() {
std::string text = "hello";
text += " cpp";
std::cout << text << '\n';
std::cout << text.size() << '\n';
return 0;
}
这里 text 不只是一个字符数组,而是一个对象。它内部会记录字符串长度,也会管理需要的空间。
所以我觉得理解 string 时,先不要把它看成"更好用的 char*"。更准确一点说:
text
string 把字符序列和操作规则封装成了一个类。
这句话和前面学类和对象时的封装是能接上的。string 的价值不是语法变短而已,它还把长度、容量、拷贝、析构这些事情收进了对象内部。
三、auto 和范围 for:让遍历少写一点,也少错一点
课件在讲 string 前补了两个 C++11 语法:auto 和范围 for。它们不是只为 string 服务,后面遍历 vector、map 这些容器都会用到。
1. auto 是编译期类型推导
auto 的作用是让编译器根据初始化表达式推导变量类型。
cpp
#include <iostream>
#include <string>
#include <vector>
int main() {
int value = 10;
auto a = value; // int
auto p = &value; // int*
auto& r = value; // int&
std::vector<std::string> words = {"C++", "string", "vector"};
auto it = words.begin();
std::cout << *it << '\n';
return 0;
}
这里最能体现 auto 用处的是迭代器:
cpp
std::vector<std::string>::iterator it = words.begin();
和:
cpp
auto it = words.begin();
后者确实清爽很多。
不过 auto 不是"想省就随便省"。有几个基础限制要记住:
auto声明变量时必须有初始化值。- 同一行声明多个
auto变量时,推导出来的类型要一致。 - 用
auto推导引用时,要显式写auto&。 auto不能直接用来声明普通数组。
对我来说,这里最容易漏的是引用。
cpp
int x = 10;
auto y = x; // y 是 int,拷贝了一份
auto& z = x; // z 是 int&,引用 x
如果后面遍历容器时要修改元素,少写这个 &,代码可能能跑,但结果不是自己想要的。
2. 范围 for 适合遍历容器和数组
范围 for 可以让遍历写得更自然。
cpp
#include <iostream>
#include <string>
int main() {
std::string s = "hello";
for (char ch : s) {
std::cout << ch << ' ';
}
return 0;
}
如果要修改元素,要用引用:
cpp
#include <iostream>
#include <string>
int main() {
std::string s = "hello";
for (auto& ch : s) {
if ('a' <= ch && ch <= 'z') {
ch = ch - 'a' + 'A';
}
}
std::cout << s << '\n';
return 0;
}
这里的 auto& ch 表示 ch 是字符串中字符的引用。修改 ch,就是修改 s 里面对应的字符。
如果写成:
cpp
for (auto ch : s) {
ch = 'X';
}
那么 ch 只是每个字符的拷贝,改它不会影响原字符串。
范围 for 底层仍然离不开迭代器。现在先知道这一点就够了:它让遍历更短,但不是另一套完全无关的机制。
四、string 常用接口:构造、容量、访问和修改
string 的接口很多,基础阶段先抓常用的。
1. 常见构造
cpp
#include <iostream>
#include <string>
int main() {
std::string s1;
std::string s2("hello");
std::string s3(5, 'x');
std::string s4(s2);
std::cout << s1 << '\n';
std::cout << s2 << '\n';
std::cout << s3 << '\n';
std::cout << s4 << '\n';
return 0;
}
对应关系可以这样记:
| 写法 | 含义 |
|---|---|
string s1; |
构造空字符串 |
string s2("hello"); |
用 C 风格字符串构造 |
string s3(5, 'x'); |
构造 5 个 'x' |
string s4(s2); |
拷贝构造 |
构造这块不难,但后面模拟实现时会发现:拷贝构造不是简单把指针复制一份。标准库已经帮我们处理好了,所以平时用 string 才能这么自然。
2. 容量相关接口
string 里最容易混的是 size、capacity、reserve、resize。
| 接口 | 作用 | 会不会改变有效字符个数 |
|---|---|---|
size() |
返回有效字符个数 | 不改变 |
length() |
返回有效字符个数 | 不改变 |
capacity() |
返回当前容量 | 不改变 |
empty() |
判断是否为空 | 不改变 |
clear() |
清空有效字符 | 会让 size 变成 0 |
reserve(n) |
预留容量 | 通常不改变 size |
resize(n) |
改变有效字符个数 | 会改变 size |
size() 和 length() 对 string 来说基本等价。平时我更习惯用 size(),因为它和其他 STL 容器的接口风格统一。
看一个例子:
cpp
#include <iostream>
#include <string>
int main() {
std::string s = "abc";
s.reserve(20);
std::cout << s.size() << ' ' << s.capacity() << '\n';
s.resize(5, 'x');
std::cout << s << '\n';
std::cout << s.size() << ' ' << s.capacity() << '\n';
s.clear();
std::cout << s.size() << ' ' << s.capacity() << '\n';
return 0;
}
这里有三个点:
reserve(20)是预留空间,字符串内容还是"abc"。resize(5, 'x')会把有效字符个数改成 5,不够的位置用'x'填。clear()清空有效字符,但一般不会把底层容量也清掉。
reserve 更像是提前打招呼:"后面我可能要放这么多字符,你先把空间准备好。"
resize 则是直接改变字符串当前认为自己有多少个字符。
3. 访问和遍历
string 访问字符最常用的是 operator[]。
cpp
#include <iostream>
#include <string>
int main() {
std::string s = "hello";
s[0] = 'H';
std::cout << s[0] << '\n';
for (std::size_t i = 0; i < s.size(); ++i) {
std::cout << s[i] << ' ';
}
return 0;
}
也可以用迭代器:
cpp
for (auto it = s.begin(); it != s.end(); ++it) {
std::cout << *it << ' ';
}
或者范围 for:
cpp
for (auto ch : s) {
std::cout << ch << ' ';
}
初学阶段不需要把所有写法都强行混着用。我的习惯是:
- 需要下标时,用
operator[]。 - 只想从头到尾扫一遍时,用范围
for。 - 要和算法、插入删除位置配合时,再用迭代器。
4. 修改字符串
常见的尾部追加有三种:
cpp
#include <iostream>
#include <string>
int main() {
std::string s;
s.push_back('A');
s.append("BC");
s += "DE";
std::cout << s << '\n';
return 0;
}
它们的区别可以先这样理解:
| 接口 | 适合场景 |
|---|---|
push_back(ch) |
尾插一个字符 |
append(str) |
尾部追加一段字符串 |
operator+= |
追加字符或字符串,写起来最自然 |
课件里提到尾部追加时,push_back、append、+= 的实现方式差别不大。日常写代码时,我会优先用 +=,因为它读起来比较顺。
如果已经大概知道后面要拼接很多内容,可以先 reserve 一下,减少扩容次数。
cpp
std::string result;
result.reserve(100);
for (int i = 0; i < 10; ++i) {
result += "cpp";
}
这不是必须写,但在大量拼接时很有用。
五、find、substr、getline 和 c_str 怎么用
这一组接口很常见,单独放出来更好记。
1. find 和 rfind
find 从前往后找,rfind 从后往前找。找到了返回位置,找不到返回 string::npos。
cpp
#include <iostream>
#include <string>
int main() {
std::string path = "notes/cpp/string.md";
std::size_t pos = path.rfind('/');
if (pos != std::string::npos) {
std::cout << path.substr(pos + 1) << '\n';
}
return 0;
}
这段代码输出文件名部分:
text
string.md
这里不要拿 npos 当普通下标用。npos 表示没找到,它通常是一个很大的无符号数。
2. substr 截取子串
substr(pos, n) 表示从 pos 开始,截取 n 个字符。
cpp
std::string s = "hello world";
std::string first = s.substr(0, 5); // hello
std::string second = s.substr(6); // world
第二个参数不写时,会截到字符串末尾。
3. cin 和 getline
std::cin >> s 读字符串时,遇到空白字符就停。
cpp
std::string name;
std::cin >> name;
如果输入是:
text
hello world
name 只会读到 hello。
如果要读整行,就用 getline。
cpp
#include <iostream>
#include <string>
int main() {
std::string line;
std::getline(std::cin, line);
std::cout << line << '\n';
return 0;
}
有一个常见坑:如果前面刚用 cin >> n 读过数字,输入缓冲区里可能还留着换行符,后面第一次 getline 会直接读到空行。
cpp
#include <iostream>
#include <limits>
#include <string>
int main() {
int n = 0;
std::cin >> n;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::string line;
std::getline(std::cin, line);
std::cout << n << ": " << line << '\n';
return 0;
}
这里的 ignore 是把这一行剩下的内容丢掉,让后面的 getline 从下一行开始读。
4. c_str 返回 C 风格字符串
有些 C 接口需要 const char*,这时可以用 c_str()。
cpp
#include <cstdio>
#include <string>
int main() {
std::string file = "log.txt";
std::printf("%s\n", file.c_str());
return 0;
}
注意 c_str() 返回的指针由 string 对象管理。不要把它当成自己申请的空间,更不要 delete 或 free。
还有一点:如果后面修改了字符串,之前拿到的 c_str() 指针可能就不适合继续用了。
cpp
std::string s = "abc";
const char* p = s.c_str();
s += "def";
// p 不建议继续使用,重新拿一遍更稳
p = s.c_str();
对我来说,这里最重要的判断是:c_str() 只是为了和 C 风格接口衔接,不是让我们重新回到手动管理字符串的写法。
六、vector 可以先理解成会扩容的数组
vector 是顺序表风格的容器。初学时可以先把它理解成:
text
vector 是一个能自动扩容的连续数组。
它和普通数组一样,支持通过下标快速访问元素。
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> nums = {1, 2, 3};
nums.push_back(4);
nums[0] = 10;
for (std::size_t i = 0; i < nums.size(); ++i) {
std::cout << nums[i] << ' ';
}
return 0;
}
输出大概是:
text
10 2 3 4
但它又不只是普通数组。普通数组大小固定,vector 可以在元素变多时重新申请更大的空间,把旧元素搬过去,再释放旧空间。
这个自动扩容很方便,但也带来一个副作用:一旦底层空间变了,原来指向旧空间的迭代器、指针、引用就可能失效。
所以学 vector 时,不能只记 push_back 很好用,还要记住它背后可能发生扩容。
七、vector 的构造、遍历和增删查改
1. 常见构造
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> v1;
std::vector<int> v2(5, 1);
std::vector<int> v3(v2);
std::vector<int> v4(v2.begin(), v2.end());
std::cout << v1.size() << '\n';
std::cout << v2.size() << '\n';
std::cout << v3.size() << '\n';
std::cout << v4.size() << '\n';
return 0;
}
对应关系:
| 写法 | 含义 |
|---|---|
vector<int> v1; |
构造空 vector |
vector<int> v2(5, 1); |
构造 5 个值为 1 的元素 |
vector<int> v3(v2); |
拷贝构造 |
vector<int> v4(v2.begin(), v2.end()); |
用迭代器区间构造 |
用迭代器区间构造时,区间一般是左闭右开:
text
[first, last)
也就是包含 first 指向的元素,不包含 last 指向的元素。
2. 遍历方式
vector 遍历常见三种写法。
下标遍历:
cpp
for (std::size_t i = 0; i < nums.size(); ++i) {
std::cout << nums[i] << ' ';
}
迭代器遍历:
cpp
for (auto it = nums.begin(); it != nums.end(); ++it) {
std::cout << *it << ' ';
}
范围 for:
cpp
for (auto value : nums) {
std::cout << value << ' ';
}
如果要修改元素,还是那句话,用引用:
cpp
for (auto& value : nums) {
value *= 2;
}
课件里提到,vector 常见使用中,遍历很多时候直接用下标访问就够方便。这个我也比较认同,因为 vector 底层连续,operator[] 和数组访问的直觉一致。
3. 增删查改接口
常用接口可以先看这张表:
| 接口 | 作用 | 注意点 |
|---|---|---|
push_back(x) |
尾插元素 | 可能引起扩容 |
pop_back() |
尾删元素 | 空容器不能随便删 |
insert(pos, x) |
在 pos 前插入 |
可能引起扩容和迭代器失效 |
erase(pos) |
删除 pos 位置元素 |
返回删除位置后面的新迭代器 |
swap(v) |
交换两个 vector 的数据 |
通常很快 |
operator[] |
按下标访问 | 不做越界检查 |
还有一个点容易记错:find 不是 vector 的成员函数,它是算法库里的函数。
cpp
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5};
auto pos = std::find(nums.begin(), nums.end(), 3);
if (pos != nums.end()) {
nums.insert(pos, 30);
}
for (auto value : nums) {
std::cout << value << ' ';
}
return 0;
}
find 只负责在 [begin, end) 这个区间里找元素。它不关心这个区间来自 vector、string,还是别的容器。这正是迭代器的意义:让算法不用直接绑定某一种容器。
八、size、capacity、reserve、resize 分清楚
vector 的容量问题比 string 更常见,因为我们经常往里面插数据。
先看一个小例子:
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
v.reserve(10);
std::cout << v.size() << ' ' << v.capacity() << '\n';
v.resize(10);
std::cout << v.size() << ' ' << v.capacity() << '\n';
return 0;
}
这两行的含义完全不一样:
cpp
v.reserve(10);
v.resize(10);
| 操作 | 改什么 | 含义 |
|---|---|---|
reserve(10) |
改容量 | 先准备能放 10 个元素的空间,当前元素个数不变 |
resize(10) |
改有效元素个数 | 让容器真的拥有 10 个元素,不够的补默认值 |
所以 reserve 之后不能直接用下标写元素:
cpp
std::vector<int> v;
v.reserve(10);
// v[0] = 1; // 错误想法:此时 size 还是 0
v.push_back(1);
capacity 只是容量,不代表已经有这么多个元素。
vector 怎么扩容
当 vector 空间不够时,会申请更大的连续空间,把旧元素搬过去,然后释放旧空间。
课件里提到一个很重要的点:不同标准库实现的扩容倍数不一定一样。比如某些环境可能接近 1.5 倍增长,另一些环境可能接近 2 倍增长。
所以不要把"vector 一定 2 倍扩容"当成死结论。更稳的说法是:
text
vector 会按实现策略扩容,但具体增长倍数依赖标准库实现。
平时写代码时,不应该依赖某个固定倍数。如果能提前知道大概要放多少元素,可以用 reserve 减少扩容次数。
cpp
std::vector<int> nums;
nums.reserve(1000);
for (int i = 0; i < 1000; ++i) {
nums.push_back(i);
}
这个例子里,reserve(1000) 的意义不是创建 1000 个元素,而是提前准备空间,避免 push_back 过程中反复扩容。
九、迭代器失效:看起来还能跑,不代表代码没问题
迭代器可以先粗略理解成"指向容器中某个位置的对象"。对 vector 来说,迭代器通常和底层连续空间里的指针关系很近。
问题在于:vector 扩容时,底层空间可能会整体搬家。
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.push_back(4); // 可能触发扩容
// 如果扩容发生,it 还指向旧空间,继续使用就有风险
// std::cout << *it << '\n';
it = v.begin();
std::cout << *it << '\n';
return 0;
}
这类问题麻烦的地方在于:有时程序不崩,甚至输出看起来也没那么离谱,但代码已经不可靠。
可能导致 vector 迭代器失效的操作包括:
- 可能改变底层空间的操作:
reserve、resize、push_back、insert、assign等。 - 删除指定位置元素的操作:
erase。
这里要把两类情况分开:
- 扩容导致旧空间被释放,原来的迭代器指向旧空间。
- 删除元素后,元素位置发生移动,原来的迭代器语义不再可靠。
string 也有类似问题。对 string 做插入、扩容、删除后,原来保存的迭代器或 c_str() 指针都要谨慎继续使用。
复习时可以记一句简单的:
text
只要容器内部空间或元素位置可能变化,旧迭代器就要重新判断。
十、erase 删除元素的正确写法
课件里有一个很典型的问题:删除 vector 中所有偶数。
先看容易写错的版本:
cpp
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4};
auto it = v.begin();
while (it != v.end()) {
if (*it % 2 == 0) {
v.erase(it);
}
++it;
}
return 0;
}
这段代码的问题在于:erase(it) 后,it 已经不适合继续用了,后面的 ++it 就可能出问题。
正确写法是接住 erase 的返回值:
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4};
auto it = v.begin();
while (it != v.end()) {
if (*it % 2 == 0) {
it = v.erase(it);
} else {
++it;
}
}
for (auto value : v) {
std::cout << value << ' ';
}
return 0;
}
erase 删除当前位置元素后,会返回下一个有效位置的迭代器。我们把它重新赋给 it,循环就能继续往下走。
这里不要在删除后无脑 ++it。因为删除时后面的元素已经往前挪了,如果再加一次,就可能跳过元素。
这个例子不复杂,但很适合检查自己是不是真的理解迭代器失效。
十一、从模拟实现 string 看深浅拷贝
课件后面讲 string 模拟实现,我觉得这一部分真正要抓的不是"手写一个完整 string",而是借它复习资源管理。
先看一个简化版 String:
cpp
#include <cstring>
#include <iostream>
class String {
public:
explicit String(const char* str = "")
: size_(std::strlen(str)),
capacity_(size_),
data_(new char[capacity_ + 1]) {
std::strcpy(data_, str);
}
~String() {
delete[] data_;
}
const char* c_str() const {
return data_;
}
private:
char* data_;
std::size_t size_;
std::size_t capacity_;
};
int main() {
String s1("hello");
String s2(s1);
std::cout << s1.c_str() << '\n';
std::cout << s2.c_str() << '\n';
return 0;
}
这段代码看起来没写什么危险操作,但它有问题。
因为我们没有写拷贝构造函数,编译器生成的默认拷贝会逐成员拷贝。data_ 是一个指针,逐成员拷贝只会把指针值复制一份。
也就是说:
text
s1.data_ 和 s2.data_ 指向同一块堆空间。
等 s1、s2 析构时,同一块空间会被 delete[] 两次,这就是典型的浅拷贝问题。
深拷贝版本
如果类自己管理资源,就要让每个对象拥有自己的资源。
cpp
#include <cstring>
#include <iostream>
#include <utility>
class String {
public:
explicit String(const char* str = "")
: size_(std::strlen(str)),
capacity_(size_),
data_(new char[capacity_ + 1]) {
std::strcpy(data_, str);
}
String(const String& other)
: size_(other.size_),
capacity_(other.size_),
data_(new char[capacity_ + 1]) {
std::strcpy(data_, other.data_);
}
String& operator=(String other) {
Swap(other);
return *this;
}
~String() {
delete[] data_;
}
void Swap(String& other) {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
std::swap(capacity_, other.capacity_);
}
const char* c_str() const {
return data_;
}
private:
char* data_;
std::size_t size_;
std::size_t capacity_;
};
这版里,拷贝构造会重新申请空间,再把字符内容拷贝过去。两个对象各自管理自己的内存,不会互相踩。
赋值运算符这里用了一个比较现代的写法:
cpp
String& operator=(String other) {
Swap(other);
return *this;
}
参数 other 是按值传递,会先用拷贝构造得到一份临时副本。然后当前对象和副本交换资源。函数结束时,other 析构,释放当前对象原来那份旧资源。
这个写法的好处是自赋值也能处理,代码也比较短。
当然,这只是学习阶段的模拟实现,不是标准库 string 的完整实现。真正的 string 还要处理更多细节,比如迭代器、插入删除、短字符串优化等。
课件里也提到,不同平台下 string 的内部结构不完全一样。有的实现会针对短字符串做优化,让短字符串直接放在对象内部的小缓冲区里;字符串变长后,再使用堆空间。我们写代码时不应该依赖这些内部布局,应该通过公开接口使用它。
这里我觉得最需要记的是:
text
只要类里自己管理堆资源,就不能放心交给默认拷贝。
这和前面类和对象、内存管理那两篇内容是接上的。
十二、从模拟实现 vector 看三指针模型
vector 的模拟实现,核心可以先抓三个指针。
text
_start 指向空间起始位置
_finish 指向最后一个有效元素的下一个位置
_end_of_storage 指向容量空间的末尾位置
可以画成这样:
text
[_start, _finish) 已经存放的有效元素
[_finish, _end_of_storage) 已申请但还没使用的空间
所以:
cpp
size = _finish - _start;
capacity = _end_of_storage - _start;
写成一个很简化的模拟版:
cpp
#include <cstddef>
template<class T>
class Vector {
public:
Vector()
: start_(nullptr),
finish_(nullptr),
end_of_storage_(nullptr) {}
~Vector() {
delete[] start_;
}
std::size_t size() const {
return finish_ - start_;
}
std::size_t capacity() const {
return end_of_storage_ - start_;
}
T& operator[](std::size_t pos) {
return start_[pos];
}
const T& operator[](std::size_t pos) const {
return start_[pos];
}
void reserve(std::size_t n) {
if (n <= capacity()) {
return;
}
std::size_t old_size = size();
T* tmp = new T[n];
for (std::size_t i = 0; i < old_size; ++i) {
tmp[i] = start_[i];
}
delete[] start_;
start_ = tmp;
finish_ = start_ + old_size;
end_of_storage_ = start_ + n;
}
void push_back(const T& value) {
if (finish_ == end_of_storage_) {
std::size_t new_capacity = capacity() == 0 ? 4 : capacity() * 2;
reserve(new_capacity);
}
*finish_ = value;
++finish_;
}
void pop_back() {
if (finish_ != start_) {
--finish_;
}
}
private:
T* start_;
T* finish_;
T* end_of_storage_;
};
这段代码是为了理解核心框架,不能把它当成标准库实现。它至少省略了异常安全、allocator、对象逐个构造析构、移动语义等很多细节。
但作为基础学习,它能说明几个关键点:
size()不是单独存一个值也可以算出来。capacity()取决于整块空间大小。push_back前要检查容量是否够。reserve可能会重新申请空间,并把旧元素搬过去。- 一旦重新申请空间,旧的迭代器、指针、引用就可能失效。
这里也能解释为什么 vector 插入数据效率有时会下降。不是 push_back 本身每次都很慢,而是扩容那一次要申请新空间、拷贝旧元素、释放旧空间。
如果提前 reserve,就能减少这种扩容搬移。
十三、为什么 vector 的 reserve 不能随便用 memcpy
课件里专门提到 memcpy 拷贝问题,这个点很重要。
如果 vector<int> 扩容,直接按字节拷贝看起来好像没什么问题。因为 int 没有自己管理资源。
但如果元素是 std::string 或者自己写的资源管理类,memcpy 就很危险。
看一个简化例子:
cpp
#include <cstring>
#include <string>
int main() {
std::string a[2] = {"hello", "world"};
std::string b[2];
// 错误示例:不要这样拷贝 string 对象
std::memcpy(b, a, sizeof(a));
return 0;
}
memcpy 只做二进制拷贝,它不知道 std::string 内部有没有指针、容量、短字符串优化,也不会调用拷贝构造或赋值运算符。
如果对象内部管理资源,按字节复制可能会让两个对象共享同一份资源,最后析构时就可能出问题。
所以模拟 vector 扩容时,更稳的写法是逐个元素拷贝:
cpp
for (std::size_t i = 0; i < old_size; ++i) {
tmp[i] = start_[i];
}
这里会走元素类型自己的赋值逻辑。对于 std::string 这种对象,它知道怎么正确拷贝自己。
基础阶段可以先记住这个判断:
text
memcpy 适合处理原始字节,不适合拿来复制有资源管理语义的 C++ 对象。
这和 String 的浅拷贝问题其实是同一个根:不能绕开对象自己的拷贝控制。
十四、动态二维数组怎么理解
vector 还能很自然地表示动态二维数组。
比如杨辉三角:
cpp
#include <iostream>
#include <vector>
std::vector<std::vector<int>> GenerateTriangle(int n) {
std::vector<std::vector<int>> triangle(n);
for (int i = 0; i < n; ++i) {
triangle[i].resize(i + 1, 1);
for (int j = 1; j < i; ++j) {
triangle[i][j] = triangle[i - 1][j - 1] + triangle[i - 1][j];
}
}
return triangle;
}
int main() {
auto triangle = GenerateTriangle(5);
for (const auto& row : triangle) {
for (auto value : row) {
std::cout << value << ' ';
}
std::cout << '\n';
}
return 0;
}
这里的类型是:
cpp
std::vector<std::vector<int>>
可以理解成:外层 vector 管理很多行,每一行又是一个 vector<int>。
它和真正的二维数组不完全一样。二维数组通常是一整块规则的连续空间,而 vector<vector<int>> 是外层连续、每一行内部也连续,但不保证所有行挨在同一整块空间里。
对杨辉三角这种每一行长度不同的结构来说,vector<vector<int>> 很合适。
这里我觉得最值得记的是:每一行都是一个独立的 vector 对象。外层扩容时,移动的是这些行对象;每一行自己的元素空间,由行对象自己管理。
十五、常见坑放一起看
这部分不需要写得太玄,直接按问题记比较实用。
| 场景 | 容易写错 | 更稳的理解 |
|---|---|---|
auto 遍历修改元素 |
写成 auto e |
要修改原元素,用 auto& e |
string::find |
找不到还拿返回值当下标 | 先判断是否等于 string::npos |
getline |
前面 cin >> 后直接读行 |
先处理掉残留换行 |
c_str() |
长期保存返回指针 | 字符串修改后重新获取 |
reserve |
以为创建了元素 | 它只改容量,不改 size |
resize |
以为只是预留空间 | 它会改变有效元素个数 |
vector 扩容 |
继续使用旧迭代器 | 扩容后重新获取迭代器 |
erase |
删除后继续 ++it |
用 it = erase(it) 接住返回值 |
find 查 vector |
以为是成员函数 | 用 <algorithm> 里的 std::find |
| 模拟实现拷贝 | 直接复制指针或 memcpy |
有资源管理的对象要走深拷贝 |
再压缩一点,我会把它分成三类:
text
接口语义类:reserve 和 resize、size 和 capacity。
遍历位置类:iterator、erase、扩容失效。
资源管理类:深浅拷贝、析构、memcpy。
这三类问题正好对应 string 和 vector 的三层学习目标:会用、懂边界、能理解简单实现。
十六、复习时怎么串起来
如果只复习一遍,我会按下面这条线走:
text
1. string 解决 C 字符串操作和空间管理麻烦的问题。
2. vector 解决普通数组大小固定的问题。
3. 它们都有 size/capacity,说明有效元素和底层容量不是一回事。
4. reserve 是预留空间,resize 是改变有效元素个数。
5. 遍历可以用下标、迭代器、范围 for,修改元素时注意引用。
6. 插入、删除、扩容会影响迭代器,erase 要接返回值。
7. 模拟实现时,string 重点看深拷贝,vector 重点看三指针和扩容。
8. 只要对象内部管理资源,就不要用 memcpy 或默认浅拷贝糊弄过去。
再用一张表把核心点收一下:
| 知识点 | 先记什么 |
|---|---|
string |
管理字符序列的类,比 C 字符串更安全、更方便 |
vector |
管理连续元素空间的动态数组 |
auto |
编译期类型推导,引用要写 auto& |
范围 for |
适合遍历,修改元素要用引用 |
size |
当前有效元素个数 |
capacity |
当前底层可容纳元素个数 |
reserve |
预留容量,不直接增加有效元素 |
resize |
改变有效元素个数,可能初始化新元素 |
find |
string 有成员 find,vector 查找常用 std::find |
erase |
删除后原迭代器失效,要接返回值 |
| 深拷贝 | 每个对象管理自己的资源 |
memcpy |
只按字节复制,不适合复制资源管理对象 |
复习自测
string::size()和string::capacity()有什么区别?vector::reserve(100)后,能不能直接访问v[99]?- 范围
for中auto e和auto& e的区别是什么? std::find(v.begin(), v.end(), x)找不到时返回什么?erase删除元素后,为什么要写it = v.erase(it)?- 一个自己管理堆空间的
String类,为什么不能只依赖默认拷贝构造? - 模拟实现
vector扩容时,为什么不能随便用memcpy拷贝元素? vector<vector<int>>和普通二维数组的内存结构完全一样吗?
这些问题能答上来,string 和 vector 的基础主线基本就通了。
小结
string 和 vector 是 C++ 里非常常用的两个类型,但它们不应该只被当成接口表来背。
string 的重点,是把 C 字符串那套手动管理空间、手动找结尾、手动拼接的麻烦收进一个类里。用它时要熟悉构造、遍历、追加、查找、截取、getline 和 c_str() 这些常见接口,也要知道 size 和 capacity 不是一回事。
vector 的重点,是理解它是一段会自动扩容的连续空间。它用起来像数组,但扩容、插入、删除会影响迭代器,所以写循环删除时要格外注意。
再往下看模拟实现,两个容器又会把前面的类和对象、内存管理知识重新带回来:
string让我们看到深浅拷贝、析构和赋值重载的重要性。vector让我们看到三指针模型、扩容和元素搬移。memcpy问题提醒我们,C++ 对象不是一块字节拷过去就一定正确。
这篇复习完后,我觉得可以先记住一句话:
text
STL 容器好用,是因为它帮我们管理了很多细节;但写对代码,仍然要知道这些细节什么时候会露出来。
等这两个容器的接口和边界都顺了,再去学 list、stack、queue、模板进阶和更底层的容器实现,就不会只是背函数名,而是能看懂它们各自解决的问题。