从 0 到 1 学习 Rust(一)

1,460 阅读16分钟
c58b1f155460c903f4ad60caa5af32aa.gif

前言

关注前端新闻的同学,在最近应该会经常听到Rust这个名称,从我们常用的一些构建打包工具从 JS 迁移至 Rust,再到 Web­Assembly在浏览器端的应用,Rust 越来越深入到大前端生态。为了让自己在 35 岁还能当一个合格的大头兵,决定从 0 到 1 的开始学习 Rust,并记录学习内容,本文是《从 0 到 1 学习 Rust》第一部分,会场 Rust 的基本介绍,再到环境搭建与工具使用,最后会对《Rust 程序设计语言》前 3 章知识点总结,阅读完本文,你会有以下收获

  • 对 Rust 有一个基本了解
  • 学会如何 Debug Rust 代码,分析 Rust 代码
  • 了解 Rust中一些常用的编程概念。

最后,强烈建议学习的同学去阅读《Rust 程序设计语言》。

Rust 简介

Rust 是一门系统级编程语言,设计初衷是为了提供更高的性能、更好的内存安全和并发性,同时保持开发者的生产力。

  1. 内存安全:Rust 的一个主要特点是强调内存安全。它使用借用、所有权和生命周期等概念来确保在编译时捕获内存错误,如空指针引用、数据竞争和缓冲区溢出。这有助于避免许多传统编程语言中常见的安全漏洞。
  2. 零成本抽象:Rust 提供了高度的抽象能力,使开发者可以编写简洁且高效的代码,而无需担心性能损失。这意味着你可以使用高级编程概念,如泛型、模式匹配和 trait,而不会牺牲性能。
  3. 并发性:Rust 内置了并发编程支持,允许你创建多线程应用程序,并使用通道(channels)和锁等机制来确保线程安全。这使得编写安全的并发代码更加容易。
  4. 零开销抽象:Rust 提供了抽象的能力,但不引入运行时开销。这意味着你可以编写高度抽象的代码,而不必担心性能问题。
  5. 生态系统:Rust 社区拥有丰富的库和工具,使开发者能够轻松构建各种应用,包括网络服务、嵌入式系统、操作系统和游戏等。
  6. 跨平台支持:Rust 支持跨多个操作系统和硬件架构,因此你可以轻松地在不同平台上开发和部署应用。
  7. 开源和活跃社区:Rust 是一个开源项目,拥有庞大而活跃的社区。这意味着有数以千计的贡献者和库可以帮助解决各种问题,同时保证了语言的不断发展和改进。
  8. 工具支持:Rust 提供了一系列强大的工具,包括 Cargo(构建工具和包管理器)、Rustdoc(文档生成工具)以及丰富的编辑器插件和集成开发环境(IDE)支持,使开发过程更加顺畅。

同时 Rust 的生态更贴近前端:

工具作用前端对标
Rustc是 Rust 的编译器,用来将源代码和生产二进制代码,变成一个或可执行文件。TypeScript
rustup是 Rust 的安装和版本管理工具。nvm
Cargo是 Rust 的构建工具和包管理器。npm

Cargo 与 npm 命令对比

功能Cargo 命令npm 命令
创建工程cargo init或者cargo new ${projectName}npm init
安装依赖cargo add 与 cargo install(全局)npm install
搜索依赖cargo searchnpm search
编译构建cargo buildnpm run build
代码测试cargo testnpm run test
包发布cargo publishnpm publish

学习前准备

环境安装

如果你使用 Linux 或 macOS,打开终端并输入如下命令:

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

运行完成,终端出现Rust is installed now. Great! 代表安装成功 另外还需要一个链接器(linker),在终端执行

xcode-select --install

更多安装信息,参考:Rust 安装

工具推荐

俗话说“工欲善其事,必先利其器”,那么在学习 Rust 的过程中有哪些好的工具呢?下面介绍一下,我在学习过程觉得不错的工具,有其它不错工具推荐的同学,欢迎在评论区补充

VSCode 插件

  1. Rust Analyzer:Rust Analyzer 是一个强大的 Rust 语言服务器,提供了更快速和准确的代码分析功能,可以取代官方 Rust 插件。安装它后,官方插件通常不再需要。 image.png
  2. Crates:这个插件用于查看和搜索 Rust 的 Crates(包),可以帮助你快速找到并添加依赖。
  3. Better TOML:Rust 项目通常使用 TOML 格式的配置文件,这个插件提供了 TOML 文件的语法高亮和格式化支持,使配置文件更容易编写和维护。
  4. Rust Test Lens:这个插件用于显示 Rust 代码中的测试信息,包括测试函数的名称和运行状态。它可以帮助你更轻松地查看和运行测试。

