- 原文链接: https://chowyi.com/Hello-Rust/
- 版权声明: 文章采用 CC BY-NC-SA 4.0 协议进行授权,转载请注明出处!
近来网上冲浪时,在 Github trending 上越来越多的看到一些 Rust 项目,也常看到一些项目的简介写着 “这是一款使用 Rust 开发的 xxx”。这令我不禁好奇,使用某种编程语言开发也能成为一个软件值得一提的特点吗?不过这确实引起了我的兴趣,简单的搜索一下,Rust 以其高性能和内存安全著称,软件作者通过强调使用 Rust,传达其软件在性能和安全性上的优势。
这两年的工作陷入了理不清的需求和讨论不完的产品方案之中,很久没有那种“长脑子”的感觉了。现在有点时间不如来学学 Rust,就算是赶下时髦(也许没赶上)?
边学边记
我找到了 Rust 程序设计语言 简体中文版,现在快速的学习一下 Rust 吧。
Tips: 这不是教程。我已经非常熟悉 Golang 和 Python,这里仅记录我学习过程中认为比较独特的地方。如果你也熟悉 Golang,可以参考我的笔记来快速了解 Rust。
安装
我使用 VsCode on MacOS,在 Terminal 使用命令安装:
1 | curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh |
基本概念
变量
使用let
定义变量,变量默认是不可变的,使用mut
关键字设置变量的可变性。
1 | let x = 5; |
常量
使用const
来定义常量。
1 | const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3; |
遮蔽(shadowing)
能看懂这段代码就理解遮蔽了。
1 | fn main() { |
数据类型
- 标量类型
- 整型
- 浮点型
- 布尔型
- 字符
- 复合类型
- 元组 tuple
- 数组 array
数组的长度是固定的,更灵活的类型是 vector(什么是 vector,后面学习)。
函数
在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。使用 return 关键字和指定值,可从函数中提前返回;但大部分函数隐式的返回最后的表达式。
要注意表达式和语句的概念。
两个有代表性的例子:
1 | fn plus_one(x: i32) -> i32 { |
控制流
if 表达式
1 | fn main() { |
注意的是代码中的条件必须是 bool 值,下面的写法会编译失败。
1 | fn main() { |
if 是表达式,不是语句,所以可以把 if 表达式的结果复制给变量。
1 | let condition = true; |
loop
loop 可以有返回值。
break 后可以添加返回值。
1 | fn main() { |
循环标签:如果存在嵌套的循环,可以用通过标签来指定break或continue哪一层循环。(我不喜欢这个特性,有点像其他语言中的 goto)
while
没什么特别的。
1 | fn main() { |
for
for 可以用来遍历集合中的元素。
还可以遍历范围表达式(range expression)。
1 | fn main() { |
(心声: for in 和 enumerate 有点像 Python [:thinking_face:])
所有权
一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;
在另一些语言中,程序员必须亲自分配和释放内存。
Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序的运行。
原来 Rust 没有垃圾回收啊。
所有权的主要目的就是管理堆数据。
所有权规则
1. Rust 中的每一个值都有一个 所有者(owner)。
2. 值在任一时刻有且只有一个所有者。
3. 当所有者离开作用域,这个值将被丢弃。
String 类型
就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。
String 类型存储在堆上,可以存储编译时未知大小的文本。
1 | { |
当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop,在这里 String 的作者可以放置释放内存的代码。Rust 在结尾的
}
处自动调用 drop。
这段代码不能运行:
1 | let s1 = String::from("hello"); |
s1
和 s2
都是栈上的指针,指向了堆上实际的数据。当 s2
和 s1
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误。
为了确保内存安全,在 let s2 = s1
; 之后,Rust 认为 s1
不再有效,因此 Rust 不需要在 s1
离开作用域后清理任何东西。只有 s2
是有效的,当其离开作用域,它就释放自己的内存。
这里的赋值类似浅拷贝,但不是!这里叫做**移动(move)**。
当你给一个已有的变量赋一个全新的值时,Rust 将会立即调用 drop 并释放原始值的内存。
1 | let mut s = String::from("hello"); |
只在栈上的数据赋值:拷贝。
如果一个类型实现了 Copy
trait,那么一个旧的变量在将其赋值给其他变量后仍然有效。
Rust 不允许自身或其任何部分实现了 Drop
trait 的类型使用 Copy
trait。
所有权与函数
在调用函数传参时,变量的所有权进入了函数内部,函数外将不能再使用。
可以通过返回值再带回所有权,但太麻烦了。可以使用引用(references)来解决这个问题。
1 | fn main() { |
引用与借用
1 | fn main() { |
&
是引用,*
是解引用。
我们将创建一个引用的行为称为借用(borrowing)。
可变引用
借用来的引用变量是(默认)不允许修改的。
除非显示的声明:
1 | fn main() { |
可变引用职能创建一个。创建了一个可变引用后,就不能再创建这个对象的引用,无论是可变的还是不可变的。(心声:类似加了写锁[:thinking_face:])
这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能同时拥有:
1 | let mut s = String::from("hello"); |
注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。
1 | let mut s = String::from("hello"); |
Slice 类型
String Slice
1 | let s = String::from("hello world"); |
(心声: 看起来和 Golang 中的 Slice 类似 [:thinking_face:])
“字符串 slice” 的类型声明写作 &str
1 | // 返回空格分隔的字符串中的第一个单词 |
1 | fn main() { |
[:thinking_face:] Golang 中类似的问题是什么情况?
1 | func main() { |
字符串字面值就是 Slice
对于上面的示例,函数签名fn first_word(s: &str) -> &str {
比fn first_word(s: &String) -> &str {
更加通用。
其他类型的 slice
1 | let a = [1, 2, 3, 4, 5]; |
这个 slice 的类型是 &[i32]
。
结构体
定义结构体
1 | struct User { |
创建结构体
1 | fn build_user(email: String, username: String) -> User { |
结构体的更新语法
1 | fn main() { |
(心声:更像 JavaScript 了, 注意这里是两个点号..
)
使用结构体更新语法和=
赋值一样,也发生了数据的移动。总体上说我们在创建 user2 后就不能再使用 user1 了,因为 user1 的 username 字段中的 String 被移到 user2 中。具体到字段来看,user1.email
可以继续使用,因为未被移动。active
和sign_in_count
也未被移动,因为它们是实现Copy
trait 的类型。
元组结构体
1 | struct Color(i32, i32, i32); |
类单元结构体(unit-like structs)
类单元结构体常常在你想要在某个类型上实现 trait 但不需要在类型中存储数据的时候发挥作用。
(心声:类似 Golang 的空结构体?[:thinking_face:])
1 | struct AlwaysEqual; |
结构体方法
在impl
块中定义方法,方法的第一个参数是self
。
(心声: 又和 Python 有点像了。)
1 | struct Rectangle { |
方法的参数&self
实际是self: &Self
的缩写。在一个 impl
块中,Self
类型是 impl
块的类型的别名。
方法名可以和字段名相同,比如可以为Rectangle
添加一个width()
方法。使用时,带括号时调用的方法,不带括号时则是值字段。
关联函数
在 impl
块中定义,但不以 self
为第一个参数的函数被称为关联函数,常被用作构造函数。我们已经使用了一个这样的函数:在 String
类型上定义的 String::from
函数。
多个 impl 块
每个结构体都允许拥有多个 impl
块。可以把方法分散在多个块中,但没必要。
枚举
定义枚举
1 | enum IpAddrKind { |
枚举类型可以嵌入值
1 | enum IpAddr { |
可以内嵌多种类型:
1 | enum Message { |
枚举上还可以定义方法:
1 | impl Message { |
(心声:和我熟悉的任何一门编程语言中的枚举都不一样,竟然可以嵌入值,还能定义方法?![:thinking_face:])
Option
Option
是标准库中定义的枚举,可以用来表示空值。
1 | enum Option<T> { |
(心声:暂时对 Rust 中的枚举还不是很理解,之后再来补充吧! [:thinking_face:])
match
分支的返回值即整个 match
的返回值。
在 Rust 中 match 必须显式的匹配所有的可能分支。other
类似于 Golang 中的 default
,_
表示忽略绑定的值。
match
本身比较简单,但要注意理解绑定值的模式和匹配 Option<T>
的用法。
if let 和 let else
可以认为是 match
的语法糖,用来写出更简洁的代码。(心声:但我认为并不好理解。也许熟悉后能体会到好处吧。)
包和Crate
包(package)是提供一系列功能的一个或者多个 crate 的捆绑。一个包会包含一个 Cargo.toml 文件。
crate 是 Rust 在编译时最小的代码单位。crate 有两种形式:二进制 crate 和库 crate。
模块用来给代码分组,在 crate 中可以层层划分模块和子模块。
书中这部分篇幅较大,举了很多例子,要耐心学习,要注意理解:
- 引用模块的路径规则(绝对路径、相对路径、super)
- 模块、函数、结构体字段、枚举的私有性
- use 的用法和惯用规则
- 使用 use 将函数引入作用域,而不是函数本身。
- 使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。
- pub use 重导出
- 使用嵌套路径合并相同路径前缀的 use
- glob 运算符,即
use std:collections::*
这样的用法(小心使用!)。
注意你只需在模块树中的某处使用一次 mod 声明就可以加载这个文件。一旦编译器知道了这个文件是项目的一部分(并且通过 mod 语句的位置知道了代码在模块树中的位置),项目中的其他文件应该使用其所声明的位置的路径来引用那个文件的代码,这在“引用模块项目的路径”部分有讲到。换句话说,mod 不是你可能会在其他编程语言中看到的 “include” 操作。
(心声:怎么理解 mod 不同于其他语言中的“include”呢? [:thinking_face:])
集合 collections
集合指向的数据是储存在堆上的,这意味着数据的数量不必在编译时就已知,并且还可以随着程序的运行增长或缩小。
向量 vector
它在内存中彼此相邻地排列所有的值。vector 只能储存相同类型的值。
使用索引或get
方法获取 vector 中的值,超出索引范围时,前者会 panic,后者会返回 None 枚举。
1 | let v = vec![1, 2, 3, 4, 5]; |
当 vector 中的某一个元素的引用被借出后,整个 vector 都将不能被改变。(底层原因是因为: vector 扩容时,可能会重新分配内存。)
1 | let mut v = vec![1, 2, 3, 4, 5]; |
使用 for in 可以遍历或修改 vector 中的元素,但不能在循环中插入或删除元素,因为 for 本身获取了 vector 的引用。
1 | let mut v = vec![100, 32, 57]; |
vector 中的元素必须是同一类型的,但使用枚举可以绕过这一限制。
(心声:那怎么分配内存啊? [:thinking_face:])
1 | enum SpreadsheetCell { |
当 vector 被丢弃时,所有其内容也会被丢弃。
(心声:vector 和 slice 有什么区别? [:thinking_face:])
字符串 string
slice str
是 Rust 核心语言中的类型,String
是 Rust 标准库中定义的类型。String
是对字节 vector 的特殊封装。
以下两种方式都可以创建String
,效果完全一样:
1 | let s1 = String::from("hello"); |
拼接字符串的几种方式:
1 | // push_str |
String
不能使用索引访问其中的字符。
因为 String
是 Vec<u8>
的封装,按字节存储 UTF-8 编码的数据。UTF-8 是一种可变长编码。使用索引不确保能访问到有效的字符,所以 Rust 不允许这样做。并且String
也不能保证索引操作预期的 O(1) 时间。
str
slice 允许使用索引访问其中的字节,但如果访问的索引截断了字符的字节,则会 panic。
1 | let hello = "Здравствуйте"; // 这些字母都是两个字节长度 |
遍历字符串
1 | fn main() { |
hash map
所有的键必须是相同类型,值也必须都是相同类型。
用法:
1 | use std::collections::HashMap; |
默认使用一种叫做 SipHash 的哈希函数,这是一种安全但不是最快的算法,可以自己指定哈希函数。
错误处理
类似于 Golang 中的函数的返回值经常会有一个 error,Rust 使用 Result
枚举来返回成功的值或 error。
可以使用 match 来匹配不同的错误。
使用 expect 来快捷处理 panic
1 | use std::fs::File; |
使用 ? 运算符来快捷的传播错误:
1 | use std::fs::File; |
还可以链式调用:
1 | use std::fs::File; |
有点像 JavaScript。
?
运算符只能被用于返回值与?
作用的值相兼容的函数。
?
也可用于Option<T>
值。
注意你可以在返回 Result 的函数中对 Result 使用 ? 运算符,可以在返回 Option 的函数中对 Option 使用 ? 运算符,但是不可以混合搭配。? 运算符不会自动将 Result 转化为 Option,反之亦然;在这些情况下,可以使用类似 Result 的 ok 方法或者 Option 的 ok_or 方法来显式转换。
泛型
函数中的泛型
1 | // 注意这里必须限制 T 实现了 std::cmp::PartialOrd 函数体中才能比大小 |
结构体中的泛型
1 | struct Point<T> { |
枚举中的泛型:
1 | enum Option<T> { |
使用泛型不会带来运行时的消耗。在编译时编译器会生成针对具体类型的代码,这一过程叫做泛型代码的单态化(monomorphization)。
trait
trait 翻译过来时“特征”的意思,类似于其他语言中的 interface。
trait 中可以定义默认方法,默认方法还能调用暂未实现的其他方法(这点想在 Golang 实现就比较复杂了)。
1 | pub trait Summary { |
trait 作为参数
两种写法效果一样,下面一种被称为 trait bound 语法。
1 | pub fn notify(item: &impl Summary) { |
可以指定多个 trait bound
1 | pub fn notify(item: &(impl Summary + Display)) { |
为了避免函数签名太长难以阅读,可以使用 where
从句:
1 | fn some_function<T, U>(t: &T, u: &U) -> i32 |
在返回值中使用 trait。返回单一的类型是没问题,但因为编译器的限制,不能返回多个类型。
1 | fn returns_summarizable() -> impl Summary { |
使用 trait bound 可以有条件地只为那些实现了特定 trait 的类型实现方法。
生命周期
编译器无法判断出返回的引用的生命周期,就需要要显示的添加注解。
1 | // 编译报错 |
生命周期省略的三条规则:
- 编译器为每一个引用参数都分配一个生命周期参数。
- 如果只有一个输入生命周期参数,那么将它赋予给所有输出生命周期参数
- 如果方法有多个输入生命周期参数并且其中一个参数是 &self 或 &mut self,说明这是个方法,那么所有输出生命周期参数被赋予 self 的生命周期。
自动化测试
(先跳过这一章,优先学习语言特性)
构建命令行程序
跟着书中的内容动手敲一下代码,有很多暂时不理解为什么这样写的地方。主要是对函数的定义,参数和返回值什么时候用引用,什么时候不用一时反应不过来。
闭包
闭包就是可以捕获环境中变量的“匿名函数”。例如,这个例子中,变量x
被捕获在了闭包add_x
中。
1 | let x = 5; |
闭包的语法:
1 | fn add_one_v1 (x: u32) -> u32 { x + 1 } // 函数 |
闭包中的类型注解通常是可以省略的,编译器可以自行推断。
使用move
来强制闭包获取它使用值的所有权。
闭包有3种Fn
trait,编译器会根据闭包的具体逻辑在自动的实现一个、两个或全部 trait。
FnOnce
适用于只能被调用一次的闭包。所有闭包至少都实现了这个 trait,因为所有闭包都能被调用。一个会将捕获的值从闭包体中移出的闭包只会实现 FnOnce trait,而不会实现其他 Fn 相关的 trait,因为它只能被调用一次。FnMut
适用于不会将捕获的值移出闭包体,但可能会修改捕获值的闭包。这类闭包可以被调用多次。Fn
适用于既不将捕获的值移出闭包体,也不修改捕获值的闭包,同时也包括不从环境中捕获任何值的闭包。这类闭包可以被多次调用而不会改变其环境,这在会多次并发调用闭包的场景中十分重要。
迭代器
实现了 Iterator
trait 的对象就是使用 .iter()
来获取它的迭代器。
iter()
方法生成的是不可变引用的迭代器。iter_mut()
方法生成的是可变引用的迭代器。into_iter()
方法生成的是可以获取所有权的迭代器。
迭代器可以链式调用,且是惰性的,直到调用消费适配器方法。
1 | fn main() { |
map()
接收实现了FnMut
trait的函数或闭包。上面的map中的闭包也可以改成函数。(试着写一个 addOne 函数,作为初学者我没写出来)。
想想参数为什么是这样的?
1 | fn add_one(x: &i32) -> i32 { |
- 原文链接: https://chowyi.com/Hello-Rust/
- 版权声明: 文章采用 CC BY-NC-SA 4.0 协议进行授权,转载请注明出处!