如果你已经在 C 语言里被 char 数组折磨过,一定记得那种感觉:明明是"一个字符串",却要自己操心它几岁、住多大地儿、够不够住、末尾那个看不见的 \0 还在不在。等你迈进 C++ 的世界,很高兴地告诉你——这些痛苦,标准库用一个 std::string 类全部替你扛下了。这一篇,我们就把 std::string 里里外外讲透,从"为什么需要它"一路讲到"它底层是怎么实现的",让你不仅会用,还能在面试时条理清晰地讲出它的砍。

先说好:这一篇会用到"类""封装"这些面向对象的概念,如果你对它们还比较陌生,记住一句话就行——类就是把一堆相关的数据和一个字符串需要的各种操作打包到一起,对外只暴露好用的接口。std::string 正是这样一个已经帮你写好的类。

再交代一句这篇文章的"配方":每一个接口,我都会从六个角度把它讲烂——它是什么(一句话定义)、为什么存在(它解决的痛点)、底层怎么运作(内存与复杂度)、常见坑(容易踩的雷)、生活类比(帮你建立直觉)、应用场景(什么代码里用得上)。每讲完一个大块,再配一段能独立编译运行的 cpp 代码。你需要的只是一台装了 C++ 编译器的电脑和一份好奇心,跟着代码敲一遍,收获会远大于只读。

C 语言的字符串到底痛在哪

在 C 语言里,字符串的本质是一段以 \0 结尾的 char 数组。这个设计简单、效率高,但也有三处让人头疼的地方。

第一,数据和处理它的函数是分离的。字符串只是普通数组,而操作它要靠一堆独立的 str 系列库函数:strlen 求长度、strcpy 拷贝、strcat 拼接、strcmp 比较……这些函数和字符串数据本身并不绑定,更像"外来工具",不符合面向对象"数据和方法放在一起"的思想。

第二,底层空间要用户自己管理。你要拼接,就得先确认目标数组够大,不然就越界;你每次手动开数组,就得记得释放。稍不留神,缓冲区溢出的坑就等着你踩。

第三,\0 全靠自觉。前面讲过,C 语言的字符串是拿 \0 当结束标志的。可这个 \0 位置留没留、够不够长,编译器并不帮你检查。忘记留位置,输出就带乱码;不小心把 \0 覆盖了,字符串会"莫名奇妙变长"。

这三条病根,可以浓缩成一句话:C 字符串本质上是一堆"裸露的资源"(字符数组),而资源是没有自毁装置、没有自我认知的。谁来开空间、谁来释放、谁来保证结尾有哨兵,全靠程序员这个"大管家"亲力亲为。管理得当一切安好,稍一疏忽就是内存泄漏、越界读写这些 C 程序的头号杀手。

我们来用一段代码感受一下 C 风格字符串的手工劳作:

#include <iostream>
#include <cstring>
using namespace std;
 
int main()
{
    char buf[32] = "hello";       // 手动定一块 32 字节的数组
    strcat(buf, " world");        // 拼接:必须保证 buf 够大,否则越界
    cout << buf << endl;          // 输出:hello world
 
    // 如果目标不够大,strcat 会把内容写到 buf 后面,
    // 直接破坏相邻内存 —— 这就是经典的缓冲区溢出漏洞
    char small[8] = "hi";
    // strcat(small, " this is a very long tail"); // 危险!千万别这样写
 
    // 看错 \0 位置的经典翻车现场
    char arr[] = {'a', 'b', 'c'};   // 注意:这个数组末尾没有 \0
    // printf 不知道在哪停,会把 arr 后面的随机内存也打印出来
    cout << arr << endl;            // 输出 abc 之后可能接一堆乱码
 
    return 0;
}

运行这段代码,cout << arr 那行几乎一定会打印出 abc 后面跟着的乱码垃圾。原因就是 arr 是三个字符的手工数组,没有 \0 哨兵,程序只能一路往下读内存,直到遇见一个碰巧为 0 的字节才肯停——读到哪儿算哪儿。你不难想象,如果一个人用 strncpy(字符数受控的拷贝函数)却又不手动补 \0,这种"看不见的尾巴"会造成多么隐蔽的 bug。

正因为这些坑,在现代 C++ 项目中,绝大多数场景我们都直接用 std::string,很少再碰 C 库的字符串函数。你可以理解为:C 语言给你一根裸电线,C++ 的 string 给你一个带绝缘层和安全插头的插座——裸电线高效,但你得自己保证别摸到火;插座封装好,插上就能安全用。

std::string:标准库帮你封装的字符串

std::string 是 C++ 标准库 <string> 头文件里定义的一个类,专门用来表示和处理字符串。它把"字符串"这件事彻底封装了起来:内部到底是一块 char 数组还是别的结构,你完全不用关心;你只管调用它的接口,它自己会管好内存的申请、扩容和释放。

稍微掰开一层看,std::string 其实是一个"模板类的实例化"。它的全名应该是 std::basic_string<char, std::char_traits<char>, std::allocator<char>>:第一个模板参数 char 表示"每个字符的类型",第二个 char_traits<char> 封装了"字符该怎么比较、怎么求长度"这类细节,第三个 allocator<char> 告诉它"内存找谁要"。绝大多数时候我们根本不用碰模板参数,标准库已经帮你写好了缩写:

  • std::string:存 char(1 字节字符),我们这篇的主角。
  • std::wstring:存 wchar_t(宽字符,Windows 上是 2 字节,Linux 上是 4 字节),用于需要大量使用宽字符接口的历史 Windows 代码。
  • std::u16string / std::u32string:存 char16_t / char32_t,分别是 UTF-16 / UTF-32 编码的字符。

你只要记住:string 是"装字符的"那一支,其余的都是它在不同字符类型下的兄弟。至于 UTF-8 多字节汉字如何放进 string(一个汉字在 UTF-8 下占 3 个 char),我们后面讲访问时会提到——string 并不懂什么是"字符交界",它只管 "字节数组"。

要使用它,先引入头文件并声明命名空间:

#include <iostream>
#include <string>   // string 类所在的头文件
using namespace std; // 把标准库的名字引入当前作用域
 
int main()
{
    string s = "hello";   // 创建一个 string 对象
    cout << s << endl;    // 直接用 cout 输出
    return 0;
}

几个直观的好处立刻能感受到:求长度不用 strlen,拿 s.size();拼接不用 strcat,直接 s += "...";比较不用 strcmp,直接写 == / <。它从语法层面就朝你招手:把字符串当普通值来用。

有一个初学者很容易懵的点必须说清:<string> 和 <string.h> / <cstring> 是两个完全不同的头文件。<string.h> 是 C 语言的那套 strlen/strcpy 老函数,<cstring> 是它的 C++ 包装版本;而 <string> 才是定义 std::string 类的地方。很多新手写了 #include <string.h> 然后惊呼"怎么没有 string 类",根源就在这里。用 std::string,就老老实实 #include <string>。

两个 C++11 小语法:auto 与范围 for

在正式讲 string 的接口之前,先补两个 C++11 的小语法,后面遍历 string 时会大量用到。

第一个是 auto。在老 C/C++ 里,auto 表示"自动存储期的局部变量",这个含义后来几乎没人用,成了鸡肋。C++11 标准委员会"变废为宝",重新赋予了它全新含义:auto 声明变量时,让编译器根据初始值自动推导类型。它推得准不准、类型写得多长,你都不用操心,一律"交给编译器"。看几个例子:

#include <iostream>
using namespace std;
 
int main()
{
    auto a = 10;      // a 被推导为 int
    auto b = 'x';     // b 被推导为 char
    auto c = 3.14;    // c 被推导为 double
 
    int x = 5;
    auto y = &x;      // y 被推导为 int*(指针)
    auto* z = &x;     // z 也是 int*,和 y 没有区别
    auto& m = x;      // 注意:这里是引用,改 m 就是改 x
 
    m = 99;
    cout << x << endl;   // 输出:99
 
    return 0;
}

