Rust 简明教程
Rust 是一门系统编程语言(Systems Programming Language),兼顾安全(Safety)、性能(Speed)和并发(Concurrency)。Rust作为一门底层的系统编程语言,理论上,使用 C/C++ 的领域都可以使用Rust实现,例如对硬件需要精细控制的嵌入式编程、对性能要求极高的应用软件(数据库引擎、浏览器引擎,3D渲染引擎等)。相对于 C/C++ 的系统性缺陷(内存管理不当造成的安全漏洞),Rust通过所有权(Ownership)机制在编译期间确保内存安全,无需垃圾回收(Garbage Collection, GC),也不需要手动释放内存。
1. Hello World
1.1 安装 Rust
在线安装
- Windows:下载 rustup-init.exe,自动引导安装。
- Linux:curl –proto ‘=https’ –tlsv1.2 -sSf https://sh.rustup.rs | sh
离线安装
- 下载独立安装包
- Windows: 下载 .msi 文件,双击安装即可。
- Linux:下载 .tar.gz 文件,tar -xvf xxx.tar.gz 解压后,执行 install.sh 即可。
查看版本
1 | $ rustc --version |
1.2 第一个Rust程序
1 | fn main() { |
使用 fn
声明函数。与大部分编程语言一致,main()
是 Rust 程序的入口。println!
表示打印文本到控制台,!
表示这是一个宏(macro),而非一个函数(function)。
- 保存为 hello_world.rs,rs 为 Rust 语言的后缀。
- 编译:rustc hello_world.rs。
- 执行:*./hello_world(Linux),hello_world.exe*(Windows)
尝试下 println! 更多的用法。
1 | fn main() { |
以上代码将输出
1 | Hello, world! |
1.3 使用 Cargo
为了方便之后的调试和学习,先介绍 Rust 内置的包管理和构建系统 Cargo
,crates.io 是 Rust 的社区仓库。
创建新项目:cargo new
编译:cargo build
运行:cargo run
更新项目依赖:cargo update
执行测试:cargo test
生成文档:cargo doc
静态检查:cargo check
新建二进制(Binary/Executable)项目
1 | $ cargo new tutu --bin |
在 main.rs 中写入
1 | fn main() { |
在项目目录下执行 cargo run
1 | $ cargo run |
- 新建 Library 项目
1 | $ cargo new tutu --lib |
- Cargo.toml 是工程的描述文件,包含 Cargo 所需的所有元信息。
- src 放置源代码。
- main.rs / lib.rs 是入口文件。
运行 cargo run 或 cargo build,可执行文件将生成在 target/debug/ 目录,运行 cargo build –release,可执行文件将生成在 target/release/ 。
2 基本概念
2.1 注释
1 |
|
///
用于 mod 块外部,//!
用于书写包/模块级别的注释
注释支持 markdown 语法,使用 cargo doc 生成 HTML 文档。
2.2 变量
- 局部变量
Rust 中变量默认是不可变的(immutable),称为变量绑定(Variable bindings),使用 mut
标志为可变(mutable)。
let 声明的变量是局部变量,声明时可以不初始化,使用前初始化即可。Rust是静态类型语言,编译时会检查类型,使用let声明变量时可以省略类型,编译时会推断一个合适的类型。
1 | // 不可变 |
- 全局变量
rust 中可用 static 声明全局变量。用 static 声明的变量的生命周期是整个程序,从启动到退出,它占用的内存空间是固定的,不会在执行过程中回收。另外,static 声明语句,必须显式标明类型,不支持类型自动推导。全局变量在声明时必须初始化,且须是简单赋值,不能包括复杂的表达式、语句和函数调用。
1 | // 静态变量(不可变) |
- 常量
const 的生命周期也是整个程序,const 与 static 的最大区别在于,编译器并不一定会给 const 常量分配内存空间,在编译过程中,它很可能会被内联优化,类似于C语言的宏定义。
1 | const N: i32 = 5; |
2.3 函数
使用 fn
声明函数。
1 | fn main() { |
参数需要指定类型
1 | fn print_sum(a: i8, b: i8) { |
默认返回值为空()
,如果有返回值,需要使用->
指定返回类型。
1 | fn plus_one(a: i32) -> i32 { |
可以利用元组(tuple)返回多个值
1 | fn plus_one(a: i32) -> (i32, i32) { |
函数指针也可以作为变量使用
1 | let b = plus_one; |
2.4 基本数据类型
- 布尔值(bool)
- 字符(char)
- 有符号整型(i8, i16, i32, i64, i128)
- 无符号整型(u8, u16, u32, u64, u128)
- 指针大小的有符号/无符号整型(isize/usize,取决于计算机架构,32bit 的系统上,isize 等价于i32)
- 浮点数(f32, f64)
- 数组(arrays),由相同类型元素构成,长度固定。
1 | let a = [1, 2, 3]; // a[0] = 1, a[1] = 2, a[2] = 3 |
数组(arrays)的长度是可不变的,动态/可变长数组可以使用 Vec
(非基本数据类型)。
- 元组(tuples),由相同/不同类型元素构成,长度固定。
1 | let a = (1, 1.5, true, 'a', "Hello, world!"); |
元组的长度也是不可变的,更新元组内元素的值时,需要与之前的值的类型相同。
- 切片(slice),指向一段内存的指针。
切片并没有拷贝原有的数组,只是指向原有数组的一个连续部分,行为同数组。访问切片指向的数组/数据结构,可以使用&
操作符。
1 | let a: [i32; 4] = [1, 2, 3, 4]; |
- 字符串(str)
在 Rust 中,str
是不可变的静态分配的一个未知长度的UTF-8字节序列。&str
是指向该字符串的切片。
1 | let a = "Hello, world!"; //a: &'static str |
字符串切片&str
指向的字符串是静态分配的,在 Rust 中,有另一个堆分配的,可变长的字符串类型String
(非基本数据类型)。通常由字符串切片&str
通过 to_string() 或 String::from() 方法转换得到。
1 | let s1 = "Hello, world!".to_string(); |
- 函数(functions)
函数指针也是基本数据类型,可以赋值给其他的变量。
2.5 操作符
- 算数运算符
1 | + - * / % |
- 比较运算符
1 | == = != < > <= >= |
- 逻辑运算符
1 | ! && || |
- 位运算符
1 | & | ^ << >> |
- 赋值运算符
1 | let mut a = 2; |
- 类型转换运算符: as
1 | let a = 15; |
- 借用(Borrowing)与解引用(Dereference)操作符
Rust 引入了所有权(Ownership)的概念,所以在引用(Reference)的基础上衍生了借用(Borrowing)的概念,所有权概念不在这里展开。
简单而言,引用是为已存在变量创建一个别名;获取引用作为函数参数称为借用;解引用是与引用相反的动作,目的是返回引用指向的变量本身。
1 | // 引用/借用: & &mut |
1 | // 解引用: * |
2.6 控制流(Control Flows)
- if - else if - else
1 | let team_size = 7; |
- match
可替代C语言的switch case
。
1 | let tshirt_width = 20; |
_
表示匹配剩下的任意情况。
- while
1 | let mut a = 1; |
- loop
类似于C语言的while(1)
1 | let mut a = 0; |
- for
1 | for a in 0..10 { //(a = 0; a <10; a++) |
在 for 表达式中的break 'outer_for
,loop 和 while 也有相同的使用方式。
3. 其他数据类型
3.1 结构体(struct)
和元组(tuple)一样,结构体(struct)支持组合不同的数据类型,但不同于元组,结构体需要给每一部分数据命名以标志其含义。因而结构体比元组更加灵活,不依赖顺序来指定或访问实例中的值。
- 定义结构体
1 | struct User { |
- 创建实例
1 | let user1 = User { |
- 修改某个字段的值
1 | let mut user1 = User { |
- 变量与字段名同名的简写语法
1 | fn build_user(email: String, username: String) -> User { |
- 元组结构体(tuple structs)
元组结构体有着结构体名称提供的含义,但没有具体的字段名。在参数个数较少时,无字段名称,仅靠下标也有很强的语义时,为每个字段命名就显得多余了。例如:
1 | struct Color(i32, i32, i32); |
VS
1 | struct Point { |
3.2 枚举(enum)
- 定义枚举
1 | enum IpAddrKind { |
- 使用枚举值
1 | let four = IpAddrKind::V4; |
- 枚举成员关联数据
1 | enum IpAddr { |
更复杂的例子
1 | enum Message { |
- match 控制流
1 | enum Coin { |
- Option
Option是标准库中定义的一个非常重要的枚举类型。Option 类型应用广泛因为它编码了一个非常普遍的场景,即一个值要么有值要么没值。对Rust而言,变量在使用前必须要赋予一个有效的值,所以不存在空值(Null),因此在使用任意类型的变量时,编译器确保它总是有一个有效的值,可以自信使用而无需做任何检查。如果一个值可能为空,需要显示地使用Option<T>
来表示。
Option 的定义如下:
1 | pub enum Option<T> { |
Option<T>
包含2个枚举项:
- None,表明失败或没有值
- Some(value),元组结构体,封装了一个 T 类型的值 value
得益于Option
,Rust 不允许一个可能存在空值的值,像一个正常的有效值那样工作,在编译时就能够检查出来。Rust显得更加安全,不用担心出现其他语言运行时才会出现的空指针异常的bug。例如:
1 | let x: i8 = 5; // Rust 没有空值(Null),因此 i8只能被赋予一个有效值。 |
尝试将不可能出现无效值的 x:i8
与可能出现无效值的y: Option<i8>
相加时,编译器会报错:
1 | error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is |
总结一下,如果一个值可能为空,必须使用枚举类型Option<T>
,否则必须赋予有效值。而为了使用Option<T>
,需要编写处理每个成员的代码,当T 为有效值时,才能够从 Some(T) 中取出 T 的值来使用,如果 T 为无效值,可以进行其他的处理,通常使用 match 来处理这种情况。
例如,当y为有效值时,返回x和y的和;为空值时,返回x。
1 | fn plus(x: i8, y: Option<i8>) -> i8 { |
- if let 控制流
match 还有一种简单场景,可以简写为 if let
。如下,y有值时,打印和,y无值时,啥事也不做。
1 | fn plus(x: i8, y: Option<i8>) { |
简写为 if let
,则是
1 | fn plus(x: i8, y: Option<i8>) { |
如果只使用 if
呢?
1 | fn plus(x: i8, y: Option<i8>) { |
if let
语句也可以包含 else
。
1 | fn plus(x: i8, y: Option<i8>) { |
3.3 实现方法和接口(impl & traits)
- 实现方法(impl)
1 | struct Rectangle { |
- 关联函数(associated functions)
关联函数不以self
作为参数,关联函数之所以成为函数而不是方法,是因为关联函数并不作用于一个结构体的实例。我们之前创建字符串类型时,使用过的 String::from 就是关联函数。关联函数经常被用作返回一个结构体新实例的构造函数。例如我们可以提供一个关联函数,它接受一个维度参数并且同时作为宽和高,这样可以更轻松的创建一个正方形 Rectangle 而不必指定两次同样的值:
1 | impl Rectangle { |
- 实现接口(traits)
1 | trait Summary { |
3.3 泛型(Generics)
当我们实现一个函数或者数据结构时,往往希望参数可以支持不同的类型,泛型可以解决这个问题。声明参数类型时,换成大写的字母,例如字母 T,同时使用<T>
告诉编译器 T 是泛型。
- 函数中使用泛型
1 | fn largest<T>(list: &[T]) -> T { |
- 结构体使用泛型
1 | struct Point<T> { |
- 枚举使用泛型
1 | enum Option<T> { |
Result 枚举有两个泛型类型,T 和 E。Result 有两个成员:Ok,它存放一个类型 T 的值,而 Err 则存放一个类型 E 的值。这个定义使得 Result 枚举能很方便的表达任何可能成功(返回 T 类型的值)也可能失败(返回 E 类型的值)的操作。回忆一下示例 9-3 中打开一个文件的场景:当文件被成功打开 T 被放入了 std::fs::File 类型而当打开文件出现问题时 E 被放入了 std::io::Error 类型。
- 方法中使用泛型
1 | struct Point<T> { |
3.4 常见集合 Vec
- 新建
1 | let v: Vec<i32> = Vec::new(); // 空集合 |
v.get(2) 和 &v[2] 都能获取到 Vec 的值,区别在于 &v[2] 返回的是该元素的引用,引用一个不存在的位置,会引发错误。v.get(2) 返回的是枚举类型 Option<&T>
。v.get(2) 返回的是 Some(&3),v.get(100) 返回的是 None。
- 更新
1 | let v: Vec<i32> = Vec::new(); |
- 遍历
1 | let v = vec![100, 32, 57]; |
- if let 控制流
如果我们想修改Vec
中第2个元素的值呢?可以这么写:
1 | fn main() { |
因为 v.get_mut() 的返回值是Option<T>
枚举类型,那么可以使用if let
来简化代码。
1 | fn main() { |
- while let 控制流
if let
可以用于单个元素的场景,while let
就适用于遍历的场景了。
1 | let mut stack = vec![1, 2, 3, 4, 5]; |
更多用法参考:Vec - 官方文档
3.5 常见集合 String
Rust 的核心语言中只有一种字符串类型:str
,字符串切片,它通常以被借用的形式出现,&str
。这里提到的字符串,是字节的集合,外加一些常用方法实现。因为是集合,增持增删改,长度也可变。
- 新建
1 | let mut s1 = String::new(); |
- 更新
1 | let mut s = String::from("foo"); |
- format
1 | let s1 = String::from("tic"); |
- 索引
1 | let v = String::from("hello"); |
- 遍历
1 | let v = String::from("hello"); |
在Rust内部,String 是一个 Vec<u8>
的封装,但是有些字符可能会占用超过2个字符,所以String
不支持直接索引,如果需要索引需要使用 chars() 转换后再使用。
3.6 常见集合 HashMap
- 新建
1 | use std::collections::HashMap; |
这里使用了use
引入了HashMap
结构体。
- 访问
1 | use std::collections::HashMap; |
- 更新
1 | use std::collections::HashMap; |
4 错误处理
4.1 不可恢复错误 panic!
Rust 有 panic!宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。出现这种场景,一般是出现了一些不知如何处理的场景。
- 直接调用
1 | fn main() { |
执行 cargo run 将打印出
1 | $ cargo run |
最后2行包含了 panic!
导致的报错信息,第1行是源码中 panic!
出现的位置 src/main.rs:2:5
- 代码bug引起的错误
1 | fn main() { |
和之前一样,cargo run 的报错信息只有2行,缺少函数的调用栈,为了便于定位问题,可以设置 RUST_BACKTRACE 环境变量来获得更多的调用栈信息,Rust 中称之为backtrace
。通过backtrace
,可以看到执行到目前位置所有被调用的函数的列表。
例如执行 RUST_BACKTRACE=1 cargo run,这种方式的好处在于,环境变量只作用于当前命令。
1 | $ RUST_BACKTRACE=1 cargo run |
第一行的报错信息,说明了错误的原因,长度越界。紧接着打印出了函数调用栈,src/main.rs:4 -> liballoc/vec.rs:1796 -> …
在windows下,可以执行 set RUST_BACKTRACE=1 && cargo run。
- release
当出现 panic 时,程序默认会开始 展开(unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止(abort),这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。
release模式下,希望程序越小越好,可以在Cargo.toml
中设置 panic 为 abort。
1 | [profile.release] |
4.2 可恢复错误 Result
- 处理 Result
有些错误,希望能够捕获并且做相应的处理,Rust 提供了 Result
机制来处理可恢复错误,类似于其他语言中的try catch
。
这是 Result<T, E>
的定义
1 | enum Result<T, E> { |
有些函数会返回Result
,那怎么知道一个函数的返回对象是不是Result
呢?很简单!
1 | fn main(){ |
当我们编译上面的代码时,将会报错。
1 | = note: expected type `u32` |
从报错信息可以看出,File::create 返回的是一个Result<fs::File. io::Error>
对象,如果没有异常,我们可以从Result::Ok<T>
获取到文件句柄。
下面是一个完整的示例,创建 hello.txt 文件,并尝试写入 “Hello, world!”。
1 | use std::fs::File; |
如果执行成功,可以看到在工程根目录下,多出了 hello.txt 文件。
- unwrap 和 expect
Result
的处理有时太过于繁琐,Rust 提供了一种简洁的处理方式 unwrap 。即,如果成功,直接返回 Result::Ok<T>
中的值,如果失败,则直接调用 !panic
,程序结束。
1 | let f = File::open("hello.txt").unwrap(); // 若成功,f则被赋值为文件句柄,失败则结束。 |
expect 是更人性化的处理方式,允许在调用!panic
时,返回自定义的提示信息,对调试很有帮助。
1 | let f = File::open("hello.txt").expect("Failed to open hello.txt"); |
- 返回 Result
我们可以实现类似于 File::open 这样的函数,让调用者能够自主绝对如何处理成功/失败的场景。
1 | use std::io; |
上面的函数如果成功,则返回 hello.txt 的文本字符串,失败,则返回 io::Error
。
- 更简单的实现方式
1 | use std::io; |
这种写法使用了?
运算符向调用者返回错误。
作用是:如果 Result
的值是 Ok,则该表达式返回 Ok 中的值且程序继续执行。如果值是 Error ,则将 Error 的值作为整个函数的返回值,好像使用了 return
关键字一样。这样写,逻辑更为清晰。
5 包、crate和模块
5.1 包和 crate
1 | . |
一个 Cargo 项目即一个包(Package),一个包至少包含一个crate;可以包含零个或多个二进制crate(binary crate),但只能包含一个库crate(library crate)。 src/main.rs 是与包名同名的二进制 crate 的根,其他的二进制 crate 的根放置在 src/bin 目录下; src/lib.rs 是与包名同名的库 crate 的根。
5.2 模块
模块 让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。即项是可以被外部代码使用的(public),还是作为一个内部实现的内容,不能被外部代码使用(private)。
- 声明模块
Rust中使用mod来声明模块,模块允许嵌套,可以使用模块名作为路径使用,例如:
1 | // src/main.rs |
- 引入作用域
使用use
可以将路径引入作用域。
我们在 src/lib.rs 中声明一个模块,在 src/main.rs 中调用。
1 | // src/lib.rs |
1 | // src/main.rs |
路径的长度可以自由定义,也可以写成
1 | // src/main.rs |
src/main.rs 和 src/lib.rs 属于不同的 crate ,所以引入作用域时,需要带上包名 tutu 。
- 分隔模块
在 src/lib.rs 中可以使用 mod
声明多个模块,但有时为了可读性,习惯将每个模块写在独立的文件中。
新建 src/greeting.rs,写入
1 | // src/greeting.rs |
在 src/lib.rs 可以这样使用,
1 | // src/lib.rs |
关键点就在于mod greeting;
这一行,mod greeting
后面没有具体实现,而是紧跟分号,则声明 greeting 的模块内容位于 src/greeting.rs 中。
其他crate,例如 src/main.rs 中的使用方式没有任何变化。
1 | // src/main.rs |
6 测试
6.1 单元测试(unit tests)
1 | fn plus(x: i32, y: i32) -> i32 { |
单元测试非常简单,只需要在每一个测试用例前加上 #[test]
即可,通过 cargo test 执行用例。
1 | $ cargo test |
更规范的写法是在每个源文件中,创建包含测试函数的 tests 模块,测试用例写在 tests 模块中,并使用 cfg(test)
标注模块。
1 | fn plus(x: i32, y: i32) -> i32 { |
因为内部测试的代码和源码在同一文件,因而需要使用 #[cfg(test)]
注解告诉 Rust 只在执行 cargo test 时才编译和运行测试代码,因此,可以在构建库时节省时间,并减少编译文件产生的文件大小。
单元测试的好处在于,可以测试私有函数。如果在独立的目录,例如 tests 下做测试,则只允许测试公开接口,即使用 pub
修饰后的模块和函数。
6.2 集成测试(integration tests)
集成测试用例和源码位于不同的目录,因而源码中的模块和函数,对于集成测试来说完全是外部的,因此,只能调用一部分库暴露的公共API。Cargo 约定集成测试的代码位于项目根路径下的 tests 目录中,Cargo 会将 tests 中的每一个文件当做一个 crate
来编译。
只有库 crate 才会向其他 crate 暴露可供调用的函数,因此在 src/main.rs 中定义的函数不能够通过 extern crate
的方式导入,所以也不能够被集成测试。
src/main.rs 定义了 main 函数,即作为一个可执行(Executable)程序入口,目的是独立运行,而非向其他 crate 提供可供调用的接口。
我们将 plus 函数移动到 src/lib.rs 中,新建 tests/test_lib.rs,最终的目录结构如下
1 | ├── Cargo.toml |
1 | // src/lib.rs |
1 | // src/main.rs |
1 | // tests/test_lib.rs |
运行 cargo test,将输出
1 | running 1 test |
参考
上一篇 « Go语言动手写Web框架 - Gee第六天 模板(HTML Template) 下一篇 » WSL, Git, Mircosoft Terminal 等常用工具配置