1. 项目概述为什么我们需要views::chunk_by如果你写过C尤其是处理过序列数据大概率遇到过这样的场景你有一个包含各种日志条目、传感器读数或者用户事件的列表这些数据在时间或逻辑上是分组的。比如日志文件里连续的错误信息应该被归为一组进行分析传感器采集的温度数据中连续高于阈值的读数构成一个“高温事件”。传统的做法是写一个循环手动维护状态变量来判断何时分组开始、何时结束。代码写起来啰嗦容易出错而且意图被埋没在繁琐的细节里。C20引入的Ranges库是一次范式转移它让我们能用声明式、可组合的方式来操作序列。views::filter,views::transform这些适配器已经极大地提升了代码的表达力。但是基于相邻元素关系的分组操作一直是个缺失的拼图。直到C23的views::chunk_by出现它完美地填补了这个空白。这个提案的编号是P2443R1它允许我们根据一个二元谓词Binary Predicate对序列进行分组这个谓词作用于相邻的两个元素上。当谓词返回true时这两个元素被视为属于同一个“块”chunk当谓词返回false时则意味着前一个块的结束和新块的开始。这听起来有点抽象我们看个最简单的例子把一个整数序列按奇偶性分组。#include ranges #include iostream #include vector int main() { std::vector nums {1, 3, 2, 4, 5, 7, 6, 8, 9}; // 当相邻两个数奇偶性相同时它们属于同一组 auto chunks nums | std::views::chunk_by([](int a, int b) { return (a % 2) (b % 2); }); for (auto chunk : chunks) { for (int n : chunk) std::cout n ; std::cout \n; } }输出会是1 3 2 4 5 7 6 8 9看它自动把连续的奇数和连续的偶数分别归组了。最后一个元素9由于没有下一个元素与之比较它独自成组。这个适配器的核心价值在于它把“基于上下文的分组逻辑”从命令式的循环中解放出来变成了一个可以轻松组合、传递的一等公民。无论你是做数据批处理、日志分析、还是实现某种状态机chunk_by都能让你的代码意图更清晰更不容易出错。注意views::chunk_by对输入范围有前向迭代器Forward Range的要求。这是因为分组逻辑需要能够回头查看前一个元素双向或随机访问迭代器自然满足但纯输入迭代器Input Iterator无法支持此操作。1.1 核心需求解析从“怎么做”到“做什么”在没有chunk_by的年代实现上述分组功能你可能会写出这样的代码std::vectorstd::vectorint manual_chunk_by(const std::vectorint nums) { std::vectorstd::vectorint result; if (nums.empty()) return result; std::vectorint current_chunk {nums[0]}; for (size_t i 1; i nums.size(); i) { if ((nums[i-1] % 2) (nums[i] % 2)) { current_chunk.push_back(nums[i]); } else { result.push_back(std::move(current_chunk)); current_chunk {nums[i]}; } } result.push_back(std::move(current_chunk)); return result; }这段代码功能上没问题但它存在几个典型问题状态管理需要显式地维护current_chunk和result。边界处理需要小心处理空序列和最后一个分组的提交。性能开销由于使用了std::vectorstd::vectorint可能涉及多次动态内存分配。不可组合这个函数是一个孤立的操作很难与其他的范围操作如过滤、转换进行流水线式组合。惰性求值它立即计算并返回所有结果如果序列很大但只需要处理前几个分组就会造成浪费。views::chunk_by优雅地解决了所有这些问题。它返回一个视图View是惰性求值的只有在迭代时才会进行计算。它不复制元素只是引用原始数据。它可以无缝嵌入到Ranges的管道操作中。更重要的是它将分组逻辑抽象为一个简单的谓词让开发者专注于定义“何时分组”而不是“如何分组”。这个需求在数据处理领域非常普遍日志分析将连续的、同一级别的日志条目分组。金融交易将同一交易会话内的操作分组。信号处理将超过某个阈值的连续信号段识别为事件。文本处理将连续的空格或标点符号分组。2. 核心细节解析与实操要点2.1 谓词的设计理解“相邻关系”chunk_by的灵魂在于你传递给它的谓词。这个谓词必须是一个可调用对象接受两个参数两个相邻元素的常量引用并返回一个可转换为bool的值。谓词定义了“同一组”的等价关系但这个关系不需要满足数学上等价关系的所有性质如传递性它只作用于紧邻的两个元素。一个常见的误区是试图用谓词表达一个“全局”的组属性。例如想把所有绝对值小于5的数分一组。这是错误的用法因为chunk_by只关心相邻项。// 错误示例试图进行“全局”分组 std::vector nums {1, 2, 6, 3, 10, 4}; auto wrong_view nums | std::views::chunk_by([](int a, int b) { return std::abs(a) 5 std::abs(b) 5; // 这只判断相邻两个是否都小于5 }); // 输出 [1, 2] [6] [3] [10] [4] // 3和4虽然都小于5但因为被10隔开不在同一组。正确的“全局”分组应该使用views::group_by如果存在或者先排序。chunk_by是基于局部相邻性的。一个强大的用法是定义状态转移。假设我们有一个代表系统状态变化的枚举序列我们想将连续处于“忙碌”状态的记录分组enum class State { Idle, Busy, Error }; std::vectorState states {State::Idle, State::Busy, State::Busy, State::Error, State::Busy, State::Idle}; auto busy_periods states | std::views::chunk_by([](State prev, State curr) { // 当前后状态都是Busy时它们属于同一个忙碌周期 return prev State::Busy curr State::Busy; }); for (auto period : busy_periods) { if (!period.empty() period.front() State::Busy) { std::cout Found a busy period with std::ranges::distance(period) entries.\n; } }2.2 视图的特性惰性、非拥有与前向迭代理解chunk_by返回的视图类型至关重要这决定了你怎么使用它以及它的性能特征。惰性求值 (Lazy Evaluation)chunk_by不会立即遍历整个输入范围并创建分组。当你开始迭代结果视图时它才会按需前进计算分组的边界。这意味着对于无限流或非常大的数据你可以安全地组合它而不用担心内存爆炸。// 假设有一个生成无限序列的生成器视图 auto infinite_numbers std::views::iota(1); // 按数字的十位数是否变化来分组 (10-19, 20-29, ...) auto groups_by_tens infinite_numbers | std::views::chunk_by([](int a, int b){ return (a / 10) (b / 10); }); // 只取前3个分组 for (auto group : groups_by_tens | std::views::take(3)) { for (int n : group | std::views::take(5)) { // 每个分组也只取前5个元素 std::cout n ; } std::cout ...\n; } // 输出 // 1 2 3 4 5 ... // 10 11 12 13 14 ... // 20 21 22 23 24 ...非拥有 (Non-Owning)视图本身不存储元素它只是包装了原始范围的迭代器。因此必须确保在视图的生命周期内底层原始数据是有效的。这是所有标准库视图的通用规则。前向范围 (Forward Range)chunk_by产生的视图是一个前向范围。这意味着它的迭代器支持多趟遍历可以复制迭代器分别前进。但是它通常不是双向范围Bidirectional Range因为从一个分组跳到前一个分组需要重新计算边界标准实现可能不提供--操作。它也更不是随机访问范围。分组是子范围 (Subrange)当你迭代chunk_by视图时得到的每个chunk本身也是一个范围通常是std::ranges::subrange你可以对它进行进一步的迭代或应用其他视图操作。auto nums std::vector{1, 1, 2, 2, 2, 3, 1, 1}; auto chunks nums | std::views::chunk_by(std::equal_to{}); for (auto chunk : chunks) { // chunk 是一个子范围 std::cout Chunk size: std::ranges::distance(chunk); std::cout , First element: *chunk.begin() \n; // 可以对chunk再应用视图例如取前两个元素 for (int n : chunk | std::views::take(2)) { std::cout n ; } std::cout \n; }2.3 与views::split和views::chunk的辨析C标准库中还有另外两个容易混淆的视图适配器views::split和views::chunk。理解它们的区别能帮你正确选用工具。views::split根据一个分隔符可以是一个值也可以是一个子序列来分割范围。它关注的是“找到分隔符并切分”。分隔符本身不会出现在结果中。using namespace std::string_view_literals; auto str hello,world,testsv; auto parts str | std::views::split(,); // parts: [hello, world, test]split是寻找一个确定的“标记”来切分而chunk_by是根据一个动态的、基于相邻元素的条件来分组。views::chunk(C23)根据一个固定大小 N将范围分成若干块最后一块可能小于 N。它只关心数量不关心内容。auto nums std::vector{1,2,3,4,5,6,7,8}; auto fixed_chunks nums | std::views::chunk(3); // fixed_chunks: [{1,2,3}, {4,5,6}, {7,8}]chunk是定长的chunk_by是变长的长度由数据内容决定。简单总结需要按固定大小切分 - 用views::chunk。需要按固定分隔符切分 - 用views::split。需要按相邻元素间的关系/条件动态分组 - 用views::chunk_by。3. 实操过程与核心环节实现让我们通过几个逐渐深入的例子来掌握views::chunk_by的实际应用。我将使用最新的编译器如GCC 13 或 Clang 17并指定-stdc2b进行演示。确保你的项目已启用C23支持。3.1 基础应用识别单调区间一个经典用例是找出序列中的单调递增或递减的连续子序列。#include ranges #include iostream #include vector #include algorithm int main() { std::vector data {5, 4, 3, 6, 7, 2, 2, 8, 9, 1}; // 找出严格递增的连续区间 auto increasing_runs data | std::views::chunk_by([](int a, int b) { return a b; // 当 a b 时它们属于同一个递增块 }); std::cout Strictly increasing runs:\n; for (const auto run : increasing_runs) { // 只打印长度大于1的递增区间 if (std::ranges::distance(run) 1) { for (int val : run) std::cout val ; std::cout \n; } } // 找出非严格递减的连续区间 (允许相等) auto non_increasing_runs data | std::views::chunk_by([](int a, int b) { return a b; // a b 时属于同一块 }); std::cout \nNon-increasing runs:\n; for (const auto run : non_increasing_runs) { for (int val : run) std::cout val ; std::cout \n; } }输出Strictly increasing runs: 6 7 8 9 Non-increasing runs: 5 4 3 6 7 2 2 8 9 1这个例子清晰地展示了谓词如何定义“连续性”。在金融分析中识别价格走势中的连续上涨或下跌波段用的就是这种逻辑。3.2 中级应用日志流的分组与分析假设我们有一个简单的日志流每条日志有级别和消息。我们想将连续的、同一级别的日志合并成一个“事件组”进行处理。#include ranges #include iostream #include vector #include string #include format // C20 enum class LogLevel { Debug, Info, Warning, Error }; struct LogEntry { LogLevel level; std::string message; // ... 其他字段如时间戳 }; int main() { std::vectorLogEntry logs { {LogLevel::Info, System started}, {LogLevel::Info, Loading configuration}, {LogLevel::Warning, Deprecated API called}, {LogLevel::Error, Disk write failed}, {LogLevel::Error, Retrying operation}, {LogLevel::Info, Operation recovered}, {LogLevel::Debug, Entering function foo}, {LogLevel::Debug, Parameter x10}, }; // 按日志级别是否变化来分组 auto grouped_logs logs | std::views::chunk_by([](const LogEntry a, const LogEntry b) { return a.level b.level; }); for (auto group : grouped_logs) { // 每组的第一条日志代表该组 const auto first_entry *group.begin(); size_t count std::ranges::distance(group); std::cout std::format([{}] x{}: , static_castint(first_entry.level), count); // 只打印该组第一条的简短消息或可以聚合所有消息 std::cout first_entry.message; if (count 1) { std::cout ... and (count - 1) more; } std::cout \n; } }输出类似于[1] x2: System started ... and 1 more [2] x1: Deprecated API called [3] x2: Disk write failed ... and 1 more [1] x1: Operation recovered [0] x2: Entering function foo ... and 1 more这种方式在日志聚合和监控系统中非常有用可以将海量的连续相同日志压缩成一条摘要大幅提升可读性。3.3 高级应用与其它视图适配器组合Ranges库的强大之处在于视图的可组合性。chunk_by可以轻松地插入到处理管道中。#include ranges #include iostream #include vector #include string int main() { // 一个包含空字符串的序列模拟可能无效的数据 std::vectorstd::string inputs {hello, world, , , foo, bar, , baz}; // 目标将非空字符串连续出现的段落分组并连接成单个字符串 auto processed inputs // 1. 过滤掉空字符串 | std::views::filter([](const std::string s) { return !s.empty(); }) // 2. 在过滤后的视图上按“总是true”的谓词分组等等这不对。 // 我们需要的是将“原始序列中连续的非空字符串”分组过滤后连续性信息丢失了。 // 所以正确的做法是先分组再对每个组进行过滤和转换。 ; // 更合理的管道先按“相邻两者是否都非空或都为空”分组 auto groups inputs | std::views::chunk_by([](const std::string a, const std::string b) { // 如果a和b都是非空或者都是空则它们属于同一组 return a.empty() b.empty(); }); // 然后过滤掉全为空的组并将非空组连接起来 for (auto group : groups) { if (!group.empty() !group.front().empty()) { // 这是一个由连续非空字符串组成的组 std::string concatenated; for (const auto s : group) { concatenated s ; } if (!concatenated.empty()) { concatenated.pop_back(); // 移除末尾空格 } std::cout Group: \ concatenated \\n; } } // 更函数式的写法使用 ranges::to (C23) 和 accumulate #ifdef __cpp_lib_ranges_to_container #include numeric // for std::accumulate auto non_empty_groups groups | std::views::filter([](auto g) { return !g.empty() !g.front().empty(); }) | std::views::transform([](auto g) { return std::accumulate(g.begin(), g.end(), std::string{}, [](std::string acc, const std::string s) { return acc (acc.empty() ? : ) s; }); }) | std::ranges::tostd::vector(); // C23 将视图转换为容器 #endif }这个例子揭示了一个关键点操作的顺序很重要。如果你先filter再chunk_by原始的连续性信息就丢失了。通常基于元素间关系的操作如chunk_by,adjacent_find需要放在改变元素顺序或删除元素的操作之前。另一个组合例子计算每个分组的统计信息。#include ranges #include iostream #include vector #include algorithm #include numeric int main() { std::vectorint readings {10, 12, 15, 8, 9, 9, 20, 22, 18, 5, 6, 7}; // 将读数按“是否大于等于10”的连续性分组高温段和低温段 auto temperature_bands readings | std::views::chunk_by([](int a, int b) { return (a 10) (b 10); }); for (auto band : temperature_bands) { bool is_high (*band.begin() 10); auto avg std::accumulate(band.begin(), band.end(), 0.0) / std::ranges::distance(band); auto [min_it, max_it] std::minmax_element(band.begin(), band.end()); std::cout (is_high ? High : Low ) temperature band: ; std::cout Size std::ranges::distance(band) , Avg avg , Min *min_it , Max *max_it \n; } }4. 常见问题与排查技巧实录在实际使用views::chunk_by时你可能会遇到一些陷阱。下面是我在项目中踩过的坑和总结的排查思路。4.1 谓词的非对称性与状态chunk_by的谓词应该是纯函数即输出只依赖于输入参数不依赖外部状态。更重要的是它通常应该是对称的吗不不一定。但它必须对序列的遍历产生一致的分组。一个容易出错的场景是使用一个依赖于外部状态的谓词或者谓词有副作用。// 危险示例带有计数器的谓词 int call_count 0; auto dangerous_view data | std::views::chunk_by([call_count](int a, int b) { call_count; // 副作用 return a % 2 b % 2 call_count 3; // 逻辑混乱结果不可预测 }); // 迭代 dangerous_view 会导致未定义行为因为分组结果依赖于迭代顺序和次数。黄金法则确保你的谓词是无副作用的并且对于相同的输入(a, b)在视图的整个生命周期内始终返回相同的值。4.2 处理空范围或单元素范围chunk_by对空范围是友好的。如果输入范围为空那么结果视图也是一个空范围迭代它不会产生任何分组。 对于单元素范围结果视图会包含一个分组该分组只有一个元素。因为不存在“相邻元素”所以谓词一次都不会被调用这个元素独自成组。这个行为是符合直觉的。4.3 性能考量与迭代器失效由于chunk_by视图是惰性的并且内部需要缓存当前分组的起始位置所以它的迭代器操作如时间复杂度是分摊O(1)的。但是每次移动到下一个分组都可能需要向前遍历原始范围直到谓词返回false。一个重要的警告是关于迭代器失效。因为chunk_by视图持有原始范围的迭代器所以任何导致原始范围迭代器失效的操作例如向std::vector插入/删除元素也会使指向它的所有chunk_by视图迭代器失效。这是所有非拥有视图的通用规则但需要格外小心因为chunk_by的内部状态可能更复杂。std::vectorint data {1, 1, 2, 2}; auto chunks data | std::views::chunk_by(std::equal_to{}); auto it chunks.begin(); // it 持有指向 data 的迭代器 data.push_back(3); // 可能导致 vector 重新分配内存所有迭代器失效 // 此时再使用 it 或解引用 *it 是未定义行为。4.4 调试与可视化技巧当分组结果不符合预期时如何调试最好的方法是隔离并测试你的谓词。单元测试谓词将谓词逻辑单独提取成一个函数或lambda用一系列测试用例验证它对于不同输入对的输出是否正确。auto my_predicate [](const MyType a, const MyType b) { /* ... */ }; // 编写测试 assert(my_predicate(A, B) true); assert(my_predicate(B, C) false); // ...打印调试在谓词内部添加打印语句确保无副作用影响逻辑观察它被调用的顺序和参数。auto debug_predicate [](int a, int b) { bool result (a / 10) (b / 10); std::cout chunk_by called with ( a , b ) - std::boolalpha result \n; return result; }; auto view data | std::views::chunk_by(debug_predicate); // 迭代view观察输出可视化输入序列对于复杂对象在应用chunk_by之前先打印出整个序列手动标记出你认为的分组边界然后与程序输出对比。4.5 与自定义类型和投影Projection结合chunk_by的谓词接收的是范围的元素类型。如果你的元素是复杂结构体而你想根据某个成员字段来分组你有两种选择在谓词中访问成员struct Person { std::string name; int age; }; std::vectorPerson people {{A, 20}, {B, 20}, {C, 25}, {D, 25}}; auto by_age people | std::views::chunk_by([](const Person a, const Person b) { return a.age b.age; });使用views::transform先投影再使用简单的谓词这需要两步且改变了元素类型auto by_age2 people | std::views::transform([](const Person p) { return p.age; }) // 现在是一个 int 的视图 | std::views::chunk_by(std::equal_to{}); // 谓词变得非常简单注意第二种方法得到的分组视图其元素是int的组而不是Person的组这可能不是你想要的。目前C23的标准chunk_by没有像sort或unique那样直接接受一个投影参数。所以第一种方法是更直接的选择。如果分组逻辑很复杂建议将谓词封装成一个有名字的函数或函数对象以提高代码可读性。views::chunk_by是C23 Ranges库中一个极具表现力的工具。它将常见的、基于上下文的序列分组模式抽象成了一个简洁的适配器。理解其基于相邻元素的谓词语义掌握其惰性和非拥有的特性并避免在谓词中使用状态和副作用你就能在数据处理、日志分析、状态识别等众多场景中游刃有余。它的加入使得C在声明式数据处理方面又向前迈进了一步让我们能够写出更清晰、更易维护的代码。