代码炼金工坊

Rust初学者-语言精要

一文总结Rust语言精要,快速形成整体风格认知。

文章构成

环境安装与工具链

Rust语言使用rustup作为安装器,它可以安装、更新和管理Rust的所有官方工具链。绝大多数情况下建议使用者使用该工具进行环境安装。

环境安装

对于*nix系统用户而言,执行:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

对于Windows系统用户而言,下载安装rustup-init.exe

安装完毕后可以通过rustup show获取工具链安装地址,进一步查看有哪些工具链,例如在笔者的macOS上是:

❯ rustup show
Default host: x86_64-apple-darwin
rustup home:  /Users/yuchanns/.rustup

stable-x86_64-apple-darwin (default)
rustc 1.49.0 (e1884a8e3 2020-12-29)
❯ ls /Users/yuchanns/.rustup
settings.toml toolchains    update-hashes
❯ ls /Users/yuchanns/.rustup/toolchains
stable-x86_64-apple-darwin
❯ ls /Users/yuchanns/.rustup/toolchains/stable-x86_64-apple-darwin
bin   etc   lib   share
❯ ls /Users/yuchanns/.rustup/toolchains/stable-x86_64-apple-darwin/bin
cargo         cargo-clippy  cargo-fmt     clippy-driver rust-gdb      rust-gdbgui   rust-lldb     rustc         rustdoc       rustfmt

通过rustup doc可以打开本地的Rust文档,而不用网络。

编译器与包管理器

rustc官方编译器,负责将源代码编译为可执行文件或库文件。经过分词和解析生成AST,然后处理为HIR(进行类型检查),接着编译为MIR(实现增量编译),最终翻译为LLVM IR,交由LLVM作为后端编译为各个平台的目标机器码,因此Rust是跨平台的,并且支持交叉编译。

rustc可以用run命令和build命令编译运行源码,但大多数情况下用户不直接使用rustc对源码执行操作,而是使用cargo这一工具间接调用rustc

cargo官方包管理器,可以方便地管理包依赖的问题。

使用cargo new proj_name可以创建一个新的项目,包含一个Cargo.toml依赖管理文件和src源码文件夹。

❯ cargo new proj_name
     Created binary (application) `proj_name` package
❯ tree proj_name
proj_name
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

执行cargo run .可以简单编译运行默认的代码,编译结果将会与src同级的target下,包含target/debugtarget/release两个文件夹。

❯ cd proj_name
❯ cargo run .
   Compiling proj_name v0.1.0 (/Users/yuchanns/Coding/backend/github/rustbyexample/trpl/proj_name)
    Finished dev [unoptimized + debuginfo] target(s) in 1.02s
     Running `target/debug/proj_name .`
Hello, world!

同时我们注意到文件根目录下生成了一个Cargo.lock文件,记录详细的依赖版本信息。然后观察Cargo.toml

❯ cat Cargo.toml
[package]
name = "proj_name"
version = "0.1.0"
authors = ["yuchanns <airamusume@gmail.com>"]
edition = "2018"

[dependencies]
rand = "0.8.1"

可以看到,[package]记录的是关于本项目的一些信息,而下方的[dependencies]则记录了对外部包的依赖。

添加依赖,是通过编辑该文件,手动写入包名和版本,然后在编译过程中cargo就会自动下载依赖并使用。

也许有的读者好奇是否还有类似于其他语言的CLI命令,通过cargo add等命令添加依赖的方式,遗憾的是官方并没有提供这样的支持。而社区则提供了一个killercup/cargo-edit实现了这一需求:

cargo install cargo-edit
cargo add rand
cargo rm rand

在一个issue Subcommand to add a new dependency to Cargo.toml #2179 中官方推荐了该工具,可能很多人(包括笔者在内)都如同下面这位老哥一样很难接受官方因为社区有解决方案而不提供官方解决的决定。不过也许可以理解为这就是官方宣称的 “重视社区” 的身体力行吧。

和许多其他语言一样,身在中国境内,用户还需要设置cargo的镜像站点,改善下载状况:

❯ cat ~/.cargo/config
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"

核心库与标准库

Rust语言分为核心库和标准库。

核心库是语言核心,不依赖于操作系统和网络,不提供并发和I/O,全部是栈分配:

标准库提供开发所需要的基础和跨平台支持:

语法和语义介绍

