一、 编译和运行是单独的两步
- 运行 Rust 程序之前必须先编译,命令为:rustc 源文件名 - rustc main.rs
- 编译成功之后,会生成一个二进制文件 - 在 Windows 上还会生产一个 .pdb 文件 ,里面包含调试信息
- Rust 是 ahead-of-time 编译的语言 - 可以先编译程序,然后把可执行文件交给别人运行(无需安装 Rust )
- rustc 只适合简单的 Rust 程序
二、Cargo
1. 简介
- Cargo 是 Rust 的构建系统和包管理工具 - 它可以:构建代码、下载依赖的库、构建这些库…
- 安装 Rust 的时候会自动把 Cargo 也安装上,不需要再另外安装
- 检查是否安装成功:cargo --version
2. 使用 Cargo 创建项目
- 创建项目命令:cargo new hello_cargo - 项目名称也是 hello_cargo,会创建一个新的目录 hello_cargo:Cargo.toml、src 目录(main.rs)、初始化了一个新的 Git 仓库(.gitignore - 可以使用其它的 VCS 或不使用 VCS:cargo new 的时候使用 --vcs 这个flag)
VCS:版本控制系统(JvJv注)
3. Cargo.toml
- TOML(Tom's Obvious,Minimal Language)格式,是 Cargo 的配置文件的格式
- [ package ]:是一个区域标题,表示下方内容是用来配置包(package)的
- name:项目名
- version:项目版本
- authors:项目作者
- edition:使用的 Rust 的版本
- [ dependencies ]:另一个区域的开始,它下面会列出项目的依赖项
- 在 Rust 中,代码的包称作 crate
4. src / main.rs
- cargo 生成的 main.rs 在 src 目录下
- Cargo.toml 在项目顶层下
- 顶层目录可放置:README、许可信息、配置文件和其它与程序源码无关的文件
- 如果创建项目时没有使用 cargo,也可以按照以下步骤把项目转化为使用 cargo 的形式:
- 把源代码文件移动到 src 下
- 创建 Cargo.toml 并依次填写相应的配置
5. 构建 Cargo 项目:cargo build
- 创建可执行文件:target / debug / hello_cargo 或 target \ debug \ hello_cargo.exe(Windows 下)
- 运行可执行文件:./ target / debug / hello_cargo 或 .\ target \ debug \ hello_cargo.exe(Windows 下)
- 第一次运行 cargo build 会在顶层目录生产 cargo.lock 文件
- 该文件负责追踪项目依赖的精确版本
- 不需要手动修改该文件
6. 构建并运行 Cargo 项目:cargo run
- 它是:编译 + 执行
- 如果之前编译成功过,且源码没有改变,那么就会直接运行二进制文件
7. 检查代码:cargo check
- 检查代码,确保代码能通过编译,但不产生任何可执行文件
- cargo check 要比 cargo build 快得多
- 好处:编写代码的时候,可以连续、反复的使用 cargo check 检查代码,提高效率
8. 为发布构建
- cargo build --release
- 编译时会进行优化
- 代码会运行的更快,但是编译时间更长
- 会在 target / release 而不是 target / debug 生成可执行文件
- 两种配置
- 开发时
- 正式发布时
三、猜数游戏
1. 游戏目标
- 生成一个 1 到 100 之间的随机数
- 提示玩家输入一个猜测
- 猜完之后,程序提示:太大了 or 太小了
- 如果猜测正常,那么打印出一个庆祝信息,程序退出
2. 实现步骤,写代码
- 新建1个文件夹,打开,地址栏输入 cmd 进入命令行窗口
- 在命令行窗口中输入:cargo new guessing_game并回车,名为 guessing_game 的包就创建好了。
- 命令行继续输入:cd gue*
- 命令行继续输入:code . (通过VS Code打开项目)
- 在VS Code中,打开 main.rs 的文件,可以看到里面有几行默认代码。
- 在命令行中输入:cargo run
- 下面我们就开始在此文件内写代码:
use std::io;
fn main() {
println!("来玩猜数游戏叭!");
println!("请输入一个数:");
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("读取错误!");
println!("你猜测的数字为:{}", guess);
}
- use std::io; - 需要获取用户输入并打印用户输出,我们需要用到 io 这个库,而 io 这个库是在 std 这个标准库中。所以,我们使用 use 这个关键字进行预导入,类似于 Python 中的 import。
- let mut guess: String = String::new- 用 let 创建一个字符串类型的变量 guess,要使它在后面是可修改的可变字符串,我们就要在中间再加一个 mut 关键字。等号后面的代码,表示将其初始化为空字符串。
- io::stdin().read_line(&mut guess).expect("读取错误!"); - 这里使用标准库 io::stdin() 函数从标准输入读取一行字符串,并将其存储到之前定义的可变字符串变量 guess 中。&mut guess 表示传递 guess 的可变引用,以便可以将读取的字符串写入到它的内存地址中。expect 表示如果读取失败,程序将抛出一个错误信息 “读取错误!”。
- println!("你猜测的数字为:{}", guess); - 这里就是看下中间的花括号,表示占位符,输出的时候就会被逗号后面的变量替换。如果有2个或者多个花括号,那么就会依次从左到右替换。
运行上面的代码,我们根据程序提示输入 18 这个数字,效果如下:
程序正常运行,没有问题,但只能猜一次数,没有完全达到我们的目的,所以我们继续完善上面的代码。
3. 生成随机数
既然是猜数游戏,肯定少不了随机数这一环。相比其它语言,在 Rust 中生成随机数还是有点麻烦的。
- 导入一个名为 rand 的库,如下图所示,我们在 Cargo.toml 这个文件中的 [ dependencies ] 下写 rand = "0.8" ,等号前面是我们要导入的库的名称,后面双引号中则是它的版本号。你可以随便指定哪个版本号,只要这个版本的库能满足你的需要,并且网上确实有发布过。
- 我们回到 main.rs 文件中,写入如下几行代码:
红框中就是我们新增的代码,use rand::Rng; - 是 Rust 编程语言中的一个导入语句,它用于在代码中使用 rand crate 中的 Rng trait。Rust 是一种系统级编程语言,rand crate 是 Rust 社区中常用的生成随机数的库。
Rng trait 提供了生成伪随机数的方法,可以用于各种随机性需求,如游戏中的随机地图生成、密码学中的密钥生成等。通过 use rand::Rng;,可以在代码中直接使用 Rng trait 中定义的方法来生成随机数。
例如,可以使用 Rng trait 的 gen_range 方法来生成指定范围内的随机整数:
let mut rng = rand::thread_rng();
let random_number = rng.gen_range(1, 101);
上述代码中,我们通过 rand::thread_rng() 函数创建了一个随机数生成器 rng,并使用其 gen_range 方法生成一个介于1 - 100之间的随机整数。注意:(1,101)是包括1,不包括101。
- 通过上面的讲解,相信你应该不难看懂中间4行代码所代表的含义了。但是你可能会觉得图片中我写的代码好像很复杂?这是因为我装了一些关于 Rust 的插件,而这4行代码中的灰色内容是编译器自动帮我生成的,所以你在自己动手写的时候,只需要参照上面举例的2行代码即可。
- 我使用的插件截图放在下面了,具体功能我就不细说了,你们可以在网上查一下。
- 生成随机数的代码写完了,我们来运行一下看看效果。首先我们打开编译器底部的终端,直接输入 cargo build 命令先构建一下,然后再输入 cargo run 来运行程序。
- 有的小伙伴可能误删了编辑器下方的页面,可以单击顶部菜单栏中的 “终端” 再新建一个终端页面出来,也可以直接按 Ctrl键 + Shift键 + ~键(ESC正下方的带小波浪号的那个键)。
4. 比大小
前面我们已经实现了用户输入输出,和生成随机数,那么接下来我们就需要将用户输入的值和 rand 生成的随机数进行比较,如果两个等于,那么就打印 “You win!”,如果比随机数大,那么就打印 “Too big!”,如果比随机数小,就打印 “Too small!” 。
这里先贴上代码,然后我们再一句句分析。
红框中第1行代码解析:
- let guess:u32 - 声明一个名为 guess 的变量,并指定它的类型为 u32,即无符号32位整数。
- guess.trim() - 对之前声明的 guess 变量进行方法调用。
- trim() - 方法用于去除字符串首尾的空白字符,返回一个新的字符串。
- parse() - 对上一步骤返回的字符串进行解析,将其转换为目标类型(在这里是 u32 )。此处使用.parse()方法对字符串进行解析,将其转换为 u32 类型。
- expect("Please enter a number!") - 在解析过程中,如果字符串无法被正确解析为 u32 类型,会产生一个错误。.expect() 方法用于处理这个错误,并打印自定义的错误信息。在这里,如果解析失败,会输出 Please enter a number! 。
红框中第2行代码解析:
- guess.cmp( &random_number ) - 这是一个方法调用,使用了 .cmp() 方法来比较 guess 和random_number 两个值。
- &random_number - 通过使用&符号获取random_number的引用。这是因为.cmp()方法要求传递引用作为参数。
- { } - 此处是match表达式的主体部分,包含了不同情况的模式匹配和对应的执行代码块。
红框中第3、4、5行代码解析:
这3行代码是在 Rust 中使用 match 语句对比较的结果进行模式匹配,并根据不同的情况执行相应的代码块。
1. Ordering::Greater => println!("Too big!") - 当比较结果为 Ordering::Greater 时,也就是 guess 大于 random_number ,执行这个代码块并打印 "Too big!" 。
2. Ordering::Equal => println!("You win!") - 当比较结果为 Ordering::Equal 时,也就是guess 等于 random_number,执行这个代码块并打印 "You win!" 。
3. Ordering::Less => println!("Too small!") - 当比较结果为 Ordering::Less 时,也就是guess 小于 random_number,执行这个代码块并打印 "Too small!" 。 这里使用了 Ordering 枚举类型,它是 Rust 标准库中定义的一种表示比较结果的类型。通过使用 match 语句和模式匹配来处理不同的比较结果,可以根据情况执行相应的逻辑代码块。在这个例子中,根据猜测与随机数的大小关系,打印不同的提示信息以给出反馈。
5. 允许多次猜测
为什么要允许用户多次猜测呢?因为大概率用户一次是猜不准随机数的,而且之前我们写的代码只能实现让用户猜一次,而且这一次不管猜大了还是猜小了,程序都会直接退出,不会给用户第二次机会,当再次运行程序时,随机数刷新,又是新的一轮了。
所以,这第5小节,我们就来实现一下允许用户多次猜测,直到猜对后程序自动退出。
多次,那么我们就需要写一个循环了,那 Rust 中循环怎么写呢?我们直接贴出写好的代码,大家先看一下。
对照之前写的代码,我们只是将之前写的一段判断直接扔进了 loop{} 这个关键字后面的花括号里,这样就实现了循环了,是不是很简单?
下面我们来运行一下这段修改后的代码:
可以看到,当我们猜了很多次之后终于猜对了这个随机数,但是这个程序仍然让我们继续猜,这是不符合逻辑的,因为我们已经知道这轮的随机数是 31 了,所以这完全没有意义。因此,我们还需要对这段代码进行优化。
怎么优化呢?是不是只要我们猜对了,程序打印了 You win!就可以直接退出了,而不是这样一直循环下去。然后我们还有一点不能忘记了,就是当用户如果输入的不是数字,而是别的内容,比如 abc、二十一这样的值,程序就会崩溃了。
在之前我们写的代码中,用到了 parse() 这个关键字,它的作用是对上一步骤返回的字符串进行解析,将其转换为目标类型,这里是转换为 u32 类型,也就是无符号32位整数。但字母和汉字这些是不可能被转换的,所以此时程序就会崩溃。
根据上面的分析与思考,我优化了一下代码,如下图:
在第1个红框处,我删掉了之前的 expect,而是在外面套了一个 match ,它是 Rust中的控制流运算符,而 parse 的返回值是枚举类型,即:Ok 和 Err 。所以,我们可以通过 match 来分开执行结果是 Ok 下的情况,和 结果是 Err 下的情况。
在这段实际代码中,它实现了当用户输入值转换成功时,程序正常运行,当用户输入值转换不成功时,程序跳过错误,继续执行。
需要提一下的是,Err 后面括号中的下划线,是一个通配符,表示我们不关心里面的错误信息,忽略的意思。
在第2个红框处,我们在 Equal 后面套了1个花括号,将打印 You win 的代码放了进去,然后再加了 break 关键字,表示当用户猜对了数字,程序打印了 You win 之后就自动退出了,这个和其他语言是一样的。
至此,我们的猜数游戏就做完了。可能有很多小伙伴看到这里有些云里雾里,但不用担心哈,我们暂时只需要大概了解一下这些概念即可,后面会逐一细讲。
四、Rust 通用的编程概念
1. 变量与可变性
1.1 声明变量使用 let 关键字
举个🌰
这里的 i32 是在我输入 let x = 5; 之后编译器自动添加进去的,它能自动推断出你声明的变量类型,这点我认为还是非常不错的。
1.2 默认情况下,变量是不可变的(immutable)
举个🌰
当我们再给 x 赋值为 6 时,运行程序就会报一个 cannot assign twice to immutable variable
如果我们在第一次声明 x 的时候就在它前面加一个 mut ,表示它是可变的,那么程序就不会报错了。
2. 常量
2.1 常量(constant),常量在绑定值以后是不可变的,它和变量的区别如下:
- 不可以使用 mut 关键字来使它变成可变的,要记住:常量永远是不可变的
- 声明常量应使用 const 关键字,它的类型必须被标注
- 常量可以在任何作用域内进行声明,包括全局作用域
- 常量只可以绑定到常量表达式,无法绑定到函数的调用结果或只能在运行时才能计算出的值
2.2 在程序运行期间,常量在其声明的作用域内一直有效
2.3 命名规范:Rust 里常量应使用全大写字母,名称较长时,每个单词之间应使用下划线分开,例如:MAX_POINTS
举个🌰
可以看到,u32 的字体颜色是黄色,而不像之前我们声明变量的时候是灰色,这是因为声明常量的时候,编译器就不会再给你自动推断并补全数据类型了,你需要自己去指定,否则就会报错。
还有一点是,我这里给 MAX_POINTS 这个常量赋的值是 10000,数字中间的下划线只是方便查看, 类似于 Excel 中的千分位字符。
那这个下划线是必须每隔三位数才能给一个下划线吗?那倒也不是,这个你想放哪就放哪。现在我把这个下滑线换个位置,你看程序依然是正常运行的,并且正确地打印出了 10000。
3. Shadowing(隐藏)
在 Rust 中,可以使用相同的名字声明新的变量,新的变量就会 shadow(隐藏)之前的同名变量。
举个🌰
可以看到,这个名为 x 的变量我用 let 关键字声明了2次,但程序并没有报错,而且 x 的值也是最后声明的那个同名变量的值为 6,这个就是 Shadowing。
shadow 和把变量标记为 mut 是不一样的:如果不使用 let 关键字,那么重新给 非mut 的变量赋值会导致编译时报错。
而使用 let 声明的同名新变量,也是不可变的。
使用 let 声明的同名新变量,它的类型可以与之前不同。
举个🌰
这里我们用 let 关键字声明了一个变量 spaces 的值为 4个空格,编译器自动推断为 &str 类型。然后我们又给 spaces 声明为 上一个 spaces 的长度,编译器自动推断为 usize 类型。
程序成功运行,并正确打印出了 4。可能 shadow 不好理解,你可以将这个概念理解为覆盖,意思就是如果程序中出现同名变量,总是以最新的赋值为准,而上一个同名的变量就会被覆盖掉。
4. 数据类型 - 标量类型
一个标量类型代表一个单个的值。
Rust 有4个主要的标量类型,分别是:整数类型、浮点类型、布尔类型、字符类型。
Rust 是静态编译语言,在编译时必须知道所有变量的类型。基于使用的值,编译器通常能够推断出它的具体类型。
但如果可能得类型比较多,例如将 String 转换为整数的 parse 方法,就必须提前添加标注,否则编译就会报错。
举个🌰
这里黄色的 u32 数据类型是我们自己写上去的,而不是编译器推断出来的,这样程序就能正常运行,并正确打印出来了 42这个结果。如果我们删掉指定的 u32,程序就会报错。
原因是,"42" 这个值可以被转换成多种整数类型,比如 u32 i32 等等,编译器无法推断你想要转换的到底是哪一种,程序就会报错了。
5. 整数类型
整数类型没有小数部分。
例如 u32 就是一个无符号的整数类型,占据 32位的空间。
无符号整数类型以 u 开头(可以把这个 u 想象成一个没有盖子的口袋,空的,就是无符号~)
有符号的整数类型以 i 开头。
Rust 的整数类型列表如下:
Length(长度) | Signed(有符号) | Unsigned(无符号) |
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch | isize | usize |
每种都分 i 和 u,以及固定的位数。
有符号范围:-(2的n次方 - 1)~ 2的n次方 -1。
无符号范围:0 ~ 2的n次方 -1。符号指的是负号,无符号即没有负号,只有正值部分。
isize 和 usize 类型:
isize 和 usize 类型的位数由程序运行的计算机的架构所决定,如果是64位计算机,那就是64位的,……
使用 isize 或 usize 的主要场景是对某种集合进行索引操作。
整数字面值
下面表格里的下划线也是为了增强可读性。
Number Literals | Example |
Decomal | 98_222 |
Hex | 0xff |
Octal | 0o77 |
Binary | 0b1111_0000 |
Byte (u8 only) | b'A' |
除了 byte 类型外,所有数值字面值都允许使用类型后缀,例如:57u8
如果你不太清楚应该使用哪种类型,可以使用 Rust 相应的默认类型。
整数的默认类型就是 i32,总体上来说速度很快,即使在64位系统中。
整数溢出
例如:u8 的范围是 0 ~ 255,如果你把一个 u8 变量的值设为 256,那么:
- 调试模式下编译:Rust 会检查整数溢出,如果发生溢出,程序在运行时就会 panic
- 发布模式下(--release)编译:Rust 不会检查可能导致 panic 的整数溢出,如果溢出发生,Rust 会执行“环绕”操作,即:256变成0,257变成1…… 但程序不会 panic。
6. 浮点类型
Rust 有两种基础的浮点类型,也就是含有小数部分的类型。
f32,32位,单精度。
f64,64位,双精度。
Rust 的浮点类型使用了 IEEE-754 标准来表述。
f64 是默认类型,因为在现代 CPU 上 f64 和 f32 的速度差不多,而且精度更高。
数值操作
加减乘除余等。
举个🌰
7. 布尔类型
Rust 的布尔类型也有2个值:true 和 false 。
一个字节大小。
符号是 bool 。
举个🌰
8. 字符类型
Rust 语言中 char 类型被用来描述语言中最基础的单个字符。
字符类型的字面值使用单引号。
占用 4字节 大小。
是 Unicode 标量值,可以表示比 ASCII 多得多的字符内容,比如:拼音、中日韩文、零长度空白字符、emoji表情等(- U+0000 ~ U+D7FF,- U+E000 ~ U+10FFFF)
但 Unicode 中并没有 “字符” 的概念,所以直觉上认为的字符也许与 Rust 中的概念并不相符。
举个🌰
9. 复合类型
复合类型可以将多个值放在一个类型里。
Rust 提供了两种基础的复合类型:元祖(Tuple)、数组。
9.1 元组(Tuple)
Tuple 可以将多个类型的多个值放在一个类型里。
Tuple 的长度是固定的,一旦声明就不能改变。
创建 Tuple
在小括号里,将值用逗号分开。
Tuple 中的每个位置都对应一个类型,Tuple 中各元素的类型不必相同。
举个🌰
Tip:在创建元祖的时候,元祖里每个值的类型不用定义,输完等号后面具体的值之后,编译器会自动推断并补全。
获取 Tuple 的元素值
可以使用模式匹配来解构(destructure)一个 Tuple 来获取元素的值。
举个🌰
访问 Tuple 的元素
在 Tuple 变量使用 点标记法,后接元素的索引号。
举个🌰
9.2 数组
数组也可以将多个值放在一个类型里。
数组中每个元素的类型必须相同。
数组的长度也是固定的。
声明一个数组
在中括号里,各值用逗号分开。
举个🌰
数组的用处
如果想让你的数据存放在 stack(栈)上而不是 heap (堆)上,或者想保证有固定数量的元素,这时使用数组更有好处。
数组没有 Vector 灵活(后面细讲)。
Vector 和数组类似,它由标准库提供。
Vector 的长度可以改变。
如果你不确定应该用数组还是 Vector,那么估计你应该用 Vector。
JvJv注:
Stack 即栈内存方式存储,数据先进后出(FILO),操作系统只需要分配栈顶空间。
所在在 Stack 上存储的内容必须有已知的固定大小。
编译时大小未知或运行时大小发生变化的数据必须存放在 heap 上。
Heap 堆内存:
把数据放入 heap 时,你会先请求一定数量的空间,操作系统会在 heap 找到一块足够大的空间,标记为在用,并返回一个指针,也就是这个空间的地址。这个过程叫做内存的分配,有时候叫分配。
注意:把数据压入(push)Stack 上要比在 heap 上分配快得多,因为操作系统不需要寻找用来存储新数据的空间,那个位置永远是 stack 的顶端。
stack 和 heap 如何访问数据比较:
访问 heap 中的数据要比访问 stack 中的数据慢的多,因为需要先通过指针才能找到 heap 中的数据。
对于现代处理器来说,因为存在多级缓存的缘故,如果指令在内存中多次跳转,速度就会大幅下降。
数组的类型
数字的类型以这种形式表示:[ 类型; 长度 ] ,中间是个分号。
另一种声明数组的方法
如果数组的每个元素值都相同,那么可以在中括号里指定初始值,然后是一个分号,最后是数组的长度。
例如:let a = [3; 5]; 它就相当于:let a = [3, 3, 3, 3, 3];
访问数组的元素
数组是 Stack 上分配的单个块的内存。
可以使用索引来访问数组的元素。
如果访问的索引超过了数组的范围,那么:编译会通过,但运行会报错(runtime 时会 panic)。Rust 不允许其继续访问相应地址的内存。
比如,如果直接写一个显式的索引值,那么编译器会直接报错。但如果把索引值写的稍微 “绕”一点,那么编译器就不会直接报错,而是在运行时才会报错。
举个🌰
10. 结构体(struct)
结构体与元组类似,区别在于结构体的每一部分可以是不同类型。
结构体需要命名各部分数据以便能清楚的表明其值的意义。由于有了这些名字,使得结构体比元组更灵活,需要依赖顺序来指定或访问实例中的值。
如何定义?
定义结构体需要使用 struct 关键字并为整个结构体提供一个名字。结构体的名字需要描述它组合的数据的意义。
接着,在花括号中定义每一部分数据的名称以及字段类型。
注意!结构体的名字要使用驼峰命名法(CamelCase name)。
定义结构体内部的字段类型时,因为还没有输入具体值,所以编译器不能自动推断出你想要的类型,这里你需要自己一个个输入。