关联类型与 GAT

本页目录

trait 的泛型参数参与选择实现,关联类型由选定的实现确定。例如 Iterator::Item 表达“这个迭代器产出什么”,而不是让每次 next 调用重新选择任意输出类型。

唯一性是相对于完整实现参数而言

Iterator 没有额外类型参数,因此一个具体迭代器类型的 Item 由该实现确定。如果 trait 自身还有泛型参数,就必须连同这些参数一起确定实现,不能概括成“有了关联类型,每种 Self 永远只能有一个 impl”。

trait Convert<Input> {
    type Output;
    fn convert(input: Input) -> Self::Output;
}
struct Decoder;
impl Convert<u8> for Decoder {
    type Output = u16;
    fn convert(input: u8) -> u16 { u16::from(input) }
}
impl Convert<bool> for Decoder {
    type Output = &'static str;
    fn convert(input: bool) -> &'static str { if input { "yes" } else { "no" } }
}
fn main() {
    assert_eq!(<Decoder as Convert<u8>>::convert(7), 7u16);
    assert_eq!(<Decoder as Convert<bool>>::convert(true), "yes");
}

Input 是选择实现的输入,Output 是该实现指定的结果;完全限定写法明确了二者。约束 Iterator<Item = u8> 则是要求所选实现的关联类型等于 u8。关联类型

相等约束和能力约束回答不同问题

I: Iterator<Item = u8> 要求输出恰好是 u8;I: Iterator, I::Item: Display 只要求输出可以格式化。后一种接口可以接受多个输出类型,调用者无需先把它们都转为 String。

use std::fmt::Display;
fn joined<I>(input: I) -> String
where
    I: IntoIterator,
    I::Item: Display,
{
    let mut output = String::new();
    for (index, value) in input.into_iter().enumerate() {
        if index != 0 { output.push(','); }
        use std::fmt::Write;
        write!(&mut output, "{value}").unwrap();
    }
    output
}
fn main() {
    assert_eq!(joined([1, 2, 3]), "1,2,3");
    assert_eq!(joined(["a", "b"]), "a,b");
    assert_eq!(joined(std::iter::empty::<u8>()), "");
}

这里的格式化是简单连接,不是 CSV 序列化:如果输入本身有逗号或换行,函数不会转义。关联类型约束描述的是编译期可用操作,不会替算法补上数据格式规则。

写法表达的关系
T: Convert<u8>选择 T 针对 u8 的实现
<T as Convert<u8>>::Output投影出该实现指定的输出类型
T: Convert<u8, Output = u16>进一步要求输出等于 u16
I::Item: Display不固定输出类型,只约束其能力

普通 Iterator 能借用什么

std::slice::Iter<'s, T> 可以实现 Item = &'s T,所以“Iterator 不能返回引用”是错的。这些引用借自迭代器所指向的外部切片,生命周期 's 已经在迭代器类型中。

困难在于让返回项借用本次 &mut self,而不是一个预先固定的外部生命周期。例如迭代器内部重用读缓冲,下一次读取会覆盖上一次内容;又如依次借出重叠可变窗口,必须阻止调用方同时保留两个窗口。

普通 IterMut 为什么能同时保留两个 &mut

fn main() {
    let mut values = [1, 2, 3];
    let mut iter = values.iter_mut();
    let first = iter.next().unwrap();
    let second = iter.next().unwrap();
    *first = 10;
    *second = 20;
    assert_eq!(values, [10, 20, 3]);
}

两个返回引用可以同时使用,因为 IterMut 每次交出不同元素,并保证后续迭代不再交出已经借出的同一位置。它的 Item 是外部切片生命周期上的 &mut T,不是绑定本次 next 对迭代器的短借用。IterMut

重叠窗口却做不到这个保证:长度为 2 的第一个窗口覆盖索引 0、1,第二个覆盖 1、2。若允许两者同时写,索引 1 就会存在两个活跃的独占访问入口。因此,关键区别是数据是否重叠,以及接口允许返回值活多久;不是“返回可变引用就必须使用 GAT”。

