Rust学习笔记#7:泛型-LMLPHP

和其他语言一样,Rust也具有泛型(Generics)能力。泛型是指把类型抽象成一种“参数”,数据和算法都针对这种抽象的类型参数来实现,而不针对具体类型。当我们需要真正使用的时候,再具体化、实例化类型参数。使用不同类型参数将泛型类型具体化后,获得的是完全不同的具体类型。

基本语法

泛型使用<>声明,且必须先声明后使用,泛型可以被用于数据结构、函数和方法中。

泛型在数据结构中的用法

对于复合数据类型,我们可以使用泛型来提高复合数据类型的通用性。之前学过的复合数据类型有tuplestructtuple structenum四种,因为tuple类型没有类型名,故其无法使用泛型,剩余三种都可以。我们熟悉的Option就是一个泛型enum类型,泛型声明放在类型名之后大括号之前,如下面代码所示。

pub enum Option<T> {
    /// No value
    None,
    /// Some value `T`
    Some(T),
}

在这个Option内部,Some(T)是一个tuple struct,包含一个类型为T的元素,T在使用时指定具体类型。例如:

let x: Option<i32> = Some(42);
let y: Option<f64> = None;

在这里,xOption<i32>类型的值,yOption<f64>类型的值,它们是完全不同的类型。

泛型在函数中的用法

泛型声明放在函数名之后括号之前,如下面代码所示。compare_option函数的两个参数均为Option,但可以具有不同的类型。

fn compare_option<T1, T2>(fir: Option<T1>, sec: Option<T2>) -> bool {
    match (fir, sec) {
        (Some(_), Some(_)) => true,
        (None, None) => true,
        _ => false,
    }
}

fn main() {
    let x: Option<i32> = Some(42);
    let y: Option<i32> = None;
    let option = compare_option(x, y);
    println!("{}", option);
}

main函数中所示,一般情况下,调用泛型函数可以不指定泛型参数类型,编译器可以通过类型推导自动判断。如果需要手动指定泛型参数类型,需要使用compare_option::<i32, i32>(x, y)形式的语法。

泛型在方法中的用法

在结构体上下文中被定义的函数称为方法,对方法也可以使用泛型,泛型声明放在impl之后类型名之前,如下面代码所示。

impl<T> Data<T> {
    fn compare_option<P>(&self, other: &Data<P>) -> bool {
        let fir: &Option<T> = &self.data;
        let sec: &Option<P> = &other.data;
        match (fir, sec) {
            (Some(_), Some(_)) => true,
            (None, None) => true,
            _ => false,
        }
    }
}

fn main() {
    let data1 = Data { data: Some(10) };
    let data2 = Data { data: Some(10) };
    let res = data1.compare_option(&data2);
    println!("{}", res);
}

当为结构体实现trait的时候也可以使用泛型,在impl <Trait> for <Type>这个语法中,泛型参数既可以出现在<Trait>位置也可以出现在<Type>位置。下面是标准库中的Intotrait。FromInto是一对功能互逆的trait,如果A:Into<B>,则意味着B:From<A。下面代码的意思是,针对所有类型T,只要满足U: From<T>,那么就为该类型impl Into<U>。有了这样一个impl块之后,我们如果想为自己的两个类型提供互相转换的功能,那么只需impl From这一个trait就可以了。

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

impl后面的泛型参数声明需要满足三个条件之一:

  • 出现在被实现的类型中,例如,impl<T> Foo<T>
  • 出现在被实现的trait中,例如,impl<T> SomeTrait<T> for Foo
  • 出现在关联类型的绑定中,例如,impl<T, U> SomeTrait for T where T: AnotherTrait<AssocType=U>

泛型约束

我们在使用泛型参数的时候,一般不是对所有类型都进行操作,而是只对满足某种类型条件的类型进行操作,这称为泛型的约束,约束一般以trait的形式表达,有两种语法:

  • 在泛型参数声明的时候使用冒号:指定
  • 使用where子句指定
use std::cmp::PartialOrd;

// 第一种写法,在泛型参数声明的时候使用冒号:指定
fn max<T: PartialOrd>(a: T, b: T) -> T;

// 第二种写法,使用 where 子句指定
fn max<T>(a: T, b: T) -> T where T: PartialOrd;

在上面的示例中,两种写法的效果相同,都是要求T类型必须实现了PartialOrd这个trait。对于复杂的约束条件,使用where子句的可读性会更好。泛型约束会在编译期执行检查,检查被实例化的泛型类型是否满足了泛型声明时的约束。

