Rust权威指南之泛型、trait和生命周期

Rust权威指南之泛型、trait和生命周期

一. 泛型

我们在上一篇文章已经学习过VecHashMap等这些都是用了泛型。下面在详细了解下泛型在定义函数、结构体、枚举以及方法中的使用。

1.1. 在函数中使用泛型

下面我们实现一个求最大值的函数:

// 这里泛型T被约束必须实现Copy和PartialOrd两个trait
fn max_value<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

1.2. 在结构体中使用泛型

直接看例子:

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

fn main() {
    let point = Point { x: 10, y: 10 };
    let point = Point { x: 1.5, y: 10.4 };
    let point = Point { x: 1, y: 10.4 }; // error 类型不一致
}

如果想让上面那个错误的代码正确我们可以定义多个泛型:

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

fn main() {
    let point = Point { x: 10, y: 10 };
    let point = Point { x: 1.5, y: 10.4 };
    let point = Point { x: 1, y: 10.4 }; // OK
}

1.3. 在枚举中使用泛型

这个我们应该非常熟悉,上一篇文章中我们详细介绍了Result<T, E>,它就是一个枚举:

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

当我们发现我们的代码拥有多个结构体或者枚举定义,且仅仅自由值类型不同时,我们就可以使用泛型来避免重复代码。

1.4. 在方法定义中使用泛型

我们接着上面Point<T>结构体看看方法中如何使用泛型:

struct Point<T> {
    x: T,
    y: T,
}
// 注意此处,impl 之后需要紧跟<T>
impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}


fn main() {
    let point = Point { x: 1.5, y: 10.4 };
    println!("point => x : {}", point.x()) // point => x : 1.5
}

另外我们还需要注意结构体定义中的泛型参数并不总是我们在方法签名上使用的类型参数一致。

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

impl<T, U> Point<T, U> {
    fn mix_up <V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1.5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };
    let p3 = p1.mix_up(p2);
    println!("point 3 => x : {} | y : {}", p3.x, p3.y); //point 3 => x : 1.5 | y : c
}

这里也说明Rust允许我们一部分泛型声明于impl关键字后,另一部分则声明于方法定义中。

1.5. 泛型性能问题

Rust会在编译时执行泛型代码的单态化。单态化是一个在编译期将泛型代码转换为特定代码的过程,它们会将所有使用过的具体类型填入泛型参数从而得到具体类型的代码。单态化使Rust的泛型代码在运行时极其高效。

二. trait

trait(特征)被用来向Rust编译器描述某些特定类型拥有且能够被其他类型共享的功能,它使我们可以以一种抽象的方式来定义共享行为。

trait与其他语言中常被称为接口的功能类似,但也不尽相同。

2.1. 定义trait

类型的行为由该类型本身可供调用的方法组成。当我们可以在不同的类型上调用相同的方法时,我们称这些类型共享了相同的行为。trait提供了一种特定方法签名组合起来的途径,它定义了为达成某种目的所必需的行为集合。

这部分比较简单,有其他编程语言基础的看一下就可以知道了。假如我们现在描述动物的一些共有行为,此时我们可以使用trait关键字声明trait,在花括号里面就是类型的行为,这和我们其他语言中的一样,下面看例子:

pub trait Animal {
    fn name(&self) -> String;
}

一个trait可以包含多个方法:每一个方法签名占据单独一行并以分号结尾。

2.2. 实现trait

这里也和其他的语言差不多,我们可以为结构体通过for关键字实现trait,看例子:

pub struct Dog {
    name: String
}
// 实现
impl Animal for Dog {
    fn name(&self) -> String {
        format!("这是一只叫「{}」的狗", self.name)
    }
}

fn main() {
    let dog = Dog {
        name: String::from("Tom")
    };
    println!("{}", dog.name())
}

这里Rusttrait中的方法是可以有默认实现的,这点和其他编程语言不一样的点,看例子:

pub trait Animal {
    fn name(&self) -> String {
        String::from("没有实现")
    }
}

