C++actions立即执行容器操作
C++ 中的 actions:别再写循环了,让容器操作“当场结案”
上周帮同事看一段老代码,他想把 std::vector<std::string> 里所有空字符串删掉,还顺手把非空字符串全转成大写。结果翻出三页纸:一个 for 循环遍历,一个 erase 配 remove_if,再套个 transform……最后还漏处理了迭代器失效——程序跑着跑着就崩了。
其实,C++20 起,标准库悄悄塞进了一个轻量但极实用的工具:std::ranges::actions(常简称为 actions)。它不是新容器,也不是魔法编译器,而是一套可组合、立即执行、不改变原容器类型的操作接口。用对了,真能省下一半胶水代码。
关键在“立即执行”四个字。和 std::views(惰性视图)相反,actions 不生成新视图,而是当场修改原容器内容,并返回该容器本身。你写 vec |= std::ranges::actions::remove_if(is_empty) | std::ranges::actions::transform(to_upper),vec 就已经变了,类型还是 std::vector<std::string>,没多一层包装,也没额外分配内存。
这事儿听着简单,但实际踩坑点不少。比如,有人试过 auto result = vec | std::ranges::actions::sort;,编译失败——因为 actions 只接受左值容器,且要求容器支持对应操作(sort 要求随机访问 + 可比较)。vec 是左值,没问题;但如果你写 get_data() | std::ranges::actions::sort,get_data() 返回临时对象,编译器直接报错:“cannot bind rvalue to lvalue reference”。
再比如,顺序不能乱。vec |= std::ranges::actions::unique | std::ranges::actions::sort 是错的——unique 要求元素已排序才有效,反过来调用,unique 只会删相邻重复项,毫无意义。动作链的执行顺序就是书写顺序,且每个动作都真实修改容器状态。
真正省心的用法,是把它当“容器方法增强包”。比如清理并规整日志行:
std::vector<std::string> lines = {" ERROR: disk full ", "", " INFO: ok", " WARNING: retry"};
lines
|= std::ranges::actions::transform([](auto& s) {
s.erase(s.begin(), std::find_if_not(s.begin(), s.end(), ::isspace));
s.erase(std::find_if_not(s.rbegin(), s.rend(), ::isspace).base(), s.end());
})
|= std::ranges::actions::remove_if([](const auto& s) { return s.empty(); })
|= std::ranges::actions::sort();
三步下来,lines 已经是去首尾空格、剔除空行、按字典序排好的干净列表。没有中间变量,没有迭代器管理,更不用查 erase-remove 惯用法文档。
注意:actions 目前仅支持有限操作——sort, reverse, unique, remove_if, transform, take, drop 等十来个。它不替代算法库,而是补足“就地批量处理”这一环。你想 filter 出子集?不行,actions 没有 filter(那属于 views 的活);你想 zip 两个容器?也不行,actions 不引入新数据结构。它的定位很清晰:对单一容器做确定性、可逆(逻辑上)、低开销的原地整形。
还有一个易被忽略的细节:transform 和 remove_if 这类动作,传入的谓词或函数对象,捕获方式要谨慎。若用 [&] 捕获局部变量,而该变量生命周期短于动作执行(比如在 lambda 里用了即将析构的 std::string_view),运行时行为未定义。稳妥做法是值捕获,或确保引用对象生命周期覆盖全程。
实际项目中,我习惯把高频动作封装成命名操作:
namespace my_actions {
inline constexpr auto trim_and_nonempty =
std::ranges::actions::transform([](std::string& s) {
auto [first, last] = std::ranges::mismatch(
s, std::string_view(" \t\n\r\f\v"),
[](char a, char b) { return a == b; }
);
s.erase(s.begin(), first);
s.erase(std::find_if_not(s.rbegin(), s.rend(), ::isspace).base(), s.end());
})
| std::ranges::actions::remove_if([](const std::string& s) { return s.empty(); });
}
// 用起来就一行:
lines |= my_actions::trim_and_nonempty;
这样既复用逻辑,又保持链式可读性。
当然,actions 不是银弹。对小容器(<100 元素),传统循环可能更快——毕竟少了函数对象调用和范围检查开销;对需要保留原始数据的场景,它也不适用(这时该用 views)。但它精准解决了那个经典痛点:我想快速、安全、可读地“收拾”一下手头这个容器,别让我再手动管迭代器、别让我写五次 vec.erase(...)。
回到开头那个崩溃的同事,我把他的代码改成两行:
vec |= std::ranges::actions::remove_if([](const std::string& s) { return s.empty(); });
vec |= std::ranges::actions::transform([](std::string& s) {
std::transform(s.begin(), s.end(), s.begin(), ::toupper);
});
他盯着看了三秒,说:“原来……真能这么写?”
能。而且它就在你装好的标准库里,不需要第三方依赖,不改构建配置,只要编译器支持 C++20 范围库(GCC 10.2+/Clang 13+/MSVC 19.30+)。下次打开编辑器,试试把那个写了八行的清理函数,换成一个带 |= 的链式调用——你会感觉到,C++ 的容器,突然有了点“听指挥”的人味。


还没有评论,来说两句吧...