ChatGPT

强烈推荐使用,还不知道如何使用的同学,可以参考,当碰到代码分析不清楚时,我们只需要将代码发给 GPT,它就能给我们很好的分析代码的运行原理与代码问题所在。这对于前期的学习,非常有帮助,如:

image.png

工程目录组织(可选)

建议大家给每个章节建一个独立文件,在对应的文件里面实践对应章节的代码。简单的代码组织可以参考

  1. 创建新工程 通过 Cargo 创建一个新的工程,执行

    cargo new rust-study 
    
  2. 创建对应章节文件,并导出方法 在 src 目录下新建一个对应章节的rs文件 ,例如第一章节,rust 的常见编程概念,我们可以在新建一个base_concepts.rs 文件,并导出一个方法

	pub fn base_concepts_main () {
		println!("this is base_concepts_main");
	}
  1. 主入口引用 在 src/main.rs 下引入 enum_comp.rs 中导出的方法,并在 main方法调用

    mod enum_comp;
    use crate::enum_comp::enum_main;
    fn main() {
      enum_main();
    }
    

    完整目录结构如下:

    image.png
  2. 运行 rust 代码 在对应的公共目录下运行 rust run,就会看到"this is base_concepts_main" 输出。

Debug Rust 代码

当安装好上述的 VScode 插件后,在我们编写的 Rust 代码上,就会出现一个 Debug 按钮,只需加上对应的断言即可开始 debug。如:

image.png

debug 窗口信息如下:

image.png

常见编程概念

工程简介

我们使用 cargo new rust-study 创建好工程后,我们会得到一个最简单的 Rust 工程目录,如图: image.png

  • src: 源代码工程目录
  • target: rust 编译后产物
  • Cargo.lock:依赖的版本锁文件,对标JS的 yarn.lockpackage-lock.json
  • Cargo.toml:项目描述文件,对标 npm 的package.json

数据定义

在 JS 中,我们对于可以发生变化的变量会使用 var 或者 let 进行定义,对于不可变的常量,会使用 const 进行定义。在 Rust 中,定义变量的方式有两种:letconst。与 JS 不同的是,Rustlet 定义的变量默认是不可变的,如果希望这个变量可变,需要在定义时加上 mut 标志。

可变性

使用let定义的变量,默认是不可变的。加上 mut 后,变量会变为可变,但是需要注意,仅可改变变量的值,并不能改变变量的类型。如下代码:

fn main() {
	let mut y = 1;
	y = 2;
	let mut x = 3;
	x = 'a';
	println!("y is {}, x is {}", y, x);
}

执行cargo check,我们就会得到以下报错expected integer, found "char"。 如果我们希望保留变量命名,但是前后类型不一致,可以通过重新定义的方式去声明一个新的变量。如下代码:

fn main() {
	let mut y = 1;
	y = 2;
	let mut x = 3;
	let x = 'a';
	println!("y is {}, x is {}", y, x);
}

这里会涉及到 Rust 可变性的一个特性 隐藏(Shadowing)。第一个 x 变量被第二个 x 变量隐藏了。这里与大部分编程语言类似,第二个变量会遮蔽第一个变量,直到第二个变量作用域结束,如:

fn main() {
    let x = 5;
    // 重新创建变量 x,其值为第一个 x 的值 +1,即为 6
    let x = x + 1;

    {
	    // 当前这个块级作用域下定义的 x 会遮蔽上面定义的第二个 x 变量,此时定义的 x 变量值为 12
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }
	// 块级作用域结束,第二个 x 遮蔽结束,输出 6
    println!("The value of x is: {x}");
}

常量

类似于不可变变量,常量 (constants) 是绑定到一个名称的不允许改变的值,不过常量与变量还是有一些区别。常量不光默认不可变,它总是不可变。它可以被设置为某一个固定值,或者常用表达式。如:

// 在编写代码时书写表达式,在编译过程中,rust 会直接将表达式转换为固定值,减少运行时计算带来的额外开销。
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;

编译器能够在编译时计算一组有限的操作,这使我们可以选择以更容易理解和验证的方式写出此值,而不是将此常量设置为值 10,800。有关声明常量时可以使用哪些操作的详细信息,请参阅 Rust Reference 的常量求值部分

常用数据类型

标量类型

