Back
Featured image of post Rust 学习笔记06 - 泛型和生命周期

Rust 学习笔记06 - 泛型和生命周期

导航页

泛型

泛型(generics)可以使得代码适应性更强,避免编写重复代码。

函数定义中的泛型

我们可以为 i32 类型编写以下代码:

fn largest_i32(list: &[i32]) -> Option<i32> {
  let mut largest = list.get(0)?;

  for item in list {
    if item > largest {
      largest = item;
    }
  }

  return Some(*largest);
}

这工作得很好,但假设也需要为 char 找到最大值呢?当然可以原封不动地抄过来,但这不太优雅。此时我们可以使用泛型改写。

fn largest<T: PartialOrd>(list: &[T]) -> Option<&T> {
  if list.is_empty() {
    return None;
  }
  let mut largest = list.get(0)?;
  for i in list {
    if i > largest {
      largest = i;
    }
  }
  return Some(largest);
}

其中 PartialOrd 一个 traitT 为一个类型。该函数拥有泛型 T;有一个参数 list,其类型为 T 的切片;返回值为类型为 &TOption

PartialOrd 表示该类型具有比较功能,所以可以找到最大值。

可以注意到,改写后的函数签名有细微的不同。返回的是 Option<&T>,而不是 Option<T>。这是因为我们并未要求 T 实现 Copy trait。现在的函数既可以用于 Copy 也可以用于 Drop trait。

如果想仅用于实现了 Copy trait 的类型,可以这样写:

fn largest_copy<T: PartialOrd + Copy>(list: &[T]) -> Option<T> {
  if list.is_empty() {
    return None;
  }
  let mut largest = list.get(0)?;
  for i in list {
    if i > largest {
      largest = i;
    }
  }
  return Some(*largest);
}

其中 + 表示并,即需要 T 同时实现了 PartialOrd trait 和 Copy trait。

还可以用 where 子句改写函数签名,这在类型要求较多时比较很有用:

fn largest_copy<T>(list: &[T]) -> Option<T>
where 
  T: PartialOrd + Copy

结构体泛型

结构体也可以使用泛型:

struct Point<T> {
  x: T,
  y: T,
}

fn main() {
  let p1 = Point { x: 5, y: 5 };   // ok
  let p2 = Point { x: 5, y: 4.0 }; // compile-error
}

上述例子限制了 xy 必须是相同类型。

也可以有多个泛型参数:

struct Point<T, U> {
  x: T,
  y: U,
}

fn main() {
  let both_integer = Point { x: 5, y: 10 };
  let both_float = Point { x: 1.0, y: 4.0 };
  let integer_and_float = Point { x: 5, y: 4.0 };
}

这样一来就允许使用不同类型了。

枚举也可以有泛型,并且具有多个类型参数,之前的 OptionResult 枚举就是极好的例子:

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

enum Result<T, E> {
  Ok(T),
  Err(E),
}

方法定义中的泛型

在为结构体和枚举实现方法时,可以使用泛型:

struct Point<T> {
  x: T,
  y: T,
}

impl<T> Point<T> {
  fn x(&self) -> &T {
    &self.x
  }
}

fn main() {
  let p = Point { x: 5, y: 10 };
  println!("p.x = {}", p.x());
}

必须在 impl 后面声明 T,这样就可以在 Point<T> 上实现的方法中使用它。

也可以声明为某个具体类型的实现:

impl Point<f32> {
  fn distance_from_origin(&self) -> f32 {
    (self.x.powi(2) + self.y.powi(2)).sqrt()
  }
}

这意味着,只有 Point<i32> 会有 distance_from_origin 方法,而非 f32T 则没有。

结构体的泛型和方法的泛型不必一致,可以不同。以下实例清晰地解释了这一点:

struct Point<X1, Y1> {
  x: X1,
  y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
  fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
    Point {
      x: self.x,
      y: other.y,
    }
  }
}

