本文是《Rust in action》学习总结系列的第二部分,更多内容请看已发布文章:
“
主要介绍 Rust 的语法、基本类型和数据结构,通过实现一个简单版 grep 命令行工具,来理解 Rust 独有的特性。
1. 编译单文件
编译器负责将源代码编译成机器码,使其成为可运行的程序,Rust 的编译器是 rustc,下面是一个最简单的 Rust 源代码:
fn main() { println!("ok") }
如果想通过 rustc 直接编译单个文件,需要满足以下要求:
-
文件必须包括一个 main() 函数
-
在命令行执行
rustc 文件名
对单文件进行编译
对于大型 Rust 项目文件,使用 cargo 进行管理,如果想观察 rustc 的编译过程,只需要添加 -v 参数。
接下来通过简单的示例理解函数和变量的使用:
fn main() { let a = 10; //<1> let b: i32 = 20; //<2> let c = 30i32; //<3> let d = 30_i32; //<4> let e = add(add(a, b), add(c, d)); println!("( a + b ) + ( c + d ) = {}", e); } fn add(i: i32, j: i32) -> i32 { //<5> i + j //<6> }
-
类型可以由编译器推断
-
也可以在声明变量的时候指定类型
-
可以直接在数值后面指定类型注解 30i32
-
数值后面可以添加下划线,增加可读性,对功能没影响
-
定义函数的时候需要指定参数和返回值的类型
-
函数默认返回最后一个表达式的值,所以不用写 return 语句
⚠️ 注意:如果在 add 函数的 i + j 之后添加 ; 将会改变语义,使得函数返回空值()而不是 i32 类型。
第一行, fn 关键字表示函数定义的开始,Rust 程序的入口是 main 函数,该函数不接受参数,也没有返回值,随后的代码块用花括号进行标识。
第二行,使用 let 关键字声明变量绑定,默认情况下,变量是不可修改的,这是和其他编程语言不同的地方,每条语句通过分号 ; 标识结束。
第三行,通过变量后的 : i32 指定变量类型,当不希望使用编译器推导的数据类型时非常有用。
第四行,Rust 中的数值可以包含类型注解,同时允许在数字后面使用下划线。
第六行,调用了函数,和其他语言的类似。
第八行,println!() 是一个宏,有点像函数,只是返回代码(code)而不是值,每种数据类型都有对应的转为字符串的方法,println!() 负责调用对应的方法。在 Rust 中,单引号和双引号的含义是不同的,双引号表示字符串,单引号表示字符。此外,Rust 使用 {} 表示占位符,而不是 C 语言中的 %s 等。
第十一行,定义函数,和其他使用显式类型声明的编程语言类似,都好分割参数,变量名后面是数据类型,-> 后面是返回值的类型。
2. 数字类型
- 整数和小数(浮点数)
Rust 使用相对传统的方式定义整数和小数,操作数字使用算数符号。为了实现不同类型的运算,Rust 支持运算符重载。和其他语言不同的方面主要表现在:
-
Rust 的数字类型非常多,通常以字节为单位来声明变量能存储值的范围以及能否表示负数。
-
不同类型互转需要明确指定类型,Rust 不会自动将 16 位整数转换为 32 位。
-
Rust 的数字可以有方法,例如,求 24.5 四舍五入后的值,使用 24.5_f32.round(),而不是 round(24.5_f32),这种调用方式必须要指定类型后缀。
以下是一个示例:
fn main() { let twenty = 20; let twenty_one: i32 = 21; let twenty_two = 22i32; let addition = twenty + twenty_one + twenty_two; println!("{} + {} + {} = {}", twenty, twenty_one, twenty_two, addition); let one_million: i64 = 1_000_000; //<1> println!("{}", one_million.pow(2)); //<2> let forty_twos = [ //<3> 42.0, //<4> 42f32, //<5> 42.0_f32, //<6> ]; println!("{:02}", forty_twos[0]); //<7> }
-
下划线能增加可读性,编译器会忽略
-
数字类型有可调用函数
-
创建数字数组,这些数组有相同的类型,用方括号包围
-
没有明确注解类型的浮点数可能是 32 位或 64 位,取决于上下文
-
浮点数也有类型后缀
-
也可以添加下划线
-
数组元素可以通过下标索引,从 0 开始
- 数字的二进制、八进制、十六进制转换
Rust 内置了对数字的支持,可以分别定义二进制、八进制、十六进制的值,在格式化宏 println! 中也是可用的。以下是一个示例:
fn main() { let three = 0b11; //<1> let thirty = 0o36; //<2> let three_hundred = 0x12C; //<3> println!("base 10: {} {} {}", three, thirty, three_hundred); println!("base 2: {:b} {:b} {:b}", three, thirty, three_hundred); println!("base 8: {:o} {:o} {:o}", three, thirty, three_hundred); println!("base 16: {:x} {:x} {:x}", three, thirty, three_hundred); }
-
“0b” 前缀表示二进制数字
-
“0o” 前缀表示八进制数字
-
“0x” 前缀表示十六进制数字
Rust 的数字类型:
Rust 的数字类型可以分成以下几类:
-
有符号的整数(i)代表负整数和正整数
-
无符号整数(u)只表示正整数,最大能表示比有符号整数大一倍的数字
-
浮点整数(f)表示实数,具有正无穷、负无穷和 “非数字” 三个特殊值
整数宽度是指该类型在 RAM 和 CPU 中使用的 bits 数,占用更多空间的类型,例如,和 i8 相比,u32 能表示更大的数字,但也会浪费额外的存储空间。
数字类型支持大量比较操作,和其他编程语言类似:
在 Rust 中,不支持直接对不同类型的数字进行比较,需要进行类型转换。以下是一个例子:b as i32
fn main() { let a: i32 = 10; let b: u16 = 100; if a < (b as i32) { println!("Ten is less than one hundred."); } }
最安全的做法是将占用内存空间较小的类型转换为较大的类型(例如:将 16 位类型转换为 32 位类型),也可以将 u32 类型转换为 u16 类型,但这种转换存在风险。
⚠️ 注意:类型转换使用错误会导致程序结果不正确,例如,300_i32 作为 i8 类型时值是 44。
使用 as 关键词进行类型转换存在很多限制,也可以使用 Rust 函数进行转换:
use std::convert::TryInto; // <1> fn main() { let a: i32 = 10; let b: u16 = 100; if a < b.try_into().unwrap() { // <2> println!("Ten is less than one hundred."); } }
-
将 try_into() 函数添加在 u16 类型
-
b.try_into() 返回一个 i32 类型的值,try_into()会在转换出错的时候返回错误信息。(细节在下一章)
- 浮点危害
对浮点数类型(f32 和 f64)进行比较是一个特别的情况,有两点原因:
-
浮点数通常近似于它们所代表的数字,因为浮点类型是以基数 2 来实现的,但我们经常以基数 10 来进行计算,产生了不匹配。
-
浮点数能表示非直观语义的值,与整数不同,浮点类型的很多值不能很好地和其他类型一起运算,形式上,只有部分等价关系。
⚠️ 注意:无法用二进制表示浮点数。
在计算机中,浮点数的是通过二进制数学运算实现的,但是通常表示的是十进制的值,这就导致了问题,比如 0.1 并不能直接用二进制表示。Rust 的目标是可用性,以下代码编译和运行不会出错:
fn main() { assert!(0.1 + 0.2 == 0.3); }
⚠️ 注意:当表达式的值不是 true 时,assert! 宏将会使进程退出。
Rust 有容忍机制,允许浮点数之间进行比较,这些机制定义在 f32::EPSILON 和 f64::EPSILON 中。更准确地说,可以更使浮点数比较更接近 Rust 内部工作方式。Rust 编译器将浮点数的比较工作委托给 CPU,浮点运算实际上是在硬件中实现的。
fn main() { let result: f32 = 0.1 + 0.1; let desired: f32 = 0.2; let absolute_difference = (desired - result).abs(); assert!(absolute_difference <= f32::EPSILON); }
数学上未定义的比较结果:
浮点类型包括 “非数字”值(通常表示为 NaN,在 Rust 中表示为 NAN),这些值表示未定义的数字运算的结果,例如对一个负数进行平方根运算。根据定义,两个 NAN 值绝不相等。以下是示例:
fn main() { assert_eq!(f32::NAN == f32::NAN, false); }
- 有理数、复数和其他数字类型
Rust 标准库相对来说精简,没有其他语言中经常用到的类型,例如:
-
处理有理数和复数的数学对象
-
任意大小的整数和浮点数,用于表示大数和小数
-
处理货币的定点小数
要访问特定的数字类型,需要格式化数字 create,Creates 是扩展标准库的可安装包。以下是示例代码,解释了(1)两个复杂数字如何相加,(2.1 + −1.2i) + (11.1 + 22.22i),输出结果为 13.2 + 21i,(2)新的命令,介绍了两种初始化非原始数据类型的方法,一是 Rust 语言提供的语法,二是 new() 静态函数,为了使用方便,很多数字类型都实现了这个方法。静态方法是某个类型的可用方法,但不是类型的实例。(3)如何访问第三方 create(num),调用类型的 new() 方法,使用 . 操作符访问字段。
use num::complex::Complex; //<1> fn main() { let a = Complex { re: 2.1, im: -1.2 }; //<2> let b = Complex::new(11.1, 22.2); //<3> let result = a + b; println!("{} + {}i", result.re, result.im) //<4> }
-
use 关键字将 create 导入到当前文件范围,命名空间操作符(::)限制了包含的内容,只需要类型:Complex
-
类型不需要构造函数,使用类型名称(Complex)并在大括号 { } 内给它们的字段(re, im)赋值(2.1, -1.2)即可初始化类型
-
为了简化,许多语言的类型实现了 new()方法,Rust 语言没有这个约定
-
num::complex::Complex 类型有两个字段:re 代表实部,im 代表虚部,都可以通过点操作符 . 访问
用 cargo 向项目添加第三方依赖关系的快捷方式:
# 添加cargo-edit命令 cargo install cargo-edit # 通过命令添加依赖 cargo add regex
会显示第三方依赖拥有的 features:
3. 迭代器
迭代器遍历集合中的元素,集合中的元素数量可能有无限多个,以下是基本语法:
for item in collection { // ... }
以下是示例代码,在数字数组中搜索一个数字(needle),代码中 haystack 前的 & 是一个单数运算符,返回对数组 haystack 的引用,对于任意类型 T,&T 返回对 T 的只读引用。对数组引用的特点是可以通过 for 循环遍历数组中元素的引用。在 Rust 中,使用 &T 表示 借用 T。
fn main() { let needle = 0o52; let haystack = [1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862]; for item in &haystack { // <1> if *item == needle { // <2> println!("{}", item); } } }
-
迭代 haystack 数组中的元素引用,每次迭代会改变 item 的值,使其指向下一个元素
-
_*_item 取消对 item 的引用,返回引用对应空间的值(和 C 语言类似),在第一次迭代中,item 返回 1,在最后一次迭代中,它返回 4862
- 创建支持循环的迭代器
以下示例代码允许通过数组的引用创建数组迭代器,具体来说,&haystack 可以对 haystack 中的元素进行迭代,但不是所有类型(例如:自定义类型)都支持这种用法,还可以使用 haystack.iter() 返回迭代器遍历元素。
fn main() { let needle = 0xCB; let haystack = [1, 1, 2, 5, 15, 52, 203, 877, 4140, 21147]; for item in haystack.iter() { if *item == needle { println!("{}", item); } } }
许多类型还提供了 iter_mut() 和 into_iter() 函数, iter_mut() 函数允许在遍历的时候修改值,into_iter() 函数和 iter() 类似,但不是使用引用而是直接返回对应元素的值。以下是一个例子:
fn main() { let needle = 0o204; let haystack = vec![1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862, 16796]; for item in haystack.into_iter() { if item == needle { // <1> println!("{}", item); } } }
- 不需要对 item 取消引用,因为已经是对应的值
“
iter() 和 into_iter() 的语法差异隐藏着一个微妙的语义差异,iter() 借用了 haystack,而 into_iter() 则取得了 haystack 的所有权。
4. 控制流
- for:迭代的核心
基本语法如下:
for item in container { // ... }
引用:局部变量 item 的作用域是 for 代码块,在 for 代码块之外访问是不允许(编译不会通过)的。
如果希望在程序结束之后继续使用 container,需要使用引用。当没有添加引用时,Rust 认为 container 不再需要,使用 & 前缀为 container 添加引用。
for item in &container { // ... }
匿名循环:如果在循环中不需要使用局部变量,可以使用 _(下划线),将这种模式和左包含右不包含范围(n..m)与左右包含范围(n..=m)结合起来使用,可以看出,目的是为了执行固定次数的循环。
for _ in 0..10 { // ... }
避免使用索引变量:在很多编程语言中,可以通过临时变量(i)进行循环迭代,Rust 版本:
let collection = [1, 2, 3, 4, 5]; for i in 0..collection.len() { let item = collection[i]; // ... }
这是合法的 Rust 语法,在不能直接对 collection 进行迭代(for item in collection)的情况下有效,但不推荐这样,有两点问题:
(1)性能:使用 collection[index] 语法进行索引会因为检查边界值而带来额外的开销,Rust 会检查 index 是否有效,直接对 collection 进行迭代不存在这个问题,编译器会分析并证明。
(2)安全:多次访问 collention 可能出现值被修改的情况,直接对 collection 进行迭代时,Rust 会保证 collection 不被其他进程修改。
- continue:跳出当前迭代的剩余部分
Continue 关键字和其他编程语言中的类似,以下是示例:
for n in 0..10 { if n % 2 == 0 { continue; } // ... }
- while:循环直到某个条件改变状态
只要条件成立,while 循环会持续执行,条件可以是任何值为 true 或 false 的 bool 表达式。以下示例代码会持续采集空气质量样本数据(take_sample 函数),避免出现异常情况:
let mut samples = vec![]; while samples.len() < 10 { let sample = take_sample(); if is_outlier(sample) { continue; } samples.push(sample); }
使用 while,当达到某个期限时停止迭代:以下是一个 while 的实例,其中,while 会持续执行,直到达到 time_limit 时间期限。
use std::time::{Duration, Instant}; //<1> fn main() { let mut count = 0; let time_limit = Duration::new(1,0); //<2> let start = Instant::now(); //<3> while (Instant::now() - start) < time_limit { //<4> count += 1; } println!("{}", count); }
-
将 std::time 中的 Duration 和 Instant 类型导入本地文件范围
-
创建代表 1 秒的 Duration
-
从系统时钟中获取时间
-
两个时刻相减得到持续时间
避免在死循环中使用 while:在 Rust 中,更好地表示死循环的方法是 loop 关键字。
- loop:Rust 中循环的基础
Rust 中的 loop 关键字能提供比 while 和 for 更多的控制功能,loop 循环不会终止,直到 break 或从函数外部终止。
loop { // ... }
loop 通常用于实现需要长期运行的服务:
loop { let requester, request = accept_request(); let result = process_request(request); send_response(requester, result); }
- break:终止一个循环
和其它编程语言一样,break 用于跳出一个循环。
for (x, y) in (0..).zip(0..) { if x + y > 100 { break; } // ... }
跳出嵌套循环:可以通过循环标签跳出嵌套循环,循环标签使用 ‘ 前缀表示,示例如下:
'outer: for x in 0.. { for y in 0.. { for z in 0.. { if x + y + z > 1000 { break 'outer; } // ... } } }
Rust 没有 goto(提供跳转的能力)关键字,goto 关键字会使控制流变得混乱,不推荐使用,但是,当函数执行出错需要清理的时候非常有用,使用 loop 关键字启用这种模式。
- if,if else 和 else :条件判断
以下是使用 if 关键字判断数字大小的例子:
if item == 42 { // ... }
if 的条件可以是任何结果为 bool 类型的表达式,其他编程语言允许使用 0 和 空字符串 表示 false,非 0 和 非空字符串 表示 true,但是,Rust 中不允许这样使用,代表“真”的值只能是 true,代表“假”的值只能是 false。
if item == 42 { // ... } else if item == 132 { // ... } else { // ... }
条件判断的返回值:Rust 条件判断有一个和其他语言不同的地方,Rust 是基于表达式的语言,这种语言的特点是所有表达式都会返回值,从而可以使用以下两种模式:(1)简洁的辅助函数,(2)从条件表达式的返回值直接进行变量赋值。
以下是辅助函数的示例:
fn is_even(a: i32) -> bool { if a % 2 == 0 { true } else { false } }
Rust 允许将条件表达式的返回值作为变量使用,以下是示例:
let description = if is_even(123456) { "even" } else { "odd" };
- match:自动判断类型的模式匹配
在使用 if/else 的代码块中,使用 match 代替更加安全,如果没有考虑到相关的代替方案,match 将会警告开发者,以下是示例:
match item { 0 => {}, // <1> 10 ..= 20 => {}, // <2> 40 | 80 => {}, // <3> _ => {}, // <4> }
-
匹配单独的值,只需提供对应值,不需要操作符
-
n..=m 语法匹配包含的范围,= 表示包括右边界
-
竖线 | 匹配列出的任意一个值
-
下划线 _ 匹配每个值,如果缺少这项(类似 switch 的 default)编译过程会报错
Rust 的 match 关键字和其他语言中的 switch 关键字类似,但也有不同的地方,match 保证一个类型的所有可能的选项都被明确处理,在匹配成功后不会默认匹配下一个选项(C 语言中的 switch 在匹配成功后会默认匹配下一个选项,如果都不匹配则是 default),而是立即返回。以下是示例:
fn main() { let haystack = [1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862]; for item in &haystack { let result = match item { // <1> 42 | 132 => "hit!", // <2> _ => "miss", // <3> }; if result == "hit!" { println!("{}: {}", item, result); } } }
-
匹配表达式返回一个可以被绑定到变量上的值
-
42 | 132 能匹配 42 或 132
-
通配符表达式,匹配所有
match 关键字在 Rust 语言中扮演着重要角色,许多控制结构,如循环,都是基于 match 实现的。
5. 函数定义
在前面已经使用了简单的函数,代码如下:
fn add(i: i32, j: i32) -> i32 { // <1> i + j }
- add 函数接收两个整数类型参数,绑定到本地变量 i 和 j,返回一个整数
Rust 的函数定义语法:
Rust 在定义函数的时候需要指定参数的类型和函数返回值的类型。
6. 项目:渲染 Mandelbrot 集
使用以下命令创建一个可以渲染 Mandelbrot 集的项目。
cargo new mandelbrot --vcs none cd mandelbrot cargo add num
代码如下:
use num::complex::Complex; // <1> fn calculate_mandelbrot( // <2> max_iters: usize, // <3> x_min: f64, // <4> x_max: f64, // <4> y_min: f64, // <4> y_max: f64, // <4> width: usize, // <5> height: usize, // <5> ) -> Vec<Vec<usize>> { let mut rows: Vec<_> = Vec::with_capacity(width); // <6> for img_y in 0..height { // <7> let mut row: Vec<usize> = Vec::with_capacity(height); for img_x in 0..width { let x_percent = img_x as f64 / width as f64; let y_percent = img_y as f64 / height as f64; let cx = x_min + (x_max - x_min) * x_percent; // <8> let cy = y_min + (y_max - y_min) * y_percent; // <8> let escaped_at = mandelbrot_at_point(cx, cy, max_iters); // <9> row.push(escaped_at); } rows.push(row); } rows } // add a line break to the following (55 max char limit) fn mandelbrot_at_point( cx: f64, cy: f64, max_iters: usize, ) -> usize { // <10> let mut z = Complex { re: 0.0, im: 0.0 }; // <11> let c = Complex::new(cx, cy); // <12> for i in 0..=max_iters { if z.norm() > 2.0 { // <13> return i; } z = z * z + c; // <14> } max_iters // <15> } fn render_mandelbrot(escape_vals: Vec<Vec<usize>>) { for row in escape_vals { let mut line = String::with_capacity(row.len()); for column in row { let val = match column { 0..=2 => ' ', 3..=5 => '.', 6..=10 => '•', 11..=30 => '*', 31..=100 => '+', 101..=200 => 'x', 201..=400 => '$', 401..=700 => '#', _ => '%', }; line.push(val); } println!("{}", line); } } fn main() { let mandelbrot = calculate_mandelbrot(1000, -2.0, 1.0, -1.0, 1.0, 100, 24); render_mandelbrot(mandelbrot); }
-
从 num creat 的子模块中导入 Complex 数字类型
-
定义函数 calculate_mandelbrot,在输出空间(行和列组成的网格)和包含 Mandelbrot 集的范围(靠近(0,0)的连续区域)之间进行转换
-
如果一个值在达到最大迭代次数之前没有逃逸,那么它就被认为是在 Mandelbrot 集之内
-
指定我们要搜索空间的参数
-
表示输出尺寸的参数,单位是像素
-
创建一个容器来存放每一行的数据,with_capacity(width) 指定数组的容量,当数组的实际长度大于容量时会自动扩容
-
逐行迭代,逐行打印输出
-
计算输出所覆盖的空间的比例,并将其转换为搜索空间内的点
-
cx 和 cy 是复数的实部和虚部
-
在每个像素点上调用的函数(例如,将每一行和每一列打印到 stdout)
-
在原点初始化一个复数,实部(re)和虚部(im)为 0.0
-
从函数参数提供的坐标初始化一个复数
-
检查逃逸条件并计算离原点(0,0)的距离,复数的绝对值
-
反复计算 z,检查 c 是否在 Mandelbrot 集内
-
由于 i 已经不在范围内,回到 max_iters
运行结果如下:
7. 高级函数定义
- 明确的生命周期注释
Rust 中有一些复杂的符号,看起来很难理解,以下是示例:
// following needs line break (55 char limit) add_with_lifetimes<'a, 'b>(i: &'a i32, j: &'b i32) -> i32 // <1>
- <‘a, ‘b> 表示在 add_with_lifetimes() 函数范围内引入生命周期 ‘a’ 和 ‘b’,&’a i32 读作 “对生命周期为 a 的 i32 的引用”
通常,这些额外信息为 Rust 编译器提供了函数外部数据的信息。使用引用(类型前面的 & 符号表示)的函数存在函数作用域之外的数据。Rust 想知道这些被引用的数据是否应该比函数的生命周期更长,还是在函数返回时被清除。
检查来自函数外部的对象,是为了确保在整个函数访问这些对象是有效的。也就是说,Rust 进行检查以确保所有输入数据的生命周期至少与函数的生命周期一样长。
Rust 安全检查的基础是一个生命周期系统,验证所有尝试访问数据的操作都是有效的。生命周期系统通常是独立工作的,尽管每个参数都有检查,但通常是不可见的,由编译器自动推断。在复杂的情况下,编译器需要来自开发者的帮助,例如当多个引用被接受为参数时,或者当从函数返回一个引用时。
当需要添加生命周期参数时,在函数名称和参数列表之间的尖括号(<, >)内,标签(如 a 和 b)任意指定,并且是函数的局部变量,使用不同的标签可以使得两个参数的生命周期不同,Rust 并没要求一定这样做。
上面的示例代码中,i: &’a i32 读作 i 是对 i32 的引用,生命周期为 a,而 j: &’b i32 读作 j 是对 i32 的引用,生命周期为 b 。在函数定义阶段,尖括号中引入的生命周期参数在这里被使用。
调用函数时,不需要生命周期注释。以下是完整示例:
fn add_with_lifetimes<'a, 'b>(i: &'a i32, j: &'b i32) -> i32 { *i + *j // <1> } fn main() { let a = 10; let b = 20; let res = add_with_lifetimes(&a, &b); // <2> println!("{}", res); }
-
将 i 和 i 引用的值相加而不是直接将引用相加
-
&10 和 &20 分别表示引用 10 和引用 20,调用函数时不需要生命周期注释
在第 2 行,*i + *j 将 i 和 j 变量所引用的值相加。在使用引用时,通常会看到生命周期参数。虽然在其它情况下,Rust 可以自动推断生命周期,但引用的生命周期需要程序员指定。使用两个参数(’a 和 ‘b)表示 i 和 j 的生命周期不关联。
8. 通用函数
当处理多种可能的输入类型时,需要另一种特殊的函数语法。以下是示例,可以传递不同类型的参数,只需要保证两个参数的类型相同,并且返回相同类型的值。
fn add<T>(i: T, j: T) -> T { // <1> i + j }
- 类型变量 T 是用尖括号()引入的,这个函数接收两个相同类型的参数,并返回一个该类型的值
大写字母代替 “类型”表示一个通用类型。通常,变量 T、U 和 V 被用作占位值,E 常被用来表示错误类型。
直接编译以上代码会出错,Rust 编译器提示不能将两个任意类型 T 的值相加。
出现这个问题的原因是 T 代表任意类型,可能是不支持相加操作的类型。
如何规定类型 T 必须实现加法运算呢?需要一些新的术语。
Rust 中的所有运算符,包括加法,都是在特征中定义的,为了要求类型 T 必须支持加法运算,在函数定义的时候包括一个特征绑定操作。
// please add line break to following (55 char limit) fn add<T: std::ops::Add<Output = T>>(i: T, j: T) -> T { // <1> i + j }
- <T:std::ops::Add<Output = T>> 要求类型 T 必须实现 Add 运算,并且产生的输出必须是相同的类型。
特征是类似于接口、协议、契约的语言特征,如果有面向对象编程经验,可以将特征理解为抽象类。如果有面向函数编程经验,可以理解为 Haskell 的 type class。在 Rust 中,特征使得类型可以表明通用行为,Rust 中的所有运算都是通过特征定义的。例如,加法运算定义为 std::ops::Add 特征。
Rust 的所有操作符都是特征方法的包装,通过这种方式实现运算符重载,在编译过程中,a+b 被转换为 a.add(b)。
以下是示例,解释通用的函数可以被不同类型调用:
use std::ops::{Add}; // <1> use std::time::{Duration}; // <2> fn add<T: Add<Output = T>>(i: T, j: T) -> T { // <3> i + j } fn main() { let floats = add(1.2, 3.4); // <4> let ints = add(10, 20); // <5> let durations = add(Duration::new(5, 0), Duration::new(10, 0)); // <6> // above line needs a line break (55 char max) println!("{}", floats); println!("{}", ints); println!("{:?}", durations); // <7> }
-
从 std::ops 导入 Add 特征到本地
-
从 std::time 导入 Duration 类型到本地
-
Add()的参数可以接受任意实现 std::ops::Add 的类型
-
传入浮点数值调用 add() 方法
-
传入整数值调用 add() 方法
-
传入 Duraton 值调用 add() 方法,代表两个时间点之间的持续时间
-
由于 std::time::Duration 没有实现 std::fmt::Display 特征,我们可以退而求其次调用 std::fmt::Debug 方法
函数签名可以变得复杂,理解这些需要耐心,有一些规则可以加快阅读 Rust 代码:
-
小写的术语(i, j)表示变量
-
单个大写字母(T)表示通用类型变量
-
以大写字母(Add)开头的术语是特征或具体类型,如 String 或 Duration
-
标签(’a)表示生命周期参数
9. 创建轻量级 grep
已经基本了解 Rust 如何处理数字,接下来会了解 Rust 如何处理 text 文本。
以下示例是轻量级 grep 的第一个版本:
fn main() { let search_term = "picture"; let quote = "/ Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; // <1> for line in quote.lines() { // <2> if line.contains(search_term) { println!("{}", line); } } }
-
多行字符串不需要特殊语法,第 4 行的 / 字符转义到新的一行
-
lines() 返回一个 quote 的迭代器,每个迭代都是一行文本,换行符和操作系统使用的一致
执行结果:
Rust 的 String 可以完成很多操作,示例代码中值得强调的功能:
-
第 7 行 quote.lines() 演示了以独立于平台的方式进行逐行迭代
-
第 8 行 line.contains() 演示了使用函数语法搜索文本的过程
“
对于刚接触 Rust 的人来说,字符串是很复杂的。实现的细节往往难以理解。计算机如何表示文本是很复杂的,而 Rust 选择暴露其中的一些复杂性,这使得程序员能够完全控制这些文本,也确实给语言学习者带来了负担。
String 和 &str 都表示文本,但又是不同的类型。在对两种类型完全理解之前,将数据转换为 String 类型通常会避免某些问题的出现。
String 可能是最接近其他编程语言的字符串类型,支持熟悉的操作,如字符串连接、追加、删除空白字符等。
str 是一个高性能、功能相对较少的类型。创建后,str 的值不能再扩展或收缩。在这个意义上,类似于与原始内存数组交互,不同的是,Rust 保证 str 的值是有效的 UTF-8 字符。
str 通常以这种形式出现:&str(读作 “字符串切片”),是一个小类型,它包含对 str 数据的引用和数据长度。试图将变量赋值给 str 类型将会失败,Rust 编译器希望在函数的栈空间内创建固定大小的变量。由于 str 值的长度可以是任意的,只能通过引用来存储为局部变量。
如果有其他编程语言经验可以很容易想到,String 使用动态内存分配来存储它所代表的文本,创建 &str 值避免内存分配。
String 是所有权类型。在 Rust 中,所有权有特殊的含义,所有者能够对数据进行任何修改,并且在离开作用域时负责删除拥有的值。&str 是借用类型,意味着 &str 可以被认为是只读数据,而 String 是可读/可写数据。
字符串(例如 “Rust in Action”)的类型是 &str,包括生命周期参数的完整类型是 &’static str。’static 生命周期有点特殊,名字和实现细节有关,可执行程序可以包含一段硬编码的内存值,称为静态内存,在执行期间是只读的。
其他类型:
(1)char:单个字符,编码为 4 个字节。char 的内部表示相当于 UCS-4/UTF-32,这与 &str 和 String 不同,后者将单个字符编码为 UTF-8。类型转换确实会带来问题,由于 char 的宽度是固定的,编译器更容易推理,编码为 UTF-8 的字符可以是 1-4 个字节。
(2)[u8]:原始 byte 的切片,通常在处理二进制数据流时使用。
(3)Vec:原始 byte 的向量,通常在使用 [u8] 数据时创建。String 对应 Vec,str 对应 [u8]。
(4)std::ffi::OSString:平台原生的字符串,行为接近于 String,但不能保证被编码为 UTF-8,也不能保证不包含零字节(0x00)。
(5)std::path::Path:专门用于处理文件系统路径的字符串类型。
接下来,为轻量级 grep 增加功能,打印行号和匹配的内容。这相当于 POSIX.1-2008 标准中 grep 工具的 -n 选项。
fn main() { let search_term = "picture"; let quote = "/ Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; let mut line_num: usize = 1; // <1> for line in quote.lines() { if line.contains(search_term) { println!("{}: {}", line_num, line); // <2> } line_num += 1; // <3> } }
-
通过 let mut 声明 line_num 为可修改变量,初始化为 1
-
更新 println!() 宏打印两个值 line_num 和 line
-
增加 line_num 的值
以下是实现这个目标的另一个方法:
fn main() { let search_term = "picture"; let quote = "/ Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; for (i, line) in quote.lines().enumerate() { // <1> if line.contains(search_term) { let line_num = i + 1; // <2> println!("{}: {}", line_num, line); } } }
-
lines() 返回一个迭代器,因此可以与 enumerate()一起使用
-
执行加法计算行号,避免了每一步都计算
grep 另一个非常有用的功能是打印匹配行之前和之后的上下文。在 GNU grep 实现中是 -C NUM 参数,为了实现这个功能,需要创建列表。
10. 通过数组、切片和向量创建列表
列表的使用是非常普遍的,最常使用的列表类型是数组和向量。数组长度固定,非常轻量,向量长度可变,但需要额外操作,会增加性能开销。继续完善轻量级 grep 的功能,打印匹配行的上下文,这需要用到向量(Vector),在这之前,先学习下两种更简单的列表类型:数组和切片。
- 数组
在数组中(至少在 Rust 中是这样),每个元素的类型相同,可以修改数组中的元素,但不能改变数组的长度,可变长度类型(例如 String)会增加复杂性。
创建数组的方式有两种,(1)以逗号分隔的列表,例如,[1,2,3](2)内容相同的表达式(一般用于初始化数组),传入两个以逗号分隔的值,分别是值和长度。例如,[0; 100])表示值为 0,长度为 100 的数组。
以下是示例代码:
fn main() { let one = [1, 2, 3]; // <1> let two: [u8; 3] = [1, 2, 3]; // <2> let blank1 = [0; 3]; // <3> let blank2: [u8; 3] = [0; 3]; // <4> let arrays = [one, two, blank1, blank2]; for a in &arrays { // <5> print!("{:?}: ", a); for n in a.iter() { // <6> print!("/t{} + 10 = {}", n, n+10); } let mut sum = 0; for i in 0..a.len() { sum += a[i]; // <7> } println!("/t(Σ{:?} = {})", a, sum); } }
-
[1, 2, 3] 表示数组,元素类型由 Rust 推导
-
[u8; 3] 指定元素类型为 u8,数组长度为 3
-
[0; 3] 是重复表达式,0 被重复 3 次
-
重复表达式也支持指定元素类型
-
使用 & 引用数组会返回切片,支持迭代,不需要调用 iter()方法
-
数组也有 iter()方法
-
使用下标索引数组,Rust 会进行边界检查
在计算机底层,数组是一种简单的数据结构,由一段连续的内存块组成,元素类型相同。虽然简单,也有些需要注意的地方:
(1)不同类型容易混淆。[T; n] 描述了一个数组的类型,其中 T 是元素类型,n 是一个非负整数,[f32; 12] 表示包含 12 个 32 位浮点数的数组。这很容易和切片 [T] 混淆,切片没有指定长度。
(2)[u8; 3] 和 [u8; 4] 是不同的类型。也就是说,数组的大小会影响类型。
(3)在实践中,大多数与数组的交互都是通过另一种叫做切片([T])的类型,切片本身通过引用(&[T])进行交互。切片和对切片的引用都称为切片。
- 切片
切片是动态长度且类似数组的对象。动态长度意味着在编译时是不知道长度的,和数组一样,这些对象的长度并不会变化,更贴切的词是“动态类型”。编译时是否知道长度是数组([T; n])和切片([T])之间的区别。
切片很重要,为切片实现特征比数组更容易。特征是 Rust 开发者为对象添加函数的方式。由于 [T; 1], [T; 2], …, [T; n] 是不同的类型,为数组实现特征会变得很麻烦。从数组中创建切片很容易,因为不需要指定长度。
切片的另一个重要用途是作为数组(和其它切片)的视图(view),视图是数据库的术语,意味着切片可以获得快速的只读数据访问而不需要内存拷贝。
Rust 希望知道程序中每个对象的大小,切片在编译时没有大小,这通过引用来解决。程序运行期间,切片在内存中的大小是固定的,由两个 usize 组件(指针和长度)构成,这就是为什么我们经常看到以引用的方式使用切片,即&[T](和字符串切片 &str 类似)。
- 向量
向量(Vec)的长度是可变的,和数组相比,向量的性能稍微差一些,因为需要管理长度变化,但向量的灵活性在很多场景下非常有用。
目前的任务是对轻量级 grep 显示匹配行的上下 n 行,有很多方法可以实现。为了减小代码的复杂性,对数组的字符串遍历两次,第一次标记匹配的行,第二次显示匹配行前后 n 行的内容。
以下是示例代码,其中部分内容可能令人困惑,如 15 行的 Vec<Vec<(usize, String)>>,这是一个向量的向量,类似 Vec<Vec
fn main() { let context_lines = 2; // 打印匹配行前后的行数 let needle = "oo"; let haystack = "/ Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; let mut tags: Vec<usize> = Vec::new(); // <1> // following line needs a line break (55 chars max) let mut ctx: Vec<Vec<(usize, String)>> = Vec::new(); // <2> for (i, line) in haystack.lines().enumerate() { // <3> if line.contains(needle) { tags.push(i); // following line needs a line break (55 chars max) let v = Vec::with_capacity(2*context_lines + 1); // <4> ctx.push(v); } } if tags.empty() { // <5> return; } for (i, line) in haystack.lines().enumerate() { // <6> for (j, tag) in tags.iter().enumerate() { // following line needs a line break (55 chars max) let lower_bound = tag.saturating_sub(context_lines); // <7> let upper_bound = tag + context_lines; if (i >= lower_bound) && (i <= upper_bound) { let line_as_string = String::from(line); // <8> let local_ctx = (i, line_as_string); ctx[j].push(local_ctx); } } } for local_ctx in ctx.iter() { for &(i, ref line) in local_ctx.iter() { // <9> let line_num = i + 1; println!("{}: {}", line_num, line); } } }
-
tags 存储匹配的行号
-
ctx 是一个向量,包含每个匹配行的上下 n 行
-
迭代每行,记录匹配的行号
-
Vec::with_capacity(m) 指定向量的初始长度为 m,不需要指定类型,可通过 ctx 推断
-
如果没有被匹配行,直接退出
-
对于匹配的行,遍历每行,检查是否在被匹配行的上下 n 行范围,如果在,把该行的行号和内容添加到 ctx 对应的 Vec中
-
usize.saturating_sub() 是一种减法,在整数下溢出时返回 0,而不是让程序崩溃
-
将某行内容复制到新的字符串中,并存储在局部变量中
-
ref 通知编译器借用这个值,而不是移动它。详细信息会在后面章节讲解。
如果可以通过 Vec::with_capacity() 提供向量长度提示,可以减少从操作系统分配内存的次数,从而提升 Vec的性能。
⚠️ 注意:在实际处理文本文件的时候,可能会因为编码而出现问题,String 保证编码是 UTF-8,如果检测到无效字节将会出错,更保险的方法是读取为[u8](u8 值的分片)再进行解码处理。
11. 引入第三方代码
Rust 标准库还缺少一些其他编程语言提供的内容,如随机数生成器和正则表达式。接下来,通过正则表达式 regex create 来进一步完善轻量级 grep 程序。
Crates 是 Rust 社区使用的名称,类似其他编程语言的 package、distribution 或 library 等术语,regex 提供了匹配正则表达式的能力。
使用 cargo 新建项目:
cargo new grep-lite cd grep-lite tree
修改 Cargo.toml 文件,添加 regex 为项目 dependency:
[package] name = "grep-lite" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] regex = "1.6.0"
编译项目,此时 cargo 会自动下载依赖的 regex create 并编译:
cargo build
接下来,为代码中添加正则表达式相关内容。
- 添加正则表达式支持
正则表达式使得搜索更加灵活,以下是不完善的示例代码:
fn main() { let search_term = "picture"; let quote = "Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; for line in quote.lines() { if line.contains(search_term) { // <1> println!("{}", line); } } }
- 执行 contains() 方法搜索子字符串。
确保 grep-lite/Cargo.toml 中包含 regex 依赖,修改 grep-lite/src/main.rs 代码:
use regex::Regex; // <1> fn main() { let re = Regex::new("picture").unwrap(); // <2> let quote = "Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; for line in quote.lines() { let contains_substring = re.find(line); match contains_substring { // <3> Some(_) => println!("{}", line), // <4> None => (), // <5> } } }
-
将 regex crate 中的 Regex 类型导入本地文件
-
新建正则规则,匹配包含字符串 “picture”的内容,unwrap() 解压结果,如果发生错误进程会崩溃
-
用 match 代替上面代码中的 contains()方法,需要处理所有可能的情况
-
Some(T) 是 Option 的肯定情况,意味着 re.find() 是成功的,_ 匹配所有的值
-
None 是 Option 的否定情况,() 在这里可以认为是一个空的占位符
通过 cargo 命令运行项目:
cargo run
通过正则表达式(未使用复杂的正则表达式),程序输出了包含 “picture” 的行:
- 在本地生成第三方 create 帮助文档
第三方 create 的帮助文档可以在网上找到,也可以在本地生成,以便在网络出现故障的时候使用。在项目根目录执行以下命令:
cargo doc
此时,已经生成了本地 HTML 文档,可以直接在浏览器打开 ./target/doc/grep_lite/index.html 文件,也可以通过命令 cargo doc --open
直接打开浏览器。可以看到所有依赖 create 的本地文档。
- 通过 rustup 管理工具链
rustup 是另一个实用的命令行工具。cargo 管理项目,rustup 管理 Rust 环境的安装,rustup 关注 Rust 工具链,能够在不同版本的编译器之间转换。这使得开发者可以在多个平台上编译项目,试验编译器的新功能,同时保留稳定版本。
rustup 还简化了对 Rust 文档的访问,输入 rustup doc
可以打开浏览器访问 Rust 标准库的本地文档。
12. 支持命令行参数
轻量级 grep 的功能越来越多,有些选项不适合通过硬编码的方式指定,为了提高可用性,需要提供交互功能。
遗憾的是,Rust 对标准库的要求很严,和正则表达式一样,处理命令行的参数也不被支持,需要引入第三方库。clap 是一个相当不错的提供 API 的第三方 create,通过在 Cargo.toml 文件添加 clap 依赖来导入:
[package] name = "grep-lite" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] regex = "1.6.0" clap = "3.2.17"
修改 src/main.rs 的内容:
use regex::Regex; use clap::{App,Arg}; // <1> fn main() { let args = App::new("grep-lite") // <2> .version("0.1") .about("searches for patterns") .arg(Arg::with_name("pattern") .help("The pattern to search for") .takes_value(true) .required(true)) .arg(Arg::with_name("test") .help("A test argument") .takes_value(true) .required(false)) .get_matches(); let pattern = args.value_of("pattern").unwrap(); // <3> let re = Regex::new(pattern).unwrap(); if let Some(test) = args.value_of("test") { // <4> println!("test: {:?}", test); } let quote = "Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; for line in quote.lines() { match re.find(line) { Some(_) => println!("{}", line), None => (), } } }
-
导入 clap::App 和 clap::Arg 对象到本地文件
-
构建命令行参数解析器,每个参数需要一个 .arg() 函数
-
提取模式匹配参数
-
判断是否传递 test 参数,如果传递则打印
由于设置了 required(true)
,此时直接通过 cargo run
运行程序会提出传入参数:
为了传递参数,cargo 支持一些特殊的语法。出现在 — 后面的参数会被传递给编译成功的二进制文件:
Clap 不光解析参数,还会自动生成程序的帮助信息。使用如下命令查看:
./target/debug/grep-lite -h
13. 从文件读取内容
如果不能对文件内容进行匹配,轻量级 grep 是不完美的。文件读取/写入比较复杂,通用的读取文件模式是打开一个 File 对象,然后将其包裹在 BufReader 中,BufReader 负责提供 I/O 缓冲区,在硬盘拥塞的情况下减少系统调用。以下是一个独立的用于读取文件的示例:
use std::fs::File; use std::io::BufReader; use std::io::prelude::*; fn main() { let f = File::open("readme.md").unwrap(); // <1> let mut reader = BufReader::new(f); let mut line = String::new(); // <2> loop { // <3> let len = reader.read_line(&mut line).unwrap(); // <4> if len == 0 { break } println!("{} ({} bytes long)", line, len); line.truncate(0); // <5> } }
-
创建文件对象,需要传递文件路径参数,并在文件不存在时进行错误处理。如果编译后的二进制文件所在目录不存在 readme.md 文件,该程序会崩溃
-
重复使用字符串对象
-
循环,直到遇到 return、break 或 panic
-
由于从磁盘上读取数据可能会失败,需要明确地处理这个错误。在例子中,错误会使程序崩溃(unwarp() 函数)
-
将字符串长度设置为 0 ,防止 line 的内容在下个循环仍然可用
手动迭代文件的每一行是很麻烦的,即使在某些情况下很有用。对于迭代文件行这种常用操作,Rust 提供了辅助迭代器。以下是示例:
use std::fs::File; use std::io::BufReader; use std::io::prelude::*; fn main() { let f = File::open("readme.md").unwrap(); let reader = BufReader::new(f); for line_ in reader.lines() { // <1> let line = line_.unwrap(); // <2> println!("{} ({} bytes long)", line, line.len()); } }
-
这里发生了微妙的行为变化,BufReader::lines() 删除了每一行尾部的换行符
-
和手动迭代一样,每行都需要处理可能出现的错误(使用 umwarp() 函数)
将读取文件的功能添加到轻量级 gerp 程序中,以下是完整代码:
use std::fs::File; use std::io::BufReader; use std::io::prelude::*; use regex::Regex; use clap::{App,Arg}; fn main() { let args = App::new("grep-lite") .version("0.1") .about("searches for patterns") .arg(Arg::with_name("pattern") .help("The pattern to search for") .takes_value(true) .required(true)) .arg(Arg::with_name("input") .help("File to search") .takes_value(true) .required(true)) .get_matches(); let pattern = args.value_of("pattern").unwrap(); let re = Regex::new(pattern).unwrap(); let input = args.value_of("input").unwrap(); let f = File::open(input).unwrap(); let reader = BufReader::new(f); for line_ in reader.lines() { let line = line_.unwrap(); match re.find(&line) { // <1> Some(_) => println!("{}", line), None => (), } } }
- line 是字符串,但是 re.find() 需要 &str 作为参数
此时,运行编译好的 grep-lite 程序需要传递两个参数,pattern 和 input,input 是文件的路径。
创建测试文件(如果使用 cargo 命令,文件路径以运行cargo run
时所在的目录为参考,如果直接运行 target/debug/grep-lite,以二进制文件所在目录为参考):
tee abin.txt <<- EOF Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages? EOF
运行程序:
cargo run -- picture abin.txt
cd target/debug ./grep-lite picture ../../abin.txt
14. 从 stdin 读取内容
如果命令行工具不能从 stdin 读取内容是不完整的。虽然使用不同方式读取内容,但处理这些内容的方式是相同的,因此,通过函数 process_lines 来抽象。以下是 grep-lite 的完整代码:
use std::fs::File; use std::io; use std::io::BufReader; use std::io::prelude::*; use regex::Regex; use clap::{App,Arg}; fn process_lines<T: BufRead + Sized>(reader: T, re: Regex) { for line_ in reader.lines() { let line = line_.unwrap(); match re.find(&line) { Some(_) => println!("{}", line), None => (), } } } fn main() { let args = App::new("grep-lite") .version("0.1") .about("searches for patterns") .arg(Arg::with_name("pattern") .help("The pattern to search for") .takes_value(true) .required(true)) .arg(Arg::with_name("input") .help("File to search") .takes_value(true) .required(false)) .get_matches(); let pattern = args.value_of("pattern").unwrap(); let re = Regex::new(pattern).unwrap(); let input = args.value_of("input").unwrap_or("-"); if input == "-" { let stdin = io::stdin(); let reader = stdin.lock(); process_lines(reader, re); } else { let f = File::open(input).unwrap(); let reader = BufReader::new(f); process_lines(reader, re); } }