5. string容器(下):子串、插入删除、C风格转换与内部实现
好,咱们接着聊string。上一章我们把string的构造、赋值、拼接和查找遍历讲透了。这一章,我带你深入几个实战中高频使用的操作——子串提取、插入删除、与C风格字符串的互转,最后再扒一扒string的底层实现。这些内容,说白了就是让你写代码时更顺手,遇到坑时能一眼看穿。
5.1 子串操作:substr的妙用
提取子串,最常用的就是substr。它的签名很简单:
string substr(size_t pos = 0, size_t len = npos) const;
从pos位置开始,取len个字符。如果len缺省或超过字符串末尾,就取到结尾。
核心用法:
- 提取固定长度的子串
- 从某个位置截取到末尾
- 配合find使用,实现字符串分割
我在项目中遇到过解析配置文件的情况。每行格式是"key=value",我需要把等号前后的内容分别提取出来。用substr配合find,几行代码就搞定了。
#include <iostream>
#include <string>
using namespace std;
int main() {
string line = "timeout=30";
size_t pos = line.find('=');
if (pos != string::npos) {
string key = line.substr(0, pos);
string value = line.substr(pos + 1);
cout << "key: " << key << ", value: " << value << endl;
}
return 0;
}
输出:key: timeout, value: 30
嗯,这里要注意:substr不会修改原字符串,它返回一个新构造的string对象。如果你频繁调用substr提取大量子串,会有一定的拷贝开销。我个人的习惯是,如果只是读取子串内容而不需要独立生命周期,可以考虑用string_view(C++17引入)来避免拷贝。不过那是后话了。
避坑指南:我曾经在代码里写str.substr(0, str.length() - 1)来去掉最后一个字符,结果当str为空字符串时,length() - 1变成了一个巨大的无符号数,直接导致程序崩溃。所以,调用substr前一定要检查边界条件。
5.2 插入与删除:灵活调整内容
string的插入和删除操作,比C风格字符串的memmove和strcpy组合拳要优雅得多。我们直接看接口。
5.2.1 插入操作
// 在pos位置前插入字符串
string& insert(size_t pos, const string& str);
string& insert(size_t pos, const char* s);
string& insert(size_t pos, const char* s, size_t n);
string& insert(size_t pos, size_t n, char c);
// 在迭代器位置前插入
iterator insert(iterator p, char c);
void insert(iterator p, size_t n, char c);
template<class InputIterator>
void insert(iterator p, InputIterator first, InputIterator last);
我个人最常用的是前两个重载。比如在构建SQL语句时,需要动态拼接条件:
string sql = "SELECT * FROM users WHERE 1=1";
string condition = " AND age > 18";
sql.insert(sql.length(), condition); // 在末尾插入
cout << sql << endl;
输出:SELECT * FROM users WHERE 1=1 AND age > 18
你想想看,如果换成C风格字符串,你得先计算长度,再分配新内存,然后手动拷贝——麻烦不说,还容易漏掉'\0'。string帮你把这些都管好了。
5.2.2 删除操作
// 删除从pos开始的len个字符
string& erase(size_t pos = 0, size_t len = npos);
// 删除迭代器指向的字符
iterator erase(iterator p);
// 删除迭代器区间 [first, last)
iterator erase(iterator first, iterator last);
删除操作最常用的场景是清理字符串中的某些部分。比如去掉字符串末尾的换行符:
string line = "hello\n";
if (!line.empty() && line.back() == '\n') {
line.erase(line.length() - 1);
}
或者删除指定范围内的字符:
string str = "Hello, World!";
str.erase(5, 2); // 删除从索引5开始的2个字符,即", "
cout << str << endl; // 输出 "HelloWorld!"
小技巧:如果你想清空一个string,用str.clear()比str.erase()更直观。另外,str.erase()如果不传参数,默认删除从0到npos,效果等同于clear。
5.3 string与C风格字符串的转换
在实际工程中,我们经常需要和C风格字符串打交道——比如调用系统API、与旧代码交互、或者使用某些只接受const char*的库函数。string提供了两个关键方法:
| 方法 | 说明 | 返回值类型 |
|---|---|---|
c_str() |
返回以'\0'结尾的C风格字符串 | const char* |
data() |
C++11前返回非const的字符数组,C++11后与c_str()等价 | const char* |
说白了,在C++11及以后的标准中,c_str()和data()的行为完全一致,都返回以'\0'结尾的字符串。但在C++98/03中,data()返回的字符数组不一定以'\0'结尾——这是个历史遗留问题。我建议你统一使用c_str(),避免踩坑。
string s = "Hello";
const char* cstr = s.c_str(); // 获取C风格字符串
printf("%s\n", cstr); // 可以安全地传给printf
// 注意:c_str()返回的指针在string对象被修改或销毁后失效
s += " World"; // 修改string
// printf("%s\n", cstr); // 危险!cstr可能已经失效
我曾经在维护一个老项目时,看到有人把c_str()返回的指针保存起来,然后在另一个线程中修改了string对象,结果导致野指针崩溃。记住:c_str()返回的指针是临时的,不要长期持有它。如果你需要长期持有,请拷贝一份到char数组或使用std::string自己管理。
反过来,从C风格字符串构造string就简单了:
const char* cstr = "Hello";
string s1(cstr); // 构造
string s2 = cstr; // 赋值构造
string s3 = "Hello"; // 直接字面量构造
这里有个细节:string的构造函数会拷贝C风格字符串的内容,所以不用担心原指针被释放的问题。
5.4 string的内部实现
聊了这么多用法,我们来扒一扒string的底层。你可能会问:string到底是怎么管理内存的?为什么它比C风格字符串更安全?
典型的string实现(以GCC的libstdc++为例)包含三个关键成员:
- 指向字符数组的指针:存储实际字符串数据
- 当前字符串长度:不包含结尾的'\0'
- 当前分配的容量:包括已用空间和预留空间
画个图你就明白了:
为什么string要同时记录长度和容量?因为string采用了一种叫"预分配"的策略。当你往string里追加字符时,如果当前容量不够,它会一次性分配更大的内存(通常是当前容量的1.5~2倍),而不是每次追加都重新分配。这样能显著减少内存分配的次数。
举个例子:
string s;
cout << "capacity: " << s.capacity() << endl; // 可能是0或15(取决于实现)
s = "Hello";
cout << "capacity: " << s.capacity() << endl; // 可能是15
s += ", World!";
cout << "capacity: " << s.capacity() << endl; // 可能是30或31
你想想看,如果每次追加都重新分配内存,那性能得多差?string的这种设计,说白了就是用空间换时间。
小技巧:如果你预先知道要存储的字符串大概有多长,可以用reserve()提前分配好容量,避免多次扩容带来的性能损失。比如:s.reserve(1024);
另外,现代C++标准库普遍实现了小字符串优化(SSO)。当字符串长度小于某个阈值(通常是15字节)时,数据直接存储在string对象内部的栈上,不需要堆分配。这能大幅提升短字符串操作的性能。我个人的经验是,如果你的字符串绝大多数都很短(比如ID、名称),SSO能帮你省掉不少堆分配的开销。
5.5 本章小结
这一章我们覆盖了string的四个重要方面:
- 子串操作:substr是字符串分割和提取的利器,注意边界检查
- 插入与删除:insert和erase提供了灵活的修改能力,比C风格字符串安全得多
- C风格转换:c_str()用于与C代码交互,但要注意指针的生命周期
- 内部实现:理解长度、容量、SSO和预分配策略,能帮你写出更高效的代码
嗯,string的内容到这里就告一段落了。下一章我们会进入另一个常用的序列容器——vector。不过在那之前,我建议你把今天讲的这些接口都亲手敲一遍,尤其是substr和erase的边界情况。代码这东西,光看是记不住的。
公众号:蓝海资料掘金营,微信deep3321