fn main() {
  let p1 = Point { x: 5, y: 10.4 };
  let p2 = Point { x: "Hello", y: 'c' };

  let p3 = p1.mixup(p2);

  println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

零成本抽象

Rust 是零成本抽象的,相对于不使用泛型的代码,泛型方法没有任何运行时速度损失。

Rust 使用单态化(monomorphization)保证效率。相当于将所有用到的类型,都生成一个对应的实例,类似这样:

enum Option_i32 {
  Some(i32),
  None,
}

enum Option_f64 {
  Some(f64),
  None,
}

fn main() {
  let integer = Option_i32::Some(5);
  let float = Option_f64::Some(5.0);
}

trait

trait 类似其他语言中的接口(interface),但略有不同。trait 可以用一种抽象的方式定义共享的行为,使用 trait bounds,可以指定泛型所需的 trait。

定义

例如,我们有 NewsArticle 用于存放新闻,而结构体 Tweet 可以存放最多 280 个字符,以及像转推和回复这样的元数据。我们想要创建一个聚合器 aggregator,用来显示可能存储在 NewsArticleTweet 实例中的数据摘要。

可以定义一个 Summary trait 来表示这个行为:

pub trait Summary {
  fn summarize(&self) -> String;
}

类似 impl 的声明,不过我们不提供具体实现。而是列举一系列必须的方法签名。

实现

pub struct NewsArticle {
  pub headline: String,
  pub location: String,
  pub author: String,
  pub content: String,
}

impl Summary for NewsArticle {
  fn summarize(&self) -> String {
    format!("{}, by {} ({})", self.headline, self.author, self.location)
  }
}

pub struct Tweet {
  pub username: String,
  pub content: String,
  pub reply: bool,
  pub retweet: bool,
}

impl Summary for Tweet {
  fn summarize(&self) -> String {
    format!("{}: {}", self.username, self.content)
  }
}

与普通的 impl 类似,但需要在后面加上 for T 表示为谁实现 trait。

调用时,只需像调用普通方法一般调用:

fn main() {
  let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
      "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
  };

  println!("1 new tweet: {}", tweet.summarize());
}

请注意,只有满足孤儿规则(orphan rule)的类型(结构体或枚举)才能实现对应的 trait,即:

  • 被实现的类型
  • 或要实现的 trait

其中之一,在本 crate。

也就是说,你可以为在标准库中的 Option<T> 实现 Summary,也可以为 Tweet 实现在标准库中的 Display。但不能为标准库中的 Option<T> 实现 Display。这为了保证程序的相关性(coherence)。这条规则确保了代码不会被互相破坏。反过来说,如果没有这条跪在,两个 crate 可以同时为一个类型实现相同的 trait,而 Rust 无从得知使用哪一个实现。

默认实现

可以在 trait 中提供默认实现,而不是要求每个实现都自定义行为。我们可以为 summarize 方法指定一个默认的字符串值:

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

使用时留空就会使用默认实现:

impl Summary for NewsArticle {}

在有些时候,我们可以要求具体类型实现一小部分代码,而其他的逻辑可以使用默认值:

pub trait Summary {
  fn summarize_author(&self) -> String;

  fn summarize(&self) -> String {
    format!("(Read more from {}...)", self.summarize_author())
  }
}

impl Summary for Tweet {
  fn summarize_author(&self) -> String {
    format!("@{}", self.username)
  }
}

请注意,Rust 不允许从重载中调用默认方法。

作为参数

这点在之前讲解泛型时,已经涉及了一点。我们可以使用 impl 关键字简写:

pub fn notify(item: &impl Summary) {
  println!("BREAKING NEWS! {}", item.summarize);
}
// trait bound 写法
pub fn notify<T: Summary>(item: T)
// 多个参数
pub fn notify<T: Summary>(item: T, item: T)
// where
pub fn notify<T>(item: T)
where
    T: Summary

通过 + 我们还可以指定多个 trait bound

pub fn notify(item: &(impl Summary + Display))
pub fn notify<T: Summary + Display>(item: &T)

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32
fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug

也可以在返回值中使用:

fn returns_summarizable() -> impl Summary {
  Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
      "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
  }
}

我们返回 trait,而不是具体类型,在类型较长的场景非常有用。

但 Rust 不允许返回 impl 的不同类型,比如这样,会造成编译错误:

fn returns_summarizable(switch: bool) -> impl Summary {
  if switch {
    NewsArticle {
      headline: String::from(
        "Penguins win the Stanley Cup Championship!",
      ),
      location: String::from("Pittsburgh, PA, USA"),
      author: String::from("Iceburgh"),
      content: String::from(
        "The Pittsburgh Penguins once again are the best \
        hockey team in the NHL.",
      ),
    }
  } else {
    Tweet {
      username: String::from("horse_ebooks"),
      content: String::from(
        "of course, as you probably already know, people",
      ),
      reply: false,
      retweet: false,
    }
  }
}

