剑客
关注科技互联网

泛型

泛型

数据结构中的泛型

有些时候,我们需要针对多种类型进行统一的抽象,这就是泛型(Generics)。泛型可以使“类型”作为参数,在函数或者数据结构中使用。

再拿我们熟悉的Option类型举例,它就是一个泛型enum类型。泛型参数声明在尖括号< >中。

enum Option<T> {
  Some(T),
  None,
}

这里的<T>实际上是声明了一个“类型”参数,在这个Option内部,Some(T)是一个tuple struct,包含一个元素类型为T。这个泛型参数类型T,可以在使用的时候指定具体类型。

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

在以上这句代码中,泛型参数T就被具体化成了i32,它内部的Some(T)成员,在这里也就具体化成了Some(i32)。

泛型参数可以有多个。泛型参数可以有默认值。比如说:

struct S<T=i32> {
  data: T
}

fn main() {
  let v1 = S { data: 0};
  let v2 = S::<bool> { data: false};
  println!("{} {}", v1.data, v2.data);
}

对于上例中的泛型参数T,如果不指定参数的话,默认为i32,也可以在使用的时候指定为其它类型。

使用不同类型参数将泛型类型具体化后,获得的是完全不同的具体类型。比如Option<i32>和Option<i64>是完全不同的类型,不可通用。当编译器为泛型类型生成代码的时候,是为每一个实例化生成一个新的类型。

函数中的泛型

泛型也可以使用在函数中,语法类似:

fn compare_option<T>(first: Option<T>, second: Option<T>) -> bool
{
    match(first, second) {
        (Some(..), Some(..)) => true,
        (None, None) => true,
        _ => false
    }
}

在上面这个例子中,函数compare_option有一个泛型参数T,两个形参类型均为Option<T>。这意味着这两个参数必须是完全一致的类型。如果我们在参数中传入了两个不同的Option,会导致编译错误:

fn main() {
    println!("{}", compare_option(Some(1i32), Some(1.0f32))); // 类型不匹配编译错误
}

如果我们希望在参数中可以接受两个不同的类型,那么需要使用两个泛型参数:

fn compare_option<T1, T2>(first: Option<T1>, second: Option<T2>) -> bool { ... }

一般情况下,泛型函数的调用,可以不指定泛型参数类型,编译器可以通过类型推导自动判断。某些时候,如果需要手动指定泛型参数类型,则需要使用function_name::<type params> (function params)的语法来使用:

compare_option::<i32, f32>(Some(1), Some(1.0));

impl块中的泛型

impl 的时候,也可以使用泛型。特别是当我们希望为某一类类型统一 impl 某个 trait 的时候,非常有用。有了这个功能,很多时候就没必要单独为每个类型去重复 impl 了。再拿标准库中的代码做例子:

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

比如说,标准库中的 Into 和 From 就是一对功能互逆的 trait。如果 A: Into<B> 意味着 B: From<A>。因此,标准库中写了这样一段代码,意思是,针对所有类型 T,只要满足 U: From<T>,那么就针对此类型 impl Into<U>。有了这样的一个 impl 块之后,我们如果想为自己的两个类型提供互相转换的功能,那么只需 impl From 这一个 trait 就够了,因为反过来的 Into trait 标准库已经帮忙实现好了。

泛型参数约束

泛型参数可以使用trait进行约束,在使用的时候只有满足trait约束条件的类型才能作为泛型参数。泛型参数约束有两种语法:

  1. 在泛型参数声明的时候使用:指定
  2. 使用where子句指定

还是用上面这个compare_option函数的例子来说,如果我们在比较的时候,还要比较Option类型内部数据,那么我们就需要使用泛型参数约束。对于类型T,如果希望它可以使用==运算符,那么它必须实现了PartialEq trait。因此,代码可实现如下:

fn compare_option<T : PartialEq>(first: Option<T>, second: Option<T>) -> bool
{
    match(first, second) {
       // f 和 s 都是 T 类型,必须满足 PartialEq 约束
        (Some(f), Some(s)) => f == s,
        (None, None) => true,
        _ => false
    }
}

如果使用where子句,以上代码可以写为:

