前言
std::for_each是<algorithm>里最简单的一个算法:对区间内的每个元素调用一次给定的函数对象,仅此而已。它没有返回值语义上的「结果」,也不改变容器大小,常被拿来和范围 for 循环比较。正因为简单,它的细节反而容易被忽略——参数按值还是按引用、返回值有什么用、并行版本有什么额外约束,都是实际写代码时会踩的点。
常见误解有三个。第一,以为for_each里的修改会自动作用到容器上。如果 lambda 的参数写成int x,拿到的是元素的副本,改它不会影响原容器。第二,以为可以像普通循环一样break。for_each没有中断机制,想提前结束只能用std::find_if之类的算法,或者退回手写循环。第三,以为 C++17 的并行重载只是「跑得更快」,忽略了它要求函数对象线程安全、执行顺序不确定、异常会直接终止程序。
本文讲清函数签名、四种典型用法、与范围 for 的取舍,以及并行重载的限制。示例以 C++17 为准。
一、签名与返回值
标准库中的声明大致是:
template<class InputIt, class UnaryFunction>
UnaryFunction for_each(InputIt first, InputIt last, UnaryFunction f);它接受一对输入迭代器和一个一元函数对象,对[first, last)中的每个元素调用f(*it),最后返回函数对象本身。C++11 起返回值是std::move(f),也就是把内部使用的那个副本移出来。这个返回值不是摆设:如果函数对象带有状态(比如累加和、计数),可以通过接收返回值把最终状态取回来。
参数按值传递,意味着调用for_each时会拷贝一份函数对象,算法内部修改的是这份副本。传入 lambda 时,lambda 的捕获列表也随之拷贝。如果 lambda 按值捕获了外部变量,内部修改的只是副本,外部变量不变;要影响外部,必须按引用捕获。
返回类型与传入的函数对象类型相同。传入的是 lambda 时,返回的也是那个 lambda 类型,通常用auto接收。传入的是自定义结构体时,可以直接赋给同类型的变量。
二、四种典型用法
只读访问。参数写成int x或const int& x都可以。前者拷贝,后者避免拷贝;对int这种小类型差别可以忽略,对大对象应当用 const 引用。
原地修改。参数必须写成int& x或auto& x。写成值类型时,修改的是副本,容器纹丝不动。这是最常见的错误,而且编译器不会报错,只会在结果不符合预期时才发现。
通过引用捕获累加。如果想把结果写到外部变量,lambda 的捕获列表要写[&sum],参数按值接收即可。注意for_each是顺序执行的(非并行重载),所以这种累加是安全的;如果换成并行重载,多个线程同时写sum就是数据竞争。
利用返回值取回状态。如果不想用引用捕获,可以传一个带成员变量的函数对象,算法返回它的副本,再从返回值里读结果。这种写法把状态封装在函数对象内部,避免了外部变量的生命周期问题,也更适合复用。
还有一个常见需求是带下标处理。for_each本身不提供下标,可以在外部维护一个计数器并按引用捕获,在 lambda 里自增。这只在顺序执行时成立;并行版本里计数器的自增顺序不确定,不能用来生成下标。
三、与范围 for 的取舍
范围 for 循环(range-based for)和for_each都能遍历区间,选择时看几个因素:
| 维度 | 范围 for | std::for_each |
|---|
| 可读性 | 更接近自然语言 | 需要写起止迭代器 |
| 提前退出 | 支持break、continue | 不支持,需换算法 |
| 下标 | 需自己维护 | 同样需自己维护 |
| 子区间 | 不方便 | 传任意迭代器区间即可 |
| 返回值 | 无 | 可返回带状态的函数对象 |
| 并行 | 不支持 | C++17 起有并行重载 |
| 异常 | 正常传播 | 串行版正常传播,并行版终止 |
如果只是遍历整个容器、循环体里可能提前退出,范围 for 更合适。如果需要对一个子区间操作、需要把状态通过返回值带出、或者要切换到并行执行,for_each更合适。两者不是替代关系,而是同一件事的两种表达。
C++17 还提供了std::for_each_n,只处理区间的前 n 个元素,适合「只知道起点和数量」的场景。
四、并行重载
C++17 为一批算法增加了执行策略重载,for_each也在其中。用法是第一个参数传std::execution::par,并包含<execution>:
// 需要 C++17 及并行执行后端
#include <algorithm>
#include <execution>
#include <vector>
void scale(std::vector<double>& v) {
std::for_each(std::execution::par, v.begin(), v.end(),
[](double& x) { x *= 2.0; });
}并行版本有三条额外约束。第一,函数对象会被多个线程同时调用,必须线程安全;访问共享变量要加锁或用原子操作,否则是数据竞争,属于未定义行为。第二,元素的处理顺序不确定,不能依赖「先处理前面的、再处理后面的」。第三,如果函数对象抛出异常,标准规定调用std::terminate,而不是把异常传播给调用者;需要在函数内部捕获并处理。
还有一点是工具链支持。<execution>和并行算法需要标准库实现和底层后端(例如 GCC 需要 TBB)配合,不同平台、不同版本的支持程度不一样,使用前应当查编译器支持表并确认链接配置。没有并行后端时,代码可能编译不过或退化为串行执行。
五、完整示例
// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <vector>
struct Sum {
long long total = 0;
void operator()(int x) { total += x; }
};
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
// 1. 只读访问
std::for_each(v.begin(), v.end(), [](int x) {
std::cout << x << ' ';
});
std::cout << '\n';
// 2. 原地修改:参数必须是非 const 引用
std::for_each(v.begin(), v.end(), [](int& x) { x *= 2; });
// 3. 引用捕获,累加到外部变量
long long sum = 0;
std::for_each(v.begin(), v.end(), [&sum](int x) { sum += x; });
std::cout << "sum=" << sum << '\n';
// 4. 通过返回值取回函数对象的状态
Sum s = std::for_each(v.begin(), v.end(), Sum{});
std::cout << "total=" << s.total << '\n';
// 5. 借外部计数器生成下标
std::size_t index = 0;
std::for_each(v.begin(), v.end(), [&index](int& x) {
x += static_cast<int>(index);
++index;
});
for (int x : v) std::cout << x << ' ';
std::cout << '\n';
return 0;
}按语义推演。初始序列是 1 到 5,第一步原样输出。第二步每个元素乘 2,序列变成 2、4、6、8、10。第三步求和,sum为 30。第四步再用Sum累加一遍,total也是 30。第五步按下标累加,2 加 0、4 加 1、6 加 2、8 加 3、10 加 4,最终序列是 2、5、8、11、14。
第 4 步的Sum{}是临时对象,for_each按值接收后内部持有一份副本,遍历结束返回这份副本,赋给s。如果写成Sum s; std::for_each(..., s);,算法修改的是s的副本,s.total仍然是 0,必须用返回值重新赋值。
常见坑点
坑点一:lambda 参数按值接收却想修改元素。
❌std::for_each(v.begin(), v.end(), [](int x) { x *= 2; });,容器不变。
✅std::for_each(v.begin(), v.end(), [](int& x) { x *= 2; });。
坑点二:按值捕获外部变量并修改。
❌int sum = 0; std::for_each(..., [sum](int x) mutable { sum += x; });,外部sum仍是 0。
✅ 按引用捕获[&sum],或使用返回值接收状态。
坑点三:在for_each中增删容器元素。
❌ lambda 里调用v.push_back(...),迭代器失效,未定义行为。
✅ 先记录要处理的结果,循环结束后统一修改容器。
坑点四:想在for_each中提前退出。
❌ 在 lambda 里写break,编译失败;用抛异常退出又会打乱控制流。
✅ 改用std::find_if、std::any_of等带短路语义的算法,或手写循环。
坑点五:并行版本里共享未同步的状态。
❌std::for_each(std::execution::par, ..., [&](int x) { sum += x; });,数据竞争。
✅ 用std::atomic、互斥量,或让每个线程维护局部结果再合并。
坑点六:并行版本里依赖处理顺序。
❌ 用外部计数器生成下标,并行执行时下标与元素不对应。
✅ 并行版本只做与顺序无关的计算;需要下标时用std::views::iota(C++20)或手写索引循环。
坑点七:忽略返回值导致状态丢失。
❌Sum s; std::for_each(v.begin(), v.end(), s);之后读s.total。
✅Sum s = std::for_each(v.begin(), v.end(), Sum{});或s = std::for_each(..., s);。
坑点八:并行版本抛异常期待被捕获。
❌ 在 lambda 里抛出异常,指望调用方try/catch接住。
✅ 并行算法中未捕获的异常会调用std::terminate,应在 lambda 内部处理。
总结
| 需求 | 写法 | 注意点 |
|---|
| 只读遍历 | [](const auto& x) | 大对象避免拷贝 |
| 原地修改 | [](auto& x) | 参数必须是引用 |
| 累加结果 | [&sum](auto x)或返回函数对象 | 并行时不能共享写 |
| 提前退出 | 不用for_each | 改用find_if或手写循环 |
| 子区间 | 传对应迭代器 | 不要求从头开始 |
| 并行执行 | std::execution::par | 线程安全、无序、异常终止 |
std::for_each的定位是「对每个元素做一件事」,它不负责筛选、不负责中断、也不负责改变容器结构。把参数类型、捕获方式和返回值这三处写对,它就是一个比手写迭代器循环更清晰的工具;需要短路或并行时,再根据上表的边界换用其他算法。