标量scalar)类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。

整型
长度有符号无符号
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

每一个有符号的变体可以储存包含从 -(2n - 1) 到 2n - 1 - 1 在内的数字,这里 n 是变体使用的位数。所以 i8 可以储存从 -(27) 到 27 - 1 在内的数字,也就是从 -128 到 127。无符号的变体可以储存从 0 到 2n - 1 的数字,所以 u8 可以储存从 0 到 28 - 1 的数字,也就是从 0 到 255。

另外,isize 和 usize 类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。如果是 32 位系统,isize 就相当于 i32,这种设计使得 isize 类型在不同的系统架构中都能提供最大的性能。

浮点型

Rust 也有两个原生的 浮点数floating-point numbers)类型,它们是带小数点的数字。Rust 的浮点数类型是 f32 和 f64,分别占 32 位和 64 位。默认类型是 f64,因为在现代 CPU 中,它与 f32 速度几乎一样,不过精度更高。所有的浮点型都是有符号的。

数值计算

Rust 中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法有小数时,会舍弃小数。代码如下:

let x = 5;
let y = x / 3;
// 5 / 3 = 1.666667。这里 rust 会丢弃小数位,返回 1
println!("The value of y is: {}", y);

另外需要注意的是,浮点型不能与整型直接进行计算,这是因为它们是两种不同的数据类型,它们的内部表示和计算方式都不同。这是为了避免可能的精度损失或数据溢出等问题。代码如下

let x = 5.0;
let y = x / 3;
// 报错:no implementation for `{float} / {integer}`
println!("The value of y is: {}", y);

如需这么操作,可先进行类型转换,如:

let x = 5.0;
let y = x as i32 / 3;
// 报错:no implementation for `{float} / {integer}`
println!("The value of y is: {}", y);

需要注意的是,类型转换可能会有精度损失或数据溢出的风险,所以在进行类型转换时需要特别小心。

字符类型

Rust 的 char 类型的大小为四个字节 (four bytes),并代表了一个 Unicode 标量值(Unicode Scalar Value),这意味着它可以比 ASCII 表示更多内容。在 Rust 中,带变音符号的字母(Accented letters),中文、日文、韩文等字符,emoji(绘文字)以及零长度的空白字符都是有效的 char 值。Unicode 标量值包含从 U+0000 到 U+D7FF 和 U+E000 到 U+10FFFF 在内的值。 代码如下:

let x = '张';
let y = 'a';

需要注意,我们用单引号声明 char 字面量,而与之相反的是,使用双引号声明字符串字面量。如:

// x 类型为 char
let x = '张';
// y 类型为 &str
let y = "张";

复合类型

元组

元组是一个将多个其他类型的值组合进一个复合类型的主要方式。元组长度固定:一旦声明,其长度不会增大或缩小。定义与解构示意代码如下:

let tup = (500, 'a', "a");
let (x, y, z) = tup;
println!("x is {}, y is {}, z is {}", x,y,z);
// 还可以通过索引直接访问
println!("x is {}, y is {}, z is {}", tup.0, tup.1, tup.2);

同理,默认定义的元组类型也是不可变的,如果需要修改,可以加上mut标志,同时也是只可修改值不可修改类型

let tup1 = (500, 'a', "a");
// 报错,tup1 不可修改
tup1.0 = 200;

let mut tup2 = (500, 'a', "a");
tup2.0 = 1;
// 报错,不可修改类型
tup2.1 = 2;
数组

与元组不同,数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,Rust 中的数组长度是固定的。常用定义数组的几个方法:

let a = [1, 2, 3, 4, 5];
// `i32` 是每个元素的类型。分号之后,数字 `5` 表明该数组包含五个元素。
let a: [i32; 5] = [1, 2, 3, 4, 5];
// 元素的值最初都将被设置为 `3`,一共有五项。与let a = [3, 3, 3, 3, 3]效果一样
let a = [3; 5];

需要注意的点,在访问不存在的索引时,rust 与 JS 的表现是不一致的。当尝试用索引访问一个元素时,Rust 会检查指定的索引是否小于数组的长度。如果索引超出了数组长度,Rust 会报错,而在JS中会返回一个 undefined 对于数组元素修改,与元组表现一致,这里就不再赘述。

函数

rust的函数,必须 声明每个参数的类型。要求在函数定义中提供类型注解,意味着编译器再也不需要你在代码的其他地方注明类型来指出你的意图。而且,在知道函数需要什么类型后,编译器就能够给出更有用的错误消息。

