前言
迭代器(iterator)是 C++ 容器与算法之间的桥梁:它用一套统一的接口表示「容器中的某个位置」,让std::find、std::sort、std::copy这类算法不必知道容器具体是std::string、std::vector还是std::list。std::string的迭代器是随机访问迭代器(random access iterator),支持+n、-n、下标式跳跃和前后比较,用起来接近指针。
常见的误解有四个。第一,认为std::string::iterator就是char*。在很多实现的标准库配置下它确实被实现为指针或指针的薄包装,但标准只规定它的行为,不规定它的类型;依赖「它一定是指针」的代码不可移植。第二,认为修改字符串后旧迭代器仍然可用。插入、删除、扩容都可能使迭代器失效,继续解引用是未定义行为(undefined behavior, UB)。第三,在erase之后又对同一个迭代器执行++,跳过一个元素甚至越过end()。第四,用迭代器逐字节处理 UTF-8 文本,把多字节字符当成独立字节修改,导致乱码。
本文讲std::string迭代器的类型、基本操作、修改时的失效规则,以及与标准算法配合的注意事项。示例以 C++17 为基准。
一、迭代器类型与基本操作
std::string提供以下几组迭代器接口:
| 接口 | 返回类型 | 用途 |
|---|
begin()/end() | iterator | 可读写遍历 |
cbegin()/cend() | const_iterator | 只读遍历,C++11 起 |
rbegin()/rend() | reverse_iterator | 从尾到头 |
crbegin()/crend() | const_reverse_iterator | 只读反向,C++11 起 |
begin()指向第一个字符,end()指向「最后一个字符之后」的位置,这是一个合法的哨兵位置,可以比较,但不能解引用。空字符串的begin() == end()。反向迭代器把顺序颠倒:rbegin()指向最后一个字符,rend()指向第一个字符之前。
// 适用于 C++17
#include <iostream>
#include <string>
int main() {
std::string s = "abcdef";
for (auto it = s.begin(); it != s.end(); ++it) {
std::cout << *it;
}
std::cout << '\n';
for (auto it = s.cbegin(); it != s.cend(); ++it) {
std::cout << *it; // 只读,不能赋值
}
std::cout << '\n';
for (auto it = s.rbegin(); it != s.rend(); ++it) {
std::cout << *it;
}
std::cout << '\n';
std::cout << *(s.begin() + 2) << '\n'; // 随机访问:'c'
return 0;
}随机访问迭代器支持it + n、it - n、it[n]、it1 - it2。两个迭代器相减得到difference_type,对std::string通常是有符号整数类型,表示距离而不是下标;把end() - begin()转成int之前要确认范围。std::distance(begin, end)是更通用的写法,对随机访问迭代器是常数时间,对链表则是线性时间。
范围 for 循环本质上就是迭代器遍历的语法糖,for (char c : s)等价于从begin()到end()逐个解引用。只读遍历时写const auto&可以避免拷贝,也防止误改。
二、修改操作与迭代器失效
std::string的修改接口大多以迭代器为位置参数,并在 C++11 起返回有用的迭代器:
// 适用于 C++17
#include <string>
void edit(std::string& s) {
auto it = s.begin() + 1;
it = s.insert(it, 'X'); // 返回指向插入字符的迭代器
it = s.erase(it); // 删除该字符,返回下一个位置
s.insert(s.end(), 2, '!'); // 在末尾插入两个 '!'
}insert返回指向第一个插入字符的迭代器;erase返回被删元素之后的迭代器。利用返回值可以在循环中安全地连续修改,而不必每次重新begin()。但前提是每次修改后都使用返回的新迭代器,不能继续用旧的那个。
失效规则需要保守理解:
- 任何可能重新分配缓冲区的操作(
reserve、append、insert、replace、resize等)都可能使所有迭代器、指针、引用失效。 erase至少会使被删位置及其之后的迭代器失效;具体范围与实现有关。operator[]、at、begin、end等不修改内容的操作不会使迭代器失效,但通过operator[]修改字符不会改变长度,也不会触发重新分配。- 一旦迭代器失效,解引用、递增、比较都是 UB,不能靠「看起来还能读到旧值」来判断安全。
标准对basic_string的失效规定相当保守:除了operator[]、at、data、front、back、begin、end等少数不修改内容的接口,任何非 const 成员函数都可能使引用、指针、迭代器失效。具体实现是否因为短字符串优化而「碰巧没动堆」不能作为依据。工程上的稳妥做法是:修改之后重新获取迭代器,或者用下标/find重新定位,不要缓存跨修改的迭代器。
反向迭代器有一个容易混淆的点:rit.base()返回的是「与rit相邻的下一个正向位置」,而不是rit本身指向的位置。例如s.rbegin().base() == s.end()。在erase反向迭代器指向的元素时,通常要写s.erase(std::next(rit).base())或先转成正向迭代器,直接传rit.base()会删错位置。
三、与标准算法配合
迭代器的价值在与算法配合时才充分体现。std::find、std::count、std::reverse、std::sort都只要求迭代器满足相应的类别,std::string的随机访问迭代器全部满足。
// 适用于 C++17
#include <algorithm>
#include <cctype>
#include <iostream>
#include <iterator>
#include <string>
int main() {
std::string s = "Hello, World";
// 转小写:必须先把 char 转成 unsigned char,避免负值传给 tolower
for (auto it = s.begin(); it != s.end(); ++it) {
const unsigned char uc = static_cast<unsigned char>(*it);
*it = static_cast<char>(std::tolower(uc));
}
auto comma = std::find(s.begin(), s.end(), ',');
if (comma != s.end()) {
std::cout << "index: "
<< std::distance(s.begin(), comma) << '\n';
s.erase(comma); // comma 之后不可再用
}
std::reverse(s.begin(), s.end());
std::cout << s << '\n';
return 0;
}几个细节:
std::tolower的参数是int,如果直接传char,在char有符号且字符值大于 127 时会得到负值,行为未定义;先转unsigned char再传,是标准推荐写法。std::distance返回difference_type,不要假设它是int;输出或与size_t比较时注意符号。std::reverse原地反转字节序列。对纯 ASCII 没问题;对 UTF-8 中文,反转后字节顺序被打乱,产生非法序列。字符级反转必须先按码点切分。std::sort(s.begin(), s.end())会按字节排序,同样只适合单字节字符集;对 UTF-8 文本没有「按字符排序」的语义。
如果只是只读遍历,C++17 的std::string_view可以避免构造std::string,但它同样按字节提供迭代器,不会自动处理编码。
常见坑点
坑点一:erase之后又对旧迭代器递增。
❌it = s.erase(it); ++it;跳过一个元素,循环可能越界。
✅ 直接用erase的返回值作为下一轮的位置:it = s.erase(it);,不要再++。
坑点二:缓存end()后修改字符串。
❌auto e = s.end(); s += "x"; for (auto it = s.begin(); it != e; ++it),扩容后e失效。
✅ 修改后重新获取begin()/end(),或使用基于范围的操作。
坑点三:把iterator与const_iterator混用。
❌ 函数返回const_iterator,调用方却用iterator接收,编译失败或需要转换。
✅ 只读接口用cbegin()/cend(),需要修改时统一用iterator;const 对象调用begin()得到的是const_iterator。
坑点四:解引用end()。
❌char c = *s.end();哨兵位置不可解引用,UB。
✅ 先判断it != s.end();空字符串要单独处理。
坑点五:用int保存size()或distance。
❌int n = s.size();超长字符串截断;int d = it2 - it1;大距离溢出。
✅ 用std::string::size_type或std::ptrdiff_t,必要时用auto。
坑点六:std::tolower(*it)直接传char。
❌ 负值字符传给tolower,行为未定义。
✅ 先转unsigned char,再转回char写回。
坑点七:对 UTF-8 字符串按字节反转或排序。
❌std::reverse(s.begin(), s.end())把三字节汉字拆散,产生非法序列。
✅ 按码点切分后再反转;或明确只处理 ASCII。
坑点八:反向迭代器base()直接用于erase。
❌s.erase(rit.base());删除的是rit的下一个位置,不是它指向的字符。
✅ 用s.erase(std::next(rit).base()),或先转成正向迭代器再删。
总结
| 操作 | 接口 | 注意点 |
|---|
| 正向遍历 | begin()/end() | end()不可解引用 |
| 只读遍历 | cbegin()/cend() | 避免误改 |
| 反向遍历 | rbegin()/rend() | base()有偏移 |
| 随机访问 | it + n、it[n] | 下标不检查边界 |
| 插入 | insert(pos, ...) | 返回新位置,旧迭代器失效 |
| 删除 | erase(pos) | 返回下一位置,不要重复递增 |
| 距离 | std::distance | 返回difference_type |
| 算法 | find/reverse/sort | 按字节处理,注意编码 |
std::string的迭代器把字符串接入了标准算法体系,用起来像指针,但失效规则比指针严格得多。写代码时守住三条:不跨修改缓存迭代器、erase只用返回值、按字节处理时先确认字符集。这样既能享受算法的便利,也不会掉进悬垂迭代器与越界解引用的坑。