这里有几个细节值得记一下:

  • 用 auto 声明指针时,写 auto 和 auto* 没区别;但声明引用时,& 绝不能省——auto& m = x 才是引用,auto m = x 只是把 x 的值拷一份给 m。
  • 编译器只对第一个变量推导类型,所以同一行用 auto 声明的多个变量必须是同一类型,否则报错。比如 auto cc = 3, dd = 4.0; 就会编译失败(3 是 int,4.0 是 double,类型不统一)。
  • auto 声明变量必须有初始值,否则编译器无从推导,auto e; 直接报错。
  • auto 不能直接用来声明数组(编译器不知道长度);也不能做函数参数(参数的类型必须明确)。它可以做函数返回值,但建议谨慎使用,因为调用者不知道返回的具体类型,可读性会打折扣。
  • auto 真正大显身手的地方,是那些类型名特别长的场景。比如 STL 容器(如 map)的迭代器类型,写起来一长串,用 auto it = dict.begin(); 就清爽多了。

第二个是范围 for(range-based for),也叫基于范围的 for 循环。面对一个有范围的集合(数组、容器、string),你自己用下标去写循环比较繁琐,范围 for 帮你"自动迭代、自动取数据、自动判断结束"。它的底层其实是把循环替换成了迭代器遍历,这个我们稍后讲迭代器时会有更深体会。

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    int arr[] = {1, 2, 3, 4, 5};
 
    // 加 & 表示引用:直接改元素
    for (auto& e : arr)
        e *= 2;              // 每个元素都翻倍
 
    // 不加 & 是"值拷贝":e 只是当前元素的副本
    for (auto e : arr)
        cout << e << " ";    // 输出:2 4 6 8 10
    cout << endl;
 
    // 遍历 string:逐个取出字符
    string str("hello");
    for (auto ch : str)
        cout << ch << " ";   // 输出:h e l l o
    cout << endl;
 
    // 用 auto& 去修改字符串里的字符
    for (auto& ch : str)
        ch = ch - 32;        // ASCII 小写转大写:hello -> HELLO
    cout << str << endl;     // 输出:HELLO
 
    return 0;
}

记住一个原则:只读遍历用 auto(拷贝副本无开销),要修改就用 auto&。这里再点破一个新手常见的误区:字符串和 int 数组很不一样——遍历 int 数组时 auto e 拷一个 int 毫不心疼;但如果某天你遍历的是 vector<string> 这种"元素本身是字符串"的容器,auto e 会逐个深拷贝容器里的每个字符串,开销很大,这时读也建议写 const auto& e。范围 for 的冒号左侧是"每个元素的变量",右侧是"被遍历的集合"。

string 的构造与基本使用

std::string 提供了好几种构造方式(构造函数),最常用的有这么几个:

构造函数作用
string()构造一个空字符串
string(const char* s)用 C 字符串构造
string(size_t n, char c)包含 n 个字符 c 的字符串
string(const string& s)拷贝构造:复制另一个 string
string(const string& s, size_t pos, size_t len = npos)用另一个 string 从 pos 开始的 len 个字符构造(子串构造)
string(const char* s, size_t n)用 C 字符串 s 的前 n 个字符构造
string(begin_iterator, end_iterator)用迭代器区间 [first, last) 构造
string{ 'a', 'b', 'c' }用初始化列表(initializer list,花括号列举)构造

先看最常用的四种:

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s1;                  // 空字符串
    string s2("hello world");   // 用 C 字符串构造
    string s3(s2);              // 拷贝构造:s3 成为 s2 的副本
    string s4(5, 'a');          // 5 个 'a',即 "aaaaa"
 
    cout << "s1:[" << s1 << "]" << endl;   // s1:[]
    cout << "s2:[" << s2 << "]" << endl;   // hello world
    cout << "s3:[" << s3 << "]" << endl;   // hello world
    cout << "s4:[" << s4 << "]" << endl;   // aaaaa
    return 0;
}

注意 s3(s2) 这种拷贝构造,背后其实牵扯到"深拷贝"的话题,我们最后专门讲。现在你只需要知道:拷贝出来的新字符串,和原来的字符串互不影响——你改 s2 不会波及 s3。

再补充两个很有意思、且面试也爱考的构造方式:

子串构造 string(const string& str, size_t pos, size_t len),从一个已有字符串里抠一段出来当新字符串的初值:

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string full = "Hello C++ World";
 
    // 从下标 6 开始,取 3 个字符:正是 "C++"
    string sub(full, 6, 3);
    cout << sub << endl;            // C++
 
    // 省略长度,等价于"从 pos 一直取到末尾"
    string tail(full, 11);
    cout << tail << endl;           // World
 
    return 0;
}

学术点叫"子串构造函数"(substring constructor)的这套逻辑,有一个非常明确的边界规则,务必记牢:如果 pos 超过了原字符串的长度 full.size(),会抛出 std::out_of_range 异常(越界异常);但如果 pos 合法、只是 pos + len 超出了末尾,则不会抛异常,而是"能取多少取多少",自动截断到字符串末尾。这跟 substr 成员函数(后面会讲)的行为完全一致——这两者本质上都在说同一件事:开头越界要报错,结尾越界就截断。

迭代器区间构造和初始化列表构造,让 string 能跟其他容器无缝协作:

#include <iostream>
#include <string>
#include <vector>
using namespace std;
 
int main()
{
    // 从 vector 的一段区间构造 string
    vector<char> vc = {'C', '+', '+', '1', '7'};
    string from_v(vc.begin(), vc.begin() + 3);  // 取前 3 个
    cout << from_v << endl;                     // C++
 
    // 初始化列表构造:把花括号里的一串字符变成字符串
    string s{'a', 'b', 'c'};
    cout << s << endl;                          // abc
 
    return 0;
}

关于构造,还有一个高频易错点要单独拎出来讲。const char*(C 字符串指针)不允许传 nullptr(空指针)给 string 的构造函数——标准规定这是未定义行为(UB,undefined behavior),绝大多数实现会直接崩溃或读非法内存。课件的模拟实现里,专门写了 if (nullptr == str) { assert(false); return; } 来拦截空指针,就是防这个。所以:

  • 你想要空字符串:直接 string s;,或 string s(""); 都行。
  • 千万别写 string s = "\0";——这看似"空串",实际上 "\0" 是 C 字符串字面量,strlen("\0") 是 0,最后确实得到空串,但写法绕且容易误导人。
  • 更别把 nullptr 传进去,那是在赌运气。

一句话总结构造心法:"想要什么,就用最直白的那一行去声明"。想要空串就 string s;,想要一串重复字符就 string s(n, ch),想要某段就子串构造,想要拷贝就复制构造——条条大路,挑不绕弯的那条走。

容量操作:size/length/capacity/empty/reserve/resize

字符串对象怎么看大小、怎么预留空间,是每次写字符串代码都绕不开的。这一组接口都围绕"容量"打转:

  • size() 和 length():都返回字符串里有效字符的个数。它俩底层实现完全相同,引入 size() 是为了和其他容器接口保持一致,所以一般都用 size()。你可以理解为:size() 是 length() 的"马甲",只是名字统一了。复杂度 O(1)——现代实现里长度就是对象里存的一个字段,直接读,不用数。
  • capacity():返回当前开辟的能容纳的最大空间大小。注意它和有效字符个数常常不相等,size() 是你"正在用多少",capacity() 是"最多能装多少"。
  • empty():判断字符串是否为空串(size() == 0),为空返回 true。
  • clear():清空所有有效字符,让 size() 变成 0,但底层空间大小不变(capacity() 保持原样)。
  • reserve(n):为字符串预留至少 n 字符的空间。它只影响 capacity(),不影响有效字符个数;如果 n 小于当前容量,标准只约定"容量不小于 n",多数实现不会缩小。
  • resize(n) 和 resize(n, c):把有效字符个数改成 n 个。如果变多,空出的位置 resize(n) 用字符 '\0'(也就是值 0)填充,resize(n, c) 用字符 c 填充;如果变少,就丢掉末尾多余的字符。注意:元素变多时可能引发扩容,元素变少时总空间不变。
#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s("hello");
    cout << "size: " << s.size() << endl;         // 5
    cout << "length: " << s.length() << endl;     // 5,和 size 一样
    cout << "capacity: " << s.capacity() << endl; // 一般大于等于 size
    cout << "empty: " << s.empty() << endl;       // 0,表示非空
 
    s.reserve(50);                                // 预留 50 的空间
    cout << "after reserve, capacity: " << s.capacity() << endl; // >= 50
 
    s.resize(8, '!');                             // 扩到 8 个,空位填 '!'
    cout << s << endl;                            // hello!!!   (5 + 3 个 !)
 
    s.clear();                                    // 清空有效字符
    cout << "size: " << s.size()                  // 0
         << ", capacity: " << s.capacity() << endl; // 容量不变
 
    return 0;
}