语句与表达式

Rust语法分为 语句(Statement)表达式(Expression)

语句用于声明数据结构和引入包、模块等:

表达式进行求值:

因此块表达式常常可以这样使用:

fn main() {
    let a = {
        let a = 1;
        let b = 2;
        a + b // 注意这里没有;会直接返回求值结果
    };
    println!("a: {}", a);
}

变量声明语义

表达式内部又可分为 位置表达式(Place Expression)值表达式(Vaue Expression)

位置表达式表示内存位置,可以对数据单元的内存进行读写,代表持久性数据;值表达式引用数据值,只能读,代表临时数据。

fn main () {
    let a = "hello world"
}

如上,a是位置表达式,持久性地将值写入到内存中;而"hello world"则是值表达式,是一个临时数据,不可写,只可被读。

有其他语言背景的读者可能就会觉得,这只是左值和右值的另一种称呼,实际上并不是,这两个概念是为了下面会提到的内存管理所服务的。

表达式的求值过程具有求值上下文,分为位置上下文和值上下文:

Rust使用let声明变量时默认不可对位置表达式重新赋值,需要在声明时通过mut关键字声明可变的位置表达式:

fn main () {
    let mut a = "hello";
    a = "world";
}

通过let可以重复对同一个变量名进行不同数据类型的赋值,这样的操作会“遮蔽”前一个同名变量,可以认为是“只对变量名字进行复用”(那个变量的实际上还在内存当中):

fn main() {
    let a = String::from("hello world");
    let b = &a;
    let a = String::from("hello yuchanns");
    println!("a is {}, b is {}", a, *b);
}

当位置表达式出现在值上下文中,会出现内存地址的转移,同时 转移(Move) 对内存的 所有权(Ownership) ,其结果是将无法再通过这个位置表达式读写该内存地址。

fn main () {
    let a = String::from("hello world");
    // 下面的表达式中位置表达式出现在值上下文(即赋值表达式的右侧)
    // 将一个位置表达式赋值给另一个位置表达式,出现了所有权的转移
    let b = a;
    println!("b is {}", b);
    // println!("a is {}", a); // 这里会编译失败,提示:a value used here after move.
}

细心的读者这时候会注意到上面的代码清单中声明字符串使用了另一种方式,这和Rust的内存分配有关,本文不展开讨论,暂时不必深究。

Rust没有GC,就是 依靠所有权实现对内存的管理

转移(Move) 语义相对的,还有 复制(Copy) 语义,不转移而对内存进行复制。

同时Rust也提供了 借用(Borrow) 操作符(&),在不转移的情况下获取内存位置,并通过 解引用(Deref) 操作符(*)取值。

变量在块表达式的词法作用域范围时结束生命周期。可以在词法作用域内主动使用{}开辟一段新的词法作用域。

函数与闭包

Rust使用fn声明函数定义,并通过在入参后面加: type的方式约定入参类型,通过在函数括号后面加-> type的方式约定函数返回类型:

fn fizz_buzz(num: i32) -> String {
    // ...
}

函数在Rust中是一等公民,可以作为参数和返回值使用。

有其他语言背景的读者也许会觉得,当函数作为返回值使用时,它就是闭包。但在Rust中还是有所不同的:

fn main() {
    // 返回值里的impl表明闭包实际上是用匿名结构体实现了一个trait
    fn make_true2() -> impl Fn() -> bool {
        let s = "hello world2";
        // 函数作为返回值
        fn is_true() -> bool {
            //函数内部无法引用外部变量
            // println!("s: {}", s); // can't capture dynamic environment in a fn item
            true
        }
        fn make_true() -> fn() -> bool {
            is_true
        }
        println!("make_true: {}", make_true()());
        // 闭包作为返回值
        // 使用||代替函数的()
        move || -> bool {
            // 闭包可以引用外部变量
            // 但需要通过move显式转移所有权,代替默认的引用
            println!("s: {}", s);
            true
        }
    }
    println!("make_true2: {}", make_true2()());
}

流程控制

Rust中没有三元操作符,if表达式的分支必须返回同一个类型的值。每一个if分支其实也是一个块表达式。

循环表达式有三种:whileloopfor...in

