C++ transform、copy 与 for_each:映射、复制和遍历
05 transform、copy和for_each把遍历从循环里抽出来
把一批商品的价格抽出来做成一个列表,手写循环大概是这样:
std::vector<int> prices;
for (const Product& product : products) {
prices.push_back(product.price);
}
说句公道话,这个循环不算差:三行,意图也读得出来。但注意它的结构:声明结果容器、逐个遍历、把结果追加进去,一个标准的三段式。同样的三段式在项目里反复出现,筛出库存不足的商品是这个骨架,打印每个元素是这个骨架,把每个元素格式化成字符串还是这个骨架。骨架一成不变,变的只有中间那一两行。代码库里这类循环越多,骨架的重复就越刺眼,因为每一处重复都是一次读代码时的重新确认:这里又是在遍历,中间干的活才是重点。
再看同一件事的算法写法:
std::vector<int> prices;
std::transform(products.begin(), products.end(),
std::back_inserter(prices),
[](const Product& product) { return product.price; });
算法版本做的事,是把三段式的骨架收进函数名,把变化的部分留在参数里:范围说明从哪读,back_inserter 说明往哪写,lambda 说明每个元素怎么变。本章要回答的问题因此很具体:遍历每个元素时,什么时候用算法比手写 for 更清楚。答案不会是一边倒的"永远用算法",而是按结果形态对号入座:一对一转换找 transform,筛选复制找 copy_if,逐项动作找 for_each,以及什么时候循环本身就是更好的表达。
这三个算法还共享同一张参数地图:输入范围打头,输出位置居中,规则对象殿后。transform 的规则是映射,copy_if 的规则是闸门,for_each 的规则是动作,三者都是第 03 章意义上的可调用对象,只是签名和用途各不相同。看懂这张地图,本章剩下的内容就是把三种规则各自的语义和边界讲透。
transform 把每个元素映射成另一个值
transform 的语义可以一句话说完:输入范围的每个元素经过规则加工,变成输出范围的恰好一个元素。数量不变,顺序不变,值可以变,类型也可以变,Product 进去、int 出来是完全合法的。它对应数据处理里最常见的一步:我有一批东西,我要它们的另一副样子。抽字段、单位换算、格式化、解析,全是映射。规则本身享受和第 03 章谓词同样的自由度:普通函数、lambda、函数对象都能上岗,需要外部状态时也照捕不误,汇率、税率、前缀串从调用点捕获进来,第 08 章会把捕获机制彻底展开。
参数顺序值得看一眼:输入范围两个迭代器打头,第三个参数是输出位置,最后才是转换规则。常见误会是以为 transform 会返回一个新容器,它不会,它只往你给的输出位置写结果,容器要你自己准备。这个设计看起来麻烦,实则把选择权留了出来:写进新容器、写回原范围、写向输出流,全由第三个参数决定。它的返回值也和 copy_if 一样是输出迭代器的最终位置,需要接续写入时正好派上用场。映射规则对每个元素恰好被调用一次,且按范围顺序进行,映射产生的是新值,大对象的返回走移动语义,不会凭空多出深拷贝。
写回原范围是明确允许的用法。一元版本的 transform 保证按顺序处理,每个位置先读出旧值、算出新值、再写回同一位置,就地转换安全成立。给全场价格打八折,就是 transform(v.begin(), v.end(), v.begin(), op) 一行的事,不需要临时容器。transform 还有一个二元版本,接收两段输入范围和二元规则,把对应位置的元素两两合成结果,比如价格序列和税率序列合成含税价序列。注意第二段范围只传起点,长度是否足够由调用方负责,这是两段范围算法的通用约定,第 07 章的 inner_product 还会遇到。
工程上 transform 最顺手的用法是投影:从重型记录里抽出几个字段,组成轻量结构体序列,供后续排序、统计使用,原始容器全程不动。输入是只读的,输出是新建的,两边的边界清清楚楚。唯一要留神的是类型:lambda 的返回类型会被写进输出容器,隐式收窄或意外转换在这里不会有人提醒你,输出容器的元素类型和规则的返回类型最好在写代码时就对一遍。
手写映射循环的常见错法也值得对照着看。忘了预留空间时,输出容器在循环里反复扩容,性能悄悄打折;用下标写入时算错偏移,结果整体错位;图省事把筛选条件写进循环体,映射和过滤混成一团,两头都说不清。transform 把骨架标准化之后,这些错法失去了寄生的土壤:空间问题由输出迭代器的选择一次性回答,过滤有 copy_if 专职负责,映射规则回到纯函数的本分。
映射规则的形态也比"抽字段"宽得多。解析是它的辖区:字符串序列映射成整数序列,原始输入和结构化结果泾渭分明;展示也是它的辖区:枚举值映射成打印名,内部表示和外部呈现各得其所;归一化同样是:不同单位的数值统一折算成基准单位。规则与输入容器类型解耦,换一个容器装数据,同一条规则原样复用,这份自由度来自规则只看元素、不看来源的设计。
copy_if 把筛选和复制合成一步
copy 做的事是整体搬运:把输入范围原样写到输出位置。copy_if 在搬运前加一道谓词闸门,规则放行的元素才进入输出,其余留在原地。筛选加复制两个动作合成一次遍历,谓词决定子集的边界,复制保持元素原有的相对顺序,输出就是输入的一个保序子集。手写对照组是熟悉的四件套:声明新容器、遍历、if 判断、push_back,copy_if 把它收进一次调用,谓词原样保留,骨架消失。
由于通过谓词的元素数量事先不可知,copy_if 的返回值派上了用场:它返回输出迭代器的最终位置,也就是"写到了哪里"。用插入迭代器时直接看新容器的 size() 更方便,但覆写已有范围时,这个返回值是知道实际写了几个元素的唯一途径。复制出来的新容器是独立副本,改它不会影响原范围,想要共享同一份数据就别复制本体,复制指针或索引,语义和成本都要提前想清楚。
手写版本还有一个经典错误值得对照着记:在同一个容器上边遍历边追加。遍历到一半 push_back 触发扩容,迭代器全部失效,循环走向不可知。copy_if 从结构上杜绝了这种写法,输入和输出是两个明确分开的参数,想写回同一个容器都得先把话说清楚。分离输入输出这个参数设计,本身就是一道防呆闸门。
筛选复制在业务代码里的出镜率极高:从订单里挑出异常的、从用户里挑出活跃的、从日志里挑出错误级别的。它们的共同点是结果比输入小,且只想保留通过条件的部分。这类需求一旦出现"先筛出来再处理"的字样,copy_if 基本就是标准答案。它还常和排序打组合:先 copy_if 筛出候选,再对候选 sort,筛选用谓词,排序用比较器,第 03 章的两种规则在一条流水线里各就各位。
copy 家族还有几个低频但有用的成员。copy_n 从指定位置复制固定个数,copy_backward 从尾部反向复制,处理区间右移重叠场景,std::move 的算法版本则把元素逐个移动而非拷贝到输出位置,大对象搬运时省掉整轮深拷贝。它们共享同一个输出模型:算法只管写,空间归调用方,这就是下一节的主题。
性能层面先说清楚一件事:copy_if 和手写筛选循环的成本结构完全相同,一次遍历加若干次元素拷贝,算法没有暗藏任何额外开销。真正决定快慢的是元素拷贝本身,这和 transform 一节的结论遥相呼应:元素重就复制句柄,元素轻就复制本体,选择在于数据,与算法无关。把算法当成性能嫌疑犯之前,先检查拷贝的对象有多大。
谓词的约束则与前几章一脉相承。copy_if 对每个元素恰好调用一次谓词,顺序由算法决定,规则保持纯判断是最稳妥的姿势。想在谓词里顺手记录"筛掉了几个"时,请回忆按值传递的副本语义:统计发生在副本上,调用点拿不到。要统计,用 count_if 单独问一遍,或者改用第 07 章的归约思路,每个问题都有对口的算法,不必让谓词身兼数职。
back_inserter 给输出范围一个生长方式
transform 和 copy_if 都只承诺一件事:往输出迭代器指向的位置写值,写满一个就前进一步。空间从哪来,算法不管,这是调用方的责任,也是新手在这里栽的第一个大坑。对一个空 vector 的 begin() 直接写入,写入位置根本不存在元素,行为是赤裸裸的越界。输出位置必须先有空间,两种供给方式要分清。
第一种是预先开好大小:目标容器先 resize 到足够长度,算法覆写已有元素。这种方式适合输出数量可预期的场景,transform 的输出数量恒等于输入数量,预先开满刚刚好。第二种是插入迭代器:back_inserter 把"写入"翻译成对容器的 push_back,容器自己随写随长,调用方完全不用操心大小,copy_if 这种输出数量未知的场景全靠它兜底。
back_inserter 的工作原理值得看一眼:它本身不重,只是一个适配器,重载了赋值运算符,让 *it = value 这步操作实际调用容器的 push_back。算法看到的仍是一个普通输出迭代器,写入动作在适配器内部被重定义成了追加。输出迭代器的契约本来就极简:只能写、只能前进、写过不读,所以这种偷梁换柱完全合法,算法无从分辨,也不必分辨。
输出目标的状态管理也全部在调用方手里。算法不会替你清空容器,back_inserter 是纯粹的追加,同一个目标容器跑两次算法,内容就累积两份。想要干净的结果,要么每次用新容器,要么先 clear,这个细节在循环里反复调用算法时尤其容易踩。
两个配套细节也经常一起考。其一,reserve 和 resize 分工不同:resize 真的造出元素,供覆写使用;reserve 只备容量,配 back_inserter 消除追加过程中的反复扩容,数量已知的 transform 场景值得加这一行。其二,插入迭代器是个小家族:front_inserter 走 push_front,服务 deque 和 list;通用的 inserter 在指定位置插入,用在 set、map 上时位置充当提示,元素按容器自己的有序规则落位。三个适配器都声明在 <iterator> 里。
覆写模式的完整姿势也值得记住:resize 开出空间后,把容器的 begin() 作为输出位置传进去,算法逐个覆写,返回值指向写入序列的末尾。这个模式里返回值第一次变得关键:实际写入数量少于预留数量时,返回值和 begin() 的距离就是真实写入量,copy_if 覆写场景全靠它收尾。忘记检查返回值,容器尾部就会留着一片默认构造的幽灵元素,它们看起来是数据,实际上从未被算法碰过。
for_each 留给有副作用的动作
for_each 对每个元素调用一次你传入的可调用对象,返回值被丢弃,算法本身不产生任何数据结果。它的全部意义都在副作用上:打印、写日志、累加外部计数、逐个发通知,元素只是触发动作的引信。经典重载版本按范围顺序逐个调用,所以打印、输出这类顺序敏感的副作用可以安心依赖它。
第 03 章提过它的一项特殊待遇:遍历结束后算法把函数对象返回给调用方,动作过程中积累的状态有正规通道取回。让一个带 total 成员的函数对象逐个累加,for_each 跑完后从返回值里读出总和,这是标准设计好的状态收割姿势。不过多数时候人们写 for_each 配的是 lambda,状态靠捕获外部变量积累,两种写法第 08 章会放在捕获机制下重新比较。
for_each 和范围 for 的关系要诚实面对。大多数"对每个元素做点事"的场景,范围 for 写起来更短更直白,for_each 并没有绝对优势。它真正的席位在三处:手里只有一对迭代器、没有容器本体时,比如处理某个子范围;需要把动作对象的状态取回来时;以及希望代码风格和前后一串算法调用保持统一时。单纯为了"显得用了算法"而把顺手的范围 for 改成 for_each,是本末倒置。
边界同样要钉死。for_each 的动作规则只被允许读取元素,想通过它修改元素本身,请改用 transform,改写有改写的正规通道。遍历过程中往正在遍历的容器里追加元素,是自找迭代器失效,vector 扩容的那一刻,算法手里的位置全部作废。需要提前退出、需要 continue 跳过、需要在循环里维护复杂状态机时,for_each 一概表达不了,这些场景本来就属于循环。它的合理使用场景可以压缩成一句判断:这个动作能否用"对每个元素执行一次"说完整,能,才用它。
在数据流里,for_each 通常守在最后一站。数据经过筛选、转换、排序一路流过来,最终要变成打印、上报、写库这些动作时,消费者就是它。认清这个位置有助于划清职责:它之前的算法负责让数据变成正确的样子,它只负责把正确的样子送达目的地。动作逻辑一旦开始膨胀,长出筛选和转换的枝节,就该把枝节还给前面的算法,各归各位。
输入输出迭代器让数据流过算法
把本章三个算法摆在一起,会看到数据流的两种端点。输入端是一对输入迭代器,算法从那里读;输出端是一个输出迭代器,算法往那里写。算法是管道,容器是水库,数据从一段范围流出来,经过规则的加工,落进另一个位置。transform 和 copy_if 同时有入口和出口,for_each 只有入口,因为数据在动作里消耗掉了。
输出迭代器本身也是个小家族,不止容器迭代器一种。覆写用的普通迭代器、生长用的插入迭代器之外,还有直接流向流的 ostream_iterator:把 copy 的输出端接到 cout 上,一行就能打印整个范围,每个元素后面还能自动跟上分隔符。文件流、字符串流同理可接,算法的出口因此可以通向任何能写值的地方,"输出位置"这个抽象被它展示得淋漓尽致。
入口方向也有对应的形态:istream_iterator 把输入流包装成迭代器,一对这样的迭代器圈出的"范围"可以直接喂给算法或容器构造,从标准输入读一串整数进 vector 可以一行完成。它有两个使用要点:作为输入迭代器它是单遍的,读过即焚,同一个位置不能读第二遍;声明时括号用错会被编译器当成函数声明,用花括号初始化可以避开。数据从流进来,在算法之间流动,最后从流出去,整条路径上全是大一号的迭代器。
流迭代器的单遍特性还带来一个实践结论:从流里读出的数据需要使用两次时,先落进容器。输入流读一遍就没了,算法想再看一眼,只能看容器里的副本。容器在这张图里扮演的不只是存储,还是数据的可重放缓存:流负责送达,容器负责留底,算法在两者之间穿行。
适配器的思想在这里一脉相承。back_inserter 把写入翻译成追加,ostream_iterator 把写入翻译成流输出,除此之外还有把解引用翻译成右值引用的 move_iterator、把前进方向反转的 reverse_iterator。它们都不改变算法本身,只改变迭代器行为的解释方式,算法家族的通用性很大程度上是这帮小型翻译官撑起来的。
链条思维随之出现:一个算法的输出容器,天然是下一个算法的输入。先 copy_if 筛出子集,再 transform 映射成另一种形态,最后 for_each 逐个输出,每一步生成一个实体容器作为中转站。这种逐步物化的写法直白、好调、好讲,代价是每一步都产生一份中间数据。想让链条惰性化、去掉中间容器,是 C++20 ranges 的课题,本系列把它留给 Modern C++ 专题,这里先把实体链条的基本功打牢。
什么时候循环反而更清楚
诚实的答案必须包含反面。需要提前退出的遍历,for_each 表达不了,循环加 break 一目了然;几件异质的事交织在一次遍历里、拆开反而割裂时,一个组织良好的循环胜过三个算法接力;逻辑严重依赖下标和位置时,算法把下标藏了起来,循环反而直接;热路径上要手工控制内存访问节奏时,循环的掌控力也无可替代。
混合场景值得单独演练一遍。一个循环里同时做"过滤、转换、收集、统计"四件事,读起来是一团;拆成 copy_if 加 transform 两步,过滤和转换各有名字,统计留在最后的循环里,每一步都可单独验证。拆分的收益不在行数,在于每一步都能被独立检查和测试,团在一起的循环只有整体对错,拆开之后每段都有清晰的输入输出。
判断标准依旧只有一条:这一步能否用一个算法名字说完整。transform 说"一对一转换",copy_if 说"筛选复制",for_each 说"逐项动作",名字能完整覆盖意图时,算法版本几乎总是更清楚;名字覆盖不了的部分,就是循环的合法领地。还有一种折中值得推荐:链条太长时拆步,给中间容器起个有业务含义的名字,算法版本的可读性还能再上一档。代码评审也因此受益:看到 transform 就检查映射规则和输出位置,看到 copy_if 就检查谓词,评审锚点全都写在函数名上。
调试体验也该算进这笔账。手写循环可以在循环体里随意打断点、看中间变量;算法调用把遍历藏进了标准库,断点只能落在规则对象上。应对方法因此前移:规则保持短小,先用三五条样例数据单独验证规则的输入输出,再接回真实数据流。规则越纯,这个策略越顺,这又一次回到第 03 章反复强调的纯度要求上。
代码示例
本章的示例文件是 代码示例/05-transform-copy-foreach/transform_copy_foreach.cc,把三种算法串成一条小型的商品数据规整流水线:抽出价格列表、筛出库存不足的商品、打印两份结果。
// Copyright (c) 2026 yus3nable
// SPDX-License-Identifier: MIT
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
struct Product {
std::string name;
int price = 0;
int stock = 0;
};
int main() {
const std::vector<Product> products{
{"keyboard", 399, 12}, {"mouse", 129, 3}, {"monitor", 1299, 1}};
std::vector<int> prices;
std::transform(products.begin(), products.end(), std::back_inserter(prices),
[](const Product& product) { return product.price; });
std::vector<Product> low_stock;
std::copy_if(products.begin(), products.end(), std::back_inserter(low_stock),
[](const Product& product) { return product.stock < 5; });
std::cout << "prices:";
std::for_each(prices.begin(), prices.end(),
[](int price) { std::cout << ' ' << price; });
std::cout << "\nlow stock:";
std::for_each(low_stock.begin(), low_stock.end(),
[](const Product& product) { std::cout << ' ' << product.name; });
std::cout << '\n';
}
第一步 transform 把 Product 序列映射成纯价格序列,三种商品对应三个价格,输出是 prices: 399 129 1299。注意输出迭代器的位置:back_inserter(prices) 夹在输入范围和 lambda 之间,prices 从空容器开始随写随长,不需要预先开空间。
第二步 copy_if 用 stock < 5 这道闸门筛出 mouse 和 monitor,keyboard 库存 12 被拦下,输出是 low stock: mouse monitor。这里复制的是完整的 Product 对象,顺手记一笔成本账:筛选复制的代价与被复制的元素大小成正比,元素很重时,复制指针或索引往往是更划算的取舍,语义不变,搬运量大减。products 本身是常量容器,两次读取互不影响,low_stock 是全新副本,后续怎么改都碰不到原始数据。
把整条流水线的输入输出列成清单,结构会更清楚:transform 读 products、写 prices,copy_if 读 products、写 low_stock,两个 for_each 分别读两个结果容器、写向 cout。products 全程只读,三个结果各自独立生长。评审任何一段算法链时,先画这张"谁读谁写"的清单,数据从哪里来、经过什么规则、到哪里去,一眼就能核对。
再设想手写版本的对照:同样的功能需要三个空容器、三次边界管理、三处追加动作,骨架重复三遍,业务逻辑稀释在样板里。算法版本里这些重复全部消失,剩下的每一行都在回答业务问题,这就是本章开头说的"骨架收进名字"的完整兑现。
两个 for_each 承担的正是它们的本职工作:打印是典型的副作用动作,算法不产生数据结果,只负责把每个元素按顺序送到 lambda 面前。整条流水线读下来,三个算法的分工和业务的三个问题一一对应:价格有哪些、库存告急的有哪些、结果长什么样。
这段代码里没有一个手写的遍历骨架,却没有损失任何信息,因为每个名字都把骨架的语义说完了。这就是本章开头那个问题的实证:当三段式在代码里反复出现时,给它起名字是值得的,标准库早就起好了。
按结果形态选算法
本章的选型逻辑可以收成一句话:看你要的结果是什么形态。要一串转换后的新值,用 transform;要原范围的一个保序子集,用 copy_if;要对每个元素执行一个动作,用 for_each 或范围 for;要位置、数量或是非答案,回到第 04 章的查询家族。结果形态先想清楚,算法名字自己会出现。看到一个手写 for 循环时,也可以用同一套问题反推:这个循环产出了什么形态的结果,能对上某个算法名字,换;对不上,它本来就该是循环。
把第 04 章和本章拼起来,还能看到一张更大的态度地图:查询算法只读数据,本章算法读一段写一段,下一章的删除算法要在原范围里挪动数据。算法对数据的介入程度逐级加深,需要小心的边界也逐级增多,这个节奏是刻意安排的。
落到写代码的动作上,本章三个算法各有一个调用前必须回答的问题。transform 问输出类型和规则返回类型对不对得上,copy_if 问谓词是否真的只读,for_each 问这个动作能否一句话说完。三个问题连同"输出空间从哪来",构成一张调用前清单,花十秒钟过一遍,本章提到的坑就都拦在了门外。
copy_if 还有一个隐藏身份:它能造出"删除之后"的新范围。用反向谓词把不想要的元素拦在外面,新容器里就是删除的结果,只是原容器的大小从头到尾没变。如果目标是让原容器真的变小,复制就不是答案,需要理解算法怎样在范围内重排元素、容器怎样收缩自身,这正是下一章 remove_if 和 erase 要讲透的配合。
阅读导航




