flatten 与 flat_map 的区别?
解读
在国内 Rust 后端/基础架构面试中,面试官抛出“flatten 与 flat_map 的区别”往往不是单纯考 API 拼写,而是快速判断候选人是否真正写过链式迭代器代码、是否理解闭包所有权与内存布局、能否在 O(n) 时间复杂度内完成扁平化。
典型追问路径:
- 先让手写一个 flat_map 实现;
- 追问两者在 collect 时的内存预分配差异;
- 最后落到“如果 Iterator::Item 是 Vec,能否用 flatten 直接复用底层 buffer”。
答不到“内存零拷贝复用”与“编译期长度展开”这两个点,基本会被判定为“只看过文档,没写过生产代码”。
知识点
- 签名差异
- flatten:
Iterator<Item: IntoIterator>→Flatten<I>,闭包参数为零; - flat_map:
Iterator<Item=T>→FlatMap<I, F, U>,需传入 FnMut(T) -> U 闭包,U: IntoIterator。
- flatten:
- 语义差异
- flatten 只做一级摊平,不转换元素;
- flat_map 先映射再摊平,一步完成 map+flatten,减少一次中间容器。
- 性能差异
- flatten 在 collect 时底层使用 TrustedLen 特性,预分配总长度,避免 Vec 反复 realloc;
- flat_map 因为闭包返回的迭代器长度未知,只能按 chunk 预分配,极端场景下多一次 memcpy。
- 所有权差异
- flatten 要求 Item 实现 IntoIterator,迭代器被消耗;
- flat_map 闭包拿到 T 的所有权,可原地复用内部 buffer,例如
vec.into_iter().flat_map(|v| v)能把子 Vec 的 ptr 直接链起来,实现 O(1) 零拷贝。
- 异步场景
- 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,这是生产环境零拷贝常用技巧。
拓展思考
- 如果 Iterator::Item 是 ArrayVec<[T; N]>,flatten 在 const-generic 场景下会被 LLVM 完全展开成寄存器级联,无循环、无分支;而 flat_map 因闭包边界无法内联,会退化成普通指针循环,在嵌入式热路径里差距 3× 以上。
- 在 no_std + allocator_api 环境,flatten 依赖 GlobalAlloc 的 realloc,可能触发 OOM;flat_map 可以手动在闭包里用 BumpAllocator 一次性分配 Arena,把摊平与分配合并,内核网络栈的 skbuff 拼接就是这么干的。
- 面试陷阱题:
“flat_map 能不能用 flatten 加 zip 代替?”
答:不能,flatten 没有索引映射能力,zip 会提前短截断,长度不一致时语义完全错误;正确姿势是 flat_map + enumerate 或者 Itertools::with_position。