因为 Rust 的泛型默认使用单态化来完成,以避免运行时性能损失,所以至多有一个实际类型。若要使得能够返回多种类型,需要使用动态分发,这会降低性能,但会提高灵活性。此问题的解决方案将在后面具体说明。

有条件地实现

如下面的代码块。我们可以限制 T 为特定的类型,并为这些类型实现对应的方法。

use std::fmt::Display;

struct Pair<T> {
  x: T,
  y: T,
}

impl<T> Pair<T> {
  fn new(x: T, y: T) -> Self {
    Self { x, y }
  }
}

impl<T: Display + PartialOrd> Pair<T> {
  fn cmp_display(&self) {
    if self.x >= self.y {
      println!("The largest member is x = {}", self.x);
    } else {
      println!("The largest member is y = {}", self.y);
    }
  }
}

在 Rust 标准库中不乏这样的例子:

impl<T: Display> ToString for T {
    // ...
}

任何实现了 Display trait 的类型,都同时拥有了 ToString trait。

所以,可以直接对任何实现了 Display trait 的类型,调用 to_string 方法:

let s = 3.to_string();

生命周期

Rust 中每个引用都有其生命周期(lifetime),也就是引用保持有效的作用域。大部分时候,生命周期可以被推断,正如类型也可以被推断一样。而有时候,我们需要手动标注生命周期,来保障运行时引用绝对有效。

避免悬垂引用

避免悬垂引用是生命周期的主要目的,悬垂引用会导致程序读取异常数据。不妨查看下面的例子:

{
  let r;

  {
    let x = 5;
    r = &x;
  }

  println!("r: {}", r);
}

此处的 r 先声明后赋值不代表 Rust 允许空值。运行时永远不会存在类似 JavaScript undefined 之类的东西。尝试在赋值前使用 r,你会发现 Rust 将给出编译时错误。

外部作用域先声明了一个值。而内部作用域尝试将 x 赋给外部的 r,并在外部作用域使用。但实际上 x 会在内部作用域结束时被消毁,所以 Rust 给出了错误提示。`x` does not live long enough

借用检查器

Rust 一大安全机制就是借用检查器(borrow checker),用来比较作用域,确保所有的借用都是有效的。查看下例:

{
  let r;                // ---------+-- 'a
                        //          |
  {                     //          |
    let x = 5;          // -+-- 'b  |
    r = &x;             //  |       |
  }                     // -+       |
                        //          |
  println!("r: {}", r); //          |
}                       // ---------+

我们将 r 的生命周期标记为 'a,将 x 的生命周期标记为 'b。显然的,'b'a 小得多——被引用的对象比引用者存在的时间更短,所以这是无效引用。

再来看一个没有错误的例子:

{
  let x = 5;            // ----------+-- 'b
                        //           |
  let r = &x;           // --+-- 'a  |
                        //   |       |
  println!("r: {}", r); //   |       |
                        // --+       |
}                       // ----------+

这里 'b'a 大,就表明 r 可以引用 x。被引用对象比引用者存在的时间长,引用是有效的。

函数中的泛型生命周期

有一个无法通过编译的函数:

fn longest(x: &str, y: &str) -> &str {
  if x.len() > y.len() {
    x
  } else {
    y
  }
}
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

提示文本告诉我们,返回值需要一个泛型生命周期参数,因为 Rust 不知道返回的引用是指向 xy。我们也不知道传入引用的具体生命周期。所以我们需要标注生命周期泛型。

生命周期注解

生命周期必须以 ' 开头,通常全小写。'a 是首选名称。位于引用的 & 后,并用一个空格将类型和生命周期分开。比如:

&i32        // 引用
&'a i32     // 显式生命周期引用
&'a mut i32 // 显式生命周期可变引用

单个生命周期注解没有多少意义,因为生命周期注解是为了告诉编译器,引用间的互相联系。若两个参数使用一个声明周期 'a,则表明这两个参数需要和 'a 存在得一样久。

函数签名中的生命周期注解

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  if x.len() > y.len() {
    x
  } else {
    y
  }
}

这段代码现在能编译了。并取得了预期的结果。

生命周期注解仅仅存在于函数签名,而不是具体的逻辑。这是函数约定的一部分。能够帮助编译器更准确得指出问题,保证运行时安全。