语句和表达式

语句Statements)是执行一些操作但不返回值的指令。 表达式Expressions)计算并产生一个值。注意:如果在表达式的结尾加上分号,它就变成了语句

// let y = 6 执行完成后,不会有返回值,所以不能赋给 x,这里就会报错。
let x = (let y = 6);

在 rust 中返回值设置有两种方式

  1. 使用 return 进行 返回
  2. 使用表达式返回
    fn plus_one(x: i32) -> i32 { 
    	// 注意:这里不能加分号
    	x + 1 
    }
    

控制流

if 表达式

rust 在判断语句上的语法与 JS侧有点不一样,这里不需要用"()"包裹起来。例如:

fn main() { 
	let number = 6; 
	if number % 4 == 0 { 
		println!("number is divisible by 4"); 
	} else if number % 3 == 0 { 
		println!("number is divisible by 3"); 
	} else { 
		println!("number is not divisible by 4, 3"); 
	} 
}

在 if 控制流中返回值,可以在外部定义变量接受 if 块级代码执行完成后的返回值,例如

let a = 1;
// 需要注意这里不可以使用 return 返回,只能使用表达式返回。同时返回的类型必须要一致
let b = if a==1 { 3 } else { 4 };
// 会报错,因为前后返回的值类型不一致
let c = if b==1 { 3 } else { 'x' };

循环

跳出循环的方式,与大多数语言一样,有以下三种方式

  1. continue: 跳出当次循环,进入下一个循环
  2. break: 结束当前循环,执行函数后续代码
  3. return: 结束当前函数
loop循环

常用 loop 循环代码示例

let mut count = 10;

loop {
	count -= 1;
	if count > 5 {
		continue;
	}
	if count > 0 {
		break;
	}
}

println!("count is {}", count);

当出现多个 loop 循环嵌套时,可以通过循环标签来消除歧义。例如:

fn main() { 
	let mut count = 0; 
	'counting_up: loop { 
		println!("count = {count}"); 
		let mut remaining = 10; 
		loop { 
			println!("remaining = {remaining}"); 
			if remaining == 9 {
				// 跳出当前这个 loop 循环
				break; 
			}
			if count == 2 {
				// 跳出第一个 loop 循环
				break 'counting_up; 
			}
			remaining -= 1;
		} 
		count += 1; 
	}
	println!("End count = {count}");
}

if 一样,loop 同样支持表达式返回值,例如:

fn main() {
	let mut counter = 0;
	let result = loop {
		counter += 1;
		if counter == 10 {
			break counter * 2;
		}
	}; 
	println!("The result is {result}");
}
while 与 for 循环

while 与 for 循环与JS中表现类似,示意代码:

fn main() {
	let a = [10, 20, 30, 40, 50];
	let mut index = 0;
	
	while index < 5 {
		println!("the value is: {}", a[index]);
		index += 1;
	}
	for element in a {
		println!("the value is: {element}");
	}
}

练习一下

题目

实现一个猜字游戏,利用rand生成一个0-100的随机数,读取用户的输入,根据用户的输入反馈是否符合要求。如果输入过大,则输出“输入值过大”,等待用户再次输入;输入过小,则输出“输入值过小”,等待用户再次输入;当输入值匹配时,输出“恭喜!回答正确!”,并结束程序。 API参考:

use rand::Rng;
use std::io;

// 生成随机数方法,生成 0 - 100 随机数
let target: i32 = rand::thread_rng().gen_range(0..=100);

// 获取用户输入并转换格式
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("Failed to read line");
let guess: i32 = match guess.trim().parse() {
	Ok(num) => num,
	Err(_) => {
		println!("输入字符非法!请重新输入");
		continue;
	},
};

解答

点击以展开内容
	```rust
	use rand::Rng;
	use std::io;
	
	fn main() {
		let target: i32 = rand::thread_rng().gen_range(0..=100); 

		loop {
			println!("请输入 0-100 数字");
			// 读取用户输入并赋值给 guess
			let mut guess = String::new();
			io::stdin().read_line(&mut guess).expect("Failed to read line");
			let guess: i32 = match guess.trim().parse() {
				Ok(num) => num,
				Err(_) => {
					println!("输入字符非法!请重新输入");
					continue;
				},
			};

			if guess == target {
				println!("恭喜你,猜对了!");
				break;
			}

			if guess < target {
				println!("猜小了,请重新输入");
				continue;
			}

			if guess > target {
				println!("猜大了,请重新输入");
				continue;
			}
		}
	}
```