fn main () {
    let n = 13;
    // if分支是块表达式,返回类型必须相同
    let result = if (n > 10) {
        true
    } else {
        false
    };
    println!("result: {}", result);
    for n in 1..10 {
        println!("now n is {}", n);
    }
    fn while_true() -> i32 {
        while true {
            return 10; // 编译器忽略内部会返回i32,因为认为while条件有真有假,不会一直为true
        }
        return 11; // 如果省略这一行,编译器会认为函数最终返回了一个单元值()
    }
    println!("while_true: {}", while_true());
}

Rust还提供了match表达式和某些场景下可以代替它进行简化的if letwhile let表达式:

fn main() {
    let number = 42;
    match number {
        0 => println!("zero"),
        n @ 42 => println!("value is {}", n),
        _ => println!("rest of all"),
    }
    let mut v = vec![1, 2, 3, 4];
    while let Some(x) = v.pop() {
        println!("{}", x);
    }
}

类型系统

基础类型

数字类型范围占用
u80~28-11个字节
u160~216-12个字节
u320~232-14个字节
u640~264-18个字节
u1280~2128-116个字节
i8-27~27-11个字节
i16-215~215-12个字节
i32-231~231-14个字节
i64-263~263-18个字节
i128-2127~2127-116个字节
usize0~232-1或0~264-14或8个字节,取决于机器的字长
isize-231~231-1或-263~263-14或8个字节,取决于机器的字长
f32-3.4x1038~3.4x1038
f64-1.8x10308~1.8x10308

复合数据类型

Rust提供4种复合数据类型:

struct People {
    name: &'static str,
    gender: u32,
}

impl People {
    fn new(name: &'static str, gender: u32) -> Self {
        return People{name: name, gender: gender};
    }

    // 自身需要mut
    fn set_name(&mut self, name: &'static str) {
        self.name = name;
    }

    fn name(&self) {
        println!("name: {:?}", self.name);
    }

    fn gender(&self) {
        let gender = if self.gender == 1 {"boy"} else {"girl"};
        println!("name: {:?}", gender);
    }
}

fn main() {
    // 需要mut才能调用set_name
    let mut p = People::new("yuchanns", 1);
    p.name();
    p.set_name("yuchanns2");
    p.name();
    p.gender();
}
// 成员是值
enum Number {
    Zero,
    One,
    Two,
}
// 类C枚举
enum Color {
    Red = 0xff0000,
    Green = 0x00ff00,
    Blue = 0x0000ff,
}
// 携带类型参数
enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

fn main() {
    // 调用
    let a = Number::One;
    match a {
        Number::Zero => println!("0"),
        Number::One => println!("1"),
        Number::Two => println!("2"),
    }
}

标准库通用集合类型

Rust标准库提供了4种通用集合类型:

智能指针

可以自动释放内存,无痛使用堆内存,确保内存安全。

Box<T>为例:

泛型

和其他语言的泛型类似,解决代码复用。

通常使用<T>来表示。

可以结合trait指定泛型行为。

trait

struct Plane;
struct Car;
trait Behave {
    fn behave(&self);
}
impl Behave for Plane {
    fn behave(&self) {
        println!("plane move by fly");
    }
}
impl Behave for Car {
    fn behave(&self) {
        println!("car move by wheels");
    }
}
// 泛型结合trait限定行为
fn behave_static<T: Behave>(s: T) {
    s.behave();
}
fn behave_dyn(s: &dyn Behave) {
    s.behave();
}
fn main() {
    let plane = Plane;
    // 静态分发,编译时展开,无运行时开销
    behave_static::<Plane>(plane);
    // 动态分发,有运行时开销
    behave_dyn(&Car);
}

错误处理

Rust的错误处理通过返回Result<T, E>的方式进行,这是一个枚举体。

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

结合match进行处理,下面这个猜数字游戏是一个简单的示例:

use rand::Rng;
use std::cmp::Ordering;
use std::io::stdin;

fn main() {
    println!("Guess the number!");
    let secret_number = rand::thread_rng().gen_range(1..101);

    loop {
        println!("Please input your guess:");

        let mut guess = String::new();

        stdin().read_line(&mut guess).expect("Failed to read line");

        // Result是个枚举
        // 可以通过match Result进行成功或失败的处理
        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => {
                // 失败的时候跳过当次循环
                println!("Please type a number!");
                continue;
            }
        };

        println!("Your guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

注释与打印

Rust注释分为普通注释和文档注释:

使用println!进行格式化打印: