Rust 学习笔记之类型、泛型和 Trait

Rust 的类型系统是比较吸引人的,实际上也算是一种代数类型系统(Algebraic data type),数学上有严格定义。

为什么有类型

计算机其实根本不关心类型。无论软件多么复杂,加上多少中间层,最终还是逃脱不了0和1。机器语言,是完全不关心类型的。我们也都清楚,这非常低效、冗长,特别容易出错。到了1950的时候,人们开始提出一些机器语言的助记符号,就有了现在仍然有用的汇编语言。汇编语言是一比一的翻译成机器语言的,没有编译的环境。

接下来是高级语言时代了,C语言等于在汇编语言上再加了一层抽象,C代码先编译成汇编语言,汇编语言再翻译成机器语言。我们都知道C语言是有类型的。类型和类型系统实际上提供了一种语义,指定什么是我想要的,什么是我不想要的,用来约束人类语言的模棱两可。

类型系统则是一套类型交互的规则。规则指明了哪些行为是正确的。Rust 的类型系统很大程度上受到了函数式编程的影响,比如 Ocaml 和 Haskell。比如,traits 就明显是 Haskell 的样子。

泛型

泛型提供了一种更高级的抽象。有的算法适用于多种类型,比如最简单的求和函数,这个算法就不关心是整数还是浮点数。如果在不支持的泛型的语言中,可能就需要把代码拷贝一遍,把参数作出相应的更改。然而,这样代码的维护起来的困难度就变大了。

泛型编程只适用在静态类型的编程语言中,最早出现在函数式编程语言 ML 中。动态语言,比如 Python,是不关心类型的,天生就带着泛型的气质,自然没有泛型的概念。静态语言有了泛型后,就可以用一个占位符比如 TKV 去代替具体类型声明,告诉编译器当初始化实例的时候再去填充具体的类型。

泛型函数

写一个简单的泛型函数,

// generic_function.rs

fn give_me<T>(value: T) {
  let _ = value;
}

fn main() {
  let a = "generics";
  let b = 1024;
  give_me(a);
  give_me(b);
}

在编译的时候,编译器会生成两个版本的 give_me,一个接受字符串类型,一个接受数字。可以通过 nm 命令来确定一下。

nm generic_function | grep "give"

nm 是 GNU 二进制工具包里的一个命令。通过把二进制文件传给 nm 然后使用 grep 过滤前缀为 give 的函数。

give 函数

可以发现,泛型提供了一种简单的粗暴的多态。编译代码后自动生成多个版本的函数,这也带来了一个问题,由于代码的重复,最终可执行文件的大小会增加。不过大多数时候,使用泛型实现多态性是首选的方式,因为运行的时候没有开销。

如果能用泛型解决的问题,应该尽可能使用泛型。只有泛型不能满足要求,例如需要在某个集合容器里存取一组类型,才应该考虑用 trait。

泛型结构体和泛型枚举

和泛型函数很类似,没什么太大的差别,语法上没什么难以理解的:

struct GenericsStruct<T>(T);

struct Container<T> {
  item: T;
}

enum Transmission<T> {
  Signal(T),
  NoSignal
}

fn main() {
  // ...
}

泛型的 impl

同样可以使用 impl 来为泛型实现相应的方法。

struct Container<T> {
  item: T
}

impl<T> Container<T> {
  fn new(item: T) -> Self {
    Container { item }
  }
}

这个方法适用于所有类型,但是有时候也可以实现特定类型的方法:

impl Container<u32> {
  fn sum(item: u32) -> Self {
    Container { item }
  }
}

因为这个方法只针对 u32,所以不需要写 impl<T>,只需要写 impl 就可以了。

使用泛型

要使用泛型,需要在编译时告诉编译器具体类型是什么,编译器才可以把 T 这种占位符替换成对应的类型。如果没有指明类型,代码是无法正确编译的:

// creating_generic_vec.rs

fn main() {
  let a = Vec::new();
}

无法正确编译

有几种方法可以告知编译器具体要什么类型:

// creating_generic_vec.rs

fn main() {
  // providing a type
  let v1: Vec<u8> = Vec::new();

  // or calling method
  let mut v2 = Vec::new();
  v2.push(2); // v2 is new Vec<i32>

  // or using turbofish
  let v3 = Vec::<u8>::new(); // not so readable
}

编程中有一个很常见的操作,把数字字符串解析成数字。Rust 提供了一个泛型的 parse 方法,通过指定类型,如i32f32usize,转换成不同的类型。

// using_generic.rs

use std::str;