reserve 为什么能提升性能? 这要说到扩容机制。std::string 为了摊平成本,扩容时往往按几何增长(GCC 常是 2 倍,MSVC 常见约 1.5 倍)而不是"差一个就扩一个"。但无论翻几倍,只要发生扩容,就要经历"新开一块更大的内存 → 把旧数据一个个搬过去 → 释放旧内存"三步。反复扩容就是反复搬家。所以如果你预先估算出最终大约要装多少字符,先 reserve 一把,把 capacity 一次性抬到位,后续的 += 就都在既有空间里塞,一次扩容都不发生。

resize 和 reserve 一字之差,含义完全不同。resize 改变的是"有效字符个数"(size),reserve 改变的是"预留空间大小"(capacity)。动手前先分清楚。再补一个容易踩的细节点:resize 变大时填充的默认字符是 '\0'(值 0)。这个 '\0' 字符是真真切切存在 string 里的一个有效字符——它占 size() 的一个名额,会被遍历到,但它看起来又是空的,所以很容易让你困惑"我明明 resize 到 10,怎么打印出来还是那 5 个字符"。实际是 '\0' 不可见地占着后面 3 个位置。

如果你想在小到适当的时候就释放多余的容量(比如一个长字符串用完想还给内存),reserve 是做不到真正缩容的——它只是一个非强制性的建议,标准库可以自由决定不缩小。C++11 起提供了 shrink_to_fit() 用于"请求"释放多余容量,但它同样只是建议,成功与否取决于实现。

遍历与访问:下标 / at / 迭代器

要读取或修改字符串里某个位置的字符,最直接的方式是下标,但这里面藏着一个非常重要的坑,我重点讲一下。

operator[](中括号访问) 返回指定位置的字符引用。但如果下标越界了,它不做任何检查——结果行为未定义(undefined behavior),可能读到垃圾、可能崩溃。这是"信任程序员"的体现,性能最好但使用者要自己保证下标合法。复杂度 O(1)。

at() 成员函数 和 operator[] 效果类似,但越界时会抛出 std::out_of_range 异常,你可以捕获它。多了一点运行时检查的开销,但更安全。一般来说,能用下标且下标你知道合法,就用 [];不确定下标是否越界,就用 at()。

front() 和 back()(C++11 引入):分别返回第一个和最后一个字符的引用。它们本质就是 operator[](0) 和 operator[](size()-1) 的"快捷方式",语义更清晰。注意空字符串上调用它们同样是未定义行为——毕竟"第一个"和"最后一个"都没有。

再说迭代器。简单来说,迭代器是"能指向容器中元素、并支持 ++ 移动和 * 取值的东西"。begin() 返回指向第一个字符的迭代器,end() 返回指向"最后一个字符的下一个位置"的迭代器。此外还有 rbegin() / rend() 反向迭代器,从后往前遍历。范围 for 的底层,其实就是用迭代器实现的。对 std::string 这种连续内存结构,迭代器内部就是"一个字符指针"的包装,++ 就是指针自增,* 就是取指向的字符。

#include <iostream>
#include <string>
#include <stdexcept>   // out_of_range 异常定义在这里
using namespace std;
 
int main()
{
    string s("hello");
 
    cout << s[0] << endl;        // 下标访问:h
    cout << s.at(1) << endl;     // at 访问:e
    cout << s.front() << endl;   // 第一个字符:h
    cout << s.back() << endl;    // 最后一个字符:o
 
    // 下标越界不检查,属于未定义行为,千万别在生产代码里写
    // cout << s[100] << endl;   // 危险!
 
    // at 越界会抛异常,可以被捕获
    try
    {
        cout << s.at(100) << endl;   // 抛出 out_of_range
    }
    catch (const out_of_range& e)
    {
        cout << "捕获异常: " << e.what() << endl;
    }
 
    // 迭代器正序遍历
    string::iterator it = s.begin();
    while (it != s.end())
    {
        cout << *it;              // 逐字符取出来打印
        ++it;                     // 指向下一个
    }
    cout << endl;
 
    // 反向迭代器:从后往前
    string::reverse_iterator rit = s.rbegin();
    while (rit != s.rend())
    {
        cout << *rit;             // 输出:olleh
        ++rit;
    }
    cout << endl;
 
    // 范围 for,本质就是迭代器的语法糖
    for (auto ch : s)
        cout << ch;
    cout << endl;
 
    return 0;
}

关于 operator[] 的一个"特例",C++11 之后标准明确:s[s.size()] 是合法的,它返回末尾那个 '\0' 结束符的引用(CharT(),也就是值 0),并且通常你可以放心地把它当 C 风格字符串的结束标志。但请注意:只有 s[size()] 这一处越界是允许的,s[size()+1]、s[100] 依然是未定义行为。这个细节在底层章节讲 c_str() 时你会再次遇到。

关于迭代器失效,这里也必须说清楚:当 string 发生更改导致内存重新分配(比如扩容),或者插入、删除了元素位置发生了变化,之前拿到的迭代器可能就"失效"了——再用它访问是未定义行为。所以一个常见的习惯是:在循环里增删元素,要特别小心迭代器是否会失效;如果要频繁在中间插入,考虑别的结构(比如 std::list 或 std::deque)。一个具体而经典的做法是:insert / erase 会返回一个新的、指向插入点/删除点后元素的迭代器,你就用这个返回值继续迭代,而不是傻乎乎地 .begin() 重来,更不是继续用旧迭代器。

增删改查:+= / append / insert / erase / clear

字符串的增删改,是最常用的操作。课件里重点强调过,在尾部追加时,s.push_back(c)、s.append(1, c)、s += c 这三种方式效果差不多,但实际项目里大家最常用的是 +=,因为它不仅支持追加单个字符,还支持追加一整串,写起来最顺手。

  • push_back(char c):在尾部追加一个字符(C++98 就有)。
  • pop_back():删除末尾一个字符(C++11 引入),坑是空串上调用是未定义行为。
  • append:在尾部追加字符串(支持字符串、子串、重复字符等多种重载)。
  • operator+=:追加重载,可以连字符,也可以连字符串,最常用。
  • insert(pos, ...):在指定位置插入。
  • erase(pos, n):从 pos 位置开始删除 n 个字符。
  • replace(pos, len, str):用 str 替换从 pos 开始的 len 个字符。
  • clear():全部清空。
#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s("Hello");
 
    s.push_back('!');          // 尾插一个字符:Hello!
    s += " C++";               // 追加字符串:Hello! C++
    s.append(" World");        // 再追加:Hello! C++ World
    s.append(2, '!');          // 追加两个感叹号:Hello! C++ World!!
    cout << s << endl;
 
    s.insert(0, ">>");         // 在下标 0 处插入 ">>"
    cout << s << endl;         // >>Hello! C++ World!!
 
    s.erase(0, 2);             // 从下标 0 开始删 2 个字符
    cout << s << endl;
 
    s.replace(11, 5, "Mars");   // 把下标 11 起 5 个字符 "World" 替换成 "Mars"
    cout << s << endl;         // Hello! C++ Mars!!
 
    s.pop_back();              // 去掉末尾 '!'
    cout << s << endl;         // Hello o C++ World!
 
    s.clear();                 // 全部清空
    cout << "[" << s << "]" << endl;  // 空串,只剩中括号
 
    return 0;
}

这里把几个接口讲透一点:

insert 有多个重载:insert(pos, string)/insert(pos, const char*)(在下标 pos 前插入整串)、insert(pos1, string, pos2, len)(插入另一字符串的子串)、insert(pos, n, char)(插入 n 个字符)、insert(iterator, char)(在迭代器处插入一个字符,返回新插入字符的迭代器)。判越界规则和前面一致:插入点超过 size 会抛 out_of_range。

erase 有三种形式:erase(pos, len) 删从 pos 起的 len 个字符;erase(iterator) 只删一个元素,返回指向被删元素后一个元素的迭代器;erase(first, last) 删除迭代器区间 [first, last)。clear() 本质上就是 erase(begin(), end())。

replace 和"长度截断"的逻辑同样遵循那套规则:替换区的开始位置越界抛异常,末尾越界截断。

