深度剖析 C++ 之 string(上)篇
前言
在 C++ 的世界里,
std::string
既熟悉又容易被低估。
很多初学者在刚接触它时,觉得无非就是个“更好用的 char 数组”,但真正用得深入,你会发现——string 远比你想象的强大,也更容易踩坑。
无论是在刷题、写项目,还是面试中,字符串处理几乎无处不在:
算法题:翻转、查找、替换、拼接……
工程代码:日志处理、输入输出、协议解析……
面试问题:SSO、小字符串优化、底层实现……
本篇是笔者 C++ 专栏中 “string” 的 上篇。
我会带你从零开始,一步步掌握std::string
的常用接口、使用技巧、经典易错点。
本文分为上下两篇:
上篇:完整讲解 string 的使用层面,包含构造、访问、容量管理、修改、查找、I/O、易错点和性能技巧。
下篇:深入剖析 string 的实现原理,手写一个
string 类
,理解它背后的原理。
准备好了吗?让我们从 C 字符串 vs
std::string
开始,一起深挖 C++ 字符串的世界。
一、C 字符串 vs std::string
在 C 语言里,字符串就是一块
char[]
或char*
内存,以\0
结尾。操作全靠strlen
、strcpy
、strcmp
这些函数来完成,数据和操作是分开的,一不小心就越界或者内存泄漏了。char str[10] = "hello"; strcpy(str, "world"); printf("%s\n", str);
C++ 给我们提供了
std::string
类,它内部帮我们管理内存,接口也丰富多了,拼接、查找、插入、删除啥都有。更重要的是:它用得舒服,错也错得少!#include <iostream> #include <string> using namespace std;int main() {string s = "hello";s += " world";cout << s << endl; // hello world }
二、构造、赋值、拷贝与移动
1. 常见构造方式
string
的构造方式非常灵活,最常用的几种就是:
string s1; // 默认构造,空字符串 ""
string s2("hello"); // 从 C 字符串构造
string s3(5, 'x'); // 构造 "xxxxx"
string s4(s2); // 拷贝构造,s4 == "hello"
小提示:
- 默认构造出来的
string
为空,但内部还是有对象结构的,size()
是 0。(n, ch)
这种构造在初始化固定长度时很方便,刷题的时候经常用来预分配。
2. 赋值与拼接
除了构造之外,我们经常会给 string
重新赋值,或者在后面拼接一些内容:
string a = "hello";
string b;b = a; // 拷贝赋值,b 变成 "hello"
b += " world"; // 在 b 后拼接一个字符串
b.append("!!!"); // append 和 += 效果类似,也是追加
cout << b << endl; // hello world!!!
区别:
=
是整体替换。+=
和append
是追加。append
比较适合循环中拼接,因为它语义更明确,效率也不错。
3. 移动语义(C++11)
移动构造听起来有点高深,其实就是“把资源偷过来”,避免不必要的拷贝
string x = "abc";
string y = std::move(x); // y 接管了 x 的内部资源
cout << y << endl; // abc
// x 处于“有效但内容未定义”的状态,不要再依赖 x 的值
在返回值、容器中插入、临时对象传递时,移动语义非常有用,能显著减少拷贝。
三、元素访问与遍历
下面我们来讲讲 string 怎么访问字符,怎么遍历。掌握好迭代器和访问方式,能写出非常优雅的字符串处理代码。
1. 下标与 at()
operator[]
就是最直观的访问方式,就像访问数组一样;
at()
的功能差不多,但是多了边界检查。
string s = "hello";
cout << s[1] << endl; // 'e'
s[1] = 'X'; // 修改也没问题
cout << s << endl; // hXllo
但要注意:如果你写 s[100]
,越界了不会报错,结果是未定义行为,很可能直接崩。
如果你想更安全一点,可以用
at()
try {string s = "abc";cout << s.at(10); // 越界,抛异常 } catch (out_of_range& e) {cout << "越界啦:" << e.what() << endl; }
小结:
- 调试期、边界敏感:用
at()
,安全。- 性能敏感、确定没越界:用
[]
,快。
2. 迭代器使用
迭代器就像一个“指针”,指向字符串里的每个字符,让你可以用统一的方式遍历、修改、配合算法使用。
正向迭代器
最基本的遍历:
string s = "abc";
for (string::iterator it = s.begin(); it != s.end(); ++it) {cout << *it << ' ';
}
// 输出:a b c
这里 it
的类型可以省略成 auto
,写起来更舒服:
for (auto it = s.begin(); it != s.end(); ++it) {*it = toupper(*it); // 也可以直接修改
}
const_iterator(只读遍历)
如果你只是想读取字符,不打算改内容,用 cbegin()
/ cend()
,迭代器类型是只读的:
string s = "xyz";
for (auto it = s.cbegin(); it != s.cend(); ++it) {cout << *it << ' ';
}
// 输出:x y z
反向迭代器
rbegin()
和 rend()
是反着遍历,从最后一个字符开始向前:
string s = "12345";
for (auto rit = s.rbegin(); rit != s.rend(); ++rit) {cout << *rit << ' ';
}
// 输出:5 4 3 2 1
这个在需要倒序输出、逆序处理的时候很好用,比如字符串翻转。
迭代器 + STL 算法
迭代器的真正威力在于能和算法库搭配
例如查找字符:
#include <algorithm>
string s = "Hello World";
auto it = find(s.begin(), s.end(), 'W');
if (it != s.end()) {cout << "找到 W,位置是:" << distance(s.begin(), it) << endl;
}
或者一行把字母全转大写:
transform(s.begin(), s.end(), s.begin(),[](unsigned char c){ return toupper(c); });
cout << s << endl; // HELLO WORLD
这些算法的好处是写法统一、可读性好,学会了可以直接套到各种容器上,不仅仅是 string。
四、大小与容量管理
string
其实就像一个动态数组,它内部维护了 “当前长度(size)” 和 “分配的空间(capacity)”,通过一些成员函数可以方便地管理。
1. size / length
这俩是最基本的函数,用来获取字符串中字符的个数(不包含 \0
结尾),length()
和 size()
完全等价:
string s = "hello";
cout << s.size() << endl; // 5
cout << s.length() << endl; // 5
小提示:
size()
是 STL 容器的标准写法;length()
保留是为了和 C 风格名字兼容,选哪个都行。
2. capacity
capacity()
返回的是底层已经分配的空间大小(不一定等于当前字符串长度)。它和 size()
的区别就像“房间实际使用面积 vs 房间总面积”
string s = "abc";
cout << s.size() << endl; // 3
cout << s.capacity() << endl; // 比 3 大,具体值和实现有关
小知识:
capacity
一般是按照倍数扩容,比如 15、31、63……,这是为了减少频繁分配的开销。- 你可以通过
reserve()
来提前申请空间。
3. empty / clear
empty()
用来判断字符串是不是空的;clear()
是清空内容,但不会释放空间:
string s = "abc";
cout << s.empty() << endl; // false
s.clear();
cout << s.empty() << endl; // true
注意:
clear()
并不会让 capacity 变小,只是 size 变成 0;- 所以清空后再次拼接,不会重新分配内存,效率挺高的。
4. reserve
reserve(n)
是一个非常实用的性能优化接口,它告诉 string:“我接下来大概要放 n 个字符,麻烦你先准备好空间”。
这样后面 append/push_back 的时候就不会频繁扩容了:
string s;
s.reserve(100); // 预留 100 个空间
cout << s.capacity() << endl; // >= 100
容易忽略:在循环里频繁用
s = s + "xxx"
,没预留空间,会不断重新分配 + 拷贝,性能会非常差。
5. resize
resize(n)
用来改变字符串的长度:
- 如果 n 比原来大,多出来的字符会被填充(默认
\0
或你指定的字符); - 如果 n 比原来小,就截断。
string s = "abc";
s.resize(5, 'x'); // abcxx
cout << s << endl;s.resize(2); // ab
cout << s << endl;
resize
经常在字符串预分配或清零时用,比如题目里让你“先准备好长度为 N 的字符串再填充”。
五、修改操作
修改操作是 string 最常用的一类函数,包括:往后追加、在中间插入、删除子串、替换等。用得好可以极大简化代码量。
1. push_back
往字符串尾部追加一个字符:
string s = "hi";
s.push_back('!');
cout << s << endl; // hi!
常用场景:循环逐个字符拼接时,用 push_back
比 +=
高效而且语义更清晰。
2. append
往字符串尾部追加一个字符串:
string s = "hello";
s.append(" world");
cout << s << endl; // hello world
你也可以追加另一个 string:
string a = "C++";
string b = " string";
a.append(b);
cout << a << endl; // C++ string
注意:
append
只是追加,不会替换原有内容;- 它比多次
+=
更清晰,尤其是循环拼接时。
3. += 运算符
+=
是最常用的拼接方式,其实本质和 append
差不多:
string s = "I love";
s += " C++";
s += '!';
cout << s << endl; // I love C++!
4. insert
insert
用来在指定位置插入内容:
string s = "world";
s.insert(0, "hello "); // 在开头插入
cout << s << endl; // hello world
你也可以插入单个字符:
s.insert(s.begin(), '[');
cout << s << endl; // [hello world
注意:在中间插入的复杂度是 O(n),因为底层要移动字符。
5. erase
erase
用来删除指定位置或区间的字符
string s = "abcdef";
s.erase(1, 3); // 从下标1开始删3个 -> aef
cout << s << endl;
也可以用迭代器删除:
s.erase(s.begin() + 1); // 删掉 'e'
cout << s << endl; // af
小提示:
erase(pos, len)
是最常用的;erase(it)
适合在遍历时删单个字符;- 支持
erase(begin(), end()-1)
这种整段删除。s.erase(s.begin(), s.end()); // 整体清空
6. replace
replace(pos, len, str)
用来把某一段替换成另一个字符串:
string s = "I like dogs";
s.replace(7, 4, "cats"); // 从下标7起,替换4个字符
cout << s << endl; // I like cats
这个在字符串模板替换、敏感词替换等场景非常好用。
常见坑:
- 替换后字符串长度可能会变长或变短,注意下标偏移;
- 在循环替换时要更新起始位置,避免死循环
string s = "a b c"; size_t pos = s.find(' '); while (pos != string::npos) {s.replace(pos, 1, "%%");pos = s.find(' ', pos + 2); // 跳过刚插入的 "%%" }
六、查找与子串
查找和切片是处理字符串时的“高频武器”,灵活掌握 find
、rfind
、substr
,可以轻松搞定路径解析、文本匹配、OJ 字符串题等。
1. find
find
会从左到右查找目标字符串或字符,返回第一次出现的位置,如果找不到,就返回 string::npos
string s = "banana";
size_t pos = s.find("na");
if (pos != string::npos) {cout << "找到 na,位置:" << pos << endl;
}
千万不要拿 find
的结果和 -1
比较,这是个经典易错点。
2. rfind
rfind
从右往左找,适合用来提取文件后缀、最后一个分隔符
string file = "test.cpp";
size_t dot = file.rfind('.');
if (dot != string::npos) {string ext = file.substr(dot);cout << ext << endl; // .cpp
}
3. substr
substr(pos, len)
是用来截取子串的,跟 Python 的切片差不多
string s = "abcdef";
string t = s.substr(2, 3); // 从下标2截3个,cde
cout << t << endl;
如果省略 len
,就会截到字符串末尾
cout << s.substr(2) << endl; // cdef
注意:
- 如果
pos > size()
会抛out_of_range
异常;- 如果
pos + len > size()
,就截取到末尾,不会抛异常。
七、I/O 与非成员函数
std::string
不仅可以用各种成员函数,还支持很多“操作符”或者标准 I/O 函数,我们来把它们串一下。
1. cin / cout
直接用 cin
和 cout
就能输入输出 string,比 scanf
/ printf
安全太多了
string s;
cin >> s; // 输入:hello world
cout << s << endl; // hello
注意:
cin >> s
遇到空格就会停止输入,所以如果你想读取整行(包括空格),要用getline。
2. getline
getline(cin, str)
可以一次性读入一整行内容(包括空格)
string line;
getline(cin, line);
cout << "你输入的是:" << line << endl;
小贴士:
如果你前面用了
cin >>
,再立刻用getline
,有可能会读到一个空行。原因是cin >>
读取完后,换行符还留在缓冲区。
解决方法:cin >> s; cin.ignore(); // 忽略掉一个换行 getline(cin, line);
3. operator+
+
运算符可以把多个字符串拼接在一起,非常直观
string a = "hello";
string b = "world";
string c = a + " " + b;
cout << c << endl; // hello world
但是!
string d = "C++" + b; // 错误!因为 "C++" 是 const char*
这个是高频坑:
"abc" + string
会报错(因为前面是 char*)- 但
string + "abc"
是 OK 的- 解决办法:把第一个字符串也改成
string
:string d = string("C++") + b; // 正确
3. 比较运算符
==
、!=
、<
、>
、<=
、>=
都可以直接用在 string 上:
string a = "abc", b = "abd";
cout << (a == b) << endl; // 0
cout << (a < b) << endl; // 1 (按字典序比较)
底层是按字典序比较,就像词典里查单词顺序一样。
这在排序时特别好用:
vector<string> v = {"banana", "apple", "cherry"};
sort(v.begin(), v.end());
for (auto& s : v) cout << s << " ";
输出:
apple banana cherry
八、常见易错点
1. find 返回值和 -1 比较
这是最高频的错误之一
string s = "abc";
if (s.find('x') == -1) { // 错了cout << "没找到" << endl;
}
find
返回的是 size_t
类型(无符号),没找到返回 string::npos
,这个值其实是 size_t
的最大值。
你拿它和 -1
比较,会发生隐式类型转换,逻辑不对了。
正确写法:
if (s.find('x') == string::npos) {cout << "没找到" << endl; }
2. c_str() 指针失效
c_str()
返回的是一个 内部的 const char 指针*,如果字符串内容改变,这个指针就可能失效
string s = "hello";
const char* p = s.c_str();
s += " world"; // 可能导致 p 失效
cout << p << endl; // 未定义行为
正确做法:**每次使用前重新获取 c_str()**
const char* p = s.c_str(); send_to_api(p);
3. cin >> 和 getline 混用
这个坑前面提过一次,但太多人中招必须再强调:
string a, b;
cin >> a;
getline(cin, b); // 错误!b 可能读到空行
原因是 cin >>
读完后,缓冲区里还留着 \n
,getline
直接读走了这个换行。
解决办法:
cin >> a; cin.ignore(); // 清掉换行 getline(cin, b);
4. substr 越界
substr(pos, len)
如果 pos 超过 size,就会抛异常:
string s = "abc";
try {string t = s.substr(10, 2);
} catch (out_of_range& e) {cout << "越界啦:" << e.what() << endl;
}
所以使用前最好加个判断:
if (pos < s.size()) {string t = s.substr(pos, 2); }
5. operator+ 拼接效率问题
很多人喜欢在循环里用 s = s + "x";
来拼接字符串,这其实是个性能大坑:
string s;
for (int i = 0; i < 1000; ++i) {s = s + "x"; // 每次都生成临时字符串,效率极差
}
高效写法:
s.reserve(1000); // 先预留 for (int i = 0; i < 1000; ++i) {s.push_back('x'); }
九、性能与实践建议
1. 参数传递:用 const string&
如果读者写函数时参数是 string,一定要传引用
void printString(const string& s) {cout << s << endl;
}
不推荐写法:
void printString(string s); // 每次都会拷贝一份
2. 返回值:放心返回局部变量
C++11 之后编译器会自动做 RVO(返回值优化)或移动,不用担心性能:
string make_hello() {string s = "hello";return s; // 不会真的拷贝 }
3. 拼接:少用 +
,多用 append
+
很方便,但频繁拼接性能很差:string s; s += "abc"; s.append("def"); // 更合适
4. 与 C 交互:小心 c_str()
使用
c_str()
传给 C 接口时,记得它只是临时借用,string 改变就失效。
要么每次都现取,要么自己拷贝一份出来。
5. 多线程:读安全,写要同步
string 自身不是线程安全的。多个线程同时读没问题,但只要有一个线程在改,就必须加锁。
总结
到这里,本篇笔者完整地把 C++ std::string
的基础用法讲了个遍:
- 构造、赋值、移动、拼接
- 元素访问与迭代器
- 容量管理、修改操作、查找子串
- 输入输出与易错点
- 性能技巧
这一篇的目标就是让读者在写实际项目、刷算法题时,不再对 string 模糊不清,遇到问题能立刻想到对应的接口来解决。
在 深度剖析 C++ 之 string(下)篇 中,我们将进入“幕后”:
- 探秘 string 的内部结构:SSO(小字符串优化)、内存布局
- 自己手写一个
string 类
- 现代形式的拷贝赋值 & 自定义迭代器
- 扩展功能实现
看完下篇,读者不仅能用 string,还能实现一个 string!