fn main() {
  let num_from_str = str::parse::<u8>("34").unwrap();
  println!("Parsed number {}", num_from_str);
}

需要注意一点,不是随随便便可以使用 parse 的。要使用 parse,需要实现 FromStr 接口(trait)。parse 正是通过 FromStr trait 来限制传入的类型。

抽象行为的trait

从多态性和代码重用的角度,可以将类型的行为(能力)和属性(数据)进行分离。trait 正是对特定一组行为的抽象。例如,基于比较的 sort函数,可以通过实现泛型和比较(Comparable)的trait 来实现一个通用的 sort 函数,不局限在某一个特定的类型。

trait

trait 类似 Java 中的 interface,本身是没什么用的,必须被特定的类型实现。trait 可以用来建立两个类之间的关系。trait 也是 Rust 众多语言特性的支柱。

下面模拟一个简单的多媒体播放应用的设计,先建立一个项目 cargo new super_player

// super_player/src/main.rs

struct Audio(String);
struct Video(String);

fn main() {
    // stuff
}

无论是播放音频还是视频,都需要有播放和暂停的功能。这是两个 struct 的公有功能。因此可以新建一个文件,定义一个 trait:

//super_player/src/media.rs

trait Playable {
    fn play(&self);
    fn pause() {
        println!("Paused");
    }
}

selfSelf 的类型别名。在这里这个方法类似 Java 中的抽象方法,没有具体的实现。当然,也可以在 trait 中像 pause() 一样,给出默认的实现。

trait 可以有两种方式,一种是关联方法(associated method),这种类似 Java 中的静态方法,不需要初始化实例变量。例如,from_str,只需要用 String::from_str("foo"),并不是需要初始化实例。

另外一种是实例方法(instance methods),这种方法的第一个参数是 selfself 类似 Java 或者 C++ 的 this,指向了具体的实例。self 也有三种形式,self&self&mut self(具体等写到内存管理的时候再聊)。

现在,针对两个 struct 实现具体的方法:

// super_player/src/main.rs
struct Audio(String);
struct Video(String);

