本教程旨在循序渐进地介绍 Rust 编程语言的特性。大家通常认为 Rust 是一门学习曲线陡峭的语言。我希望在此说明,在我们开始学习复杂的部分之前,还有很多东西需要探索。
点击标题
模块
每个 Rust 程序或者库都叫 crate。
每个 crate 都是由模块的层次结构组成。
每个 crate 都有一个根模块。
模块里面可以有全局变量、全局函数、全局结构体、全局 Trait 甚至是全局模块!
在 Rust 中,文件与模块树的层次结构并不是一对一的映射关系。我们必须在我们的代码中手动构建模块树。
编写程序
应用程序的根模块需要在一个叫 main.rs 的文件里面。
编写库
库的根模块需要在一个叫 lib.rs 的文件里面。
引用其他模块和 crate
你可以使用完整的模块路径路径引用模块中的项目: std::f64::consts::PI。
更简单的方法是使用use关键字。此关键字可以让我们在代码中使用模块中的项目而无需指定完整路径。例如 use std::f64::consts::PI 这样我在 main 函数中只需要写 PI 就可以了。
std 是 Rust 的标准库。这个库中包含了大量有用的数据结构和与操作系统交互的函数。
由社区创建的 crate 的搜索索引可以在这里找到: https://crates.io.
引用多个项目
在同一个模块路径中可以引用多个项目,比如:
use std::f64::consts::{PI,TAU}
创建模块
当我们想到项目时,我们通常会想象一个以目录组织的文件层次结构。Rust 允许您创建与您的文件结构密切相关的模块。
在 Rust 中,有两种方式来声明一个模块。例如,模块 foo 可以表示为:
- 一个名为 foo.rs 的文件。
- 在名为 foo 的目录,里面有一个叫 mod.rs 文件。
模块可以互相依赖。要建立一个模块和其子模块之间的关系,你需要在父模块中这样写:
mod foo;
上面的声明将使编译器寻找一个名为 foo.rs或 foo/mod.rs 的文件,并将其内容插入这个作用域内名为 foo 的模块中。
内联模块
一个子模块可以直接内联在一个模块的代码中。
内联模块最常见的用途是创建单元测试。 下面我们创建一个只有在使用 Rust 进行测试时才会存在的内联模块!
// 当 Rust 不在测试模式时,这个宏会删除这个内联模块。 |
模块内部引用
你可以在你的 use 路径中使用如下 Rust 关键字来获得你想要的模块:
- crate - 你的 crate 的根模块
- super - 当前模块的父模块
- self - 当前模块
导出
默认情况下,模块的成员不能从模块外部访问(甚至它的子模块也不行!)。 我们可以使用 pub 关键字使一个模块的成员可以从外部访问。
默认情况下,crate 中的成员无法从当前 crate 之外访问。我们可以通过在根模块中 (lib.rs 或 main.rs), 将成员标记为 pub 使它们可以访问。
变量
变量使用 let 关键字来声明。
在赋值时,Rust 能够在 99% 的情况下自动推断其类型。如果不能,你也可以手动将类型添加到变量声明中。
你也许注意到了,我们可以对同一个变量名进行多次赋值。这就是所谓的变量隐藏,可以更改变量类型以实现对该变量名的后续使用。
变量名总是遵循 蛇形命名法 (snake_case)。
fn main() { |
可变变量
Rust 非常关心哪些变量是可修改的可改变。值分为两种类型:
- 可变的 - 编译器允许对变量进行读取和写入。
- 不可变的 - 编译器只允许对变量进行读取。
可变值用 mut 关键字表示。
fn main() { |
基本类型
Rust 有多种常见的类型:
- 布尔型 - bool 表示 true 或 false
- 无符号整型- u8 u32 u64 u128 表示正整数
- 有符号整型 - i8 i32 i64 i128 表示正负整数
- 指针大小的整数 - usize isize 表示内存中内容的索引和大小
- 浮点数 - f32 f64
- 元组(tuple) - (value, value, ...) 用于在栈上传递固定序列的值
- 数组 - 在编译时已知的具有固定长度的相同元素的集合
- 切片(slice) - 在运行时已知长度的相同元素的集合
- str(string slice) - 在运行时已知长度的文本
你也可以通过将类型附加到数字的末尾来明确指定数字类型(如 13u32 和 2u8)
fn main() { |
用 as 关键字,Rust 使数字类型转换非常容易:
fn main() { |
常量
常量允许我们高效地指定一个在代码中会被多次使用的公共值。不同于像变量一样在使用的时候会被复制,常量会在编译期间直接用它们的值来替换变量的文本标识符。
不同于变量,常量必须始终具有显式的类型。
常量名总是遵循 全大写蛇形命名法(SCREAMING_SNAKE_CASE)。
const PI: f32 = 3.14159; |
数组
数组是所有相同类型数据元素的固定长度集合。
一个数组的数据类型是 [T;N],其中 T 是元素的类型,N 是编译时已知的固定长度。
可以使用 [x] 运算符提取单个元素,其中 x 是所需元素的 usize 索引(从 0 开始)。
fn main() { |
函数
函数可以有 0 个或者多个参数。
在这个例子中,add 接受类型为 i32(32 位长度的整数)的两个参数。
函数名总是遵循 蛇形命名法 (snake_case)。
fn add(x: i32, y: i32) -> i32 { |
多个返回值
函数可以通过元组来返回多个值。
元组元素可以通过他们的索引来获取。
Rust 允许我们将后续会看到的各种形式的解构,也允许我们以符合逻辑的方式提取数据结构的子片段。
fn swap(x: i32, y: i32) -> (i32, i32) { |
返回空值
如果没有为函数指定返回类型,它将返回一个空的元组,也称为单元。
一个空的元组用 () 表示。
直接使用 () 的情况相当不常见。但它经常会出现(比如作为函数返回值),所以了解其来龙去脉非常重要。
fn make_nothing() -> () { |
if/else if/else
Rust 中的代码分支不足为奇。
Rust 的条件判断没有括号!~~需要括号干什么。~~我们现有的逻辑就看起来就很干净整洁呀。
不过呢,所有常见的逻辑运算符仍然适用:==,!=, <, >, <=, >=, !,
fn main() { |
循环
需要一个无限循环?
使用 Rust 很容易实现。
break 会退出当前循环。
fn main() { |
loop 可以被中断以返回一个值。
fn main() { |
while
while 允许你轻松地向循环添加条件。
如果条件一旦变为 false,循环就会退出。
fn main() { |
for
Rust 的 for 循环是一个强大的升级。它遍历来自计算结果为迭代器的任意表达式的值。 迭代器是什么?迭代器是一个你可以一直询问“下一项是什么?”直到没有其他项的对象。
我们将在以后的章节中进一步探讨这一点,与此同时,我们知道 Rust 使创建生成整数序列的迭代器变得容易。
.. 运算符创建一个可以生成包含起始数字、但不包含末尾数字的数字序列的迭代器。
..= 运算符创建一个可以生成包含起始数字、且包含末尾数字的数字序列的迭代器。
fn main() { |
match
想念你的 switch 语句吗?Rust 有一个非常有用的关键字,用于匹配值的所有可能条件, 并在匹配为真时执行相应代码。我们先来看看对数字的使用。在未来章节中,我们将有更多 更复杂的数据模式匹配的说明,我向你保证,它将值得等待。
match 是穷尽的,意为所有可能的值都必须被考虑到。
匹配与解构相结合是迄今为止你在 Rust 中看到的最常见的模式之一。
fn main() { |
从块表达式返回值
if,match,函数,以及作用域块都有一种返回值的独特方式。
如果 if、match、函数或作用域块中的最后一条语句是不带 ; 的表达式, Rust 将把它作为一个值从块中返回。这是一种创建简洁逻辑的好方法,它返回一个 可以放入新变量的值。
注意,它还允许 if 语句像简洁的三元表达式一样操作。
fn example() -> i32 { |
Struct结构体
一个 struct 就是一些字段的集合。
字段是一个与数据结构相关联的数据值。它的值可以是基本类型或结构体类型。
它的定义就像给编译器的蓝图,告诉编译器如何在内存中布局彼此相邻的字段。
struct SeaCreature { |
方法调用
与函数(function)不同,方法(method)是与特定数据类型关联的函数。类似类中的方法。
静态方法 — 属于某个类型,调用时使用 :: 运算符。
实例方法 — 属于某个类型的实例,调用时使用 . 运算符。
fn main() { |
所有权和数据借用
相较于其他编程语言,Rust 具有一套独特的内存管理范例。为了不让您被概念性的东西淹没,我们将一一展示这些编译器的行为和验证方式。 有一点很重要:所有这些规则的终极目的不是为了为难您,而是为了更好地降低代码的出错率!
所有权
实例化一个类型并且将其绑定到变量名上将会创建一些内存资源,而这些内存资源将会在其整个生命周期中被 Rust 编译器检验。 被绑定的变量即为该资源的所有者。
struct Foo { |
基于作用域scope的资源管理
Rust 将使用资源最后被使用的位置或者一个函数域的结束来作为资源被析构和释放的地方。 此处析构和释放的概念被称之为 drop(释放丢弃)。
内存细节:
- Rust 没有垃圾回收机制。
- 在 C++ 中,这被也称为“资源获取即初始化“(RAII)。
struct Foo { |
释放是分级进行的
删除一个结构体时,结构体本身会先被释放,紧接着才分别释放相应的子结构体并以此类推。
内存细节:
- Rust 通过自动释放内存来帮助确保减少内存泄漏。
- 每个内存资源仅会被释放一次。
struct Bar { |
移交所有权
将所有者作为参数传递给函数时,其所有权将移交至该函数的参数。 在一次移动后,原函数中的变量将无法再被使用。
内存细节:
- 在移动期间,所有者的堆栈值将会被复制到函数调用的参数堆栈中。
struct Foo { |
归还所有权
所有权也可以从一个函数中被归还。
struct Foo { |
使用引用借用所有权
引用允许我们通过 & 操作符来借用对一个资源的访问权限。 引用也会如同其他资源一样被释放。
struct Foo { |
通过引用借用可变所有权
我们也可以使用 &mut 操作符来借用对一个资源的可变访问权限。 在发生了可变借用后,一个资源的所有者便不可以再次被借用或者修改。
内存细节:
Rust 之所以要避免同时存在两种可以改变所拥有变量值的方式,是因为此举可能会导致潜在的数据争用(data race)。
struct Foo { |
解引用
使用 &mut 引用时, 你可以通过 * 操作符来修改其指向的值。 你也可以使用 * 操作符来对所拥有的值进行拷贝(前提是该值可以被拷贝——我们将会在后续章节中讨论可拷贝类型)。
fn main() { |
传递借用的数据
Rust 对于引用的规则也许最好用以下的方式总结:
- Rust 只允许同时存在一个可变引用或者多个不可变引用,不允许可变引用和不可变引用同时存在。
- 一个引用永远也不会比它的所有者存活得更久。
而在函数间进行引用的传递时,以上这些通常都不会成为问题。
内存细节:
- 上面的第一条规则避免了数据争用的出现。什么是数据争用?在对数据进行读取的时候,数据争用可能会因为同时存在对数据的写入而产生不同步。这一点往往会出现在多线程编程中。
- 而第二条引用规则则避免了通过引用而错误的访问到不存在的数据(在 C 语言中被称之为悬垂指针)。
struct Foo { |
引用的引用
引用甚至也可以用在其他引用上。
struct Foo { |
显式生命周期
尽管 Rust 不总是在代码中将它展示出来,但编译器会理解每一个变量的生命周期并进行验证以确保一个引用不会有长于其所有者的存在时间。 同时,函数可以通过使用一些符号来参数化函数签名,以帮助界定哪些参数和返回值共享同一生命周期。 生命周期注解总是以 ' 开头,例如 'a,'b 以及 'c。
struct Foo { |
多个生命周期
生命周期注解可以通过区分函数签名中不同部分的生命周期,来允许我们显式地明确某些编译器靠自己无法解决的场景。
struct Foo { |
静态生命周期
一个静态变量是一个在编译期间即被创建并存在于整个程序始末的内存资源。他们必须被明确指定类型。 一个静态生命周期是指一段内存资源无限期地延续到程序结束。需要注意的一点是,在此定义之下,一些静态生命周期的资源也可以在运行时被创建。 拥有静态生命周期的资源会拥有一个特殊的生命周期注解 'static。 'static 资源永远也不会被 drop 释放。 如果静态生命周期资源包含了引用,那么这些引用的生命周期也一定是 'static 的。(任何缺少了此注解的引用都不会达到同样长的存活时间)
内存细节:
- 因为静态变量可以全局性地被任何人访问读取而潜在地引入数据争用,所以修改它具有内在的危险性。我们会在稍后讨论使用全局数据的一些挑战。
- Rust 允许使用 unsafe { ... } 代码块来进行一些无法被编译器担保的内存操作。The R̸͉̟͈͔̄͛̾̇͜U̶͓͖͋̅Ṡ̴͉͇̃̉̀T̵̻̻͔̟͉́͆Ơ̷̥̟̳̓͝N̶̨̼̹̲͛Ö̵̝͉̖̏̾̔M̶̡̠̺̠̐͜Î̷̛͓̣̃̐̏C̸̥̤̭̏͛̎͜O̶̧͚͖͔̊͗̇͠N̸͇̰̏̏̽̃(常见的中文翻译为:Rust 死灵书)在讨论时应该被严肃地看待,
static PI: f64 = 3.1415; |
数据类型中的生命周期
和函数相同,数据类型也可以用生命周期注解来参数化其成员。 Rust 会验证引用所包含的数据结构永远也不会比引用指向的所有者存活周期更长。 我们不能在运行中拥有一个包括指向虚无的引用结构存在!
struct Foo<'a> { |
希望您能愈发清晰地认识到 Rust 是如何致力于解决系统编程中的诸多常见挑战:
- 无意间对资源的修改
- 忘记及时地释放资源
- 资源意外地被释放两次
- 在资源被释放后使用了它
- 由于读取数据的同时有其他人正在向资源中写入数据而引起的数据争用
- 在编译器无法做担保时,清晰看到代码的作用域
内存
Rust 程序有 3 个存放数据的内存区域:
- 数据内存 - 对于固定大小和静态(即在整个程序生命周期中都存在)的数据。 考虑一下程序中的文本(例如 “Hello World”),该文本的字节只能读取,因此它们位于该区域中。 编译器对这类数据做了很多优化,由于位置已知且固定,因此通常认为编译器使用起来非常快。
- 栈内存 - 对于在函数中声明为变量的数据。 在函数调用期间,内存的位置不会改变,因为编译器可以优化代码,所以栈数据使用起来比较快。
- 堆内存 - 对于在程序运行时创建的数据。 此区域中的数据可以添加、移动、删除、调整大小等。由于它的动态特性,通常认为它使用起来比较慢, 但是它允许更多创造性的内存使用。当数据添加到该区域时,我们称其为分配。 从本区域中删除 数据后,我们将其称为释放。
在内存中创建数据
当我们在代码中实例化一个结构体时,我们的程序会在内存中并排创建关联的字段数据。
当我们通过制定所有字段值的方式来实例化时:
struct 结构体名 { ... }.
结构体字段可以通过 . 运算符来获取。
struct SeaCreature { |
例子的内存详情:
- 引号内的文本是只读数据(例如“ferris”),因此它位于数据内存区。
- 函数调用 String::from 创建一个结构体 String,该结构体与 SeaCreature 的字段并排放置在栈中。 字符串容器通过如下步骤表示可更改的文本:
- 在堆上创建可修改文本的内存。
- 将堆中存储对象的内存位置的引用存储在 String 结构体中(在以后的课程中会详细介绍)。
- 最后,我们的两个朋友 Ferris 和 Sarah 有在程序中总是固定的位置的数据结构,所以它们被放在栈上。
类元组结构体:
struct Location(i32, i32); |
待续..