这里再强调一个性能相关的原则,和前面 reserve 呼应:如果已经大概知道要往字符串里装多少内容,先 reserve 预留好空间,再反复 +=,能避免一次次扩容拷贝。下面这个例子直观展示预留和不预留的差别:

#include <iostream>
#include <chrono>
#include <string>
using namespace std;
 
int main()
{
    // 不预留:每追加到容量上限就扩容搬家一次,扩容次数随数据规模增长
    string a;
    auto t0 = chrono::steady_clock::now();
    for (int i = 0; i < 100000; ++i)
        a += "abc;";
    auto t1 = chrono::steady_clock::now();
 
    // 预留:一次把空间抬到位,全程零扩容
    string b;
    b.reserve(500000);
    for (int i = 0; i < 100000; ++i)
        b += "abc;";
    auto t2 = chrono::steady_clock::now();
 
    cout << "不预留耗时(ms): "
         << chrono::duration_cast<chrono::milliseconds>(t1 - t0).count() << endl;
    cout << "预留后耗时(ms): "
         << chrono::duration_cast<chrono::milliseconds>(t2 - t1).count() << endl;
    cout << "b 的最终长度: " << b.size() << endl;
    return 0;
}

这段代码里的 chrono(C++11 的时间库)你不用深究,只要知道它是用来量耗时的即可。跑出来的结果,通常会显示"预留版"明显更快。它给你的是一个强直觉:扩容是有成本的,能预判就预判,能预留就预留——这个原则同样适用于 std::vector 等其他容器,是 C++ 性能优化的通用心法。

查找与子串:find / substr / rfind

字符串处理里,查找和截取是高频操作。

  • find(c) / find(str) / find(str, pos):从指定位置(默认 0)往后找,返回第一个匹配的位置(下标,size_type 类型)。找不到时返回一个特殊值 string::npos。
  • rfind(...):和 find 相反,从后往前找。
  • find_first_of(str):返回 str 里任一个字符第一次出现的位置("找集合里的任意一员")。
  • find_last_of(str):str 里任一个字符最后一次出现的位置。
  • find_first_not_of(str)、find_last_not_of(str):反之,找不在 str 里的字符第一次/最后一次出现的位置。
  • substr(pos, n):从 pos 位置开始,截取 n 个字符返回一个新的字符串。第二个参数不写(默认 npos)时,一直截到末尾。

这里先解释一下 string::npos 是什么:它是 size_type 型的静态常量,值是 size_type 的最大值((size_type)-1,即 (size_t)-1 或 std::numeric_limits<size_t>::max())。因为正常下标不可能这么大,所以拿它当"找不到"的哨兵。判断查找失败,一定要拿结果和 string::npos 比较,别想当然地用 -1 判断——size_type 是无符号数,(size_type)-1 被隐式转换后恰恰就等于 npos,你硬要比较也能"歪打正着"地凑对,但这是靠巧合,极易看走眼;更常见的是有人把 find 的返回值存进 int 再比较,遇到 npos 时 size_t 转 int 就成了 -1,逻辑就乱了。

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s("hello world, hello C++");
 
    // 从前往后找 "hello" 第一次出现的位置
    size_t pos = s.find("hello");
    cout << "find: " << pos << endl;          // 0
 
    // 从后往前找字符 'l' 最后出现的位置
    size_t rp = s.rfind('l');
    cout << "rfind: " << rp << endl;
 
    // 找不到时返回 npos
    size_t np = s.find("xyz");
    if (np == string::npos)
        cout << "没找到!" << endl;
 
    // 截取前 5 个字符
    string sub = s.substr(0, 5);
    cout << "substr: " << sub << endl;        // hello
 
    // 从 pos+1 继续往后找第二次出现的 "hello"
    size_t pos2 = s.find("hello", pos + 1);
    cout << "second find: " << pos2 << endl;  // 13
 
    // find_first_of:找下面任意一个标点第一次出现的位置
    string punct = ",.!?";
    size_t p = s.find_first_of(punct);
    cout << "first punct at: " << p << endl;  // 下标 11(那个逗号)
 
    // 从一个表格式文本里抠字段:找到第一个、第二个分隔符
    string row = "apple,banana,orange";
    size_t comma1 = row.find(',');
    string first = row.substr(0, comma1);
    size_t comma2 = row.find(',', comma1 + 1);
    string second = row.substr(comma1 + 1, comma2 - comma1 - 1);
    string third = row.substr(comma2 + 1);
    cout << first << " / " << second << " / " << third << endl;
 
    return 0;
}

注意 substr 和构造函数 string(s, pos, n) 的越界规则如出一辙:pos 越界抛 out_of_range,长度越界自动截断。另外,substr 会新申请内存并拷贝内容,复杂度 O(n);如果你只是"看一眼"子串而不去改动它,C++17 之后有更省内存的 std::string_view(字符视图,零拷贝)可选用——它不拥有数据,只是一个"指向某段字符串的视图",这个话题本节先埋个伏笔,底层章节会重新cue到。

find 系列的时间复杂度,标准并未强约束,但主流实现里 find 用的是高效的字符串匹配算法(如两段比对 / BNDM 之类的优化),最坏情况退化为 O(n·m)(n 是主串长,m 是模式串长),实际接近线性。"找子串"这类操作,调库永远比自己写循环可靠,这也是"库函数的价值"最直接的体现。

有一个很经典的小题目天然用到了 rfind——"找字符串里最后一个单词的长度"。用 rfind(' ') 找到最后一个空格的位置,再用 s.size() - pos - 1 就能算出最后一个单词的长度,代码非常简洁:

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string line;
    cout << "请输入一行文本(可含空格): ";
    getline(cin, line);              // 读整行,看下一节 getline 详解
 
    // 从后往前找最后一个空格
    size_t pos = line.rfind(' ');
    // 若整行没有空格,rfind 返回 npos,此时就把整行当"最后一个单词"
    size_t len;
    if (pos == string::npos)
        len = line.size();
    else
        len = line.size() - pos - 1;
 
    cout << "最后一个单词长度: " << len << endl;
    return 0;
}

这段代码里 getline 一会儿排上用场了。这样你也能体会到:很多看似复杂的字符串题,其实只是常用接口的组合拳——rfind + substr + size 几下就能拼出一个优雅的小解法。

string 的比较:compare 与运算符

字符串比较,最常见的方式是直接用比较运算符 ==、!=、<、>、<=、>=。这些运算符比较的是字典序(按字符的 ASCII/Unicode 编码,从头到尾依次比较,第一个不等的位置决定大小)。这是 C++ 面向对象封装的典型红利:你可以跟比较两个 int 一样比较两个字符串,不用像 C 语言那样写 strcmp 再猜返回值含义。

字典序的精确定义(务必记准):从第一个字符开始逐位比较,遇到第一对不等的字符,谁的字符编码小谁整体就小;如果一路相等直到某串更短,那么较短的那个整体更小("abc" 小于 "abcd")。是按字符逐个比,不是按长度比,也不存在"只看首字母"的说法。

另外还有成员函数 compare,它返回:相等返回 0,小于给定串返回负数(严格说不保证是 -1,只是负),大于返回正数。当你需要"三态"结果(小于/等于/大于)时,compare 更清晰,也更适合写进 map 等需要严格弱序的排序比较器里。它还有多个重载:compare(str)、compare(pos1, len1, str)(只比原串的一段)、compare(pos1, len1, str, pos2, len2)(两串各取一段比)。

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string a("apple");
    string b("banana");
    string c("apple");
 
    cout << (a == c) << endl;    // 1(true),两个字符串相等
    cout << (a < b)  << endl;    // 1,字典序 apple < banana
    cout << (a != b) << endl;    // 1,不相等
 
    // 字典序逐字符比较的直观例子
    string big("hello");
    string small("hello world");  // 前缀相同,更短者更小
    cout << (big < small) << endl;   // 1
 
    int r = a.compare("apple");  // 相等返回 0
    cout << "compare: " << r << endl;   // 0
 
    r = a.compare(b);            // a 比 b 小,返回负数
    cout << "compare b: " << r << endl; // 负数(常见实现是 -1)
 
    r = b.compare(1, 3, a, 1, 3); // b 的 [1,4) "ana" vs a 的 [1,4) "ppl"
    cout << "compare 子串: " << r << endl; // "ana" < "ppl",负数
 
    return 0;
}