pub struct Cat {
    name: String
}
// 没有实现
impl Animal for Cat {}

fn main() {
    let cat = Cat {
        name: String::from("小猫")
    };
    println!("{}", cat.name()); // 没有实现
}

2.3. trait作为参数

下面我们看一个使用trait来定义接收不同类型参数的函数。下面我们定义一个call函数来接收不同的实现了Animal的结构体:

pub trait Animal {
    // 动物名称
    fn name(&self) -> String {
        String::from("没有实现")
    }
    // 叫
    fn call(&self);
}
pub struct Dog {
    name: String
}

impl Animal for Dog {
    fn name(&self) -> String {
        format!("这是一只「{}」", self.name)
    }
    fn call(&self) {
        println!("汪 汪 ......")
    }
}
// 叫
fn call(item: impl Animal) {
    item.call()
}

fn main() {
    let dog = Dog {
        name: String::from("狗")
    };
    call(dog)
}

上面我们实现的call可以用我们上面了解了泛型优化下,可以如下面这样写:

fn call<T: Animal>(item: T) {
    item.call()
}

Rust还支持我们使用+语法来指定多个trait约束

fn call<T: Animal + Display>(item: T) {
    item.call()
}

那么现在问题又来了,如果我们拥有很多的约束怎么办呢?如下:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {
  // 省略具体实现......
}

这里Rust为我们提供了where关键字可以优化代码写法:

fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone,
      U: Clone + Debug
{
  // 省略具体实现......
}

瞬间清爽,哈哈!

2.4. 返回trait

上面我们介绍了参数,最后在介绍下返回值,如下:

fn return_animal() -> impl Animal {
    Dog {
        name: String::from("狗")
    }
}

上面这种写法很简单,我们是不是还会想在其他语言中可以根据条件返回Dog或者Cat这两种不同的结构体:

// error[E0308]: `if` and `else` have incompatible types
fn return_animal(condition: bool) -> impl Animal {
    if condition {
        Dog {
            name: String::from("狗")
        }
    } else {
        Cat {
            name: String::from("猫")
        }
    }
}

但是可惜,Rust编译无法通过,编译器提示我们需要做下面的修改。后面章节在详细介绍。

三. 生命周期

Rust的每个引用都有自己的生命周期,它对应着引用保持有效性的作用性。这里我们在之前文章中介绍借用引用简单说过。下面我们简单详细说一下。

在大多数时候,生命周期都是隐式且可以被推导出来的,就如同大部分时候类型也是可以被推到的一样。当出现了多个可能的类型时,我们就必须手动声明类型。

3.1. 悬垂引用

生命周期最主要的作用就是避免悬垂引用,进而避免程序引用到非预期的数据。例子:

fn main() {
    let r;                 // -------------------- + ------ 'a 
    {                      //                      |
        let x = 5;         // ----- + ---- 'b      |                  
        r = &x;            //       |              |
    }                      //                      |
    // error[E0597]: `x` does not live long enough |
                           //                      |
    println!("r: {}", r);  //                      |
}                          // -------------------- +

上面的代码执行的时候编译器是会报错:error[E0597]: `x` does not live long enough

这时因为变量x当离开大括号的作用域之后,就会被销毁。此时将其引用赋值到变量r上时,因为变量x已经被销毁,导致变量r出现了空值的问题。而在Rust中有一个借用检查器的工具,它主要是被用于比较不同的作用域并确定所借用的合法性。

我们在上面例子中,rx的生命周期标注分别是'a'b,我们很清楚的看到'a的生命周期大于'b的生命周期。在Rust的编译过程中,Rust会比较两段生命周期的大小,此时发现r拥有生命周期'a,但却指向了拥有生命周期的'b,这里会因为'b小于'a而被拒绝通过编译:被引用对象的存在范围短于引用者。

3.2. 函数中的生命周期

下面我们定义一个函数,返回两个字符串切片中较长的一个。

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

fn main() {
    let result = longest("hello", "tom");
    println!("result => {}", result);
}