GAT:随借用变化的关联类型族

next(&'a mut self)独占借用迭代器Item<'a> = &'a mut [u8]返回窗口借用窗口仍被使用时,不能再次借用迭代器结束该窗口的使用后,才能获取下一窗口GAT 把本次借用的生命周期带到返回类型普通 Iterator 的 Item 对一个实现固定;GAT 可随本次借用形成类型族。
trait LendingIterator {
    type Item<'a> where Self: 'a;
    fn next(&mut self) -> Option<Self::Item<'_>>;
}

struct Windows<'s> {
    data: &'s mut [u8],
    width: usize,
    position: usize,
}
impl<'s> Windows<'s> {
    fn new(data: &'s mut [u8], width: usize) -> Self {
        assert!(width > 0);
        Self { data, width, position: 0 }
    }
}
impl LendingIterator for Windows<'_> {
    type Item<'a> = &'a mut [u8] where Self: 'a;
    fn next(&mut self) -> Option<Self::Item<'_>> {
        let end = self.position.checked_add(self.width)?;
        if end > self.data.len() { return None; }
        let start = self.position;
        self.position += 1;
        Some(&mut self.data[start..end])
    }
}
fn main() {
    let mut bytes = [1, 2, 3];
    let mut windows = Windows::new(&mut bytes, 2);
    {
        let first = windows.next().unwrap();
        first[1] = 9;
    }
    {
        let second = windows.next().unwrap();
        assert_eq!(second, &[9, 3]);
        second[0] = 8;
    }
    assert!(windows.next().is_none());
    assert_eq!(bytes, [1, 8, 3]);
}

Item<'a> 把本次借用长度带入返回值。where Self: 'a 表示在使用这个关联类型时,Self 中涉及的借用满足该生命周期要求;它不是要求 Self 永久存活。

如果取得 first 后,在还要使用 first 的情况下再次 windows.next(),编译器会拒绝重叠的独占借用。窗口之间可以共享底层位置,但不能同时通过两个活跃的可变借用访问它们。这个示例不需要 unsafe。

索引 0初始值 1索引 1初始值 2索引 2初始值 3first:索引 0、1;把 1 写成 9second:索引 1、2;先读到 9第一次借用第一次使用结束后重叠窗口共享位置,但必须分时借出索引 1 的修改保留在底层数组;借用结束不会回滚数据,也不需要复制窗口。

逐项消费,或者主动取得独立所有权

如果处理逻辑只需要计算统计量,可以在每次 next 后立刻得到一个拥有的结果,再获取下一项。若要保存完整窗口,可以复制成 Vec;这会增加分配和复制,却明确解除了结果对当前窗口借用的依赖。把窗口引用直接收集起来,则与重叠可变窗口的安全目标冲突。

在上例的 main 中,first 的大括号是为了把使用顺序展示清楚;借用通常按后续使用分析,不是必须等变量的词法作用域结束。若先取得 second,再读取 first,编译器必须认为 first 仍活跃,因此拒绝第二次独占借用。

边界输入上例 Windows 的行为原因
width = 0构造时 panic示例明确排除空窗口语义;公共接口可改用 Result
width 大于数据长度首次 next 返回 None不存在完整窗口
width 等于数据长度只产生一个窗口下一起点已无法容纳 width
宽度为 2、数据长 3产生两个相互重叠的窗口起点每次只增加 1
起点加宽度溢出next 返回 Nonechecked_add 拒绝无法表示的末端

where Self: 'a 在约束什么

可以把方法签名展开理解为 fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>。Item 不再是一个固定类型,而是接收生命周期参数的类型族;本次调用选择的 'a 进入返回类型,使借用检查能关联“还在用窗口”与“还在借用迭代器”。

对于含有外部借用的 Windows<'s>,Self: 'a 要求这些借用对 'a 有效;不能从寿命较短的底层数据派生更长的窗口。GAT 声明上的 where 条件提供实现可依赖的前提,例子因借用 self 的方法关系需要写出它。它不是“迭代器对象必须活到程序结束”,也不是运行时寿命检查。GAT 与必需的 where 条件