顺带一提,== 和 < 这些运算符其实也是标准库通过"重载"帮字符串实现好的——这正是"类的封装 + 运算符重载"带来的表达力。你写的代码读起来几乎和自然语言一样。从语言设计高度看:运算符重载让"用户自定义类型"享受和"内建类型"同等的语法待遇。int a, b; if (a < b) 和 string a, b; if (a < b) 读起来毫无二致,但前者是硬件层面的比较指令,后者在底层是一段标准库写好的字典序比对函数——这层"语法糖"是整个 STL 优雅性的基石。

字符串与数字的相互转换

现实里字符串常和数字纠缠:从输入文本里解析出一个数,或者把统计结果拼成字符串写进日志。C++11 提供了一组现成的转换函数,非常方便。

数字 → 字符串:用 to_string(),它把整数、浮点数都转成对应的字符串。比如 to_string(2024) 得到 "2024"。注意 to_string(浮点数) 默认保留 6 位小数,to_string(3.14) 会得到 "3.140000"——这是个常见的"小惊吓",需要精确格式时要注意(要用 sprintf / snprintf / ostringstream 配合格式控制符,或 C++20 的 std::format)。

字符串 → 数字:用 stoi()(转整数 int)、stol()/stoll()(long/long long)、stof()/stod()/stold()(float/double/long double)等。它们从字符串开头解析出一个数字,返回数值。

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    // 数字 -> 字符串
    int n = 2024;
    string s = to_string(n);        // "2024"
    cout << s << endl;
 
    double d = 3.14;
    string sd = to_string(d);       // "3.140000"(默认保留 6 位小数)
    cout << sd << endl;
 
    // 字符串 -> 数字
    string num("12345");
    int m = stoi(num);              // 12345
    cout << m << endl;
 
    string pd("3.99");
    double f = stod(pd);            // 3.99
    cout << f << endl;
 
    return 0;
}

stoi 这类函数有几个签名细节和坑必须讲清楚(以 stoi 为例):

  • 典型签名是 int stoi(const string& str, size_t* pos = nullptr, int base = 10)。
  • 第二个参数 pos 是"吃掉了多少个字符"的输出指针,配合它可以做"扫描到数字后面的位置"这种解析。
  • 第三个参数 base 是进制,默认 10;如果想解析十六进制数,可传 16。
  • 解析失败会抛异常:完全解析不出数字,抛 std::invalid_argument(无效实参);数字超出目标整型范围,抛 std::out_of_range(越出数域)。
  • 常见坑:解析一旦遇到字符串开头的非数字字符,比如 " 42abc",stoi 会先跳过开头的空白(空格/制表符/换行),解析出 42,然后停在 a 前——它并不要求整个字符串都是数字。如果你想要"必须是纯数字"的严格校验,得自己先检查,或用 pos 参数判断解析是否停在了字符串末尾。
  • 一个莫名惊奇的坑:stoi 对空字符串会抛 invalid_argument。所以解析用户输入前,先判空是稳妥的习惯。
#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s("  42abc");     // 前导空格 + 数字 + 尾巴
    size_t used = 0;         // used 会被填成"实际消费的字符数"
    int v = stoi(s, &used);  // 跳过前导空格后解析出 42
    cout << "值: " << v << ", 消费字符数: " << used << endl;
 
    // 用 used 判断是否"整个字符串都是数字"
    bool whole = (used == s.size());
    cout << "整个串都被解析成数字了吗: " << whole << endl; // 0(false)
 
    // 十六进制解析
    string hexs("ff");
    int hv = stoi(hexs, nullptr, 16);
    cout << "十六进制 ff = " << hv << endl;  // 255
 
    // 完全无法解析 -> 抛异常
    try
    {
        int bad = stoi("hello");
        cout << bad << endl;
    }
    catch (const invalid_argument& e)
    {
        cout << "解析失败,抛 invalid_argument" << endl;
    }
 
    return 0;
}

这类转换函数都是 C++11 引入的,如果你在很老的编译器(还没开启 C++11)上写,就得自己手写转换逻辑。另外要注意:to_string 是把数值的十进制文本给你,不是把数字的位映射成字符;stoi 反过来解析文本成数值。之所以要单独强调"这俩是函数不是运算符",是因为很多新手会混淆 '5' - '0'(手动把单个字符 '5' 变成数字 5,靠 ASCII 差值)和 stoi(把整串文本变数字)——前者只处理单个数字字符,后者处理多位数。

c_str、非成员函数与 getline

c_str() 返回一个以 \0 结尾的 C 风格字符串指针(指向 string 内部的管理缓冲区)。它的典型用途是:当你需要把 string 传给那些只接受 const char* 的 C 库函数(比如 fopen、printf("..."))时。C++11 之后,c_str() 返回的缓冲区一定是 \0 结尾且内存连续的,你可以放心当普通 C 字符串用。

版本差异要记一下:在 C++11 之前的旧标准里,data() 返回的缓冲区不保证以 \0 结尾,c_str() 才保证;而 C++11 之后,标准明确 data() 和 c_str() 行为一致——data()[size()] == '\0' 恒成立。所以在新代码里两者可以互换,读老代码时才需要区分。

c_str() 有两个必须死死记住的坑:

  1. 返回的是内部缓冲区的指针,不是拷贝。也就是说,一旦你之后对 string 做了任何会改动内容或触发扩容的操作(+=、insert、erase、assign、reserve 扩容……),这个指针就可能失效(指向被释放或搬迁的内存)。正确姿势是:用完即用,不要长期保存;每次要传给 C 函数前再取一次 c_str()。
  2. 返回类型是 const char*,你不能通过它去修改字符串内容(这符合封装精神)。真想改,请回到 string 的接口上。
#include <iostream>
#include <string>
#include <cstdio>   // 引入 C 的 sprintf/snprintf 等
using namespace std;
 
int main()
{
    string s("hello");
    const char* c = s.c_str();   // 拿到 C 风格字符串,供 C 库函数使用
    cout << c << endl;           // hello
 
    // 演示 1:把 string 用 C 风格 printf 输出(printf 只认 const char*)
    printf("printf 版: %s\n", s.c_str());
 
    // 演示 2:把 string 内容格式化成另一个 C 缓冲区
    char buf[64];
    snprintf(buf, sizeof(buf), "%s + %d", s.c_str(), 100);
    string merged(buf);          // 再包回 string
    cout << merged << endl;      // hello + 100
 
    // 演示 1 的关键:printf 这一行执行完,s 还没被改动时 c 有效;
    // 一旦下面 s 被改动,c 就说不准了,所以前面 printf 必须"用完即取"
    s += "!!";
    c = s.c_str();               // 需要时重新取
    cout << c << endl;           // hello!!
 
    return 0;
}

string 类还有一些非成员函数值得了解:

  • operator<< 和 operator>>:<< 把 string 输出到流,>> 从流输入到 string。但注意,cin >> s 会先跳过开头的空白,遇到空格/换行就停止,读不到带空格的一句话——它适合读"单个词"。"hello world" 用 cin >> s 只能读到 "hello"。
  • getline(cin, str):读取一整行,包括空格,遇到换行才结束,且读取的换行符不会被放进字符串里。这是读取带空格文本的推荐方式。它在 <string> 里,不是 string 的成员函数。它还有个三参版本 getline(cin, str, delim),用任意字符 delim 当分隔符而不是换行。
  • operator+:可以用 + 拼接两个字符串(返回新串)。但课件特别提醒:尽量少用,尤其在老实现里它按值返回,会引发额外的深拷贝,效率比 += 低。现代 C++ 有移动语义后这一问题缓解(见底层章节),但养成"能 += 就 +="的习惯仍是好的。
  • relational operators(关系运算符):就是我们上一节讲的 ==、< 等,它们既以成员重载形式提供,也提供非成员形式(比如 string 和 const char* 混用比较时,靠非成员版本自动转接)。