fn compare_option<T>(first: Option<T>, second: Option<T>) -> bool
    where T : PartialEq
// 在上面的泛型参数列表中没有约束,约束条件单独写在where关键字后面
{
    match(first, second) {
        (Some(f), Some(s)) => f == s,
        (None, None) => true,
        _ => false
    }
}

在上面这个示例中,这两种写法达到的目的是一样的。但是,在某些情况下,where子句比参数声明中的冒号约束具有更强的表达能力,在泛型参数列表中是无法表达的。我们拿Iterator trait中的函数举例:

fn max(self) -> Option<Self::Item>
    where Self: Sized, Self::Item: Ord
{
}

它要求Self类型满足Sized约束,同时关联类型Self::Item要满足Ord约束。另外,对于比较复杂的约束条件,where子句的可读性明显更好。

关联类型(associated type)

在 trait 中,不仅可以包含方法(包括静态方法)、常量,还可以包含“类型”。比如说,我们常见的迭代器 Iterator 这个 trait,它里面就有一个类型叫 Item,源码如下:

pub trait Iterator {
    type Item;
    ...
}

这样的在 trait 中声明的类型叫做“关联类型”(associated type)。关联类型也同样是这个 trait 的“泛型参数”。只有指定了所有的泛型参数和关联类型,这个 trait 才能真正的具体化。示例如下,在泛型函数中,使用 Iterator 这个泛型作为泛型约束:

use std::iter::Iterator;
use std::fmt::Debug;

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

fn main() {
    let v: Vec<i32> = vec![1,2,3,4,5];
    use_iter(v.iter());
}

我们可以看到,我们希望参数是一个泛型迭代器,我们可以在约束条件中这么写 Iterator<Item=ITEM>。跟普通泛型参数比起来,关联类型参数必须使用名字赋值的方式。那么关联类型跟普通泛型参数相比,有哪些不同点呢,我们为什么需要关联参数呢?

一、可读性可扩展性

在上面这个例子中,我们可以看到,虽然我们的函数只接受一个参数 iter,但是它却需要两个泛型参数,一个用于表示迭代器本身的类型,一个用于表示迭代器中包含的元素的类型。这是相对冗余的写法。实际上,在有关联类型的情况下,我们可以将上面的代码简化一下,示例如下:

use std::iter::Iterator;
use std::fmt::Debug;

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

fn main() {
    let v: Vec<i32> = vec![1,2,3,4,5];
    use_iter(v.iter());
}

这个版本的写法相对上一个版本来说,泛型参数明显简化了,我们只需要一个泛型参数即可。在泛型约束条件中,我们可以写上 ITER 符合 Iterator 约束,此时,我们就已经知道ITER 存在一个关联类型 Item,我们可以再针对这个 ITER::Item 再加一个约束即可。如果我们的 Iterator 中的 Item 类型不是关联类型,而是普通泛型参数,就没法做这样的简化了。

我们再看另外一个例子,假如说,我们想设计一个泛型的“图”类型,它包含了“顶点”和“边”两个泛型参数,如果我们把它们作为普通的泛型参数设计,那么看起来就是这个样子:

trait Graph<N, E> {
    fn has_edge(&self, &N, &N) -> bool;
    ...
}

现在如果有一个泛型函数,要计算一个图中两个顶点的距离,它的签名会是这个样子:

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

我们可以看到,泛型参数比较多,比较麻烦。对于指定的Graph类型,它的顶点和边的类型应该是固定的。在函数签名中再写一遍其实没什么道理。如果我们把普通的泛型参数改为“关联类型”设计,那么数据结构就成了这个样子:

trait Graph {
    type N;
    type E;
    fn has_edge(&self, &N, &N) -> bool;
    ...
}

对应的,计算距离的函数签名可以简化成这个样子:

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

由此可见,在某些情况下,关联类型比普通泛型参数更具有可读性。

二、trait的impl匹配规则

泛型的类型参数,既可以写在尖括号里面的参数列表中,也可以写在 trait 内部的关联类型中。这两种写法有什么区别呢?我们用一个示例来演示一下。