impl Playable for Audio {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

impl Playable for Video {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

fn main() {
    println!("Super player!");
}

如果使用 cargo check 或者 cargo run,会发现出现:
不在作用域

这个错误已经清楚告诉我们:trait 默认也是私有的。解决方法也很简单,在 media.rs 的声明前面加上 pub

pub trait Playable {
  fn play(&self);
  fn pause() {
    println!("Paused");
  }
}

需要在 main.rs 中明确的引入该 trait:

// super_player/src/main.rs
mod media;
use crate::media::Playable;

struct Audio(String);
struct Video(String);

impl Playable for Audio {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

impl Playable for Video {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

fn main() {
    println!("Super player!");
    let audio = Audio("ambient_music.mp3".to_string());
    let video = Video("big_buck_bunny.mkv".to_string());
    audio.play();
    video.play();
}

trait 也可以声明依赖于其他的 trait,可以称为 trait继承(trait inheritance):

// trait_inheritance.rs

trait Vehicle {
    fn get_price(&self) -> u64;
}

trait Car: Vehicle {
    fn model(&self) -> String;
}

struct TeslaRoadster {
    model: String,
    release_date: u16,
}

impl TeslaRoadster {
    fn new(model: &str, release_date: u16) -> Self {
        Self {
            model: model.to_string(),
            release_date,
        }
    }
}

impl Car for TeslaRoadster {
    fn model(&self) -> String {
        "Tesla Roadster I".to_string()
    }
}

impl Vehicle for TeslaRoadster {
    fn get_price(&self) -> u64 {
        self.release_date as u64
    }
}

fn main() {
    let my_roadster = TeslaRoadster::new("Tesla Roadster II", 2020);
    println!(
        "{} is priced at ${}",
        my_roadster.model,
        my_roadster.get_price()
    );
}

由于 TeslaRoadster 是一辆 Car,而 Car 又依赖于 Vehicle,因此必须为 TeslaRoadster 实现 Car 和 Vehicle。

其他形式的 trait

trait 可以算得上是 Rust 的灵魂。Rust 语言的所有的抽象,比如接口抽象、OOP 范式抽象、函数式抽象,都基于 trait 完成。与此同时,trait 也保证抽象几乎是运行零开销。

最简单的 trait

标准库中的 Default 就是类似下面这种 trait:

trait Foo {
    fn foo();
}

泛型 trait

pub trait From<T> {
    fn from(T) -> Self;
}

联合 trait

一个典型例子就是 Iterator trait。

trait Foo {
    type Out;
    fn get_value(self) -> Self::Out;
}

trait 继承

比如说,标准库里的 Copy trait 就实现了 Clone trait。

trait Bar {
    fn bar();
}

trait Foo: Bar {
    fn foo();
}

trait 与泛型

对泛型和 trait 有大致的了解之后,就可以考虑如何把它们结合起来。

struct Game;
struct Enemy;
struct Hero;

impl Game
{
  fn load<T>(&self, entity: T) {
    entity.init();
  }
}

fn main()
{
  let game = Game;
  game.load(Enemy);
  game.load(Hero);
}

如果用 rustc 命令编译这段代码会发现报错:
无法确定是否实现 init

问题在于,编译器无法确定 T 到底有没有实现 init() 方法,既然函数要调用这个方法,那么必须约束只有实现了 init 方法的类型才能使用。为了使得编译通过,我们实现一个 Loadable 类,然后为 EnemyHero 实现这个 trait,并且要求作为传入 load 形参的类型必须实现这个 Loadable

struct Game;
struct Enemy;
struct Hero;

trait Loadable
{
  fn init(&self);
}

impl Loadable for Enemy
{
  fn init(&self)
  {
    println!("Enmey loaded");
  }
}

impl Loadable for Hero
{
  fn init(&self)
  {
    println!("Hero loaded");
  }
}

impl Game
{
  // 注意::Loadable
  fn load<T: Loadable>(&self, entity: T) {
    entity.init();
  }
}

fn main()
{
  let game = Game;
  game.load(Enemy);
  game.load(Hero);
}

注意上面代码中的 :Loadable,这指定了一个 trait 的界限,可以以此来约束接受的 API。定义泛型函数的时候,几乎都需要一个 trait 限制,例如实现一个最基础的泛型加法,也必须要求传入的类型要实现 Add trait:

fn add_thing<T: Add>(fst: T, snd: T)
{
  let _ = fst + snd;
}

类似的,要调用 println 就必须实现 Display trait:

fn show_me<T: Display>(val: T)
{
  println!("{}", val);
}

除了使用 :,还可以使用 where 来指定需要实现的 trait,对于类型较长的情况下,可以使得代码具有更好的可读性,比如说标准库中的 parse

fn parse<F>(&self) -> Result<F, <F as FromStr>::Err>
  where F: FromStr
{
  // ...
}

在函数或方法上添加 trait 约束是非常常见的,也可以针对类型进行添加约束:

struct Foo<T: Display>
{
  bar: T
}

不过,不推荐在类型上使用这种方式,因为它意味着对类型本身添加了限制。一般来讲,类型应该尽可能通用。

组合 trait

可以使用 + 组合多个 trait。例如在标准库中的 HashMap 类型:

impl<K: Hash + Eq, V> HashMap<K, V, RnadomState>

这就意味着传入的类型 K 必须使用 Hash trait,也需要使用 Eq trait。当然,还可以把多个 trait 通过 + 组合成为一个新的 trait:

trait Eat {
  fn eat(&self) {
    println!("eat");
  }
}

trait Code {
  fn code(&self) {
    println!("code");
  }
}

trait Sleep {
  fn sleep(&self) {
    println!("sleep");
  }
}

trait Programmer: Eat + Code + Sleep {
  fn animate(&self) {
    self.eat();
    self.code();
    self.sleep();
    println!("repeat!");
  }
}

struct Bob;
impl Programmer for Bob {}
impl Eat for Bob {}
impl Code for Bob {}
impl Sleep for Bob {}

fn main()
{
  Bob.animate();
}

impl trait 语法

定义 trait 约束还有另外一种语法,就是使用 impl Display 而不是 T: Display

use std::fmt::Display;

fn show_me(val: impl Display)
{
  println!("{}", val);
}

fn main()
{
  show_me("Trait bounds are awesome");
}

这种写法的优点体现在返回一个复杂类型的时候,特别是闭包类型。如果没有这种语法,就必须要把返回的类型放到 Box 智能指针中,这意味着会调用堆分配。

fn lazy_adder(a: u32, b: u32) -> impl Fn() -> u32 {
  move || a + b
}

fn main()
{
  let add_later = lazy_adder(1024, 2048);
  println!("{:?}", add_later());
}

这种语法主要推荐用于返回类型,而不是用于函数形参。如果用在函数形参,:: 就用不了。如果有的代码使用 :: 去调用方法,那么就会产生不兼容。

小结

Rust 使用 trait 实现抽象,它几乎是 Rust 的灵魂。

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页