operator+ 为什么"尽量少用",这里用一句话讲透:a + b + c 会先算 a + b 得到一个临时 string,再用这个临时 string 去 + c 得到第二个临时 string——多一个操作就多一份临时对象。在老 C++11 之前,每一次临时对象都要做一次深拷贝,纯属浪费;C++11 引入移动语义和(NRVO)返回优化后,编译器能大概率把这些临时拷贝消除掉,痛点缓解不少。但要拼接一串,老老实实 s += "a"; s += "b"; s += "c";,或者在正式拼前先 reserve 好总长度,依然是稳妥高效的写法。这里也正好承上启下引出移动语义——它是理解现代 C++ 里 string 不再"谈拷贝色变"的关键,正式的移动语义讲解就在底层章节,届时我们回头看这段会更透彻。

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s("hello");
    const char* c = s.c_str();   // 拿到 C 风格字符串,供 C 库函数使用
    cout << c << endl;           // hello
 
    // 读一整行(包含空格)
    cout << "请输入一行: ";
    string line;
    getline(cin, line);          // 推荐用 getline,而不是 cin>>line
    cout << "你输入了: " << line << endl;
 
    // cin >> string 只能读单个词:遇到空白就停
    cout << "请输入用空格隔开的若干词: ";
    string word;
    cin >> word;
    cout << "cin >> 只读到了第一个词: " << word << endl;
 
    // getline 的三参版:用别的字符当分隔符
    cout << "请输入(用 ',' 分隔,如 a,b,c): ";
    string token;
    getline(cin, token, ',');
    cout << "逗号前的那段: " << token << endl;
 
    return 0;
}

这段代码有点"交互式",你运行时要按它的提示输入,才能看到效果。这正是学习字符串输入输出该有的样子:getline 想读一行,>> 想读一个词,别选错。

string 的底层:SSO、写时拷贝与深浅拷贝

讲完了怎么用,聪明的你可能会问一句:既然 string 要自动管理内存,那它内部到底长什么样?为什么拷贝一个 string 是安全的?这里就进入面试常考的"底层实现思想"部分。

在讲具体方案之前,先给一个"从大到小"的整体观:历史上 std::string 为解决"拷贝/分配太贵"的问题,经历过若干个方案,主流的两个关键字就是 SSO(小字符串优化)和 COW(写时拷贝),而决定"用哪个"的关键,是 C++11 之后标准对稳定性的新要求。我们一个个拆。

小字符串优化(SSO,Small String Optimization)

无论是微软的 MSVC 还是主流实现,string 内部都不一定一上来就开堆内存。反而是"能省则省":当字符串很短时,直接存在一个内部的固定 char 数组里,根本不去 new;只有超过某个阈值,才到堆上申请空间。为什么值得这样设计? 因为去堆上 new 一块内存的成本(分配器锁、系统调用、内存碎片、缓存未命中)远远大于在对象里塞几个字节。而现实里绝大多数字符串都很短——日志标签、状态名、文件名、键值……都不超过十几二十个字符。把这些短串直接存在对象内部,一次堆分配都不发生,性能收益极大。不同实现的具体阈值(都在 64 位平台上):

实现sizeof(std::string)SSO 能原生容纳的最大字符数备注
MSVC(微软)32 字节15 个字符联合体内有一个 16 字节缓冲,末字节供 \0
libstdc++(GCC)32 字节15 个字符现代版本的默认 ABI
libc++(Clang)24 字节22 个字符位域复用得更紧凑

课件里描述的 MSVC 结构那个联合体(union),就是用来在同一块地方存放"短字符串缓存"或"堆指针"的。也是因为"对象总共 28/32 字节、其中大部分在短串时可作字符仓库",SSO 才有它的空间基础。注意:SSO 是标准库实现细节,标准并不要求,同样是 std::string,跨编译器/跨架构的阈值可能不同。所以 "15"、"22" 这些数字只能当参考,不能写进依赖明确的移植代码里。

如果你想亲眼验证"短串不碰堆",有个小技巧:比较 &s[0] 和 &s 的地址。如果字符串在对象内部(SSO),&s[0] 的地址会落在 &s 到 &s + sizeof(s) 这个对象自身的地址范围内;如果在堆上,地址会差很远:

#include <iostream>
#include <cstring>
using namespace std;
 
int main()
{
    cout << "sizeof(std::string) = " << sizeof(string) << endl;
 
    // 判断一个 string 是否命中 SSO(数据就存在对象内部)
    auto is_sso = [](const string& s) {
        const char* obj_begin = reinterpret_cast<const char*>(&s);
        const char* obj_end   = obj_begin + sizeof(s);
        const char* data      = s.data();
        return data >= obj_begin && data < obj_end;   // 数据指针落在对象地址区间内
    };
 
    string s1("hi");                     // 短串
    string s2("a string longer than fifteen chars xxxxx");
    cout << "短串是否 SSO(内部存储): " << is_sso(s1) << endl;   // 1
    cout << "长串是否 SSO(内部存储): " << is_sso(s2) << endl;   // 0(堆上)
 
    // 遍历 0~30 个 'a',找到第一个触发堆分配的阈值
    for (int n = 0; n <= 30; ++n)
        if (!is_sso(string(n, 'a')))
        {
            cout << "从 " << n << " 个字符开始不再命中 SSO" << endl;
            break;
        }
 
    return 0;
}

这段代码里 is_sso 是一个 C++11 的 lambda 表达式(匿名函数闭包),你未必学过;它的作用就是把我那段"数据指针是否落在对象地址区间内"的判断,抽成一个小到能整个写在 auto is_sso = [...] 里的函数式写法。看不懂 lambda 也没关系,抓住核心逻辑就行——通过地址比较去判断"数据住在对象肚子里(SSO)还是堆上(HEAP)"。一句话总结本小节的核心:短串数据住在对象肚子里,长串数据住在堆上。

SSO 的一个反直觉坑:短字符串的"移动"并不比"拷贝"快(甚至一样,都是把内部缓冲一字不差地搬走),因为短串数据在对象内部、没有共享指针可交换;反而是长串的移动,才只是把指针换来换去(O(1))。理清这点,很多 SSO 讨论里"移动语义对短串没收益"的说法你就能看懂了。

写时拷贝(COW,Copy-On-Write)——历史的方案

写时拷贝 是另一种历史方案:拷贝 string 时不真正复制内容,只增加一个"引用计数",多个对象共享同一块内存;只有当某个对象要"写"(修改)时,才真正复制出一份独立数据。课件里描述的老版本 g++ 就是这种结构——对象里只有一个指针,指向一块包含"容量 + 长度 + 引用计数 + 数据"的堆内存。它的设计动机和 SSO 不同:SSO 想省"分配",COW 想省"拷贝"。拷贝一个长字符串时,COW 只花 O(1) 加个引用计数,看似血赚。

但 COW 在现代 C++ 里基本被淘汰了,原因很硬:C++11 之后,标准要求 c_str()/data()/operator[] 能稳定地直接访问内部缓冲区,而 COW 会因"返回的引用指向共享数据"而无法满足语义。想象一下:两个 string 共享一块内存,你拿 s1[0] 的引用准备修改,可 COW 在"写"的那一刻才复制,那么 s1[0] 返回的那个引用到底指向谁的缓冲?标准要求 operator[] 返回必须稳定、可当左值修改且不产生副作用(不得偷偷改 s2),COW 做不到,于是现代标准库基本都放弃 COW 改走 SSO 路线。你和我握手言和:你看到的"老版 g++ 用 COW"是历史实现,现在的 libstdc++(g++)早就切到 SSO 了——这个"版本演变"本身就是一个很好的面试聊点,能讲清"标准变了 → 实现跟着变"的因果。

深浅拷贝:手写 String 的核心考点

前面讲 SSO、COW 都是标准库内部的事。真正面试里高频考、也最能检验"你会不会管理资源"的,是自己模拟实现一个 String 类,核心就是四个同名函数:构造函数、拷贝构造函数、拷贝赋值运算符、析构函数。地面规则一句话:如果一个类自己用 new 管理了资源,这四兄弟必须整套显式正确,缺一个就出 bug。

先讲什么是浅拷贝(也叫位拷贝):编译器默认生成的拷贝构造,只是把对象里的值(比如指针)原样复制一份。假设类里有个 char* _str 指向堆上内存,浅拷贝后两个对象的 _str 指向同一块内存。于是第一个对象析构时 delete 掉这块内存,第二个对象的指针就成了"悬空指针"(dangling pointer,指向已释放内存),再用就访问违规;更糟的是两个对象都析构时,同一块内存被释放两次,直接崩溃。课件里有个传神的比喻:像家里有两个孩子,父母只买了一份玩具,一个人玩坏了,另一个也没得玩。

#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
 