假如说,我们要设计一个 trait,名字叫做 ConvertTo,用于类型转换。那么,我们就有两种选择,一种是使用泛型类型参数:

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

另外一种是,使用关联类型:

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

如果我们想写一个从 i32 类型到 f32 类型的转换,在这两种设计下,代码分别是这样:

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

以及:

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

到目前为止,这两种设计似乎都没什么区别。但是,假如说,我们想继续增加一种从 i32 类型到 f64 类型的转换,使用泛型参数来实现的话,可以编译通过:

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

如果用关联类型来实现的话,就编译不过了:

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

错误信息为:

error: conflicting implementations of trait `ConvertTo` for type `i32`

由此可见,如果我们采用了“关联类型”的设计方案,就不能针对这一个类型实现多个 impl。在编译器的眼里,如果 trait 有类型参数,那么给定不同的类型参数,它们就已经是不同的 trait,可以同时针对同一个类型实现 impl。如果 trait 没有类型参数,只有关联类型,给关联类型指定不同的类型参数,是不能用它们针对同一个类型实现 impl 的。

何时使用关联类型

从前文中大家可以看到,虽然关联类型也是类型参数的一种,但它与泛型类型参数列表是不同的。我们可以把这两种泛型类型参数分为两个类型:

  • 输入类型参数
  • 输出类型参数

在尖括号中存在的泛型参数,是输入类型参数;在 trait 内部存在的关联类型,是输出类型参数。输入类型参数,是用于决定匹配哪个 impl 版本的参数;输出类型参数,则是由输入类型参数和Self类型决定的类型参数。比如继续拿上面的例子来说,用泛型参数实现的版本:

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

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

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

fn main() {
    let i = 1_i32;
    let f = i.convert();
    println!("{:?}", f);
}

编译的时候,编译器会报错:

error: unable to infer enough type information about `_`; type annotations or generic parameter binding required

因为编译器不知道选择使用哪个 convert 方法,我们需要为它指定一个类型参数,比如:

let f : f32 = i.convert();
// 或者
let f = ConvertTo::<f32>::convert(&i);

在标准库中,何时使用泛型参数列表,何时使用关联类型,实际上有非常好的示范。

拿标准库中的AsRef来说,我们希望String类型能实现这个trait,而且既能实现String::as_ref::<str>()也能实现String::as_ref::<[u8]>()。因此AsRef必须有一个类型参数。这样impl AsRef<str> for String和impl AsRef<[u8]> for String才能同时存在,互不冲突。如果我们把目标类型设计为关联类型,那么针对任何一个类型,最多只能impl一次,这就失去了AsRef的意义了。

我们再看标准库中的Deref这个trait,我们希望一个类型实现Deref的时候,最多只能impl一次,解引用的目标类型是唯一固定的。因此Deref不能有类型参数,目标类型应该设计为“关联类型”。否则,我们可以为一个类型实现多次Deref,比如impl Deref<str> for String和impl Deref<char> for String,那么针对 String 类型做解引用操作,还需要指定一个类型参数才行,这显然不是我们希望看到的。解引用的目标类型应该由Self类型唯一确定,不应该被其它类型干扰。impl Deref for String { type Target = str; },这样才是最符合我们需求的写法。

还有一些情况下,我们既需要类型参数,也需要关联类型。比如标准库中的各种运算符相关的trait。拿加法运算符来说,对应的trait为std::ops::Add。它的定义为:

trait Add<RHS=Self> {
    type Output;
    fn add(self, rhs: RHS) -> Self::Output;
}

这个trait中,“加数”类型为Self,“被加数”类型被设计为类型参数RHS,它有默认值为Self,求和计算结果的类型被设计为关联类型Output。大家用前面所讲解的思路来分析,可以发现,这样设计,是最合理的方式。“被加数”类型在泛型参数列表中,因此我们可以为不同的类型实现 Add 加法操作,类型 A 可以与类型 B 相加,也可以与类型 C 相加。而计算结果的类型不能是泛型参数,因为它是被 Self 和 RHS 所唯一固定的,它是典型的“输出类型参数”。

本文同步发布在微信公众号: Rust编程 ,欢迎关注。

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址