这个 trait 并不是 std::iter::Iterator,标准迭代器组合方法不能自动全部套用。GAT 扩展了可表达的接口,不会自动生成对应的适配器生态;还应考虑当前编译器对复杂约束和 dyn compatibility 的支持边界。

泛型使用不等于自动支持 dyn

GAT 能描述 Item<'a>,但带此类关联类型的 lending 接口不能据此直接变成 dyn LendingIterator。普通 dyn Iterator<Item = u8> 固定一个输出类型的做法,也不会自动解决一整个随生命周期变化的类型族。需要动态边界时,可以考虑把处理动作放入 trait 方法内,或返回拥有的数据;选择取决于是否允许复制以及调用者需要哪些组合能力。

复杂消费者还可能需要 for<'a> 高阶生命周期约束。它表示对所量化的生命周期满足条件,不能机械替换成某一个命名生命周期或 'static。遇到编译器诊断时应先缩小到实际 trait、where 条件与编译器版本验证,不应为了消除报错而给借用数据盲目加 'static。

重叠窗口为什么不能同时保留?

先观察宽度为 3 的窗口如何共享元素,再把第二个窗口移到不相交处。只要旧窗口还要使用,本文 lending 接口仍然拒绝再次借用 self:公共签名表达的是输出依赖本次 self 借用,不是编译器在每次调用时重新证明任意窗口算术。普通 IterMut 则由实现保证各次产出的元素不相交。

正在呈现知识画面
重叠窗口为什么不能同时保留?

LendingIterator 的输出关联本次 &mut self;旧输出仍要使用时不能再次 next,即使这次选中的范围不相交。

返回位置 impl Trait:隐藏一个具体类型

fn selected(even: bool) -> impl Iterator<Item = i32> {
    (0..6).filter(move |value| (*value % 2 == 0) == even)
}
fn main() {
    assert_eq!(selected(true).collect::<Vec<_>>(), [0, 2, 4]);
    assert_eq!(selected(false).collect::<Vec<_>>(), [1, 3, 5]);
}

两个分支行为由闭包捕获的 bool 决定,返回的隐藏类型仍相同。若在 if 两侧分别返回 Range 与 Filter,不会仅因它们都实现 Iterator 就自动统一;需要统一结构、显式 enum,或使用 dyn 抹去差异。impl Trait

隐藏类型可以依赖函数的泛型参数,生命周期捕获规则也与 edition 有关,因此“所有调用全局只有一个类型”同样过度简化。接口承诺的 trait 约束是调用者可依赖的能力,不是公开底层迭代器组合名称。

用同一种结构容纳不同运行时选择

前面的 selected 把选择放入闭包状态,因而两次调用具有同样的返回类型。另一种常见情况是“可能没有元素”:可以把分支变成 Option,再返回同一种 IntoIter。

fn maybe_value(enabled: bool) -> impl Iterator<Item = u8> {
    enabled.then_some(7).into_iter()
}
fn main() {
    assert_eq!(maybe_value(true).collect::<Vec<_>>(), [7]);
    assert!(maybe_value(false).collect::<Vec<_>>().is_empty());
}

如果改成 true 分支返回 std::iter::once(7)、false 分支返回 std::iter::empty(),两者虽有相同 Item,却是不同的具体类型,不能直接成为同一个返回位置 impl Iterator。这个限制与元素个数无关;同一类型完全可以在运行时产生不同数量的元素。

与异步 trait 的联系

GAT 可以表达带借用的关联 Future 类型,让接口明确返回值与 &self 的关系。但这不意味着稳定 Rust 可以在每个关联类型定义中任意写 impl Future 来命名匿名 async 块。

原生 async fn in trait 和返回位置 impl Trait in trait 已在 Rust 1.75 稳定,可用于相应静态调用;它们也不自动使 trait 可用于 dyn。需要动态擦除 Future 时,常见接口会返回 Pin<Box<dyn Future<Output = T> + 'a>>,并另外决定是否要求 Send。实际异步机制见 async/await,对象限制见 trait 对象。