C++ string 和 vector 基础:从常用接口到模拟实现

C++ string 和 vector 基础:从常用接口到模拟实现

前言

学到 STL 的时候,我一开始最容易犯的错误是:把 stringvector 当成一堆接口来背。

比如 push_back 是尾插,size 是元素个数,begin 是起始迭代器。这些当然要记,但只背接口很快就会遇到两个问题:

  • 写代码时知道函数名,却不知道什么时候用 reserve,什么时候用 resize
  • 遍历、插入、删除一混起来,就容易踩迭代器失效这种坑。
  • 看模拟实现时,才发现 stringvector 背后其实都绕不开内存管理、拷贝控制和对象生命周期。

所以这篇文章不打算按课件接口表逐项复述,而是按我自己复习时更顺的一条线来整理:

text 复制代码
先会用 string 和 vector。
再理解 size、capacity、iterator 这些概念。
最后通过简单模拟实现,看清楚它们为什么不能只当普通数组用。

内容范围主要围绕当前 08.string.pdf09.vector.pdf 两份课件:auto、范围 forstring 常用接口、vector 常用接口、容量增长、迭代器失效、string/vector 的模拟实现、深浅拷贝、memcpy 拷贝问题和动态二维数组。

这篇先不展开 listdequemap、空间配置器、完整标准库源码这些内容。它们当然重要,但现在更关键的是先把这两个最常用的顺序容器用明白。

文章目录

前言

[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 放到同一条线

stringvector 看起来一个处理字符串,一个处理数组,表面上不是一个东西。

但如果从使用方式看,它们有很多相似点:

对比点 string vector
管理的内容 一段字符序列 一段同类型元素序列
底层特点 通常会管理一段连续字符空间 管理一段连续元素空间
常见操作 构造、遍历、尾插、查找、截取 构造、遍历、尾插、插入、删除
容量相关 sizecapacityreserveresize sizecapacityreserveresize
需要注意 字符串结束、c_str、扩容 扩容、迭代器失效、元素搬移
模拟实现重点 深拷贝、析构、赋值 三指针、扩容、对象拷贝

我现在更愿意把它们放在同一条线上理解:

text 复制代码
string 是管理字符序列的类。
vector 是管理连续元素序列的类。

它们都帮我们把"空间申请、空间扩容、元素访问、对象释放"这些细节包装起来。平时用的时候不用自己手动 new[],但如果完全不知道底层大概怎么做,一到插入、删除、拷贝这些场景,还是很容易写错。

这也是为什么学完类和对象、内存管理之后,再看 stringvector 会顺很多。前面的知识不是孤立的,到了 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 服务,后面遍历 vectormap 这些容器都会用到。

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 里最容易混的是 sizecapacityreserveresize

接口 作用 会不会改变有效字符个数
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_backappend+= 的实现方式差别不大。日常写代码时,我会优先用 +=,因为它读起来比较顺。

如果已经大概知道后面要拼接很多内容,可以先 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 对象管理。不要把它当成自己申请的空间,更不要 deletefree

还有一点:如果后面修改了字符串,之前拿到的 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) 这个区间里找元素。它不关心这个区间来自 vectorstring,还是别的容器。这正是迭代器的意义:让算法不用直接绑定某一种容器。

八、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 迭代器失效的操作包括:

  • 可能改变底层空间的操作:reserveresizepush_backinsertassign 等。
  • 删除指定位置元素的操作:erase

这里要把两类情况分开:

  1. 扩容导致旧空间被释放,原来的迭代器指向旧空间。
  2. 删除元素后,元素位置发生移动,原来的迭代器语义不再可靠。

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_ 指向同一块堆空间。

s1s2 析构时,同一块空间会被 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) 接住返回值
findvector 以为是成员函数 <algorithm> 里的 std::find
模拟实现拷贝 直接复制指针或 memcpy 有资源管理的对象要走深拷贝

再压缩一点,我会把它分成三类:

text 复制代码
接口语义类:reserve 和 resize、size 和 capacity。
遍历位置类:iterator、erase、扩容失效。
资源管理类:深浅拷贝、析构、memcpy。

这三类问题正好对应 stringvector 的三层学习目标:会用、懂边界、能理解简单实现。

十六、复习时怎么串起来

如果只复习一遍,我会按下面这条线走:

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 有成员 findvector 查找常用 std::find
erase 删除后原迭代器失效,要接返回值
深拷贝 每个对象管理自己的资源
memcpy 只按字节复制,不适合复制资源管理对象

复习自测

  1. string::size()string::capacity() 有什么区别?
  2. vector::reserve(100) 后,能不能直接访问 v[99]
  3. 范围 forauto eauto& e 的区别是什么?
  4. std::find(v.begin(), v.end(), x) 找不到时返回什么?
  5. erase 删除元素后,为什么要写 it = v.erase(it)
  6. 一个自己管理堆空间的 String 类,为什么不能只依赖默认拷贝构造?
  7. 模拟实现 vector 扩容时,为什么不能随便用 memcpy 拷贝元素?
  8. vector<vector<int>> 和普通二维数组的内存结构完全一样吗?

这些问题能答上来,stringvector 的基础主线基本就通了。

小结

stringvector 是 C++ 里非常常用的两个类型,但它们不应该只被当成接口表来背。

string 的重点,是把 C 字符串那套手动管理空间、手动找结尾、手动拼接的麻烦收进一个类里。用它时要熟悉构造、遍历、追加、查找、截取、getlinec_str() 这些常见接口,也要知道 sizecapacity 不是一回事。

vector 的重点,是理解它是一段会自动扩容的连续空间。它用起来像数组,但扩容、插入、删除会影响迭代器,所以写循环删除时要格外注意。

再往下看模拟实现,两个容器又会把前面的类和对象、内存管理知识重新带回来:

  • string 让我们看到深浅拷贝、析构和赋值重载的重要性。
  • vector 让我们看到三指针模型、扩容和元素搬移。
  • memcpy 问题提醒我们,C++ 对象不是一块字节拷过去就一定正确。

这篇复习完后,我觉得可以先记住一句话:

text 复制代码
STL 容器好用,是因为它帮我们管理了很多细节;但写对代码,仍然要知道这些细节什么时候会露出来。

等这两个容器的接口和边界都顺了,再去学 liststackqueue、模板进阶和更底层的容器实现,就不会只是背函数名,而是能看懂它们各自解决的问题。

相关推荐
良木生香19 小时前
【C++初阶】STL—— Stack & Queue 从入门到精通:容器适配器、迭代器与经典面试题
java·开发语言·c++·算法·zookeeper
小小晓.19 小时前
C++小白记:vector
开发语言·c++·算法
脱胎换骨-军哥19 小时前
C++ 嵌入式编程实例:从寄存器操作到底层驱动开发
开发语言·c++·驱动开发
鱼子星_20 小时前
【C++】vector
开发语言·c++·笔记·stl
吃着火锅x唱着歌20 小时前
Effective C++ 学习笔记 条款38 通过复合塑模出has-a或“根据某物实现出”
c++·笔记·学习
syagain_zsx20 小时前
库制作与原理 · 链接知识笔记
c语言·c++·笔记·动态库·静态库
qz5zwangzihan120 小时前
题解:Atcoder Beginner Contest abc467 A~D
c++·题解·atcoder·abc467
jinyishu_1 天前
模拟实现 C++ 栈和队列——从适配器模式看懂 STL 容器之美
java·c++·适配器模式
hehelm1 天前
AI大模型接入SDK—通用模块设计
linux·开发语言·c++