这段代码是无法编译通过的,编译器会报如下错误:缺失生命周期标注,这是因为Rust并不确定返回引用指向x还是指向y

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
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`

怎么解决这里的错误呢?答案是使用生命周期标注引用类型的参数。

3.2.1. 生命周期标注语法

这里需要注意生命周期的标注并不会改变引用的生命周期长度。如同使用了泛型参数的函数可以接受任何类型一样,使用了泛型生命周期的函数也可以接受带有任何生命周期的引用。在不影响生命周期的前提下,标注本身会被用于描述多个引用生命周期之间的关系。下面看几个例子:

&i32           // 引用
&'a i32        // 拥有显示生命周期的引用
&'a mut i32    // 拥有显示生命周期的可变引用

注意:单个生命周期的标注本身并没有太多意义,标注之所以存在是为了向Rust描述多个泛型生命周期参数之间的关系。

那么我们现在就可以修改上面报错的代码了:此时函数向Rust表明,所获取的两个字符串切片参数的存活时间,必须不短于给定的生命周期'a

// 泛型'a的生命周期会被具体化为x与y中生命周期较短的那一个
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

注意:此时我们并没有修改任何传入值或返回值的生命周期,我们只是向借用检查器指出了一些可以用于检查非法调用的约束。

当我们在函数中标注生命周期时,这些标注会出现在函数签名而不是函数体中。Rust可以独立的完成对函数内代码引用的分析。但是,当函数开始引用或被函数外部的代码引用时,想单靠Rust自身来确定参数或返回值的生命周期,就几乎不可能了。函数所使用的生命周期可能在每次调用中都会发生变化。这也是我们需要对生命周期进行标注的原因。

下面我们在看一个例子:

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        // error[E0597]: `string2` does not live long enough
        result = longest(string1.as_str(), string2.as_str());
    } // @1
    println!("result => {}", result);
}

从上面的例子我们现在应该就算不编译执行也可以知道是什么结果吧!上面变量string1在整个作用域中都是有效的,但是string2作用域当离开@1的时候就结束了,当我们在@1作用域之后再去使用result就出现在string2生命周期结束的问题,Rust编译失败;这就是result引用的生命周期必须小于两个参数的生命周期。

3.2.2. 深入理解

指定生命周期的方式往往取决于函数的具体功能。下面我们先看一个例子:

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

在这个例子中我们为参数x与返回类型指定了相同的生命周期'a,却忽略了参数y,这是因为y的生命周期与x和返回值的生命周期没有任何相互关系。

接着我们在看一个编译出错的例子,就算我们添加生命周期标注也无法编译通过!

fn longest_plus(x: &str, y: &str) -> &str {
    let result = String::from("hello world");
    result.as_str()
}

其实原因很简单,这是因为result在函数结束时离开了作用域,就会被清理。但是我们依然尝试从函数中返回一个指向result的引用,此时就会造成悬垂引用的产生,在Rust中是不允许创建悬垂引用。上面例子最好的办法是返回一个持有自身所有权的数据类型而不是引用,这样就可以将清理值的责任转移给函数的调用者了。

3.3. 结构体中的生命周期标注

在上一部分我们已经讲了解决上一个例子无法编译的解决方法了,下面我们了解下在结构体中如何使用生命周期标注的,例子:

#[derive(Debug)]
struct Example<'a> {
    part: &'a str
}

fn main() {
    let result = String::from("hello world");
    let example = Example { part: result.as_str() };
    println!("{:#?}", example)
}

结构体中的生命周期标注是泛型标注,在Example的结构体中的标注意味着Example的实例的存活时间不能超过存储在part字段中引用的存活时间。

3.4. 生命周期的省略

从上面学习我们知道任何引用都有一个生命周期,并且需要为使用引用的函数或结构体置顶生命周期参数。下面看一下例子:

fn first_world(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

我们发现这个函数的参数和返回值的类型虽然是引用,但是并没有使用生命周期的标注。这时因为随着Rust版本迭代,早期缺失需要对其进行生命周期标注:fn first_world<'a>(s: &'a str) -> &'a str,后面Rust团队在特定情况下的生命周期标注做了优化,在一些特定的情况下借用检查器可以自动对生命周期进行推到而无需显示标注。

在没有显示标注的情况下,编译器使用下面三种规则计算引用的生命周期。具体计算规则如下:

  • 每一个引用参数都会拥有自己的生命周期参数;

    fn foo<'a>(x: &'a i32) {}
    // 双参数拥有两个不同的生命周期参数
    fn foo<'a, 'b>(x: &'a i32, y: &'b i32) {}
    
    
  • 当只存在一个输入生命周期参数时,这个生命周期会赋予给所有输出生命周期的参数;

    fn foo<'a>(x: &'a i32) -> &'a i32
    
  • 当拥有多个输入生命周期参数,而其中一个&self&mut self时,self的生命周期会被赋予给所有的输出生命周期参数;

3.4.1. 例子分析一

我们接着看上面的例子:

fn first_world(s: &str) -> &str {

我们先拿第一个条规则检测,为每一个参数指定生命周期:

fn first_world<'a>(s: &'a str) -> &str {

接着我们再使用第二条规则,发现也适用,输入参数的生命周期将被赋予输出的生命周期参数:

fn first_world<'a>(s: &'a str) -> &'a str {

此时函数签名所有引用都已经有了生命周期。此时就无需我们显示的标注了。

3.4.2. 例子分析二

接着看一下我们之前常用的一个函数:

fn longest(x: &str, y: &str) -> &str {

此时我们会发现由于函数的输入生命周期超过一个,所有第二条规则不适用,另外由于该方法是一个函数而不是方法,所有第三条规则也不适用(第三条规则实际上只适用于方法签名),此时依然无法计算出返回类型的生命周期;这就需要我们显示的标注了。

3.4.3. 例子分析三

当我们需要为某个拥有生命周期的结构体实现方法时,可以使用与泛型参数相似的语法。申明和使用生命周期参数的位置取决于它们是与结构体字段相关,还是与方法参数、返回值相关。

结构体字段中的生命周期标注总是需要申明在impl关键字后,并被用于结构体名称之后,因此这些生命周期是结构体的一部分。

impl代码块中的方法签名,引用可能是独立的,也可能会与结构体中引用生命周期相互关联。另外生命周期省略规则在大部分情况下都可以省略在方法签名中进行生命周期的标注。

下面我们看例子:

#[derive(Debug)]
struct Example<'a> {
    part: &'a str
}
impl<'a> Example {
    fn level(&self) -> i32 {
        3
    }
} 

impl后面的生命周期是不能省略的,但是根据第一条规则我们是可以不用在方法中的self引用标注生命周期。

接着我看另一个方法:

#[derive(Debug)]
struct Example<'a> {
    part: &'a str
}
impl<'a> Example {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("{}", announcement);
        self.part
    }
}

根据第一条规则给了&selfannouncement各自的生命周期,接着根据第三条规则,其中一个参数是&self,返回类型被赋予&self的生命周期,这时所有的生命周期都被计算出来了。

3.6. 静态生命周期

Rust中还存在一种特殊的生命周期'static,它表示整个程序的执行期。所有的字符串字面量都拥有'static生命周期,我们可以显示的把它们标注出来:

let s: &'static str = "hello world";

字符串的文本是直接存储在二进制程序中,并总是可用的。所有字符串字面量的生命周期都是'static

在错误提示中看过关于’static的生命周期建议,但是在使用’static之前,记得思考下我们所持有的引用是否真的可以在整个程序的生命周期内有效。即便它可以,我们还需要思考下它是否真的需要存活那么长时间。大部分情况下,错误原因都在于尝试创建一个悬垂引用或可用生命周期不匹配。这时应该去解决这些问题,而不是指定’static生命周期。

3.7. 同时时候泛型参数、trait约束和生命周期

先看下面的例子:

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
    }
}

这里还是我们一直说的,生命周期也是一种泛型,所有生命周期参数'a和泛型参数T都被放置到了函数名后的尖括号列表中就可以了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值