trait的关联类型

trait中不仅可以包含方法、常量,还可以包含“类型”,例如常用的迭代器Iterator这个trait,它里面就有一个Item类型。

pub trait Iterator {
    type Item;
    ...
}

这样在trait中声明的类型被称为关联类型(associated type),关联类型也同样是这个trait的“泛型参数”。只有指定了所有的泛型参数和关联类型,这个trait才能真正的具体化。可以看到,我们希望参数是一个泛型迭代器,可以在约束条件中写ITER: Iterator<Item = ITEM>,且其关联类型Item必须实现了Debug这个trait。跟普通泛型参数比起来,关联类型参数必须使用名字赋值的方式。

fn use_iter<ITEM, ITER>(mut iter: ITER)
where
    ITER: Iterator<Item = ITEM>,
    ITEM: Debug,
{
    while let Some(i) = iter.next() {
        println!("{:?}", i);
    }
}

目前来看,关联类型和普通泛型参数的功能一致,那它存在的意义是什么呢?其实,关联类型更加强调了“关联性”,即强调了所声明的泛型参数和该trait的相关性,从而能够达到封装的效果。例如,下面这个例子中,假如我们想设计一个泛型的“图”类型,它包含“顶点”和“边”两个泛型参数,如果使用普通泛型参数,如下所示:

trait Graph<N, E> {
    fn has_edge(&self, n1: &N, n2: &N);
    // ...
}

fn distance<N, E, G: Graph<N, E>>(graph: &G, start: &N, end: &N) -> i32 {
    // ...
}

distance函数用来计算一个图中两个顶点之间的距离,它的函数签名里会有很多的泛型参数。但实际上,对于确定的Graph类型,它的顶点和边的类型应该是固定的,所以在函数签名中再写一遍没有什么意义,关联类型就解决了这个问题,如下面代码所示:

trait Graph {
    type N;
    type E;
    fn has_edge(&self, n1: &Self::N, n2: &Self::N);
    // ...
}

fn distance<G>(graph: &G, start: &G::N, end: &G::N) -> i32
where
    G: Graph,
{
    // ...
}

除了关注关联类型的优点,我们也得关注其缺点。如果某个trait使用了关联类型,则不能为某个类型实现多个该trait,如下面的代码所示:

trait ConvertTo {
    type DEST;
    fn convert(&self) -> Self::DEST;
}

impl ConvertTo for i32 {
    type DEST = i32;
    fn convert(&self) -> i32 {
        *self as i32
    }
}

// error[E0119]: conflicting implementations of trait `ConvertTo` for type `i32`:
impl ConvertTo for i32 {
    type DEST = f64;
    fn convert(&self) -> f64 {
        *self as f64
    }
}

但如果改用普通泛型参数,则无此问题,可以正常编译通过:

trait ConvertTo<DEST> {
    fn convert(&self) -> DEST;
}

impl ConvertTo<i32> for i32 {
    fn convert(&self) -> i32 {
        *self as i32
    }
}

impl ConvertTo<f64> for i32 {
    fn convert(&self) -> f64 {
        *self as f64
    }
}

由此可见,在编译器眼里,如果trait有类型参数,那么给定不同的类型参数,它们就已经是不同的trait,可以针对同一个类型impl。如果trait没有类型参数,只有关联类型,给关联类型指定不同的类型参数是不能用它们针对同一个类型impl的。

对于普通泛型参数和关联类型,它们各有利弊,我们该如何选择呢?一句话,对于需要在impl阶段就确定下来的,选择关联类型;对于在函数调用阶段才确定下来的,选择普通泛型参数。在标准库中,何时使用普通泛型参数、何时使用关联类型,实际上有非常好的示例。

  • 普通泛型参数:对于标准库中的AsRef,实现这个trait的类型可能会想转化为很多类型的引用,具体的类型由用户在函数调用阶段选择,所以必须使用普通泛型函数。

    pub trait AsRef<T: ?Sized> {
        /// Performs the conversion.
        fn as_ref(&self) -> &T;
    }
    
  • 关联类型:对于标准库中的Deref,实现这个trait的类型解引用的目标类型是唯一固定的,需要在impl阶段就确定下来,所以必须使用关联类型。

    pub trait Deref {
        /// The resulting type after dereferencing.
        type Target: ?Sized;
    
        /// Dereferences the value.
        fn deref(&self) -> &Self::Target;
    }
    

参考文献

  • 《Rust编程之道》张汉东
  • 《深入浅出Rust》范长春
03-09 13:32