2024秋冬季操作系统训练营-基础阶段(一)
2024秋冬季操作系统训练营-系列:
Rust 编程
变量与可变性
变量
在 Rust 中 我们可以使用 let
声明变量。
例如let x = 5;
当变量不可变时,一旦值被绑定一个名称上,你就不能改变这个值。尽管变量默认是不可变的,可以在变量名前添加 mut
来使其可变
1 | fn main() { |
常量
不允许对常量使用 mut
。常量不光默认不可变,它总是不可变。声明常量使用 const
关键字而不是 let
,并且 必须 注明值的类型。Rust 对常量的命名约定是在单词之间使用全大写加下划线。
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
隐藏
可以定义一个与之前变量同名的新变量,称之为第一个变量被第二个 隐藏(Shadowing) 了。
1 | fn main() { |
这个程序首先将 x
绑定到值 5
上。接着通过let x =
创建了一个新变量 x
,获取初始值并加 1
,这样 x
的值就变成 6
了。然后,在使用花括号创建的内部作用域内,第三个 let
语句也隐藏了 x
并创建了一个新的变量,将之前的值乘以 2
,x
得到的值是 12
。当该作用域结束时,内部 shadowing
的作用域也结束了,x
又返回到 6
。
mut
与隐藏的另一个区别是,当再次使用 let
时,实际上创建了一个新变量,我们可以改变值的类型,并且复用这个名字。
数据类型
标量类型
标量(scalar)类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。
整型
长度 | 有符号 | 无符号 |
---|---|---|
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch | isize | usize |
isize
和 usize
类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。
浮点型
Rust 有两个原生的** 浮点数**(floating-point numbers)类型,它们是带小数点的数字。Rust 的浮点数类型是 f32
和 f64
布尔型
Rust 中的布尔类型有两个可能的值:true
和 false
。Rust 中的布尔类型使用 bool
表示。
字符型
Rust 的 char
类型是语言中最原生的字母类型。
1 | fn main() { |
Rust 的 char
类型的大小为四个字节 (four bytes),并代表了一个 Unicode 标量值(Unicode Scalar Value)
用单引号声明 char
字面量,而与之相反的是,使用双引号声明字符串字面量。
Rust 的 char 类型的大小为四个字节 (four bytes),并代表了一个 Unicode 标量值(Unicode Scalar Value)
复合类型
复合类型(Compound types)可以将多个值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。
元组类型
元组是一个将多个其他类型的值组合进一个复合类型的主要方式。元组长度固定:一旦声明,其长度不会增大或缩小。使用包含在圆括号中的逗号分隔的值列表来创建一个元组。元组中的每一个位置都有一个类型,而且这些不同值的类型也不必是相同的。
1 | fn main() { |
为了从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值
1 | fn main() { |
也可以使用点号(.)后跟值的索引来直接访问它们。
1 | fn main() { |
不带任何值的元组有个特殊的名称,叫做 单元(unit) 元组。这种值以及对应的类型都写作 (),表示空值或空的返回类型。如果表达式不返回任何其他值,则会隐式返回单元值。
数组类型
数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,Rust 中的数组长度是固定的。
1 | fn main() { |
可以像这样编写数组的类型:在方括号中包含每个元素的类型,后跟分号,再后跟数组元素的数量。
1 | let a: [i32; 5] = [1, 2, 3, 4, 5]; |
i32
是每个元素的类型。分号之后,数字 5
表明该数组包含五个元素。
可以通过在方括号中指定初始值加分号再加元素个数的方式来创建一个每个元素都为相同值的数组
1 | let a = [3; 5]; |
变量名为 a
的数组将包含 5 个元素,这些元素的值最初都将被设置为 3。
函数
Rust 代码中的函数和变量名使用 snake case 规范风格。在 snake case 中,所有字母都是小写并使用下划线分隔单词。在 Rust 中通过输入 fn 后面跟着函数名和一对圆括号来定义函数。大括号告诉编译器哪里是函数体的开始和结尾。
参数
可以定义为拥有 参数(parameters)的函数,参数是特殊变量,是函数签名的一部分。当函数拥有参数(形参)时,可以为这些参数提供具体的值(实参)。
1 | fn main() { |
语句和表达式
语句(Statements)是执行一些操作但不返回值的指令。 表达式(Expressions)计算并产生一个值。
用大括号创建的一个新的块作用域也是一个表达式,表达式的结尾没有分号。如果在表达式的结尾加上分号,它就变成了语句,而语句不会返回值。
1 | fn main() { |
具有返回值的函数
函数可以向调用它的代码返回值。我们并不对返回值命名,但要在箭头(->
)后声明它的类型。在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。使用 return 关键字和指定值,可从函数中提前返回;但大部分函数隐式的返回最后的表达式。
1 | fn five() -> i32 { |
five
函数没有参数并定义了返回值类型,不过函数体只有单单一个 5 也没有分号,因为这是一个表达式,我们想要返回它的值。
1 | fn main() { |
但如果在包含 x + 1 的行尾加上一个分号,把它从表达式变成语句,我们将看到一个错误。
1 | error[E0308]: mismatched types |
函数 plus_one
的定义说明它要返回一个 i32
类型的值,不过语句并不会返回值,使用单位类型 () 表示不返回值。因为不返回值与函数定义相矛盾,从而出现一个错误。
控制流
if 表达式
if
表达式允许根据条件执行不同的代码分支。你提供一个条件并表示 “如果条件满足,运行这段代码;如果条件不满足,不运行这段代码。
1 | fn main() { |
因为 if 是一个表达式,我们可以在 let 语句的右侧使用它
1 | fn main() { |
循环
Rust 有三种循环:loop
、while
和 for
loop
loop
关键字告诉 Rust 一遍又一遍地执行一段代码直到你明确要求停止。
1 | fn main() { |
loop
的一个用例是重试可能会失败的操作,比如检查线程是否完成了任务。然而你可能会需要将操作的结果传递给其它的代码。如果将返回值加入你用来停止循环的 break
表达式,它会被停止的循环返回
1 | fn main() { |
如果存在嵌套循环,break
和 continue
应用于此时最内层的循环。你可以选择在一个循环上指定一个 循环标签(loop label),然后将标签与 break
或 continue
一起使用,使这些关键字应用于已标记的循环而不是最内层的循环。
1 | fn main() { |
while条件循环
当条件为 true
,执行循环。当条件不再为 true
,调用 break
停止循环。
1 | fn main() { |
for
可以使用 for
循环来对一个集合的每个元素执行一些代码。
1 | fn main() { |
所有权
什么是所有权
所有权(ownership)是 Rust 用于如何管理内存的一组规则。所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。
所有权有以下规则:
- Rust 中的每一个值都有一个 所有者(owner)。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
变量作用域
作用域是一个项(item)在程序中有效的范围。假设有这样一个变量:
let s = "hello";
变量 s 绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。这个变量从声明的点开始直到当前 作用域 结束时都是有效的。
1 | { // s 在这里无效,它尚未声明 |
为了演示所有权规则,我们介绍一种更复杂的数据类型。
String类型
前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速简单地复制它们来创建一个新的独立实例。通过存储在堆上的数据来探索 Rust 是如何知道该在何时清理数据的。
字符串字面值,即被硬编码进程序里的字符串值。字符串字面值是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道。
1 | fn main(){ |
内存与分配
就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。所以对于 String
类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这要求:
- 必须在运行时向内存分配器(memory allocator)请求内存。
- 需要一个当我们处理完
String
时将内存返回给分配器的方法。
第一点由我们完成:当调用 String::from
时,它的实现 (implementation) 请求其所需的内存。这在编程语言中是非常通用的。
对于第二点,在有 垃圾回收(garbage collector,GC)的语言中,GC 记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有 GC 的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。例如
1 | { |
变量与数据交互的方式
移动
在 Rust 中,多个变量可以采取不同的方式与同一数据进行交互。
1 | let s1 = String::from("hello"); |
这段代码看起来会生成一个 s1
的拷贝并绑定到 s2
上。但实际上不是如此。
String
由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。
当我们将 s1
赋值给 s2
,String
的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。
如果 Rust 也拷贝了堆上的数据,那么操作 s2 = s1
在堆上数据比较大的时候会对运行时性能造成非常大的影响。
当变量离开作用域后,Rust 自动调用 drop
函数并清理变量的堆内存。当 s2
和 s1
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误,是内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。为了确保内存安全,在 let s2 = s1;
之后,Rust 认为 s1
不再有效,因此 Rust 不需要在 s1
离开作用域后清理任何东西。所以当你在 let s2 = s1;
之后,尝试调用 s1
时将会报错。
1 | fn main() { |
克隆
如果我们 确实 需要深度复制 String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。
1 | fn main() { |
你可能会疑惑,为什么以下代码没有调用 clone
,却也能正常运行
1 | fn main() { |
原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y
后使 x
无效。
Rust 有一个叫做 Copy trait
的特殊注解,可以用在类似整型这样的存储在栈上的类型上。如果一个类型实现了 Copy trait
,那么一个旧的变量在将其赋值给其他变量后仍然可用。
哪些类型实现了 Copy trait
呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:
- 所有整数类型,比如
u32
。 - 布尔类型,
bool
。 - 所有浮点数类型,比如
f64
。 - 字符类型,
char
。 - 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,
(i32, i32)
实现了 Copy,但(i32, String)
就没有。
所有权与函数
将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。
1 | fn main() { |
返回值与作用域
返回值也可以转移所有权。
1 | fn main() { |
变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop
被清理掉,除非数据被移动为另一个变量所有。可以使用元组来返回多个值。
1 | fn main() { |
引用与借用
引用(reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。
1 | fn main() { |
注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1
给 calculate_length
,同时在函数定义中,我们获取 &String
而不是 String
。这些 &
符号就是 引用,它们允许你使用值但不获取其所有权。
&s1
语法让我们创建一个 指向 值 s1
的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。同理,函数签名使用 &
来表明参数 s
的类型是一个引用。
变量 s
有效的作用域与函数参数的作用域一样,不过当 s
停止使用时并不丢弃引用指向的数据,因为 s
并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。
我们将创建一个引用的行为称为 借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。
可变引用
为了允许修改一个引用的值,可以采用 可变引用(mutable reference)
1 | fn main() { |
必须将 s
改为 mut
。然后在调用 change
函数的地方创建一个可变引用 &mut s
,并更新函数签名以接受一个可变引用 some_string: &mut String
。这就非常清楚地表明,change
函数将改变它所借用的值。可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能 同时 拥有,我们 也 不能在拥有不可变引用的同时拥有可变引用。
1 | let mut s = String::from("hello"); |
注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。
1 | let mut s = String::from("hello"); |
悬垂引用
在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。
1 | fn main() { |
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String
,解决方法是直接返回 String
。
Slice 类型
slice
允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice
是一种引用,所以它没有所有权。
字符串 Slice
**字符串 slice**
(string slice)是 String 中一部分值的引用
1 | let s = String::from("hello world"); |
不同于整个 String
的引用,hello
是一个部分 String
的引用,由一个额外的 [0..5]
部分指定。可以使用一个由中括号中的 [starting_index..ending_index]
指定的 range
创建一个 slice
,其中 starting_index
是 slice
的第一个位置,ending_index
则是 slice
最后一个位置的后一个值。在其内部,slice
的数据结构存储了 slice
的开始位置和长度,长度对应于 ending_index
减去 starting_index
的值。所以对于 let world = &s[6..11];
的情况,world
将是一个包含指向 s
索引 6
的指针和长度值 5
的 slice
。
对于 Rust 的 .. range
语法,如果想要从索引 0
开始,可以不写两个点号之前的值。依此类推,如果 slice
包含 String 的最后一个字节,也可以舍弃尾部的数字。也可以同时舍弃这两个值来获取整个字符串的 slice
。
让我们重写 first_word
来返回一个 slice
。“字符串 slice
” 的类型声明写作 &str
:
1 | fn first_word(s: &String) -> &str { |
字符串字面值就是 Slice
s
的类型是 &str
:它是一个指向二进制程序特定位置的 slice
。这也就是为什么字符串字面值是不可变的;&str
是一个不可变引用。
1 | let s = "Hello, world!"; |
字符串 slice 作为参数
如果有一个字符串 slice
,可以直接传递它。如果有一个 String
,则可以传递整个 String
的 slice
或对 String
的引用。这种灵活性利用了 deref coercions
的优势。定义一个获取字符串 slice
而不是 String
引用的函数使得我们的 API 更加通用并且不会丢失任何功能。
1 | fn main() { |