第三章:std::string的核心操作
引言:日常开发中最频繁使用的功能
在C++项目中,std::string的核心操作(如赋值、拼接、访问字符、查询长度等)是开发者每天都会接触的功能。这些操作看似简单(例如用=赋值、用+拼接、用[]访问字符),但它们的底层实现细节、性能特征以及潜在陷阱却常常被忽视。例如:
- 直接使用
+拼接多个字符串时,为何性能可能比预期的差?如何优化? s[0]和s.at(0)都能访问字符,它们的区别是什么?哪种更安全?- 当字符串容量不足时,拼接或赋值操作会触发什么行为(如内存重新分配)?
- 如何高效判断字符串是否为空?
size()==0和empty()有何区别?
本章将围绕这些最常用但最容易被低估的操作,深入解析它们的实现原理、性能特点、使用场景及最佳实践。我们将覆盖以下内容:
- 赋值操作(
=与assign()):深拷贝、移动语义与多种赋值来源; - 拼接操作(
+、+=与append()):性能差异与优化技巧; - 访问字符(
[]、at()与迭代器):安全性与效率的权衡; - 长度与容量管理(
size()、length()、capacity()):逻辑长度与物理存储的关系; - 清空与判空(
clear()与empty()):状态重置的正确方式。
3.1 赋值操作(=与assign())
3.1.1 拷贝赋值(深拷贝)
最常见的赋值方式是通过=将一个std::string对象赋值给另一个对象,这会触发拷贝赋值运算符,执行深拷贝(新对象与原对象内容相同,但内存独立)。
std::string s1 = "Hello";
std::string s2;
s2 = s1; // 拷贝赋值:s2的内容变为"Hello",内存独立于s1
s1[0] = 'h'; // 修改s1
std::cout << s1; // 输出"hello"
std::cout << s2; // 输出"Hello"(不受影响)
底层过程
- 释放旧内存:若
s2原本持有动态内存(非空字符串),先释放其原有的堆内存。 - 分配新内存:根据
s1的长度分配新的堆内存(大小与s1相同)。 - 复制内容:将
s1的字符逐字节复制到s2的新内存中,并同步更新长度、容量等元数据。
性能注意
当字符串较大时(例如几MB的文本),拷贝赋值的开销较高(需要分配内存+复制数据)。此时应优先考虑移动语义(见3.1.2节)。
3.1.2 移动赋值(C++11优化)
通过std::move将一个临时对象(或显式转换为右值的对象)赋值给目标对象,触发移动赋值运算符,直接“窃取”原对象的资源(如堆内存指针),避免深拷贝。
std::string createTemp() {
std::string temp = "This is a temporary string";
return temp; // 可能触发移动构造(依赖RVO),最终返回一个右值
}
int main() {
std::string s1;
s1 = createTemp(); // 移动赋值:s1直接获取temp的原内存,temp被置空
std::cout << s1; // 输出临时字符串的内容
return 0;
}
手动触发移动赋值
std::string s1 = "Original";
std::string s2 = "To be moved";
s1 = std::move(s2); // 显式移动赋值
std::cout << s1; // 输出"To be moved"(内容来自s2的原内存)
std::cout << s2; // 输出未定义(s2的内存已被转移,但仍是合法对象,empty()返回true)
移动后的原对象状态
移动赋值后,原对象(如s2)的内部指针被置空(或指向空缓冲区),长度和容量变为0,但对象本身仍处于合法状态(可安全调用成员函数,如s2.empty()返回true)。
3.1.3 通过assign()方法赋值
assign()是更灵活的赋值接口,支持从多种来源赋值(C风格字符串、子串、重复字符等),其功能覆盖了=的常见场景,但提供了更明确的意图表达。
常用重载形式
std::string s;
// 1. 从C风格字符串赋值
s.assign("Hello"); // 等价于 s = "Hello";
// 2. 从部分C风格字符串赋值(指定起始位置和长度)
const char* cstr = "Hello, World";
s.assign(cstr + 7, 5); // 从第7个字符('W')开始,取5个字符 → "World"
// 3. 从另一个string的子串赋值
std::string src = "Source String";
s.assign(src, 7, 6); // 从src的第7个字符('S')开始,取6个字符 → "String"
// 4. 重复字符赋值
s.assign(3, 'X'); // 赋值为"XXX"
// 5. 通过迭代器范围赋值(例如从vector<char>构造)
std::vector<char> vec = {'A', 'B', 'C'};
s.assign(vec.begin(), vec.end()); // 赋值为"ABC"
底层逻辑
assign()的实现与对应的构造函数类似(例如assign(const char*)和string(const char*)的逻辑几乎相同),但它是对已有对象的重新赋值,因此会先释放当前对象的旧内存,再按新内容分配内存并复制数据。
3.2 拼接操作(+、+=与append())
3.2.1 使用+运算符拼接
+是最直观的拼接方式,但它返回一个新的字符串对象(原字符串不变),且多次拼接时可能产生临时对象,导致性能问题。
std::string s1 = "Hello";
std::string s2 = "World";
std::string s3 = s1 + ", " + s2; // 等价于 (s1 + ", ") + s2 → 最终为"Hello, World"
底层过程(多次拼接的陷阱)
s1 + ", ":创建临时字符串temp1(内容为"Hello, "),需要分配内存并复制s1和", "的内容。temp1 + s2:创建临时字符串temp2(内容为"Hello, World"),需要分配新内存并复制temp1和s2的内容。- 最终
s3通过拷贝或移动构造获取temp2的内容。
问题:当拼接多个字符串时(例如循环中累加),每次+都会生成临时对象,导致多次内存分配和复制,性能低下。
3.2.2 使用+=运算符拼接(推荐)
+=直接在原字符串的末尾追加内容,避免生成临时对象,性能更高。
std::string s = "Hello";
s += ", "; // 直接修改s,变为"Hello, "
s += "World"; // 继续追加,变为"Hello, World"
底层过程
- 检查容量:若当前字符串的容量(
capacity())不足以容纳追加后的总长度(size() + 追加内容长度),则触发扩容(重新分配更大的内存,复制原内容)。 - 追加内容:将追加的字符(或字符串)逐字节复制到原字符串的末尾(原
size()位置之后)。 - 更新长度:将
size()增加追加内容的字符数。
优势:减少了临时对象的生成,尤其适合循环或多次追加的场景。
3.2.3 使用append()方法(更灵活的拼接)
append()是功能最丰富的拼接接口,支持从多种来源追加内容(C风格字符串、子串、迭代器范围等),其行为类似于+=,但提供了更明确的意图表达。
常用重载形式
std::string s = "Hello";
// 1. 追加C风格字符串
s.append(", "); // 变为"Hello, "
// 2. 追加另一个string的子串
std::string src = "Source";
s.append(src, 0, 4); // 追加src的前4个字符("Sour")→ "Hello, Sour"
// 3. 追加重复字符
s.append(3, '!'); // 追加"!!!" → "Hello, Sour!!!"
// 4. 追加迭代器范围(例如从vector<char>)
std::vector<char> vec = {'A', 'B'};
s.append(vec.begin(), vec.end()); // 追加"AB" → "Hello, Sour!!!AB"
性能建议
- 优先用
+=或append()替代+:尤其在循环中拼接字符串时(例如读取文件行并累积内容)。 - 预分配容量:若已知最终字符串的大致长度,提前调用
reserve()分配足够内存,避免多次扩容(见第8章性能优化)。
3.3 访问字符([]、at()与迭代器)
3.3.1 []运算符(快速但不安全)
通过下标运算符[]可以直接访问字符串中指定位置的字符(从0开始),不进行边界检查,性能最高,但若索引越界会导致未定义行为(可能崩溃或读取到随机内存)。
std::string s = "ABC";
char c1 = s[0]; // 'A'(合法)
char c2 = s[3]; // 未定义行为!s的有效索引是0~2
适用场景
- 确定索引一定合法时(例如通过
size()检查后访问); - 对性能要求极高的场景(例如高频访问字符的算法)。
3.3.2 at()方法(安全但稍慢)
at()方法同样用于访问指定位置的字符,但会检查索引是否越界。若越界,抛出std::out_of_range异常(需包含<stdexcept>头文件)。
std::string s = "ABC";
try {
char c1 = s.at(0); // 'A'(合法)
char c2 = s.at(3); // 抛出std::out_of_range异常
} catch (const std::out_of_range& e) {
std::cerr << "索引越界: " << e.what() << std::endl;
}
适用场景
- 不确定索引是否合法时(例如用户输入的索引);
- 需要安全性的场景(避免程序因越界崩溃)。
3.3.3 迭代器访问(与STL算法协作)
std::string提供了标准的迭代器接口(begin()、end()、rbegin()、rend()等),支持通过迭代器遍历字符,或与STL算法(如std::for_each、std::transform)配合使用。
std::string s = "Hello";
// 正向迭代器
for (auto it = s.begin(); it != s.end(); ++it) {
std::cout << *it; // 逐个输出字符'H','e','l','l','o'
}
// 范围for循环(本质是迭代器的语法糖)
for (char c : s) {
std::cout << c; // 同上
}
// 反向迭代器
for (auto rit = s.rbegin(); rit != s.rend(); ++rit) {
std::cout << *rit; // 逆序输出'o','l','l','e','H'
}
迭代器失效场景
- 当字符串发生修改操作(如
insert、erase、resize导致内存重新分配)时,所有迭代器、指针和下标都会失效。 - 安全操作(如
append未触发扩容时)不会使迭代器失效。
3.4 长度与容量管理(size()、length()、capacity())
3.4.1 逻辑长度(size()与length())
size()和length()是完全等价的成员函数,均返回字符串中实际存储的字符数(不包括结尾的隐式\0)。- 例如:
std::string s = "ABC";的size()和length()均为3。
std::string s = "Hello";
std::cout << s.size(); // 输出5
std::cout << s.length(); // 输出5(与size()相同)
3.4.2 物理容量(capacity())
capacity()返回字符串当前预分配的堆内存大小(字节数),即在不重新分配内存的情况下,最多能存储的字符数(capacity() >= size())。- 例如:若字符串当前长度为3,但容量为15(因SSO或历史扩容),则
capacity()返回15。
std::string s = "Hello";
std::cout << s.size(); // 输出5
std::cout << s.capacity(); // 可能为15(依赖实现,可能因SSO优化)
为什么需要区分长度与容量?
- 长度(size):表示当前实际使用的字符数(逻辑大小)。
- 容量(capacity):表示已分配的内存能容纳的最大字符数(物理大小)。通过预分配足够的容量(
reserve()),可以避免后续操作频繁触发内存重新分配。
3.4.3 判空(empty())
empty()返回bool值,表示字符串是否为空(即size() == 0)。- 与
size() == 0等价,但更直观且语义清晰。
std::string s1; // 默认构造(空)
std::string s2 = "";
std::cout << s1.empty(); // 输出1(true)
std::cout << s2.empty(); // 输出1(true)
std::cout << (s1.size() == 0); // 输出1(true,但empty()更推荐)
3.5 清空与判空(clear()与empty())
3.5.1 clear()方法
clear()将字符串的内容清空(逻辑长度变为0),但不释放已分配的内存(容量capacity()通常保持不变)。
std::string s = "Hello";
s.clear();
std::cout << s.size(); // 输出0
std::cout << s.empty(); // 输出1(true)
std::cout << s.capacity(); // 可能为15(原容量未被释放)
适用场景
- 需要复用字符串对象(避免反复构造/析构),但清除当前内容(例如循环中处理多个字符串)。
- 与
reserve()配合:清空后若需重新填充大量数据,可先reserve()预分配内存,避免后续扩容。
3.5.2 empty()方法(判空首选)
如前所述,empty()是判断字符串是否为空的最佳方式(比size() == 0更直观)。它的语义明确表示“是否有有效字符”,且性能与size() == 0完全相同(通常直接返回size() == 0的结果)。
std::string s;
if (s.empty()) {
std::cout << "字符串为空"; // 会执行
}
核心操作总结表
| 操作类别 | 方法/运算符 | 是否修改原对象 | 是否深拷贝 | 性能特点 | 适用场景 |
|---|---|---|---|---|---|
| 赋值 | = | 是 | 是(拷贝) | 高(大字符串时) | 需要独立副本时 |
= + std::move | 是 | 否 | 极低(仅指针转移) | 接收临时对象(右值)时 | |
assign() | 是 | 是/否 | 依赖来源类型 | 灵活赋值(支持多种来源) | |
| 拼接 | + | 否(返回新对象) | 是 | 低(多次拼接产生临时对象) | 简单拼接(少量固定字符串) |
+= | 是 | 否 | 高(直接修改原对象) | 循环或多次追加时(推荐) | |
append() | 是 | 否 | 高(功能更丰富) | 需要明确意图的拼接场景 | |
| 访问字符 | [] | 否 | - | 极高(无检查) | 确定索引合法时(高性能需求) |
at() | 否 | - | 稍低(有边界检查) | 不确定索引合法性时(安全) | |
| 迭代器 | 否 | - | 高(与STL协作) | 遍历或算法处理时 | |
| 长度与容量 | size()/length() | 否 | - | 极高(直接返回元数据) | 获取字符数 |
capacity() | 否 | - | 极高(直接返回元数据) | 检查预分配内存大小 | |
empty() | 否 | - | 极高(等价于size()==0) | 判断字符串是否为空(推荐) | |
| 清空 | clear() | 是 | - | 极高(仅重置元数据) | 复用对象时清除内容 |
下一章预告:第四章《修改与编辑操作》——深入讲解插入、删除、替换、子串提取及查找匹配的实现细节与性能优化。

被折叠的 条评论
为什么被折叠?