当具体的引用传递给 longest 时,'a 的实际生命周期,将是 xy 的并集,也就是重叠的部分,xy 中较小的那一个作用域。因为我们也用 'a 标注了返回的引用值,所以能够保证返回值在 'a 中有效。

以下代码能够通过编译:

let string1 = String::from("long string is long");

{
  let string2 = String::from("xyz");
  let result = longest(string1.as_str(), string2.as_str());
  println!("The longest string is {}", result);
}

而以下代码不行:

let string1 = String::from("long string is long");
let result;
{
  let string2 = String::from("xyz");
  result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);

因为后者在 string2 作用域之外使用了 resultresult 又有可能是 string2 的引用,会导致错误。所以 Rust 在编译时提前检查了这一问题。

从人的角度上来说,上述代码是正确的,因为 result 永远都会返回 string1 的引用。但逻辑复杂起来时,这种考虑需要深入到代码实现,仔细思考是否存在无效引用,将是极大的心智负担。所以应当使用统一的方法,避免手动检查,保证运行时安全。

结构体的生命周期

之前我们说过,结构体可以存放引用,但需要标注生命周期:

struct ImportantExcerpt<'a> {
  part: &'a str,
}

fn main() {
  let novel = String::from("Call me Ishmael. Some years ago...");
  let first_sentence = novel.split('.').next().expect("Could not find a '.'");
  let i = ImportantExcerpt {
    part: first_sentence,
  };
}

和函数的生命周期类似,这可以保证,在 ImportantExcerpt 实例存在时,part 一定有效。

生命周期省略

生命周期省略(lifetime elision)让我们减少手动标注的次数和精力。之前我们写过一个函数,没有标生命周期也没有报错:

fn first_word(s: &str) -> &str {
  let bytes = s.as_bytes();

  for (i, &item) in bytes.iter().enumerate() {
    if item == b' ' {
      return &s[0..i];
    }
  }

  &s[..]
}

早期 Rust 必须对每个引用,都标注声明周期,这太麻烦了。所以开发者们尝试寻找了一些规则,让编译器自动标注。

函数或方法参数的生命周期被称为输入生命周期(input lifetimes),而返回值的生命周期则叫输出生命周期(output lifetimes)。

编译器具有 3 条规则,来推导默认的生命周期:

  1. 每个引用的参数,都是自身的生命周期参数。换句话说:
    • 1 参数对 1 生命周期:fn foo<'a>(x: &'a i32)
    • 2 参数对 2 生命周期:fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
  2. 若只有一个输入生命周期参数,那么输出生命周期参数将和其保持一致:
    • fn foo<'a>(x: &'a i32) -> &'a i32
  3. 若有多个生命周期参数,且其中一个参数是 &self&mut self,则说明这是对象的方法。所有输出生命周期参数都赋予 self 的生命周期。

看看下面几个例子:

  • 原始函数:fn first_word(s: &str) -> &str
    • 应用第一条规则:fn first_word<'a>(s: &'a str) -> &str
    • 应用第二条:fn first_word<'a>(s: &'a str) -> &'a str
    • 第三条规则不适用
  • 原始函数:fn longest(x: &str, y: &str) -> &str {
    • 应用第一条规则:fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str
    • 第二条和第三条都不适用

方法定义中的生命周期

当我们需要写 impl 块时,语法和函数的类似。我们继续使用之前 ImportantExcerpt 的例子:

struct ImportantExcerpt<'a> {
  part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
  fn level(&self) -> i32 {
    3
  }
  fn announce_and_return_part(&self, announcement: &str) -> &str {
    println!("Attention please: {}", announcement);
    self.part
  }
}

impl 块后面的泛型参数是不可省略的。

对于 level 方法,我们不需要标注,因为只有一个 &self 引用。

对于 announce_and_return_part 方法,Rust 应用第一条规则和第三条规则。相当于:

fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str {
  println!("Attention please: {}", announcement);
  self.part
}

static

有一种特殊的生命周期,'static,即对应的引用在整个程序的存活期间都有效。

默认情况下,字符串字面量就是 'static 的,因为这些值直接存储在二进制文件中,而且总是可用。也可以显式标注出来:

let s: &'static str = "I'm long live!"

在标注一个引用为 'static 之前,应该首先考虑这么做是否合理,即该值是否应该在整个程序运行时都可用。不应该使用 'static 解决悬垂引用和生命周期不匹配问题。

将本文内容总结成一个函数:

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

以上就是和泛型、生命周期有关的内容了。

comments powered by Disqus