flatten 与 flat_map 的区别?

解读

在国内 Rust 后端/基础架构面试中,面试官抛出“flatten 与 flat_map 的区别”往往不是单纯考 API 拼写,而是快速判断候选人是否真正写过链式迭代器代码、是否理解闭包所有权与内存布局、能否在 O(n) 时间复杂度内完成扁平化
典型追问路径:

  1. 先让手写一个 flat_map 实现;
  2. 追问两者在 collect 时的内存预分配差异;
  3. 最后落到“如果 Iterator::Item 是 Vec,能否用 flatten 直接复用底层 buffer”。
    答不到“内存零拷贝复用”与“编译期长度展开”这两个点,基本会被判定为“只看过文档,没写过生产代码”。

知识点

  1. 签名差异
    • flatten: Iterator<Item: IntoIterator>Flatten<I>闭包参数为零
    • flat_map: Iterator<Item=T>FlatMap<I, F, U>需传入 FnMut(T) -> U 闭包,U: IntoIterator。
  2. 语义差异
    • flatten 只做一级摊平,不转换元素;
    • flat_map 先映射再摊平,一步完成 map+flatten,减少一次中间容器
  3. 性能差异
    • flatten 在 collect 时底层使用 TrustedLen 特性,预分配总长度,避免 Vec 反复 realloc;
    • flat_map 因为闭包返回的迭代器长度未知,只能按 chunk 预分配,极端场景下多一次 memcpy。
  4. 所有权差异
    • flatten 要求 Item 实现 IntoIterator,迭代器被消耗
    • flat_map 闭包拿到 T 的所有权,可原地复用内部 buffer,例如 vec.into_iter().flat_map(|v| v) 能把子 Vec 的 ptr 直接链起来,实现 O(1) 零拷贝
  5. 异步场景
    • StreamExt 里 flatten 与 flat_map 同样存在,但 flat_map 的闭包可以是 async move,避免嵌套 Stream 的 Box::pin 分配,在网关转发代码里能省一次堆分配。

答案

一句话区分:flatten 是“无映射摊平”,flat_map 是“先映射后摊平”
代码层面对比:

let a = vec![vec![1, 2], vec![3]];
let f: Vec<_> = a.into_iter().flatten().collect();   // [1, 2, 3]

let b = vec!["a b", "c d"];
let fm: Vec<_> = b.iter().flat_map(|s| s.split_whitespace()).collect(); // ["a", "b", "c", "d"]

性能层面:

  • flatten 能利用 TrustedLen 提前算出总长度,一次分配
  • flat_map 因闭包返回长度未知,只能边迭代边 grow,但在元素可复用场景下,flat_map 可以把子 Vec 的堆指针直接链出,省掉 memcpy,这是生产环境零拷贝常用技巧

拓展思考

  1. 如果 Iterator::Item 是 ArrayVec<[T; N]>,flatten 在 const-generic 场景下会被 LLVM 完全展开成寄存器级联无循环、无分支;而 flat_map 因闭包边界无法内联,会退化成普通指针循环,在嵌入式热路径里差距 3× 以上。
  2. no_std + allocator_api 环境,flatten 依赖 GlobalAlloc 的 realloc,可能触发 OOM;flat_map 可以手动在闭包里用 BumpAllocator 一次性分配 Arena把摊平与分配合并,内核网络栈的 skbuff 拼接就是这么干的。
  3. 面试陷阱题:
    “flat_map 能不能用 flatten 加 zip 代替?”
    答:不能,flatten 没有索引映射能力,zip 会提前短截断,长度不一致时语义完全错误;正确姿势是 flat_map + enumerate 或者 Itertools::with_position