// —— 演示浅拷贝的灾难现场 ——
// 注意:这段代码本身是"错误的示范",会崩溃/未定义行为,仅供理解原理,
// 不要拿去当正常代码跑。真要跑,关掉内存检查、接受它崩给你看。
class Shallow
{
public:
    Shallow(const char* str = "")
    {
        if (nullptr == str) { assert(false); return; }
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    // 没写拷贝构造/赋值 => 编译器生成"位拷贝"(浅拷贝)
    ~Shallow()
    {
        delete[] _str;          // 两个对象会各 delete 一次同一块内存 => 崩溃
    }
 
private:
    char* _str;
};
 
int main()
{
    Shallow a("hello");
    Shallow b(a);   // 浅拷贝:a._str 和 b._str 指向同一块内存
    // —— 到这里 a、b 析构时,同一块堆内存被释放两次,
    // 通常表现为 double free 或程序崩溃 ——
    cout << "(浅拷贝实例,供理解原理,出现崩溃属预期)" << endl;
    return 0;
}

深拷贝则相反:每个对象都各自申请一份独立的内存、复制内容,谁都不共享。就像"每个孩子都有一份属于自己的玩具,各玩各的,互不打扰"。凡是自管资源的类,专家共识都是按深拷贝实现。我们模拟实现一个合格的 String 类(为和标准库区分,这里用大写 String),先看传统写法——逻辑最直白,照着语义一步步写:

#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
 
// 传统写法:深拷贝的"老实"版本
class String
{
public:
    String(const char* str = "")       // 构造函数,默认空串
    {
        if (nullptr == str)            // 不允许传空指针
        {
            assert(false);             // 触发断言,帮忙快速定位问题
            return;
        }
        _str = new char[strlen(str) + 1]; // 在堆上开空间(+1 给 \0)
        strcpy(_str, str);              // 拷贝内容过去
    }
 
    // 拷贝构造:重新开一块空间再复制 —— 这就是深拷贝
    String(const String& s)
        : _str(new char[strlen(s._str) + 1])
    {
        strcpy(_str, s._str);
    }
 
    // 拷贝赋值(传统写法)
    String& operator=(const String& s)
    {
        if (this != &s)                 // 防止自己给自己赋值(self-assignment)
        {
            char* tmp = new char[strlen(s._str) + 1]; // 先开新空间
            strcpy(tmp, s._str);        // 复制内容
            delete[] _str;              // 再释放旧空间
            _str = tmp;                 // 指向新空间
        }
        return *this;                   // 支持连续赋值 a = b = c;
    }
 
    ~String()                           // 析构:释放自己这块资源
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;             // 置空,防止误用
        }
    }
 
    // 加一点能力方便演示
    const char* c_str() const { return _str; }
 
private:
    char* _str;                         // 指向堆上那块字符串内存
};
 
int main()
{
    String s1("hello bit");
    String s2(s1);    // 拷贝构造,s2 拥有独立的拷贝
    String s3;
    s3 = s1;          // 赋值,s3 也拥有独立拷贝
    // 三个对象各自管理自己的内存,析构时互不干扰
    cout << s1.c_str() << " / " << s2.c_str() << " / " << s3.c_str() << endl;
    return 0;
}

传统写法有一个顺序要特别小心:必须先"开新空间 + 拷内容",再"删旧空间"(代码里的 tmp 就是为此准备的)。反过来先 delete[] _str 再 new 会有两个隐患:一是如果 new 抛异常(申请失败),this 的状态已经是"旧空间没了"的残缺态;二是万一 s 和 this 内部共享了什么(这里没有,但正规写法严谨起见要防),先删除会误伤。用 tmp 先独立造好新空间再替换,是异常安全的经典手法。如果忘记 this != &s 这个自赋值检查:a = a 时先删了自己的空间再去拷贝 s._str,读的正是已经被 free 的内存,未定义行为。传统写法这些"插销"都要自己拿捏。

所以还有一种更聪明的现代写法(也叫 copy-and-swap,拷贝-交换惯用法),它利用"先构造临时副本,再把指针交换过来"的技巧,代码更短、天然防自赋值、天然异常安全,更好维护。它的精妙在于,把"写新空间 + 释放旧空间"这种易错的操作,彻底交给"构造函数(负责造新空间)"和"临时对象的析构(负责释放)"去自动完成,自己只敲两行 swap:

#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
 
// 现代写法:借助临时对象 + swap,简洁、自防自赋值、异常安全
class String
{
public:
    String(const char* str = "")
    {
        if (nullptr == str)
        {
            assert(false);
            return;
        }
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
 
    // 拷贝构造(现代版)
    String(const String& s)
        : _str(nullptr)              // 先置空,方便后面交换
    {
        String tmp(s._str);          // 复用构造函数,造出 s 的一个副本
        swap(_str, tmp._str);        // 把自己的指针和副本的空间交换
    }
 
    // 赋值(现代版):以值传参,天然触发拷贝构造
    String& operator=(String s)
    {
        swap(_str, s._str);          // 交换后,s 里存的正是旧的 this 空间
        return *this;                // s 是局部变量,析构时会释放旧空间
    }
 
    ~String()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }
 
    const char* c_str() const { return _str; }
 
private:
    char* _str;
};
 
int main()
{
    String s1("hello");
    String s2(s1);     // 拷贝构造:s2 拿到独立副本
    String s3("world");
    s3 = s1;           // 赋值:s3 先把旧空间让给临时对象 s(形参),再拿走 s1 的副本
    s3 = s3;           // 自己赋给自己:交换后自己的空间和临时对象又换回来,安全
    cout << s1.c_str() << " / " << s2.c_str() << " / " << s3.c_str() << endl;
    return 0;
}

现代写法的精妙在于:传参(拷贝构造)和赋值都复用了"构造函数"和"swap",很多逻辑被自动复用,出错面更小,连自赋值都不用单独判断——s3 = s3 时传入的形参 s 就是 s3 的深拷贝,交换后 s3 拿回自己的数据,临时对象析构释放掉从 s3 换来的那份内容,一切自洽。这也是面试时备受青睐的写法。

最后把深浅拷贝和上面提到的 COW 串一下:浅拷贝一个人多的时候加个引用计数、等真正要改才深拷贝,就成了写时拷贝。所以这三者其实是"内存管理策略"从笨到精的递进——浅拷贝最省事但最危险,深拷贝最安全但每次拷贝都全量搬,COW 想鱼与熊掌兼得(拷贝便宜 + 数据独立)但受限于标准对引用稳定性的要求而退场,最终现代实现用 SSO(省分配)+ 深拷贝 + 移动语义(省拷贝)的组合解决了大部分性能问题。你不需要完整手写标准库那种高度优化的 string,但理解深浅拷贝、能分清浅拷贝的隐患、知道深拷贝的两种写法,是真正掌握 C++ 资源的标志,也是这一类面试题的高频考点。

从 C++11 移动语义再看 string 拷贝

讲完深拷贝,正好补上前面 operator+ 节埋的伏笔。C++11 给所有对象新加了两件"衣服":移动构造和移动赋值(还有 std::move)。对于一个持有堆指针的类(比如我们手写的 String,或标准库的 std::string),"移动"就不再是逐字节深拷贝,而是把对方的指针直接移交过来,再把对方置空——O(1),几乎零成本。这就是为什么现代 C++ 里"返回一个局部 string"、"把 string 放进容器"这些曾经令人肉疼的操作,如今几乎零开销:要么编译器做返回值优化(NRVO)直接省略拷贝,要么退而求其次走移动。

把这三样拼起来,标准库 std::string 的完整性能故事就闭环了:拷贝短串 → SSO 内部缓冲,成本可控;拷贝长串 → 深拷贝,必要时(无法省略的那一次)老实搬;移动 → 指针移交,O(1)。一句话收尾:现代 C++ 的 string 不再是"拷贝重担"的代名词,因为有移动语义和 SSO 兜底。这也是为什么除非明确要"复制后再改",否则函数传参如今普遍推荐 const std::string&,而函数返回局部 string 则放心大胆按值返回——编译器和库会帮你把成本降到最低。

牛刀小试

知识没有实战就是"知识刷子"。课件在最后给了几个经典练习题,这里挑两个最有代表性的,用我们刚学的接口各写一版,让你体会"组合拳"的威力。

