关键词:Rust
References:
未完,待施工…
前言
什么是 Rust
Rust 是一门新的静态编译编程语言,其功能定位与 C++ 相似,它的 1.0 版本于 2015 年发布。
rustc
使用 LLVM 作为它的后端。
Rust 支持多种平台和架构:x86、ARM、WebAssembly、Linux、Mac、Windows……
Rust 被广泛用于各种设备中:
- 固件和引导程序
- 智能显示器
- 手机
- 桌面
- 服务器
Rust 和 C++ 适用于类似的场景:
- 极高的灵活性。
- 高度的控制能力。
- 能够在资源匮乏的设备(如手机)上运行。
- 没有运行时和垃圾收集。
- 关注程序可靠性和安全性,而不会牺牲任何性能。
Rust 系统由许多工具组成:
rustc
:Rust 编译器,将.rs
文件转换为二进制文件和其他中间格式。cargo
:Rust 依赖项管理器和构建工具(包管理工具)。负责下载依赖项并在构建项目时传递给编译器。其还附带内置的测试运行程序,用于执行单元测试。rustup
:Rust 工具链安装和更新工具。当更新版本时,其用于安装并更新rustc
和cargo
。还可用于下载标准库的文档,同时安装多个 Rust 版本。
Rust 区分版本。这些版本支持对语言进行向后不兼容的更改。
- 为防止破坏代码,版本是可选的: 通过
Cargo.toml
文件为crate
选择合适的版本。 - 为免分割生态系统,Rust 编译器可以混合使用为不同版本编写的代码。
Rust 的优势
编译时内存安全:在编译时可防止所有类内存 bug。
- 不存在未初始化的变量。
- 不存在“双重释放”。
- 不存在“释放后使用”。
- 不存在 NULL 指针。
- 不存在被遗忘的互斥锁。
- 不存在线程之间的数据竞争。
- 不存在迭代器失效。
没有未定义的运行时行为:每个 Rust 语句的行为都有明确定义。
- 数组访问有边界检查。
- 整数溢出有明确定义(panic 或回绕)。
现代语言功能:具有与高级语言一样丰富且人性化的表达能力。
- 枚举和模式匹配。
- 泛型。
- 无额外开销的外部函数接口(FFI)。
- 零成本抽象。
- 强大的编译器错误提示。
- 内置依赖管理器。
- 对测试的内置支持。
- 优秀的语言服务协议(Language Server Protocol)支持。
Windows 下安装 Rust
可执行二进制文件下载地址:
点击运行 rustup-init.exe
。
显示默认安装在 C 盘。
- 此处已做修改,自定义安装路径。
自定义安装路径步骤如下:
- 创建自定义文件夹,我此处为
D:/rust
。在内创建两个文件夹,分别为.cargo
和.rustup
。
- 配置环境变量:把新建的文件夹添加到环境变量中。
- 在
PATH
环境变量中加入上两个变量。
至此再打开 rustup-init.exe
,观察路径是否修改。
接着输入 1
执行默认安装即可。
在 VSCode 上使用 Rust
步骤如下:
- 在扩展中查找并安装
rust-analyzer
和Native Debug
两个插件。
- 另外两个推荐插件:
- Even Better TOML,支持 .toml 文件完整特性
- Error Lens,更好的获得错误展示
- 新建代码文件夹,在终端使用命令生成工程:
1 | cargo new hello |
- 编写代码后,在终端使用命令运行:
1 | cargo build |
第一个 Rust 程序
每一个语言一开始会有它的 Hello World。
1 | fn main() |
- 函数以
fn
开头。 - 代码块以
{
开头,以}
结尾。 - Rust 有卫生宏,
println!
就是一个例子。- 这意味着它们不会意外地捕获它们所在作用域中的标识符。
- Rust 字符串是
UTF-8
编码,可以包含 Unicode 字符。
下载依赖卡顿问题
解决方法是:覆盖默认的镜像地址
在 $HOME/.cargo/config.toml
添加以下内容:
1 | [source.crates-io] |
- 创建一个新的镜像源
[source.ustc]
,然后将默认的crates-io
替换成新的镜像源:replace-with = 'ustc'
。
Rust 基础入门
速览语法:
1 | // Rust 程序入口函数,跟其它语言一样,都是 main,该函数目前无返回值 |
变量绑定与解构
Rust 默认变量是不可修改的,这使得程序运行时性能上的提升。
在命名方面,和其它语言没有区别,不过当给变量命名时,需要遵循 Rust 命名规范。
变量绑定
在其他语言中,如 int a = 1
称为赋值。但 Rust 中, let a = 1
称为 变量绑定。
绑定一词源自 Rust 的所有权问题。
- 任何内存对象都是有主人的(Owner),对象完全属于它的主人。
- 绑定就是把这个对象绑定给一个变量,使变量成为它的主人。
变量的不可变与可变
1 | fn main() { |
这种不可变的优势是:一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。
忽略未使用的变量
Rust 的高安全性会认为不使用的变量可能会是个 BUG,所以进行警告。
使用下划线 _
开头的变量名会被忽略。
1 | fn main { |
变量解构
let
不仅可以进行变量的绑定,还可以进行复杂变量的解构。
- 从一个相对复杂的变量中,匹配出该变量的一部分内容。
1 | fn main() { |
不可变量与常量
Rust 默认的是不可变量。常量是经过 const
修饰的量,且在 Rust 中值类型必须标注。
1 | fn main() { |
变量遮蔽
像上面写过的,Rust 允许声明相同的变量名,且后声明的变量名会遮蔽前面声明的。
1 | fn main() { |
- 这个程序首先将数值 5 绑定到
x
,然后通过重复使用let x =
来遮蔽之前的x
,并取原来的值加上 1,所以x
的值变成了 6。第三个let
语句同样遮蔽前面的x
,取之前的值并乘上 2,得到的x
最终值为 12。 - 这和
mut
变量的使用是不同的,第二个let
生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配,而mut
声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。
变量遮蔽的用处,在于在某个作用域内无需再使用之前的变量时,就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。
基本类型
内置数据类型有:
类型 | 字面量 | |
---|---|---|
有符号整数 | i8 、i16 、i32 、i64 、i128 、isize |
-10 、0 、1_000 、123_i64 |
无符号整数 | u8 、u16 、u32 、u64 、u128 、usize |
0 、123 、10_u16 |
浮点数 | f32 、f64 |
3.14 、-10.0e20 、2_f32 |
Unicode 标量类型 | char |
'a' 、'α' 、'∞' |
布尔值 | bool |
true 、false |
单元类型 | () |
其唯一的值也是 () |
- 数字中的下划线均可忽略,仅方便辨识,即
1_000
可以写成1000
,123_i64
等价123i64
每种类型占用空间为:
iN
、uN
和fN
占用 位。isize
和usize
占用一个指针大小的空间。char
占用 4 个字节,32 位空间。bool
占用 1 个字节,8 位空间。
类型推导与标注:编译器必须在编译期知道所有变量的类型,但这不意味着你需要为每个变量指定类型。
- Rust 编译器可以根据变量的值和上下文中的使用方式来自动推导出变量的类型。
- 在某些情况下,Rust 编译器无法推导出变量类型,需要手动去给予一个类型标注。
数值类型
整数运算溢出
关于运算时整数溢出:
1 | fn mul(a: i16, b: i16, c: i16) -> i16 |
- 在 debug 模式编译时,Rust 会检查整型溢出,若存在这些问题,则使程序在编译时 panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。
可以显式处理溢出,通过标准库针对原始数字类型提供的方法:
wrapping_*
在所有模式下都按照补码循环溢出规则,如wrapping_add
。checked_*
在发生溢出时返回None
值。overflowing_*
返回该值和一个指示是否存在溢出的布尔值。saturating_*
可以限定计算后的结果不超过目标类型的最大值或低于最小值。
1 | fn main() { |
浮点数陷阱
浮点数的使用需要谨慎,原因:
- 浮点数往往是数字的近似表达(并不是精确的)。
- 浮点数在某些特性上时反直觉的(比如比较时)。
所以:
- 避免在浮点数上判断相等;
- 当结果在数学上存在未定义时需要小心。
一段代码:
1 | fn main() { |
NaN
对于数学上未定义的结果,比如负数取平方根,会返回一个 NaN
表示。
- 所有与
NaN
交互的操作都会返回一个NaN
。 NaN
并不能用于比较。
可以使用 is_nan()
方法判断是否为 NaN
。
数字运算
Rust 支持所有数字类型的基本数学运算:加法、减法、乘法、除法和取模运算。
更多,运算符如下:
运算符 | 示例 | 解释 | 是否可重载 |
---|---|---|---|
! |
ident!(...), ident!{...}, ident![...] |
宏展开 | |
! |
!expr |
按位非或逻辑非 | Not |
!= |
var != expr |
不等比较 | PartialEq |
% |
expr % expr |
算术取模 | Rem |
%= |
var %= expr |
算术取模与赋值 | RemAssign |
& |
&expr , &mut expr |
借用 | |
& |
&type , &mut type , &'a type , &'a mut type |
借用指针类型 | |
& |
expr & expr |
按位与 | BitAnd |
&= |
var &= expr |
按位与及赋值 | BitAndAssign |
&& |
expr && expr |
逻辑与 | |
* |
expr * expr |
算术乘法 | Mul |
*= |
var *= expr |
算术乘法与赋值 | MulAssign |
* |
*expr |
解引用 | |
* |
*const type , *mut type |
裸指针 | |
+ |
trait + trait , 'a + trait |
复合类型限制 | |
+ |
expr + expr |
算术加法 | Add |
+= |
var += expr |
算术加法与赋值 | AddAssign |
, |
expr, expr |
参数以及元素分隔符 | |
- |
- expr |
算术取负 | Neg |
- |
expr - expr |
算术减法 | Sub |
-= |
var -= expr |
算术减法与赋值 | SubAssign |
-> |
fn(...) -> type , \|...\| -> type |
函数与闭包,返回类型 | |
. |
expr.ident |
成员访问 | |
.. |
.. , expr.. , ..expr , expr..expr |
右排除范围 | |
.. |
..expr |
结构体更新语法 | |
.. |
variant(x, ..) , struct_type { x, .. } |
“与剩余部分”的模式绑定 | |
... |
expr...expr |
模式: 范围包含模式 | |
/ |
expr / expr |
算术除法 | Div |
/= |
var /= expr |
算术除法与赋值 | DivAssign |
: |
pat: type , ident: type |
约束 | |
: |
ident: expr |
结构体字段初始化 | |
: |
'a: loop {...} |
循环标志 | |
; |
expr; |
语句和语句结束符 | |
; |
[...; len] |
固定大小数组语法的部分 | |
<< |
expr << expr |
左移 | Shl |
<<= |
var <<= expr |
左移与赋值 | ShlAssign |
< |
expr < expr |
小于比较 | PartialOrd |
<= |
expr <= expr |
小于等于比较 | PartialOrd |
= |
var = expr , ident = type |
赋值/等值 | |
== |
expr == expr |
等于比较 | PartialEq |
=> |
pat => expr |
匹配准备语法的部分 | |
> |
expr > expr |
大于比较 | PartialOrd |
>= |
expr >= expr |
大于等于比较 | PartialOrd |
>> |
expr >> expr |
右移 | Shr |
>>= |
var >>= expr |
右移与赋值 | ShrAssign |
@ |
ident @ pat |
模式绑定 | |
^ |
expr ^ expr |
按位异或 | BitXor |
^= |
var ^= expr |
按位异或与赋值 | BitXorAssign |
\| |
pat \| pat |
模式选择 | |
\| |
expr \| expr |
按位或 | BitOr |
\|= |
var \|= expr |
按位或与赋值 | BitOrAssign |
\|\| |
expr \|\| expr |
逻辑或 | |
? |
expr? |
错误传播 |
序列
即基于范围,如 1..5
生成从1到4的连续数字,左闭右开,常用于循环中。
1..=5
即可表示全闭区间。
1 | fn main() { |
使用 As 完成类型转换
使用 As
来完成一个类型到另一个类型的转换,其最常用于将原始类型转换为其他原始类型。
有理数与复数
社区开发的高质量 Rust 数值库:num。
导入也十分简单:
- 创建工程:
cargo new complex-num
; - 在
Cargo.toml
中的[dependencies]
添加num = "0.4.0"
。 - 导入
num
:use num::Complex;
。 cargo build && cargo run
。
实例代码:
1 | use num::complex::Complex; |
字符、布尔、单元类型
字符:
- 所有的 Unicode 值都可以作为 Rust 字符。
- Unicode 为 4 字节编码,故字符类型也占用 4 个字节。
布尔类型:
- 占用 1 字节。
单元类型:
- 就是
()
,唯一的值也为()
,可以理解为void
。 main
函数返回的就是单元类型。- 可以用
()
作为 map 的值,表示不关注具体的值,只关注key
。
Rust中没有返回值的函数称为发散函数,即无法收敛的函数。
语句与表达式
Rust 的语句和表达式:
- 语句会执行一些操作但是不会返回一个值
- 表达式会在求值后返回一个值。
1 | let mut x = 0; // 语句 |
表达式总要返回值,且不能包含分号
- 带上分号则变成一条语句。
let
也是语句,故不能将 let
语句赋值给其他值。
表达式可以成为语句的一部分,例如 let y = 6
中,6
就是一个表达式,处理后后返回一个值 6
。
能返回值,它就是表达式。
函数
大概的格式如:
1 | fn <函数名> ( <参数> ) <函数体> |
- 规范上,函数名和变量名需要使用蛇形命名法。
- 函数的位置随便,不在乎定义在何处。
Rust 中定义函数如果需要具备参数 必须声明参数名称和类型 。
1 | fn main() |
Rust 函数声明返回值类型的方式:
- 在参数声明之后用
->
来声明函数返回值的类型。
1 | fn five() -> i32 |
- 在函数体中,随时都可以以
return
关键字结束函数运行并返回一个类型合适的值。
1 | fn ten() -> i32 |
当函数无返回值时:
- 返回一个
()
。 - 通过
;
结尾的语句返回一个()
。
上面的发散函数,可以用 !
作返回类型,表示永不返回。
- 这种语法往往用作会导致程序崩溃的函数。
1 | fn eend() -> ! { |
所有权和借用
所有权
Rust 使用 所有权系统。
对于内存管理方面,计算机语言出现了三种流派:
- 垃圾回收机制(GC):程序运行时不断寻找不再使用的内存,代表有 Java、Go;
- 手动管理内存:通过调用函数方式进行申请和释放内存,代表有 C++;
- 通过所有权管理内存:编译器在编译时会根据一系列规则进行检查。
- 检查只发生在编译器,所有在运行期并不会有性能上的损失。
C 语言上的不安全:
1 | int * fun() |
- 上述代码可以编译通过。
a
为局部变量,当离开作用域时,其内存会被系统回收,从而返回成了悬挂指针。c
的值时常量,存储在常量区,其生命周期为整个程序运行期间,当程序结束系统才会回收这片内存。
栈与堆
栈:先进后出。栈中的所有数据都必须占用已知且固定大小的内存空间。
堆:可存放对于大小未知或可能变化的数据。
- 当往堆上放入数据时,请求一定大小的内存空间,OS在堆的某处寻找并标记区域为已使用,返回该地址的指针。这个过程称为在堆上分配内存(分配)。
- 指针会被推入栈中,通过栈中的指针获取在堆上的数据。
两者在性能上:在栈上分配内存比在堆上分配内存要快,处理器在栈上分配数据会比在堆上分配数据更高效。
当代码调用函数时,传递给函数的参数(可能指向堆上数据的指针和函数的局部变量)依次入栈;当调用结束时,这些值按相反顺序出栈。
堆上的数据是缺乏组织的,所以管理堆上数据的分配和释放尤为重要。
- 当没有及时释放时,便造成了内存泄漏(数据无法被回收)。
所有权的原则
原则如下:
- Rust 中每一个值都被一个变量所拥有,该变量称为值的所有者(Owner)。
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者。
- 当变量(所有者)离开作用域时,这个值将被丢弃。
变量绑定背后的数据交互
了解深拷贝和浅拷贝。
简单来说,深拷贝在内存上独立,复制内容在新的内存空间上。浅拷贝在内存上共享。比如把A复制到B,如果是深复制,则A和B独立互不影响;如果是浅复制,在修改A,B也会改变。
拷贝(浅拷贝)
浅拷贝只发生在栈上,如:
1 | let x = 5; |
基本类型在编译时是已知大小,会存储在栈上,所以拷贝其值是快速的。
Rust 具有 Copy
的特征,可以用在类似整型这样在栈中存储的类型。
- 如果拥有
Copy
特征,则一个旧变量在被赋值给其他变量后仍可用,即赋值的过程是拷贝的过程。
规则:
- 任何基本类型的组合可以
Copy
; - 不需要分配内存或某种形式的资源的类型是可以
Copy
一些 Copy
的类型:
- 所有整数类型;
- 所有浮点数类型;
- 布尔类型;
- 字符类型;
- 包含的类型都可
Copy
的元组; - 不可变引用
&T
&mut T
是可变引用,不可以Copy
。
克隆(深拷贝)
Rust 永远不会自动创建数据的深拷贝。
- 任何自动的复制都不是深拷贝。
当需要深度复制数据时,使用方法 clone()
。
- 使用
clone()
会降低程序性能
转移所有权
有代码如下:
1 | let x = 5; |
- 这是浅拷贝,且没有发生所有权的转移。
- 整个过程的赋值都是通过浅拷贝方式完成,发生在栈中,所以不需要所有权转移。
另有代码如下:
1 | let s1 = String::from("hello"); |
String
为字符串类型,是一个复杂类型,由存储在栈中的堆指针、字符串长度、字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存(跟 C++ 的std::vector
类似)。- 即不会自动拷贝。
- 此时处理方式为:当
s1
被赋予s2
后,Rust 认为s1
不再有效,因此也无需在s1
离开作用域后 drop(丢弃) 任何东西。- 把所有权从
s1
转移给了s2
,s1
在被赋予s2
后就马上失效了。 - 这种类似于移动语义的机制,C++ 的
std::move()
。
- 把所有权从
如果真的有两个所有者,那么当 s1
和 s2
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误。
函数传值与返回
将值传递给函数,一样会发生移动或者赋值。
1 | fn main() { |
对于返回的值:
1 | fn main() { |
由于所有权,所以总是把一个值传来传去来使用它。
引用和借用
Rust 具有 借用 这一概念。
- 借用:获取变量的引用。
- 如果一个人拥有某样东西,你可以从他那里借来,当使用完毕后,也必须要物归原主。
引用和解引用
常规引用是一个指针类型,指向了对象存储的内存地址。
1 | fn main() { |
- 使用
*
进行解引用。
不可变引用
有如下代码:
1 | fn calculate_length(s: &String) -> usize { |
- 此处函数传入字符串,但无需再用返回的方式传出所有权。
- 函数参数为
&String
。 &
在此处表示引用,允许使用值,但不获取所有权。&s1
为指向s1
的引用,但不拥有它。
这样借用可以进行对变量一定的访问。
可变引用
当然也可以试着修改借用的变量。(得寸进尺)
1 | fn change(string: &mut String) { |
s
是可变类型,&mut s
是可变的引用,string: &mut String
是接收可变引用的参数。
但是,可变引用同时只能存在一个。
1 | fn main() { |
- 可变借用
r1
持续到最后一次使用的位置(即输出); - 在
r1
存活间,又尝试创建第二个可变借用r2
是会引起出错的。
这种限制的好处就是使 Rust 在编译期就避免数据竞争。数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。
数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
可以通过加大括号限制作用域的方式解决部分问题。引用的作用域从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号
还有一件事,可变引用与不可变引用不能同时存在。(太安全了吧QAQ)
1 | fn main() { |
- 可以理解为,正在借用不可变引用的用户,肯定不希望借用的东西莫名其妙被改了。
NLL
NLL:Not-Lexical-Lifetimes,一种 Rust 编译器优化行为。
- 用于找到某个引用在作用域
}
结束前就不再被使用的代码位置。
悬垂引用
悬垂引用也叫做悬垂指针。
- 指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。
如在 C++ 中,易见的悬挂:
1 | int* fun() |
- 这其实是可以编译通过,但函数
fun
返回的指针式悬挂的。- 当
fun
中的a
离开作用域时,a
会被释放,但fun
返回的指针仍然指向a
。
- 当
在 Rust 中编译器可以确保 引用永远也不会变成悬垂状态。
- 当获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。(🐂牛)
1 | fn main() { |
借用规则总结
- 同一时刻,要么只有一个可变引用,要么有任意数量的不可变引用。
- 引用必须总是有效的(非悬挂)。
复合类型
复合类型是由其它类型组合而成的,最典型的就是结构体 struct
和枚举 enum
。
字符串与切片
Rust 的字符串并没有想象中的简单。
切片
切片:允许引用集合中部分连续的元素序列,而不是引用整个集合。
- 创建切片的语法:
[开始索引..终止索引]
,其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,左闭右开。
1 | let s = String::from("hello world"); |
注意,中文在 UTF-8 中占用三个字节。
字符串切片的类型标识是 &str
。
一个对初学者难受的代码:
1 | fn main() { |
字符串
Rust 中的字符是 Unicode 类型。
- 每个字符占据 4 个字节内存空间。
但是在字符串中是 UTF-8 编码。
- 也就是字符串中的字符所占的字节数是变化的(1 - 4)。
Rust 语言级别上的字符串类型:str
。
- 通常以引用类型出现
&str
,即字符串切片。 str
是硬编码进可执行文件,无法修改。
String
是一个可增长、可改变且具有所有权的 UTF-8 编码字符串。
当提及字符串,往往指的是 String
类型和 &str
字符串切片类型。
- 除了
String
,还有OsString
、OsStr
、CsString
、CsStr
等。 - 都以
String
或Str
结尾,分别对应具有所有权和被借用的变量。
String 与 &str 的转换
从 &str
类型生成 String
:
1 | let s1 = String::from("hello") |
从 String
类型转为 &str
:取引用
1 | fn say_hello(s: &str) { |
字符串索引
字符串底层数据存储是 [u8]
。
- 但不能使用索引进行访问字符串的子串。
- 因为字符串中的字符所占的字节数是变化的。
- 比如汉字占 3 个字节,而英文占 1 个字节。
以此类推,使用索引进行字符串切片(如 &str[0..2]
)时也需要格外小心。
字符串的操作
追加:在原有的字符串上追加,并不会返回新的字符串。
push(@pos, @char)
追加字符char
;push_str(@pos, @str)
追加字符串字面量。- 字符串需要是可变的。
1 | fn main() { |
替换:将字符串中的某个字符串替换成其他字符串。
replace(@target, @replacement)
:将所有的目标字符串替换成新字符串。适用于String
和&str
。- 该方法是返回一个新的字符串,而不是操作原来的字符串。
replacen(@target, @replacement, @count)
:将count
个的目标字符串替换成新字符串。适用于String
和&str
。- 该方法是返回一个新的字符串,而不是操作原来的字符串。
replace_range(@range, @replacement)
:将范围内的字符串替换成新字符串。仅适用于String
类型。- 该方法是直接操作原来的字符串,不会返回新的字符串。
1 | fn main() { |
删除:都仅适用于 String
类型。
pop()
:删除并返回字符串的最后一个字符。- 该方法直接操作原来的字符串。
- 返回值是一个
Option
类型,如果字符串为空,则返回None
。
remove(@pos)
:删除并返回字符串指定位置(按字节处理)的字符。- 该方法直接操作原来的字符串。
truncate(@pos)
:删除字符串从指定位置(按字节处理)开始到结尾的全部字符。- 该方法直接操作原来的字符串。
clear()
:清空字符串。- 该方法直接操作原来的字符串。
1 | fn main() { |
连接:连接字符串。
- 使用
+
或者+=
连接字符串:要求右边参数必须为字符串的切片引用类型,不能直接传递String
类型。+
返回一个新的字符串,调用了add()
方法:fn add(self, s: &str) -> String
。
format!
:与print!
类似。适用于String
和&str
类型。
1 | fn main() { |
字符串转义
通过转移的方式 \
输出 ASCII 和 Unicode 字符。
1 | fn main() { |
操作 UTF-8 字符串
逐字符遍历:
- 避免索引尴尬情况。
逐字节遍历:
- 遍历字符串的底层字节数组表现形式。
1 | fn main() { |
如果需要准确从 UTF-8 字符串中获取子串是较为复杂的事情。
- 比如,想要从
holla中国人नमस्ते
这种变长的字符串中取出某一个子串,使用标准库是做不到的。- 需要在 crates.io 上搜索第三方库来寻找想要的功能。
剖析字符串
关于 String
可变,而字符串字面值 str
却不可以:
- 字面值文本在编译时就知道内容,直接硬编码进可执行文件中。
- 字面值是不可变的,而字符串是有在程序运行中动态变化的需求。
String
类型支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来管理内容:
- 在使用
String::from
是就会构造String
类型; - 在
}
处会自动调用释放内存函数drop
,用于释放离开作用域的变量。
元组
元组是由多种类型组合到一起形成的。
创建元组的语法如下:
1 | let tup: (i32, f64, u8) = (500, 6.4, 1); |
解构元组
用同样的形式把一个复杂对象中的值匹配出来或者使用 .
都可以访问元组的元素。
1 | fn main() { |
- 使用
let (x, y, z, w) = tup
进行模式匹配,用相似的模式进行匹配,元组对应的值就会绑定到变量x
、y
、z
和w
上。
元组常用于函数的返回值上,返回多个值。
1 | fn main() { |
结构体
结构体与元组:都是由多种类型组合而成。
- 但是与元组不同的是,结构体可以为内部的每个字段起一个富有含义的名称。
结构体的语法
一个结构体由几部分组成:
- 关键字
struct
定义; - 清晰的结构体名称;
- 几个结构体字段。
如:
1 | struct Student { |
- 初始化实例时,每个字段都需要初始化;
- 初始化时的顺序不必与定义时的顺序一致。
- 访问结构体字段时使用
.
。
另外注意,Rust 不支持将结构体的某个字段标记为可变,需要整个结构体声明为可变。
支持简化结构体构建:
1 | fn make_student(id: i32, name: String, age: i8) -> Student { |
进行结构体更新时,也是挺方便的:
1 | fn main() { |
..
语法表明凡是没有显式声明的字段,全部从student1
中自动获取。- 需要注意的是
..student1
必须在结构体的尾部使用。 - 此处
student1.name
的所有权已经移到student2
,所以student1.name
不再有效,不能输出,但是其他字段(基本类型)依旧有效。
结构体的内存排序
1 | struct File { |
File
的内存排序如下:
name | data | ||||
---|---|---|---|---|---|
String | Vec | ||||
ptr | size | capacity | ptr | size | capacity |
name
的ptr
指向一块[u8; name.size]
内存的开头。data
的ptr
指向另一块[u8; data.size]
内存的开头。
元组结构体
结构体必须要有名称,但是结构体的字段可以没有名称。
- 元组结构体:这种结构体长得很像元组,字段没有名称。
1 | struct Color(i32, i32, i32); |
单元结构体
单元结构体跟单元类型很像,没有任何字段和属性。
- 当定义一个类型,但是不关心该类型的内容, 只关心它的行为时,就可以使用单元结构体。
结构体数据的所有权
结构体当中它所拥有的数据应当是拥有所有权的,而不是其他地方借用的。
- 借用数据需要考虑生命周期:生命周期确保结构体的作用范围比它所借用的数据的作用范围要小。
[derive(Debug)]
Rust 默认没有给结构体实现 Display
特征,而把输出格式的选择权利交给程序员。
- 顾名思义,
Display
特征能够使得结构体实现自动格式输出。
使用 #[derive(Debug)]
对结构体进行了标记,这样才能使用 println!("{:?}", s);
的方式对其进行打印输出:
1 |
|
- 使用
println!("{:?}", s);
输出时,需要结构体实现Debug
特征。 #[derive(Debug)]
用derive
派生实现了Debug
特征。
当结构体较大时,此时可以使用 {:#?}
来替代 {:?}
会有更美观的格式。
还有一个简单的输出 debug 信息的方法:使用 dbg!
宏。
- 该宏会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,还有表达式的求值结果。
- 它最终还会把表达式值的所有权返回。
1 |
|
枚举
通过列举可能的成员来定义一个枚举类型,如熟悉的枚举一周:
1 | enum Weekday { |
枚举类型是一个类型,其会包含所有可能的枚举成员, 而枚举值是该类型中的具体某个成员的实例。
1 |
|
在 Rust 中,任何数据类型都可以放入到枚举中,这给枚举增加了更多功能。
1 | enum Operation { |
数组
数组:array
,长度固定,是基本类型。
动态数组:Vector
,长度可动态增长。
- 数组的长度是编译时确定的,而动态数组的长度是运行时确定的。
- 数组
array
是存储在栈上,而动态数组Vector
是存储在堆上。 Vector
和String
一样都是高级类型,即集合类型。
创建数组
1 | fn main() { |
- 数组类型通过方括号声明;
- 可以显式指定数组元素类型和长度。
- 数组的长度也是类型的一部分。
访问数组元素
数组是连续的,可以通过索引访问。
- 下标从 0 开始。
另外,如果使用索引访问元素时,编译器会在编译期预防越界情况。但如果索引是外部输入的,那么编译器并不能预防。
如果数组元素非基础类型,且出现:
1 | fn main() { |
- 那必然是报错的。
String
是具有所有权的高级类型,其并不能在数组中使用这种形式进行复制(没有深拷贝)。
而解决方法应该是调用 std::array::from_fn
:
1 | fn main() { |
数组切片
数组也允许引用集合中的部分连续片段:
1 | let a = [1, 2, 3, 4, 5]; |
- 省略主函数部分。
切片的特点:
- 切片的长度可以与数组不同,并不是固定的,取决于指定的起始和结束位置。
- 创建切片的代价非常小,因为切片只是针对底层数组的一个引用。
- 切片类型
[T]
拥有不固定的大小,而切片引用类型&[T]
则具有固定的大小,&[T]
更有用,&str
字符串切片也同理。
流程控制
if-else if
if else
表达式根据条件执行不同的代码分支:
1 | if condition == true { |
- 若
condition
的值为true
,则执行 A 代码,否则执行 B 代码。
if
语句(块)是表达式,可以返回值,但是需要保证每个分支的返回类型一样:
1 | let a = 1; |
与其它语言一样,通过 if-else if
可以处理多重条件判断。
循环控制
Rust 有三种循环控制方式:
for
循环;while
循环;loop
循环。
for 循环
简单举个例子:
1 | fn main() { |
1..=5
是一个范围,表示 从 1 到 5 的 序列。
除了数字的循环外,for-in
用法更靓眼:
1 | for 元素 in 集合 { |
使用方法 | 等价使用方式 | 所有权 |
---|---|---|
for item in collection |
for item in IntoIterator::into_iter(collection) |
转移所有权 |
for item in &collection |
for item in collection.iter() |
不可变借用 |
for item in &mut collection |
for item in collection.iter_mut() |
可变借用 |
for-in
中也可以获取元素的索引:
1 | fn main() { |
如果想单纯的循环十次,(用于)循环变量不使用,可以这样写:
1 | fn main() { |
_
的含义是忽略该值或类型的意思。
比较两种循环:
1 | // 第一种 |
- 性能比较:
- 第一种方式使用索引,会触发边界检查,导致性能损耗。
- 第二种方式在编译时就完成分析并证明访问时合法的,性能不会有损失。
- 安全性比较:
- 第一张对
collection
的索引访问是非连续的,存在一定可能性在两次访问之间collection
发生了变化,导致脏数据产生。 - 第二种直接迭代是连续访问。(由于所有权限制,访问过程中,数据不会发生变化)
- 第一张对
continue 和 break
- 使用
continue
可以跳过当次循环,开始下次循环。(在其它语言也这样吧) - 使用
break
可以直接跳出当前整个循环。
while 循环
跟 C++ 类似。
1 | fn main() { |
loop 循环
loop
就是一个简单的 无限循环,不会自动结束。
- 需要额外的
break
关键字控制循环结束。 loop
是一个表达式,可以返回值。- 在
loop
循环中,break
结束时可以带出一个返回值。
1 | fn main() { |
模式匹配
match
的魅力。
match 匹配
match
的通用形式:
1 | match target { |
match
允许将一个值与一系列的模式相比较,并根据相匹配的模式执行对应的代码。match
的分支有两个部分:一个模式和针对该模式的处理代码。- 跟
switch
很像,_
类似于default
。- 除了
_
,还可以随便用一个变量名承接即可。
- 除了
match
语句会从上往下匹配,遇到第一个匹配的就执行对应的表达式,然后结束。- 可能会出现
|
,类似于逻辑或,比如X|Y
可以匹配X
也可以匹配Y
。
举一个例子:
1 | enum Direction { |
match 表达式赋值
match
本身也是一个表达式,因此可以用来赋值:
1 | enum Direction { |
模式绑定
模式匹配还可以从模式中取出绑定的值,例如:
1 | enum Action { |
enum
中可以放入数据类型,再加上模式匹配可以从模式中取出绑定的值,所以可以实现上述代码。
穷尽匹配
match
的匹配必须穷尽所有情况,比如下述代码因为没有穷尽所有情况而报错。
1 | enum Direction { |
if let 匹配
当只要匹配一个条件,且忽略其他条件时就用 if let
。
如下面两个代码是等价的:
实现1:
1 | fn main() { |
实现2:
1 | fn main() { |
变量遮蔽
无论是 match
还是 if let
,这里都是一个新的代码块,而且这里的绑定相当于新变量,如果你使用同名变量,会发生变量遮蔽:
1 | enum Action { |
matches! 宏
matches!
宏可以将一个表达式跟模式进行匹配,然后返回匹配的结果 true
或者 false
。
如使用:
1 | fn main() { |
解构 Option
Option
是一种枚举,用于解决 Rust 中变量是否有值的问题:
1 | enum Option<T> { |
- 一个变量要么有值:Some(T),要么为空:None。
- 由于封装,可以直接使用
Some(T)
和None
,而不需要使用Option::Some(T)
和Option::None
。
使用 Option<T>
,是为了从 Some
中取出其内部的 T
值以及处理没有值的情况。
编写一个函数,它获取一个 Option<i32>
,如果其中含有一个值,将其加一;如果其中没有值,则函数返回 None
值:
1 | fn add_one(x: Option<i32>) -> Option<i32> { |
模式使用场景
用到模式的地方:
match
分支。if let
语句。while let
循环。
1 | fn main() { |
for
循环:使用特定模式匹配可迭代容器。let
语句:使用变量绑定数据也是一种模式匹配。- 函数参数也是模式。
还有关于 let
和 if let
:
1 | let Some(x) = some_option_value; |
- 因为右边的值可能不为
Some
,而是None
,这种时候就不能进行匹配。 - 对于
let
、for
、match
都要求完全覆盖匹配。
而 if let
:
1 | if let Some(x) = some_option_value { |
if let
允许匹配一种模式,忽略其余模式。
全模式列表
模式的相关语法
匹配字面值
1 | fn main() { |
- 代码获得特定的具体值。
匹配命名变量
1 | fn main() { |
- 变量遮蔽。
单分支多模式
1 | fn main() { |
- 使用
|
表示或。
通过序列 …= 匹配值范围
1 | fn main() { |
- 序列只允许用于数字或字符类型,原因是它们可以连续。
解构并分解值
使用模式来解构结构体、枚举、元组、数组和引用。
解构结构体
1 | struct Point { |
- 模式中的变量名不必与结构体中的字段名一致。
还可以匹配结构体中的某个字段:
1 | fn main() { |
解构枚举
1 | enum Op { |
- 模式匹配需要类型相同。
解构嵌套的结构体和枚举
1 | enum Color { |
match
可以匹配嵌套的项。
解构结构体和元组
1 | fn main() { |
- 用复杂的方式来混合、匹配和嵌套解构模式。
- 上述代码为结构体和元组嵌套在元组中,把原始类型解构出来。
解构数组
- 定长数组解构:
1 | fn main() { |
- 不定长数组解构:
1 | fn main() { |
忽略模式中的值
- 使用
_
忽略整个值:当不再需要特定函数参数时,最好修改签名不再包含无用的参数。
1 | fn fun(_: i32, y: i32) -> () { |
- 使用嵌套的
_
忽略部分值。
1 | fn main() { |
-
使用下划线开头忽略未使用的变量。
- 带
_
的变量仍会将值绑定到变量,而_
则完全不会绑定。
- 带
-
用
..
忽略剩余值。
1 | fn main() { |
匹配守卫提供的额外条件
匹配守卫(match guard)是一个位于 match
分支模式之后的额外 if
条件,它能为分支模式提供更进一步的匹配条件。
1 | fn main() { |
- 匹配守卫直接取得解构后的值作比较(如匹配分支1);
- 匹配守卫可以直接用外部的
y
(如匹配分支2); - 使用
|
加上匹配守卫,需要先满足前面 或 的条件再判断匹配守卫的条件(如匹配分支3),即(Some(_) | None) if y
。
@ 绑定
@
运算符允许为一个字段绑定另一个变量。
1 | enum Op { |
- 第一个匹配分支中,测试
Op::Operation
的id
字段是否位于3..=7
范围内,同时也希望能将其值绑定到mid
变量中以便此分支中相关的代码可以使用它。- 其实也可以把
mid
命名为id
,不影响、
- 其实也可以把
- 第二个匹配分支中,没有使用
@
绑定,所以不能再使用结构体中的id
。
在 Rust 1.56 时新增,使用 @
还可以在绑定新变量的同时对目标进行解构。
1 | struct Point { |
在 Rust 1.53 新增特性:
- 在 Rust 1.53 之前,需要这么写:
1 | fn main() { |
- 但是在 Rust 1.53 之后,可以这么写:
1 | fn main() { |
方法
在面向对象编程中,方法指的是对象可执行的函数。
1 | object.method(); |
定义方法
使用 impl
来定义方法。
1 | struct Rect { |
impl Rect
表示为Rect
实现方法,即impl
语句块中一切都是跟Rect
相关联的。new
是Rect
的关联函数,因为第一个参数不是self
,且new
不是关键字。area
中的参数&self
表示借用当前的Rect
结构体,
Rust 的对象定义和方法定义是分离的。
方法代替函数的好处有:
- 不用再在函数签名中书写
self
对应的类型; - 代码的组织性、内聚性更强,对于代码维护和阅读有好处。
self
self
指代类型的实例(跟Python中挺像)。
- 为哪个结构体实现方法,那么
self
就是指代哪个结构体的实例。
self
依然具有所有权的概念:
self
表示Rect
的所有权转移到该方法中,这种形式用的较少。&self
表示该方法对Rect
的不可变借用。&mut self
表示可变引用。
方法名
在 Rust 中,允许方法名跟结构体的字段名相同。
1 | struct Rect { |
- 此时,
rect.width()
表示调用方法,rect.width
表示访问字段。
方法跟字段同名,适用于 getter
访问器的实现。
->运算符?
C/C++ 中,如果对象指针调用方法时,会使用到 ->
:object->fun()
。
但在 Rust 中,会有自动引用和解引用的功能。
- 当使用
object.fun()
调用方法时,会自动为object
添加&
、&mut
或*
以便使得与方法签名匹配。 - 因为方法中明确接收
self
的类型。
带有多个参数的方法
和普通函数一样:
1 | impl Rect { |
关联函数
关联函数:定义在 impl
中且参数没有 self
的函数。
构造函数的写法:不包含 self
即可。
Rust 中有一个约定俗成的规则,使用 new
来作为构造器的名称,出于设计上的考虑,Rust 特地没有用 new
作为关键字。
1 | struct Rect { |
多个 impl 定义
Rust 允许为一个结构体定义多个 impl
块,目的是提供更多的灵活性和代码组织性。
- 例如当方法多了后,可以把相关的方法组织在同一个
impl
块中。
为枚举实现方法
枚举可以像结构体一样,实现方法。
1 |
|
泛型和特征
泛型 Generics
当出现需求:用同一功能的函数处理不同类型的数据,例如两个数的加法,无论是整数还是浮点数,甚至是自定义类型,都能进行支持。
- C++ 中的模板函数就是一种解决方法。
泛型怎么不是一种多态呢。
Rust 给出的解决方案是:
1 | fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T { |
T
就是泛型参数。std::ops::Add<Output = T
为对T
进行限制,因为不是所有的T
类型都能进行相加。
结构体中使用泛型
1 | struct Point<T> { |
- 需要提前声明泛型参数
Point<T>
。 x
和y
字段时相同的类型。
当然可以不止一个泛型参数:
1 | struct Point<T, U> { |
枚举中使用泛型
很明显,Option
中过就有一个泛型参数 T
。
1 | enum Option<T> { |
还有一个:
1 | enum Result<T, E> { |
- 这个枚举主要用于函数返回值,
Result
关注的主要是值的正确性。
方法中使用泛型
1 | struct Rect<T> { |
- 使用泛型参数前,需要提前声明,如
impl<T>
。 impl
处的Rect<T>
不再是泛型声明,而是一个完整的结构体类型。
为具体的泛型类型实现方法
把 T
换成特定的具体类型:
1 | struct Rect<T, U> { |
const 泛型
Rust 1.51 版本引入。
const 泛型是针对值的泛型。
正好可以用于处理数组长度的问题。
- 数组而言,长度也是类型的一部分。
1 | fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { |
- 定义一个类型为
[T; N]
的数组,T
是一个基于类型的泛型参数;而N
是一个基于值的泛型参数,用来代替数组的长度。
泛型的性能
在 Rust 中泛型是零成本的抽象,意味着在使用泛型时,完全不用担心性能上的问题。
- 实际上是损失了编译速度和增大了最终生成文件的大小。
Rust 通过在编译时进行泛型代码的单态化来保证效率。
- 单态化:将通用代码转换为特定代码的过程。
- 编译器的工作与创建泛型函数的步骤相反。
对于程序员而言,使用泛型可以编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。
特征 Trait
特征定义了一组可以被共享的行为:只要实现了特征,就能使用这组行为。
定义特征
定义特征:把一些方法组合在一起。
- 目的是定义一个实现某些目标所必需的行为的集合。
举个例子,在数据中有小说和日记等内容载体,希望对相应的内容进行总结。那么总结这个行为就是共享的,可以都用一个特征:
1 | pub trait Summary { |
- 使用
trait
关键字声明一个特征,Summary
是特征名。 - 大括号中定义了该特征的所有方法。
- 特征不定义行为具体是怎么样的,因此使用函数签名。
- 每一个实现这个特征的类型都需要具体实现该特征的相应方法,编译器也会确保任何实现
Summary
特征的类型都拥有与这个签名的定义完全一致的summarize
方法。
为类型实现特征
1 | pub trait Summary { |
特征定义与实现的位置
孤儿规则
上述代码中,Summary
被定义为公开的 pub
,所以只需要引入到包中,就可使用该特征。
关于特征实现与定义的位置:如果想要为类型 A
实现特征 T
,那么 A
或者 T
至少有一个是在当前作用域中定义的。
这样确保其他人编写的代码不会破坏自己的代码。
默认实现
在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法:
1 | pub trait Summary { |
还有,默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现:
1 | pub trait Summary { |
- 那么通过上述代码,
Novel
的实例可以通过调用summarize
方法间接调用了simple_summary
方法。
使用特征作为函数参数
先定义一个函数,使用特征作为函数参数:
1 | pub fn notify(item: &impl Summary) { |
impl Summary
表示实现了Summary
特征的item
参数。- 可以使用任何实现了
Summary
特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法。
特征约束
通过特征约束一些变量类型。
1 | pub fn notify<T: Summary>(item: &T) { |
T: Summary
被称为特征约束。
特征约束的表达很奇妙,比如;
1 | pub fn notify(item1: &impl Summary, item2: &impl Summary) {} |
- 函数的两个参数可以是实现了
Summary
特征的不同的类型。
1 | pub fn notify<T: SUmmary>(item1: &T, item2: &T) {} |
- 函数的两个参数都必须是实现了
Summary
特征的相同的类型。
多重约束
可以指定多个约束条件:
1 | pub fn notify(item: &(impl Summary + Send)) {} |
或
1 | pub fn notify<T: Summary + Send>(item: &T) {} |
T: Summary + Send
表示T
必须同时实现Summary
和Send
特征。
Where 约束
当特征约束变得很多时,使用 where
进行一些形式上的改进:
1 | fn fun<T, U>(t: &T, u: &U) -> i32 |
例-找最大值
方式一:使用特征约束,且使用引用方式。
1 | fn largest_1<T: PartialOrd>(list: &[T]) -> &T { |
PartialOrd
特征可以用于比较两个值。
方法二:使用特征约束,使得值具有 Copy
特征。
1 | fn largest_2<T: PartialOrd + Copy>(list: &[T]) -> T { |
特征约束有条件地实现方法或特征
特征约束,可以在指定类型 + 指定特征的条件下去实现方法:
1 | fn main() { |
- 只有同时实现了
Display
和PartialOrd
特征的类型T
,才可以调用cmp_display
方法。
函数返回中的 impl Trait
可以通过 impl Trait
来说明一个函数返回了一个类型,该类型实现了某个特征:
1 | fn ret_summary() -> impl Summary { |
Novel
实现了Summary
特征,所以可以用它作为返回值。ret_summary
返回一个实现了Summary
特征的类型,但不知道具体什么类型。
可能在数据类型十分复杂,不知道怎么声明,就可以使用这种返回类型。如闭包和迭代器的类型就是很复杂。
但是这种返回值只能有一种具体的类型,不能模棱两可。
- 即一个分支下返回实现了某特征的 A 类型,而另一个分支又返回实现了某特征的 B 类型。这种情况是拒绝的。
通过 derive 派生特征
形如 #[derive(Debug)]
的代码,是一种特征派生语法。
derive
派生出来的是 Rust 默认提供的特征。
更多见派生特征。
调用方法需要引入特征
如果要使用一个特征的方法,那么需要将该特征引入当前的作用域中。
Rust 把最常用的标准库中的特征通过 std::prelude
模块提前引入到当前作用域中。
综合例子
- 自定义类型实现加法操作。
- 自定义类型实现打印输出。
1 | use std::{fmt::Display, ops::Add}; |