如果你已经在 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() 有两个必须死死记住的坑:
- 返回的是内部缓冲区的指针,不是拷贝。也就是说,一旦你之后对 string 做了任何会改动内容或触发扩容的操作(
+=、insert、erase、assign、reserve扩容……),这个指针就可能失效(指向被释放或搬迁的内存)。正确姿势是:用完即用,不要长期保存;每次要传给 C 函数前再取一次c_str()。 - 返回类型是
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 等容器的"零别名/强异常安全"实现里反复看到。
还没有评论 — 第一条由你来留。