练习一:找字符串中第一个只出现一次的字符——统计每个字符出现次数,再按顺序找第一个只出现 1 次的。用定长数组做计数表(ASCII 码作下标)是经典做法:

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string s("abaccdeff");
 
    int count[256] = {0};          // ASCII 码作下标,先全置 0
    for (auto ch : s)              // 第一遍:统计每个字符出现次数
        ++count[(unsigned char)ch];
 
    bool found = false;
    for (size_t i = 0; i < s.size(); ++i)   // 第二遍:按原始顺序找第一个计数值为 1 的
    {
        if (count[(unsigned char)s[i]] == 1)
        {
            cout << "第一个只出现一次的字符: " << s[i] << endl;
            found = true;
            break;
        }
    }
 
    if (!found)
        cout << "所有字符都出现多次" << endl;
 
    return 0;
}

练习二:字符串相加(大数加法)——两个用十进制字符串表示的超大数相加,不能用整型转换(会溢出),要自己模拟逐位加法、处理进位。课件里"先尾插再 reverse"的套路正是处理"从低位往高位进位"的精髓:

#include <iostream>
#include <string>
#include <algorithm>   // std::reverse
using namespace std;
 
string addStrings(string num1, string num2)
{
    string ret;                       // 结果,先逆序累计,最后统一反转
    int i = (int)num1.size() - 1;
    int j = (int)num2.size() - 1;
    int carry = 0;                    // 进位
 
    while (i >= 0 || j >= 0 || carry != 0)
    {
        int d1 = (i >= 0) ? num1[i--] - '0' : 0;   // 逐位取出,不够的前补 0
        int d2 = (j >= 0) ? num2[j--] - '0' : 0;
        int sum = d1 + d2 + carry;
        carry = sum / 10;             // 判断是否满 10 进 1
        ret += char('0' + sum % 10);  // 当前位数字转回字符,尾插
    }
 
    reverse(ret.begin(), ret.end());  // 因为是低位在前,反转成正常的高位在前
    return ret;
}
 
int main()
{
    cout << addStrings("789", "123") << endl;        // 912
    cout << addStrings("999", "1") << endl;          // 1000(连续进位)
    cout << addStrings("0", "0") << endl;            // 0
    cout << addStrings("123456789012345678901", "98765432109876543210")
         << endl;                                    // 大数相加:222222221122222222111
    return 0;
}

这两个题都很好说明了"库接口组合拳"的价值:第一题是"范围 for 遍历 + 定长数组计数",第二题是"尾插 += + reverse 反转 + char('0' + d) 数字转字符"。一个个接口单独看都很简单,合起来却能在十五分钟内解出中等难度的算法题。互联网上那些字符串题目,十有八九都是这么几张牌在反复打。

结语:从"会 Ctrl+C"到"讲出砍"

到这里,std::string 就从"为什么有它"、"怎么用"一直讲到了"底层怎么实现"。走完这一整篇,你应该能随手回答这几类问题:

  • 会用:构造有哪些重载、size/capacity/resize/reserve 谁改谁、[] 与 at 的差别、find 返回什么、getline 怎么读整行、c_str() 为什么"用完即取"。
  • 懂原理:std::string 是 basic_string<char> 的实例化、SSO 短串不走堆、COW 为何被标准淘汰、npos 是 size_t 最大值((size_t)-1)、为何 data() 与 c_str() 在 C++11 后一致、移动语义如何补上最后的性能缺口。
  • 能写:自管资源类的"铁三角"(构造/拷贝构造/拷贝赋值/析构)必须整套正确,深浅拷贝的区别,深拷贝传统版与现代 swap 版各怎么实现,自赋值和双重释放的坑在哪。

一个诚实的提醒:上面讲到的 SSO 阈值(15 / 22 字符)、sizeof(string)(24 / 32 字节)、扩容倍数,都是标准库的具体实现细节,标准并不强制——它们在同一份代码编译到不同编译器/架构时可能不同。把它当成"帮助理解内存布局"的参考,而不是写进代码里的依赖。真正跨平台的保证只有:size() <= capacity()、短串常见实现会避免堆分配、data()[size()] == '\0'(C++11 起)。其余"长什么样",由实现自由裁量。

你可以试着做一个收尾练习,把这篇文章的能量全压进去:用 find 把一段话里所有数字子串找出来;用 getline + rfind 统计某一行的最后一个单词长度;再动手把课件的 String 类从"传统版"改成"现代版",亲手跑一遍深浅拷贝。学完这一篇,再看到 std::string,它就该像老朋友一样亲切了——不但是会用,而且是用得明明白白,讲得出它背后每一块"砍"的那种亲切。准备好了吗?下一站,std::vector 在等你。

收尾练习参考答案

1. 用 find 把一段话里所有数字子串找出来

思路是扫一遍字符串,遇到数字就把它连续的一段"剪"出来输出,再跳过这一段继续找。这里用下标扫描最直白(isdigit 来判断字符是不是数字,<cctype> 里):

#include <iostream>
#include <string>
#include <cctype>     // isdigit
using namespace std;
 
int main()
{
    string s = "abc123de45f6";
    size_t i = 0;
    while (i < s.size())
    {
        if (isdigit((unsigned char)s[i]))          // 是数字,开始收集一段
        {
            size_t j = i;
            while (j < s.size() && isdigit((unsigned char)s[j])) ++j;  // 一直扩到数字串结束
            cout << s.substr(i, j - i) << endl;    // 输出这一整段数字
            i = j;                                  // 跳过它,避免重复找
        }
        else
        {
            ++i;                                    // 不是数字,继续往后挪
        }
    }
    return 0;
}
// 输出:
// 123
// 45
// 6

(这个练习里的核心其实是 substr(pos, len)——找到起点、量出长度、一刀切下。isdigit 前加上 (unsigned char) 转型是为了避免负数 char 传给 iscntrl/isdigit 这类函数时的未定义行为,是防御性写法的好习惯。)

2. 用 getline + rfind 统计一行中最后一个单词的长度

要点有二:一是必须用 getline 读整行(cin >> line 会在空格处停住,读不全);二是定位最后一个空格用 rfind(' ')。注意处理"整行只有一个单词、找不着空格"的边界——此时 rfind 返回 string::npos:

#include <iostream>
#include <string>
using namespace std;
 
int main()
{
    string line;
    while (getline(cin, line))      // 读整行,包括空格
    {
        size_t pos = line.rfind(' ');             // 找最后一个空格
        size_t len;
        if (pos == string::npos)
            len = line.size();                    // 整行就是一个单词,长度就是整行长
        else
            len = line.size() - pos - 1;          // 最后一个空格后面那一小段
        cout << len << endl;
    }
    return 0;
}

课件里直接写 line.size() - pos - 1,那是默认输入里一定有空格的前提(牛客评测保证这一点)。自己写更严谨的版本时,先对 npos 判一下更稳妥。

3. 把课件的 String 类从"传统版"改成"现代版"(swap 版)

传统版是"自己 new 一块、自己 strcpy";现代版借力打力——先让别人的拷贝构造/拷贝赋值把活干完,最后用 swap 换回来,代码又短又安全:

#include <cstring>
#include <cassert>
 
class String
{
public:
    String(const char* str = "")
    {
        if (nullptr == str) { assert(false); return; }
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
 
    // 拷贝构造(现代版):先构造一个已经深拷贝好的临时对象 tmp,
    // 再把自己的 _str 和 tmp 的 _str 交换 —— 数据就"到了"自己手里
    String(const String& s)
        : _str(nullptr)
    {
        String tmp(s);          // tmp 内部完成深拷贝
        swap(_str, tmp._str);   // 换过来之后,tmp 析构时释放的只是原来的空指针
    }
 
    // 赋值(现代版):按值传参会先自动做一次"拷贝构造",得到一个深拷贝的形参 s,
    // 直接 swap 即可。这个版本天然安全:自赋值、异常场景都不用额外操心
    String& operator=(String s)
    {
        swap(_str, s._str);
        return *this;
    }
 
    ~String()
    {
        delete[] _str;
    }
 
private:
    char* _str;
};

对比传统版你会发现,现代版拷贝构造和拷贝赋值逻辑高度一致,都归结成一个 swap,并且自动具备两件事:自赋值安全(s = s 时形参 s 拷贝自自身,swap 前后无副作用);更强的异常安全(若中途 new 抛异常,当前对象状态不会被破坏)。代价只是"按值传参"多绕了一次拷贝,但对 String 这种对象完全可接受。这个手法你以后也会在 std::vector、std::unique_ptr 等容器的"零别名/强异常安全"实现里反复看到。