Rust学习笔记(第一章)
刚开始学习Rust需要注意的几点
-
Rust 原生支持 UTF-8 编码的字符串,这意味着你可以很容易的使用世界各国文字作为字符串内容
-
println
后面的!
,在 Rust 中,这是宏
操作符,你目前可以认为宏是一种特殊类型函数 -
占位符只需要使用
{}
,Rust本身会自动识别类型 -
和其他语言不同Rust 的集合类型不能直接进行循环,需要变成迭代器(这里是通过
.iter()
方法),才能用于迭代循环let southern_germany = "Grüß Gott!"; let chinese = "世界,你好"; let english = "World, hello"; let regions = [southern_germany, chinese, english]; for region in regions.iter() { println!("{}", ®ion); } #[实际上这段代码可以简写,在 2021 edition 及以后,支持直接写 for #region in regions,原因会在迭代器章节的开头提到,是因为 for #隐式地将 regions 转换成迭代器。]
语法及代码规范
至于函数声明、调用、数组的使用,和其它语言没什么区别
变量绑定与解构
变量
一般的rust中不包含变量和常量,而是一个量既可能为常量也可以为变量,默认量为常量,当加入mut
关键字后,该量为常量
// 变量
let mut x = 5;
// 常量
let x = 5;
_value 表示未使用或或忽略的变量
类似golang的语法可以使用_变量
来表示需忽略的量,或未使用的量,若有没有使用该命名规范,则会在编译期间出现报错。
变量解构
let
表达式不仅仅用于变量的绑定,还能进行复杂变量的解构:从一个相对复杂的变量中,匹配出该变量的一部分内容:
fn main() {
let (a, mut b): (bool,bool) = (true, false);
// a = true,不可变; b = false,可变
println!("a = {:?}, b = {:?}", a, b);
b = true;
assert_eq!(a, b);
}
解构式赋值
struct Struct {
e: i32
}
fn main() {
let (a, b, c, d, e);
(a, b) = (1, 2);
// _ 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 _
[c, .., d, _] = [1, 2, 3, 4, 5];
Struct { e, .. } = Struct { e: 5 };
assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e]);
}
常量
- 常量不允许使用
mut
。常量不仅仅默认不可变,而且自始至终不可变,因为常量在编译完成后,已经确定它的值。 - 常量使用
const
关键字而不是let
关键字来声明,并且值的类型必须标注。
变量遮蔽
Rust 允许声明相同的变量名,在后面声明的变量会遮蔽掉前面声明的,如下所示:
fn main() {
let x = 5;
// 在main函数的作用域内对之前的x进行遮蔽
let x = x + 1;
{
// 在当前的花括号作用域内,对之前的x进行遮蔽
let x = x * 2;
println!("The value of x in the inner scope is: {}", x);
}
println!("The value of x is: {}", x);
}
这个程序首先将数值 5
绑定到 x
,然后通过重复使用 let x =
来遮蔽之前的 x
,并取原来的值加上 1
,所以 x
的值变成了 6
。第三个 let
语句同样遮蔽前面的 x
,取之前的值并乘上 2
,得到的 x
最终值为 12
。
这和 mut
变量的使用是不同的,第二个 let
生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配 ,而 mut
声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。
变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。
(ps:我这里倒是觉得,若一个变量有多个含义反而会减少脑细胞)
基本类型
当一门语言不谈类型时,你得小心,这大概率是动态语言(别拍我,我承认是废话)。但是把类型大张旗鼓的用多个章节去讲的,Rust 是其中之一。
Rust 每个值都有其确切的数据类型,总的来说可以分为两类:基本类型和复合类型。 基本类型意味着它们往往是一个最小化原子类型,无法解构为其它类型(一般意义上来说),由以下组成:
- 数值类型: 有符号整数 (
i8
,i16
,i32
,i64
,isize
)、 无符号整数 (u8
,u16
,u32
,u64
,usize
) 、浮点数 (f32
,f64
)、以及有理数、复数 - 字符串:字符串字面量和字符串切片
&str
- 布尔类型:
true
和false
- 字符类型: 表示单个 Unicode 字符,存储为 4 个字节
- 单元类型: 即
()
,其唯一的值也是()
类型与编译器
Rust是一门静态语言,之前说过每一个值都有确切的类型,但为什么还可以用类似js的const,let关键字,这主要是因为Rust编译器的原因
Rust的编译器可以根据变量的值或上下文,自动推断推导出相应的变量类型,但在一些比较复杂的情况下,当Rust编译器无法推断类型的时候,就需要手动去指定类型,例子如下:
#![allow(unused)]
fn main() {
let guess = "42".parse().expect("Not a number!");
}
先忽略 .parse().expect..
部分,这段代码的目的是将字符串 "42"
进行解析,而编译器在这里无法推导出我们想要的类型:整数?浮点数?字符串?因此编译器会报错
$ cargo build
Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
--> src/main.rs:2:9
|
2 | let guess = "42".parse().expect("Not a number!");
| ^^^^^ consider giving `guess` a type
因此我们需要提供给编译器更多的信息,例如给 guess
变量一个显式的类型标注:let guess: i32 = ...
或者 "42".parse::<i32>()
。
数值类型
关于数值类型没有什么好说的
i32
即有符号的 32 位整数,i
是英文单词 integer 的首字母,与之相反的是 u
,代表无符号 unsigned
类型
关于整型溢出
若其中一个值的类型,例如u8,数值超过其范围之后0~255的值时,Rust在Debug模式编译的时候,会检查整型溢出,若存在这类问题,则会使程序在编译时 panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。
在当使用 --release
参数进行 release 模式构建时,Rust 不检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理。简而言之,大于该类型最大值的数值会被补码转换成该类型能够支持的对应数字
的最小值。比如在 u8
的情况下,256 变成 0,257 变成 1,依此类推。程序不会 panic,但是该变量的值可能不是你期望的值。依赖这种默认行为的代码都应该被认为是错误的代码。
要显式处理可能的溢出,可以使用标准库针对原始数字类型提供的这些方法:
- 使用
wrapping_*
方法在所有模式下都按照补码循环溢出规则处理,例如wrapping_add
- 如果使用
checked_*
方法时发生溢出,则返回None
值 - 使用
overflowing_*
方法返回该值和一个指示是否存在溢出的布尔值 - 使用
saturating_*
方法使值达到最小值或最大值
下面是一个演示wrapping_*
方法的示例:
fn main() {
let a : u8 = 255;
let b = a.wrapping_add(20);
println!("{}", b); // 19
}
浮点类型
f32
和 f64
,分别为 32 位和 64 位大小。默认浮点类型是 f64
,在现代的 CPU 中它的速度与 f32
几乎相同,但精度更高。
浮点数陷阱可以参考Rust圣经中的一部分,至于我怎么总结浮点类型,就是非必须尽量不用
数字运算
Rust 支持所有数字类型的基本数学运算:加法、减法、乘法、除法和取模运算。
综合性的示例:
fn main() {
// 编译器会进行自动推导,给予twenty i32的类型
let twenty = 20;
// 类型标注
let twenty_one: i32 = 21;
// 通过类型后缀的方式进行类型标注:22是i32类型
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;
println!("{}", one_million.pow(2));
// 定义一个f32数组,其中42.0会自动被推导为f32类型
let forty_twos = [
42.0,
42f32,
42.0_f32,
];
// 打印数组中第一个值,并控制小数位为2位
println!("{:.2}", forty_twos[0]);
}
位运算
Rust的运算基本上和其他语言一样
运算符 | 说明 |
---|---|
& 位与 | 相同位置均为1时则为1,否则为0 |
| 位或 | 相同位置只要有1时则为1,否则为0 |
^ 异或 | 相同位置不相同则为1,相同则为0 |
! 位非 | 把位中的0和1相互取反,即0置为1,1置为0 |
<< 左移 | 所有位向左移动指定位数,右位补0 |
>> 右移 | 所有位向右移动指定位数,带符号移动(正数补0,负数补1) |
fn main() {
// 二进制为00000010
let a:i32 = 2;
// 二进制为00000011
let b:i32 = 3;
println!("(a & b) value is {}", a & b);
println!("(a | b) value is {}", a | b);
println!("(a ^ b) value is {}", a ^ b);
println!("(!b) value is {} ", !b);
println!("(a << b) value is {}", a << b);
println!("(a >> b) value is {}", a >> b);
let mut a = a;
// 注意这些计算符除了!之外都可以加上=进行赋值 (因为!=要用来判断不等于)
a <<= b;
println!("(a << b) value is {}", a);
}
序列
Rust 提供了一个非常简洁的方式,用来生成连续的数值,例如 1..5
,生成从 1 到 4 的连续数字,不包含 5 ;1..=5
,生成从 1 到 5 的连续数字,包含 5,它的用途很简单,常常用于循环中:
#![allow(unused)]
fn main() {
for i in 1..=5 {
println!("{}",i);
}
}
序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。如下是一个使用字符类型序列的例子:
#![allow(unused)]
fn main() {
for i in 'a'..='z' {
println!("{}",i);
}
}
数值类型总结
Rust 的数值类型和运算跟其他语言较为相似,但是实际上,除了语法上的不同之外,还是存在一些差异点:
- Rust 拥有相当多的数值类型. 因此你需要熟悉这些类型所占用的字节数,这样就知道该类型允许的大小范围以及你选择的类型是否能表达负数
- 类型转换必须是显式的. Rust 永远也不会偷偷把你的 16bit 整数转换成 32bit 整数
- Rust 的数值上可以使用方法. 例如你可以用以下方法来将
13.14
取整:13.14_f32.round()
,在这里我们使用了类型后缀,因为编译器需要知道13.14
的具体类型
字符类型 char
下面的代码展示了几个颇具异域风情的字符:
fn main() {
let c = 'z';
let z = 'ℤ';
let g = '国';
let heart_eyed_cat = '😻';
}
如果大家是从有年代感的编程语言过来,可能会大喊一声:这 XX 叫字符?是的,在 Rust 语言中这些都是字符,Rust 的字符不仅仅是 ASCII
,所有的 Unicode
值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode
值的范围从 U+0000 ~ U+D7FF
和 U+E000 ~ U+10FFFF
。不过“字符”并不是 Unicode
中的一个概念,所以人在直觉上对“字符”的理解和 Rust 的字符概念并不一致。
由于 Unicode
都是 4 个字节编码,因此字符类型也是占用 4 个字节:
fn main() {
let x = '中';
println!("字符'中'占用了{}字节的内存大小",std::mem::size_of_val(&x));
}
$ cargo run
Compiling ...
字符'中'占用了4字节的内存大小
注意,我们还没开始讲字符串,但是这里提前说一下,和一些语言不同,Rust 的字符只能用 ''
来表示, ""
是留给字符串的。
布尔
Rust 中的布尔类型有两个可能的值:true
和 false
,布尔值占用内存的大小为 1
个字节:
多用于流程控制,懂得都懂。
单元类型
单元类型就是 ()
,对,你没看错,就是 ()
,唯一的值也是 ()
main
函数就返回这个单元类型 ()
,你不能说 main
函数无返回值,因为没有返回值的函数在 Rust 中是有单独的定义的:发散函数( diverge function )
,顾名思义,无法收敛的函数。
例如常见的 println!()
的返回值也是单元类型 ()
。
再比如,你可以用 ()
作为 map
的值,表示我们不关注具体的值,只关注 key
。 这种用法和 Go 语言的 struct{} 类似,可以作为一个值用来占位,但是完全不占用任何内存。
语句和表达式
Rust 的函数体是由一系列语句组成,最后由一个表达式来返回值,例如:
fn add_with_extra(x: i32, y: i32) -> i32 {
let x = x + 1; // 语句
let y = y + 5; // 语句
x + y // 表达式
}
语句会执行一些操作但是不会返回一个值,而表达式会在求值后返回一个值,因此在上述函数体的三行代码中,前两行是语句,最后一行是表达式。
对于 Rust 语言而言,这种基于语句(statement)和表达式(expression)的方式是非常重要的,你需要能明确的区分这两个概念, 但是对于很多其它语言而言,这两个往往无需区分。基于表达式是函数式语言的重要特征,表达式总要返回值。
其实,在此之前,我们已经多次使用过语句和表达式。
语句
fn main() {
let a = 8;
let b: Vec<f64> = Vec::new();
let (a, c) = ("hi", false);
}
以上都是语句,它们完成了一个具体的操作,但是并没有返回值,因此是语句。
由于 let
是语句,因此不能将 let
语句赋值给其它值,如下形式是错误的:
let b = (let a = 8);
表达式
表达式会进行求值,然后返回一个值。例如 5 + 6
,在求值后,返回值 11
,因此它就是一条表达式。
表达式可以成为语句的一部分,例如 let y = 6
中,6
就是一个表达式,它在求值后返回一个值 6
(有些反直觉,但是确实是表达式)。
调用一个函数是表达式,因为会返回一个值,调用宏也是表达式,用花括号包裹最终返回一个值的语句块也是表达式,总之,能返回值,它就是表达式:
fn main() {
let y = {
let x = 3;
x + 1
};
println!("The value of y is: {}", y);
}
上面使用一个语句块表达式将值赋给 y
变量,语句块长这样:
{
let x = 3;
x + 1
}
该语句块是表达式的原因是:它的最后一行是表达式,返回了 x + 1
的值,注意 x + 1
不能以分号结尾,否则就会从表达式变成语句, 表达式不能包含分号。这一点非常重要,一旦你在表达式后加上分号,它就会变成一条语句,再也不会返回一个值,请牢记!
最后,表达式如果不返回任何值,会隐式地返回一个 ()
。
fn main() {
assert_eq!(ret_unit_type(), ())
}
fn ret_unit_type() {
let x = 1;
// if 语句块也是一个表达式,因此可以用于赋值,也可以直接返回
// 类似三元运算符,在Rust里我们可以这样写
let y = if x % 2 == 1 {
"odd"
} else {
"even"
};
// 或者写成一行
let z = if x % 2 == 1 { "odd" } else { "even" };
}
函数
Rust 的函数跟其他语言几乎没有什么区别
在函数界,有一个函数只闻其名不闻其声,可以止小孩啼!在程序界只有 hello,world!
可以与之媲美,它就是 add
函数:
fn add(i: i32, j: i32) -> i32 {
i + j
}
该函数如此简单,但是又是如此的五脏俱全,声明函数的关键字 fn
,函数名 add()
,参数 i
和 j
,参数类型和返回值类型都是 i32
,总之一切那么的普通,但是又那么的自信,直到你看到了下面这张图:
函数就差不多就这样,没了。
其他要点:
- 函数命名规范:函数名和变量名使用
蛇形命名法(snake case)
,例如fn add_two() -> {}
- 函数的位置可以随便放,Rust 不关心我们在哪里定义了函数,只要有定义即可
- 每个函数参数都需要标注类型
特殊返回类型
无返回值()
例如单元类型 ()
,是一个零长度的元组。它没啥作用,但是可以用来表达一个函数没有返回值:
- 函数没有返回值,那么返回一个
()
- 通过
;
结尾的表达式返回一个()
例如下面的 report
函数会隐式返回一个 ()
:
use std::fmt::Debug;
fn report<T: Debug>(item: T) {
println!("{:?}", item);
}
与上面的函数返回值相同,但是下面的函数显式的返回了 ()
:
fn clear(text: &mut String) -> () {
*text = String::from("");
}
在实际编程中,你会经常在错误提示中看到该 ()
的身影出没,假如你的函数需要返回一个 u32
值,但是如果你不幸的以 表达式;
的方式作为函数的最后一行代码,就会报错:
fn add(x:u32,y:u32) -> u32 {
x + y;
}
只有表达式能返回值,而 ;
结尾的是语句,在 Rust 中,一定要严格区分表达式和语句的区别,这个在其它语言中往往是被忽视的点。
ps:我这里理解的()
为null
,void
,当然最应该注意的就是Rust的函数默认返回值需要一个没有分号的表达式。
永不返回的发散函数 !
当用 !
作函数返回类型的时候,表示该函数永不返回( diverge function ),特别的,这种语法往往用做会导致程序崩溃的函数
fn dead_end() -> ! {
panic!("你已经到了穷途末路,崩溃吧!");
}
下面的函数创建了一个无限循环,该循环永不跳出,因此函数也永不返回:
fn forever() -> ! {
loop {
//...
};
}
所有权及借用
所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。在计算机语言不断演变过程中,出现了三种流派:
- 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
- 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++
- 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查
其中 Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。
一段不安全的代码
先来看看一段来自 C 语言的糟糕代码:
int* foo() {
int a; // 变量a的作用域开始
a = 100;
char *c = "xyz"; // 变量c的作用域开始
return &a;
} // 变量a和c的作用域结束
的地址返回,但局部变量 a
存在栈中,在离开作用域后,a
所申请的栈上内存都会被系统回收,从而造成了 悬空指针(Dangling Pointer)
的问题。这是一个非常典型的内存安全问题,虽然编译可以通过,但是运行的时候会出现错误, 很多编程语言都存在。
再来看变量 c
,c
的值是常量字符串,存储于常量区,可能这个函数我们只调用了一次,也可能我们不再会使用这个字符串,但 "xyz"
只有当整个程序结束后系统才能回收这片内存。
所以内存安全问题,一直都是程序员非常头疼的问题,好在, 在 Rust 中这些问题即将成为历史,因为 Rust 在编译的时候就可以帮助我们发现内存不安全的问题,那 Rust 如何做到这一点呢?
这里就简略跳过栈及堆的知识了,不明白的可以谷歌,我们直接介绍
所有权原则
关于所有权的规则,首先请谨记以下规则:
- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
变量作用域
作用域是一个变量在程序中有效的范围, 假如有这样一个变量:
let s = "hello";
变量 s
绑定到了一个字符串字面值,该字符串字面值是硬编码到程序代码中的。s
变量从声明的点开始直到当前作用域的结束都是有效的:
{ // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的
// 使用 s
} // 此作用域已结束,s不再有效
简而言之,s
从创建伊始就开始有效,然后有效期持续到它离开作用域为止,可以看出,就作用域来说,Rust 语言跟其他编程语言没有区别。
String类型
我们已经见过字符串字面值 let s ="hello"
,s
是被硬编码进程序里的字符串值(类型为 &str
)。字符串字面值是很方便的,但是它并不适用于所有场景。原因有二:
- 字符串字面值是不可变的,因为被硬编码到程序代码中
- 并非所有字符串的值都能在编写代码时得知
例如,字符串是需要程序运行时,通过用户动态输入然后存储在内存中的,这种情况,字符串字面值就完全无用武之地。 为此,Rust 为我们提供动态字符串类型: String
, 该类型被分配到堆上,因此可以动态伸缩,也就能存储在编译时大小未知的文本。
可以使用下面的方法基于字符串字面量来创建 String
类型:
let s = String::from("hello");
::
是一种调用操作符,类似Java与PHP,这里表示调用 String
中的 from
方法,因为 String
存储在堆上是动态的,你可以这样修改它:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在字符串后追加字面值
println!("{}", s); // 将打印 `hello, world!`
变量绑定背后的数据交互
转移所有权
先来看一段代码:
let x = 5;
let y = x;
代码背后的逻辑很简单, 将 5
绑定到变量 x
;接着拷贝 x
的值赋给 y
,最终 x
和 y
都等于 5
,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。
栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32
,4 个字节)的内存即可,因此在这种情况下,拷贝的速度远比在堆上创建内存来得快的多。
然后再来看一段代码:
let s1 = String::from("hello");
let s2 = s1;
对于基本类型(存储在栈上),Rust 会自动拷贝,但是 String
不是基本类型,而且是存储在堆上的,因此不能自动拷贝。
实际上, String
类型是一个复杂类型,由存储在栈中的堆指针、字符串长度、字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存,至于长度和容量,如果你有 Go 语言的经验,这里就很好理解:容量是堆内存分配空间的大小,长度是目前已经使用的大小。
总之 String
类型指向了一个堆上的空间,这里存储着它的真实数据, 下面对上面代码中的 let s2 = s1
分成两种情况讨论:
- 拷贝
String
和存储在堆上的字节数组 如果该语句是拷贝所有数据(深拷贝),那么无论是String
本身还是底层的堆上数据,都会被全部拷贝,这对于性能而言会造成非常大的影响 - 只拷贝
String
本身 这样的拷贝非常快,因为在 64 位机器上就拷贝了8字节的指针
、8字节的长度
、8字节的容量
,总计 24 字节,但是带来了新的问题,还记得我们之前提到的所有权规则吧?其中有一条就是:一个值只允许有一个所有者,而现在这个值(堆上的真实字符串数据)有了两个所有者:s1
和s2
。
就假定一个值可以拥有两个所有者,会发生什么呢?
当变量离开作用域后,Rust 会自动调用 drop
函数并清理变量的堆内存。不过由于两个 String
变量指向了同一位置。这就有了一个问题:当 s1
和 s2
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
因此,Rust 这样解决问题:当 s1
赋予 s2
后,Rust 认为 s1
不再有效,因此也无需在 s1
离开作用域后 drop
任何东西,这就是把所有权从 s1
转移给了 s2
,s1
在被赋予 s2
后就马上失效了。
再来看看,在所有权转移后再来使用旧的所有者,会发生什么:
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
由于 Rust 禁止你使用无效的引用,你会看到以下的错误:
error[E0382]: use of moved value: `s1`
--> src/main.rs:5:28
|
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value used here after move
|
= note: move occurs because `s1` has type `std::string::String`, which does
not implement the `Copy` trait
回看之前的规则,这下就解释了为什么一个值只有一个所有者
- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
如果你在其他语言中听说过术语 浅拷贝(shallow copy) 和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据听起来就像浅拷贝,但是又因为 Rust 同时使第一个变量 s1
无效了,因此这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1
被移动到了 s2
中。
这样就解决了我们之前的问题,s1
不再指向任何数据,只有 s2
是有效的,当 s2
离开作用域,它就会释放内存。
再来看一段代码:
fn main() {
let x: &str = "hello, world";
let y = x;
println!("{},{}",x,y);
}
这段代码编译器不会报错,由于&str不属于包装数据类型(Java术语,类似String),属于基本数据类型,所以当let y = x;这个语句相当于将x的值拷贝给了y,而不是引用。
因此 let y = x
中,仅仅是对该引用进行了拷贝,此时 y
和 x
都引用了同一个字符串。
克隆及深拷贝
首先,Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。
如果我们确实需要深度复制 String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone
的方法。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
这段代码能够正常运行,因此说明 s2
确实完整的复制了 s1
的数据。
如果代码性能无关紧要,例如初始化程序时,或者在某段时间只会执行一次时,你可以使用 clone
来简化编程。但是对于执行较为频繁的代码(热点路径),使用 clone
会极大的降低程序性能,需要小心使用!
拷贝及浅拷贝
浅拷贝只发生在栈上,因此性能很高,在日常编程中,浅拷贝无处不在。
例如之前的例子:
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone
,不过依然实现了类似深拷贝的效果 —— 没有报所有权的错误。
原因是像整型这样的基本类型在编译时是已知大小的,会被存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y
后使 x
无效(x
、y
都仍然有效)。换句话说,这里没有深浅拷贝的区别,因此这里调用 clone
并不会与通常的浅拷贝有什么不同,我们可以不用管它(可以理解成在栈上做了深拷贝)。
Rust 有一个叫做 Copy
的特征,可以用在类似整型这样在栈中存储的类型。如果一个类型拥有 Copy
特征,一个旧的变量在被赋值给其他变量后仍然可用。
那么什么类型是可 Copy
的呢?可以查看给定类型的文档来确认,不过作为一个通用的规则: 任何基本类型的组合可以 Copy
,不需要分配内存或某种形式资源的类型是可以 Copy
的。如下是一些 Copy
的类型:
- 所有整数类型,比如
u32
- 布尔类型,
bool
,它的值是true
和false
- 所有浮点数类型,比如
f64
- 字符类型,
char
- 元组,当且仅当其包含的类型也都是
Copy
的时候。比如,(i32, i32)
是Copy
的,但(i32, String)
就不是 - 不可变引用
&T
,例如转移所有权中的最后一个例子,但是注意: 可变引用&mut T
是不可以 Copy的
函数传值与返回
将值传递给函数,一样会发生 移动
或者 复制
,就跟 let
语句一样,下面的代码展示了所有权、作用域的规则:
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效
let x = 5; // x 进入作用域
makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,所以在后面可继续使用 x
} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 所以不会有特殊操作
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作
你可以尝试在 takes_ownership
之后,再使用 s
,看看如何报错?例如添加一行 println!("在move进函数后继续使用s: {}",s);
。
同样的,函数返回值也有所有权,例如:
fn main() {
let s1 = gives_ownership(); // gives_ownership 将返回值
// 移给 s1
let s2 = String::from("hello"); // s2 进入作用域
let s3 = takes_and_gives_back(s2); // s2 被移动到
// takes_and_gives_back 中,
// 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
// 所以什么也不会发生。s1 移出作用域并被丢弃
fn gives_ownership() -> String { // gives_ownership 将返回值移动给
// 调用它的函数
let some_string = String::from("hello"); // some_string 进入作用域.
some_string // 返回 some_string 并移出给调用的函数
}
// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
a_string // 返回 a_string 并移出给调用的函数
}
所有权很强大,避免了内存的不安全性,但是也带来了一个新麻烦: 总是把一个值传来传去来使用它。 传入一个函数,很可能还要从该函数传出去,结果就是语言表达变得非常啰嗦,幸运的是,Rust 提供了新功能解决这个问题。
引用与借用
上节中提到,如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。 Rust 能否像其它编程语言一样,使用某个变量的指针或者引用呢?答案是可以。
Rust 通过 借用(Borrowing)
这个概念来达成上述的目的,获取变量的引用,称之为借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来,当使用完毕后,也必须要物归原主。
引用与解引用
常规引用是一个指针类型,指向了对象存储的内存地址。在下面代码中,我们创建一个 i32
值的引用 y
,然后使用解引用运算符来解出 y
所使用的值:
fn main() {
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
}
变量 x
存放了一个 i32
值 5
。y
是 x
的一个引用。可以断言 x
等于 5
。然而,如果希望对 y
的值做出断言,必须使用 *y
来解出引用所指向的值(也就是解引用)。一旦解引用了 y
,就可以访问 y
所指向的整型值并可以与 5
做比较。
相反如果尝试编写 assert_eq!(5, y);
,则会得到如下编译错误:
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}` // 无法比较整数类型和引用类型
|
= help: the trait `std::cmp::PartialEq<&{integer}>` is not implemented for
`{integer}`
不可变引用
下面的代码,我们用 s1
的引用作为参数传递给 calculate_length
函数,而不是把 s1
的所有权转移给该函数:
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
能注意到两点:
- 无需像上章一样:先通过函数参数传入所有权,然后再通过函数返回来传出所有权,代码更加简洁
calculate_length
的参数s
类型从String
变为&String
这里,&
符号即是引用,它们允许你使用值,但是不获取所有权,如图所示:
通过 &s1
语法,我们创建了一个指向 s1
的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。
同理,函数 calculate_length
使用 &
来表明参数 s
的类型是一个引用:
fn calculate_length(s: &String) -> usize { // s 是对 String 的引用
s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
// 所以什么也不会发生
当我们通过引用获取该对象的值时,我们不止想调用它原本的值及函数,想尝试修改引用(借用)的变量,编译器会报错。
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
7 | fn change(some_string: &String) {
| ------- help: consider changing this to be a mutable reference: `&mut String`
------- 帮助:考虑将该参数类型修改为可变的引用: `&mut String`
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
`some_string`是一个`&`类型的引用,因此它指向的数据无法进行修改
正如变量默认不可变一样,引用指向的值默认也是不可变的。
可变引用
只需要一个小调整,即可修复上面代码的错误:
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
我们可以直观的看到当在引用符号加入mut关键字后,这个引用变量即可修改了。
请注意:可变引用只能存在一个
不过可变引用并不是随心所欲、想用就用的,它有一个很大的限制: 同一作用域,特定数据只能有一个可变引用:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
以上代码就会出现报错
error[E0499]: cannot borrow `s` as mutable more than once at a time 同一时间无法对 `s` 进行两次可变借用
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here 首个可变引用在这里借用
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here 第二个可变引用在这里借用
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here 第一个借用在这里使用
这段代码出错的原因在于,第一个可变借用 r1
必须要持续到最后一次使用的位置 println!
,在 r1
创建和最后一次使用之间,我们又尝试创建第二个可变借用 r2
。
对于新手来说,这个特性绝对是一大拦路虎,也是新人们谈之色变的编译器 borrow checker
特性之一,不过各行各业都一样,限制往往是出于安全的考虑,Rust 也一样。
ps:这段我自己也比较难理解,什么叫持续到最后一次使用的位置
,通过ChatGPT了解到,Rust的变量存在对应的生命周期(即从创建到销毁),因为上述代码中r1和r2全部指向同一个可变引用s,然而代码中r1和r2的生命周期不一样,导致在调用r1后,r1被销毁,其对应的可变引用也会被销毁,这样r2就会出现引用错误,所以Rust编译器会规定在同一作用域中,只能存在一个可变引用
这种限制的好处就是使 Rust 在编译期就避免数据竞争,数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。而 Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
很多时候,大括号可以帮我们解决一些编译不通过的问题,通过手动限制变量的作用域:
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用
let r2 = &mut s;
可变引用与不可变引用不能同时存在
下面的代码会导致一个错误:
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题
println!("{}, {}, and {}", r1, r2, r3);
错误如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
// 无法借用可变 `s` 因为它已经被借用了不可变
--> src/main.rs:6:14
|
4 | let r1 = &s; // 没问题
| -- immutable borrow occurs here 不可变借用发生在这里
5 | let r2 = &s; // 没问题
6 | let r3 = &mut s; // 大问题
| ^^^^^^ mutable borrow occurs here 可变借用发生在这里
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here 不可变借用在这里使用
其实这个也很好理解,正在借用不可变引用的用户,肯定不希望他借用的东西,被另外一个人莫名其妙改变了。多个不可变借用被允许是因为没有人会去试图修改数据,每个人都只读这一份数据而不做修改,因此不用担心数据被污染。
注意,引用的作用域
s
从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号}
Rust 的编译器一直在优化,早期的时候,引用的作用域跟变量作用域是一致的,这对日常使用带来了很大的困扰,你必须非常小心的去安排可变、不可变变量的借用,免得无法通过编译,例如以下代码:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// 新编译器中,r1,r2作用域在这里结束
let r3 = &mut s;
println!("{}", r3);
} // 老编译器中,r1、r2、r3作用域在这里结束
// 新编译器中,r3作用域在这里结
在老版本的编译器中(Rust 1.31 前),将会报错,因为 r1
和 r2
的作用域在花括号 }
处结束,那么 r3
的借用就会触发 无法同时借用可变和不可变的规则。
但是在新的编译器中,该代码将顺利通过,因为 引用作用域的结束位置从花括号变成最后一次使用的位置,因此 r1
借用和 r2
借用在 println!
后,就结束了,此时 r3
可以顺利借用到可变引用。
NLL
对于这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(}
)结束前就不再被使用的代码位置。
虽然这种借用错误有的时候会让我们很郁闷,但是你只要想想这是 Rust 提前帮你发现了潜在的 BUG,其实就开心了,虽然减慢了开发速度,但是从长期来看,大幅减少了后续开发和运维成本。
悬垂引用(Dangling References)
悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。
让我们尝试创建一个悬垂引用,Rust 会抛出一个编译时错误:
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
这里是错误:
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| ~~~~~~~~
错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。不过,即使你不理解生命周期,也可以通过错误信息知道这段代码错误的关键信息:
this function's return type contains a borrowed value, but there is no value for it to be borrowed from.
该函数返回了一个借用的值,但是已经找不到它所借用值的来源
仔细看看 dangle
代码的每一步到底发生了什么:
fn dangle() -> &String { // dangle 返回一个字符串的引用
let s = String::from("hello"); // s 是一个新字符串
&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
// 危险!
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放,但是此时我们又尝试去返回它的引用。这意味着这个引用会指向一个无效的 String
,这可不对!
其中一个很好的解决方法是直接返回 String
:
fn no_dangle() -> String {
let s = String::from("hello");
s
}
这样就没有任何错误了,最终 String
的 所有权被转移给外面的调用者。
复合类型(包装类型)
字符串
首先来看段很简单的代码:
fn main() {
let my_name = "Pascal";
greet(my_name);
}
fn greet(name: String) {
println!("Hello, {}!", name);
}
ps:这段代码肯定是报错的,为什么就是因为是使用的类型是字符&str,而不是对应的字符串。
error[E0308]: mismatched types
--> src/main.rs:3:11
|
3 | greet(my_name);
| ^^^^^^^
| |
| expected struct `std::string::String`, found `&str`
| help: try using a conversion method: `my_name.to_string()`
error: aborting due to previous error
Bingo,果然报错了,编译器提示 greet
函数需要一个 String
类型的字符串,却传入了一个 &str
类型的字符串
在讲解字符串之前,先来看看什么是切片
切片(Slice)
切片并不是 Rust 独有的概念,在 Go 语言中就非常流行,它允许你引用集合中部分连续的元素序列,而不是引用整个集合。
对于字符串而言,切片就是对 String
类型中某一部分的引用,它看起来像这样:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
hello
没有引用整个 String s
,而是引用了 s
的一部分内容,通过 [0..5]
的方式来指定。
这就是创建切片的语法,使用方括号包括的一个序列:[开始索引…终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,也就是这是一个 右半开区间
。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引
- 开始索引
的方式计算得来的。
对于 let world = &s[6..11];
来说,world
是一个切片,该切片的指针指向 s
的第 7 个字节(索引从 0 开始, 6 是第 7 个字节),且该切片的长度是 5
个字节。
在使用 Rust 的 ..
range 序列语法时,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
同样的,如果你的切片想要包含 String
的最后一个字节,则可以这样使用:
let s = String::from("hello");
let len = s.len();
let slice = &s[4..len];
let slice = &s[4..];
你也可以截取完整的 String
切片:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
在使用多中语言切片的时候的时候,需要注意,有些文字在UTF-8中占2个或多个字符,例如下面的代码就会崩溃:
let s = "中国人";
let a = &s[0..2];
println!("{}",a);
因为我们只取 s
字符串的前两个字节,但是本例中每个汉字占用三个字节,因此没有落在边界处,也就是连 中
字都取不完整,此时程序会直接崩溃退出,如果改成 &s[0..3]
,则可以正常通过编译。 因此,当你需要对字符串做切片索引操作时,需要格外小心这一点。
字符串切片的类型标识是 &str
,因此我们可以这样声明一个函数,输入 String
类型,返回它的切片: fn first_word(s: &String) -> &str
。
有了切片就可以写出这样的代码:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // error!
println!("the first word is: {}", word);
}
fn first_word(s: &String) -> &str {
&s[..1]
}
编译器报错如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {}", word);
| ---- immutable borrow later used here
回忆一下借用的规则:当我们已经有了可变借用时,就无法再拥有不可变的借用。因为 clear
需要清空改变 String
,因此它需要一个可变借用(利用 VSCode 可以看到该方法的声明是 pub fn clear(&mut self)
,参数是对自身的可变借用 );而之后的 println!
又使用了不可变借用,也就是在 s.clear()
处可变借用与不可变借用试图同时生效,因此编译无法通过。
其他切片
因为切片是对集合的部分引用,因此不仅仅字符串有切片,其它集合类型也有,例如数组:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
字符串字面量是切片
之前提到过字符串字面量,但是没有提到它的类型:
let s = "Hello, world!";
实际上,s
的类型是 &str
,因此你也可以这样声明:
let s: &str = "Hello, world!";
该切片指向了程序可执行文件中的某个点,这也是为什么字符串字面量是不可变的,因为 &str
是一个不可变引用。
什么是字符串
顾名思义,字符串是由字符组成的连续集合,但是在上一节中我们提到过,Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间。
Rust 在语言级别,只有一种字符串类型: str
,它通常是以引用类型出现 &str
,也就是上文提到的字符串切片。虽然语言级别只有上述的 str
类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String
类型。
str
类型是硬编码进可执行文件,也无法被修改,但是 String
则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String
类型和 &str
字符串切片类型,这两个类型都是 UTF-8 编码。
除了 String
类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString
, OsStr
, CsString
和 CsStr
等,注意到这些名字都以 String
或者 Str
结尾了吗?它们分别对应的是具有所有权和被借用的变量。
String 与 &str 的转换
在之前的代码中,已经见到好几种从 &str
类型生成 String
类型的操作:
String::from("hello,world")
"hello,world".to_string()
那么如何将 String
类型转为 &str
类型呢?答案很简单,取引用即可:
fn main() {
let s = String::from("hello,world!");
say_hello(&s);
say_hello(&s[..]);
say_hello(s.as_str());
}
fn say_hello(s: &str) {
println!("{}",s);
}
实际上这种灵活用法是因为 deref
隐式强制转换
ps:这里就稍微介绍一下 deref
, deref
为一个用于进行隐式转换的trait
,trait
类似Java中的interface和抽象类,在Rust中,trait
是一种行为约束,表示类型可以遵循trait
的规范进行行为操作
Trait的语法形式为:
trait MyTrait {
fn method1(&self);
fn method2(&self) -> u32 {
42
}
}
其中trait
与interface最显著的区别是trait
可以提供默认的行为,使得实现它的类型可以重写它的行为。
说回 deref
,deref
是一个用于进行隐式转换的trait
。该trait定义有一个名称为 deref
的方法,可以将一个类型的实例按照特定的规则转换为另一个类型的实例。这个特性可以方便地将一个类型转换为另一个类型,从而让代码更加简洁易懂。
例如,在Rust中,如果你有一个字符串类型的变量 s
,你可以通过 &s
获取到一个指向该字符串的引用。但是,如果你想要将这个字符串作为一个原始字符数组来处理,你可以通过实现 Deref
trait
来进行转换:
use std::ops::Deref;
struct MyString(String);
impl Deref for MyString {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
fn main() {
let my_str = MyString(String::from("Rust is awesome!"));
println!("{}", my_str.len()); // 调用 MyString 实例的 len 方法
println!("{}", my_str.to_uppercase()); // 调用 MyString 实例的 to_uppercase 方法
let my_chars = my_str.chars().collect::<Vec<char>>(); // 将 MyString 转换为 Vec<char>
println!("{:?}", my_chars);
}
这里的&self.0
中的 .0
表示对于 MyString
结构体中的 String
类型的具体实例进行引用。在这个实现 Deref
trait 的函数中,返回了一个指向 self.0
的 &str
引用。两者类型不同,但是由于实现了 Deref
trait,因此可以实现隐式类型转换,使得一个 MyString
实例可以像一个 &str
引用一样使用。
字符串索引
在其它语言中,使用索引的方式访问字符串的某个字符或者子串是很正常的行为,但是在 Rust 中就会报错:
let s1 = String::from("hello");
let h = s1[0];
该代码会产生如下错误:
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
深入字符串内部
字符串的底层的数据存储格式实际上是[ u8
],一个字节数组。对于 let hello = String::from("Hola");
这行代码来说,Hola
的长度是 4
个字节,因为 "Hola"
中的每个字母在 UTF-8 编码中仅占用 1 个字节,但是对于下面的代码呢?
let hello = String::from("中国人");
如果问你该字符串多长,你可能会说 3
,但是实际上是 9
个字节的长度,因为大部分常用汉字在 UTF-8 中的长度是 3
个字节,因此这种情况下对 hello
进行索引,访问 &hello[0]
没有任何意义,因为你取不到 中
这个字符,而是取到了这个字符三个字节中的第一个字节,这是一个非常奇怪而且难以理解的返回值。
字符串的不同表达形式
现在看一下用梵文写的字符串 “नमस्ते”
, 它底层的字节数组如下形式:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
长度是 18 个字节,这也是计算机最终存储该字符串的形式。如果从字符的形式去看,则是:
['न', 'म', 'स', '्', 'त', 'े']
但是这种形式下,第四和六两个字母根本就不存在,没有任何意义,接着再从字母串的形式去看:
["न", "म", "स्", "ते"]
所以,可以看出来 Rust 提供了不同的字符串展现方式,这样程序可以挑选自己想要的方式去使用,而无需去管字符串从人类语言角度看长什么样。
还有一个原因导致了 Rust 不允许去索引字符串:因为索引操作,我们总是期望它的性能表现是 O(1),然而对于 String
类型来说,无法保证这一点,因为 Rust 可能需要从 0 开始去遍历字符串来定位合法的字符。
字符串索引
前文提到过,字符串切片是非常危险的操作,因为切片的索引是通过字节来进行,但是字符串又是 UTF-8 编码,因此你无法保证索引的字节刚好落在字符的边界上,例如:
let hello = "中国人";
let s = &hello[0..2];
运行上面的程序,会直接造成崩溃:
thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '中' (bytes 0..3) of `中国人`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这里提示的很清楚,我们索引的字节落在了 中
字符的内部,这种返回没有任何意义。
因此在通过索引区间来访问字符串时,需要格外的小心,一不注意,就会导致你程序的崩溃!
操作字符串
由于 String
是可变字符串,下面介绍 Rust 字符串的修改,添加,删除等常用方法:
追加(Push)
在字符串尾部可以使用 push()
方法追加字符 char
,也可以使用 push_str()
方法追加字符串字面量。这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut
关键字修饰。
示例代码如下:
fn main() {
let mut s = String::from("Hello ");
s.push_str("rust");
println!("追加字符串 push_str() -> {}", s);
s.push('!');
println!("追加字符 push() -> {}", s);
}
代码运行结果:
追加字符串 push_str() -> Hello rust
追加字符 push() -> Hello rust!
插入(Insert)
可以使用 insert()
方法插入单个字符 char
,也可以使用 insert_str()
方法插入字符串字面量,与 push()
方法不同,这俩方法需要传入两个参数,第一个参数是字符(串)插入位置的索引,第二个参数是要插入的字符(串),索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut
关键字修饰。
示例代码如下:
fn main() {
let mut s = String::from("Hello rust!");
s.insert(5, ',');
println!("插入字符 insert() -> {}", s);
s.insert_str(6, " I like");
println!("插入字符串 insert_str() -> {}", s);
}
代码运行结果:
插入字符 insert() -> Hello, rust!
插入字符串 insert_str() -> Hello, I like rust!
替换 (Replace)
如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace()
方法。与替换有关的方法有三个。
replace
该方法可适用于 String
和 &str
类型。replace()
方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
fn main() {
let string_replace = String::from("I like rust. Learning rust is my favorite!");
let new_string_replace = string_replace.replace("rust", "RUST");
dbg!(new_string_replace);
}
代码运行结果:
new_string_replace = "I like RUST. Learning RUST is my favorite!"
- replacen
该方法可适用于 String
和 &str
类型。replacen()
方法接收三个参数,前两个参数与 replace()
方法一样,第三个参数则表示替换的个数。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
fn main() {
let string_replace = "I like rust. Learning rust is my favorite!";
let new_string_replacen = string_replace.replacen("rust", "RUST", 1);
dbg!(new_string_replacen);
}
代码运行结果:
new_string_replacen = "I like RUST. Learning rust is my favorite!"
- replace_range
该方法仅适用于 String
类型。replace_range
接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。该方法需要使用 mut
关键字修饰。
示例代码如下:
fn main() {
let mut string_replace_range = String::from("I like rust!");
string_replace_range.replace_range(7..8, "R");
dbg!(string_replace_range);
}
代码运行结果:
string_replace_range = "I like Rust!"
删除(Delete)
与字符串删除相关的方法有 4 个,他们分别是 pop()
,remove()
,truncate()
,clear()
。这四个方法仅适用于 String
类型。
pop
—— 删除并返回字符串的最后一个字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是一个 Option
类型,如果字符串为空,则返回 None
。 示例代码如下:
fn main() {
let mut string_pop = String::from("rust pop 中文!");
let p1 = string_pop.pop();
let p2 = string_pop.pop();
dbg!(p1);
dbg!(p2);
dbg!(string_pop);
}
代码运行结果:
p1 = Some(
'!',
)
p2 = Some(
'文',
)
string_pop = "rust pop 中"
remove
—— 删除并返回字符串中指定位置的字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是删除位置的字符串,只接收一个参数,表示该字符起始索引位置。remove()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
fn main() {
let mut string_remove = String::from("测试remove方法");
println!(
"string_remove 占 {} 个字节",
std::mem::size_of_val(string_remove.as_str())
);
// 删除第一个汉字
string_remove.remove(0);
// 下面代码会发生错误
// string_remove.remove(1);
// 直接删除第二个汉字
// string_remove.remove(3);
dbg!(string_remove);
}
代码运行结果:
string_remove 占 18 个字节
string_remove = "试remove方法"
truncate
—— 删除字符串中从指定位置开始到结尾的全部字符
该方法是直接操作原来的字符串。无返回值。该方法 truncate()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
fn main() {
let mut string_truncate = String::from("测试truncate");
string_truncate.truncate(3);
dbg!(string_truncate);
}
clear
—— 清空字符串
该方法是直接操作原来的字符串。调用后,删除字符串中的所有字符,相当于 truncate()
方法参数为 0 的时候。
示例代码如下:
fn main() {
let mut string_clear = String::from("string clear");
string_clear.clear();
dbg!(string_clear);
}
代码运行结果:
string_clear = ""
连接(Concatenate)
- 使用
+
或者+=
连接字符串
使用 +
或者 +=
连接字符串,要求右边的参数必须为字符串的切片引用(Slice)类型。其实当调用 +
的操作符时,相当于调用了 std::string
标准库中的 add()方法,这里 add()
方法的第二个参数是一个引用的类型。因此我们在使用 +
, 必须传递切片引用类型。不能直接传递 String
类型。+
和 +=
都是返回一个新的字符串。所以变量声明可以不需要 mut
关键字修饰。
示例代码如下:
fn main() {
let string_append = String::from("hello ");
let string_rust = String::from("rust");
// &string_rust会自动解引用为&str
let result = string_append + &string_rust;
let mut result = result + "!";
result += "!!!";
println!("连接字符串 + -> {}", result);
}
代码运行结果:
连接字符串 + -> hello rust!!!!
add()
方法的定义:
fn add(self, s: &str) -> String
因为该方法涉及到更复杂的特征功能,因此我们这里简单说明下:
fn main() {
let s1 = String::from("hello,");
let s2 = String::from("world!");
// 在下句中,s1的所有权被转移走了,因此后面不能再使用s1
let s3 = s1 + &s2;
assert_eq!(s3,"hello,world!");
// 下面的语句如果去掉注释,就会报错
// println!("{}",s1);
}
self
是 String
类型的字符串 s1
,该函数说明,只能将 &str
类型的字符串切片添加到 String
类型的 s1
上,然后返回一个新的 String
类型,所以 let s3 = s1 + &s2;
就很好解释了,将 String
类型的 s1
与 &str
类型的 s2
进行相加,最终得到 String
类型的 s3
。
由此可推,以下代码也是合法的:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
// String = String + &str + &str + &str + &str
let s = s1 + "-" + &s2 + "-" + &s3;
String + &str
返回一个 String
,然后再继续跟一个 &str
进行 +
操作,返回一个 String
类型,不断循环,最终生成一个 s
,也是 String
类型。
- 使用
format!
连接字符串
format!
这种方式适用于 String
和 &str
。format!
的用法与 print!
的用法类似。
字符串转义
我们可以通过转义的方式 \
输出 ASCII 和 Unicode 字符。
操作UTF-8字符串
前文提到了几种使用 UTF-8 字符串的方式,下面来一一说明。
字符
如果你想要以 Unicode 字符的方式遍历字符串,最好的办法是使用 chars
方法,例如:
for c in "中国人".chars() {
println!("{}", c);
}
输出如下
中
国
人
字节
这种方式是返回字符串的底层字节数组表现形式:
for b in "中国人".bytes() {
println!("{}", b);
}
输出如下:
228
184
173
229
155
189
228
186
186
字符串深度剖析
那么问题来了,为啥 String
可变,而字符串字面值 str
却不可以?
就字符串字面值来说,我们在编译时就知道其内容,最终字面值文本被直接硬编码进可执行文件中,这使得字符串字面值快速且高效,这主要得益于字符串字面值的不可变性。不幸的是,我们不能为了获得这种性能,而把每一个在编译时大小未知的文本都放进内存中(你也做不到!),因为有的字符串是在程序运行得过程中动态生成的。
对于 String
类型,为了支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容,这些都是在程序运行时完成的:
- 首先向操作系统请求内存来存放
String
对象 - 在使用完成后,将内存释放,归还给操作系统
其中第一部分由 String::from
完成,它创建了一个全新的 String
。
重点来了,到了第二部分,就是百家齐放的环节,在有垃圾回收 GC 的语言中,GC 来负责标记并清除这些不再使用的内存对象,这个过程都是自动完成,无需开发者关心,非常简单好用;但是在无 GC 的语言中,需要开发者手动去释放这些内存对象,就像创建对象需要通过编写代码来完成一样,未能正确释放对象造成的后果简直不可估量。
对于 Rust 而言,安全和性能是写到骨子里的核心特性,如果使用 GC,那么会牺牲性能;如果使用手动管理内存,那么会牺牲安全,这该怎么办?为此,Rust 的开发者想出了一个无比惊艳的办法:变量在离开作用域后,就自动释放其占用的内存:
{
let s = String::from("hello"); // 从此处起,s 是有效的
// 使用 s
} // 此作用域已结束,
// s 不再有效,内存被释放
与其它系统编程语言的 free
函数相同,Rust 也提供了一个释放内存的函数: drop
,但是不同的是,其它语言要手动调用 free
来释放每一个变量占用的内存,而 Rust 则在变量离开作用域时,自动调用 drop
函数: 上面代码中,Rust 在结尾的 }
处自动调用 drop
。
元组
元组是由多种类型组合到一起形成的,因此它是复合类型,元组的长度是固定的,元组中元素的顺序也是固定的。
可以通过以下语法创建一个元组:
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
变量 tup
被绑定了一个元组值 (500, 6.4, 1)
,该元组的类型是 (i32, f64, u8)
,看到没?元组是用括号将多个类型组合到一起,简单吧?
可以使用模式匹配或者 .
操作符来获取元组中的值。
模式匹配解构元组
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {}", y);
}
上述代码首先创建一个元组,然后将其绑定到 tup
上,接着使用 let (x, y, z) = tup;
来完成一次模式匹配,因为元组是 (n1, n2, n3)
形式的,因此我们用一模一样的 (x, y, z)
形式来进行匹配,元组中对应的值会绑定到变量 x
, y
, z
上。这就是解构:用同样的形式把一个复杂对象中的值匹配出来。
用.
来访问元组
模式匹配可以让我们一次性把元组中的值全部或者部分获取出来,如果只想要访问某个特定元素,那模式匹配就略显繁琐,对此,Rust 提供了 .
的访问方式:
fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
}
和其它语言的数组、字符串一样,元组的索引从 0 开始。
元组的使用示例
元组在函数返回值场景很常用,例如下面的代码,可以使用元组返回多个值:
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() 返回字符串的长度
(s, length)
}
calculate_length
函数接收 s1
字符串的所有权,然后计算字符串的长度,接着把字符串所有权和字符串长度再返回给 s2
和 len
变量。
在其他语言中,可以用结构体来声明一个三维空间中的点,例如 Point(10, 20, 30)
,虽然使用 Rust 元组也可以做到:(10, 20, 30)
,但是这样写有个非常重大的缺陷:
不具备任何清晰的含义
结构体
上一节中提到需要一个更高级的数据结构来帮助我们更好的抽象问题,结构体 struct
恰恰就是这样的复合数据结构,它是由其它数据类型组合而来。 其它语言也有类似的数据结构,不过可能有不同的名称,例如 object
、 record
等。
Rust的结构体类似golang的结构体,当然Rust的结构体中包含方法。这意味着Rust的结构体可以拥有自己的方法,而不仅仅是属性。这通过在结构体定义中使用 impl
块来实现。
还有一个与golang结构体的区别是:在如何处理结构体初始化时。在 Rust 中,结构体定义需要显式地声明字段的类型,而且必须提供所有字段的初始化值。在 Go 中,结构体定义只需要在字段名称后面添加它们的类型,而结构体的零值即为空值。这意味着在 Rust 中必须指定所有字段的初始化值或者利用类型的默认值。如果你不想为所有字段提供初始化值,你可以使用类似 Go 语言中 Struct 字面量才能完成部分字段初始化的功能。
其中Rust 的结构体可以实现 Trait(interface),从而获得Trait的功能及行为
结构体跟之前讲过的元组有些相像:都是由多种类型组合而成。但是与元组不同的是,结构体可以为内部的每个字段起一个富有含义的名称。因此结构体更加灵活更加强大,你无需依赖这些字段的顺序来访问和解析它们。
结构体语法
定义结构体
一个结构体由几部分组成:
- 通过关键字
struct
定义 - 一个清晰明确的结构体
名称
- 几个有名字的结构体
字段
例如, 以下结构体定义了某网站的用户:
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
该结构体名称是 User
,拥有 4 个字段,且每个字段都有对应的字段名及类型声明,例如 username
代表了用户名,是一个可变的 String
类型。
创建结构体实例
为了使用上述结构体,我们需要创建 User
结构体的实例:
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
有几点值得注意:
- 初始化实例时,每个字段都需要进行初始化
- 初始化时的字段顺序不需要和结构体定义时的顺序一致
访问结构体字段
通过 .
操作符即可访问结构体实例内部的字段值,也可以修改它们:
let mut user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");
需要注意的是,必须要将结构体实例声明为可变的,才能修改其中的字段,Rust 不支持将某个结构体某个字段标记为可变。
简化结构体创建
下面的函数类似一个构建函数,返回了 User
结构体的实例:
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
它接收两个字符串参数: email
和 username
,然后使用它们来创建一个 User
结构体,并且返回。可以注意到这两行: email: email
和 username: username
,非常的扎眼,因为实在有些啰嗦,如果你从 TypeScript 过来,肯定会鄙视 Rust 一番,不过好在,它也不是无可救药:
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}
如上所示,当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样。
结构体更新语法
在实际场景中,有一种情况很常见:根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1
实例来构建 user2
:
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
};
老话重提,如果你从 TypeScript 过来,肯定觉得啰嗦爆了:竟然手动把 user1
的三个字段逐个赋值给 user2
,好在 Rust 为我们提供了 结构体更新语法
:
let user2 = User {
email: String::from("another@example.com"),
..user1
};
因为 user2
仅仅在 email
上与 user1
不同,因此我们只需要对 email
进行赋值,剩下的通过结构体更新语法 ..user1
即可完成。
..
语法表明凡是我们没有显式声明的字段,全部从 user1
中自动获取。需要注意的是 ..user1
必须在结构体的尾部使用。
结构体更新语法跟赋值语句
=
非常相像,因此在上面代码中,user1
的部分字段所有权被转移到user2
中:username
字段发生了所有权转移,作为结果,user1
无法再被使用。明明有三个字段进行了自动赋值,为何只有
username
发生了所有权转移?仔细回想一下所有权那一节的内容,我们提到了
Copy
特征:实现了Copy
特征的类型无需所有权转移,可以直接在赋值时进行 数据拷贝,其中bool
和u64
类型就实现了Copy
特征,因此active
和sign_in_count
字段在赋值给user2
时,仅仅发生了拷贝,而不是所有权转移。值得注意的是:
username
所有权被转移给了user2
,导致了user1
无法再被使用,但是并不代表user1
内部的其它字段不能被继续使用,例如:
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
};
println!("{}", user1.active);
// 下面这行会报错
println!("{:?}", user1);
结构体的内存排列
先来看以下代码:
#[derive(Debug)]
struct File {
name: String,
data: Vec<u8>,
}
fn main() {
let f1 = File {
name: String::from("f1.txt"),
data: Vec::new(),
};
let f1_name = &f1.name;
let f1_length = &f1.data.len();
println!("{:?}", f1);
println!("{} is {} bytes long", f1_name, f1_length);
}
上面定义的 File
结构体在内存中的排列如下图所示:
从图中可以清晰地看出 File
结构体两个字段 name
和 data
分别拥有底层两个 [u8]
数组的所有权(String
类型的底层也是 [u8]
数组),通过 ptr
指针指向底层数组的内存地址,这里你可以把 ptr
指针理解为 Rust 中的引用类型。
该图片也侧面印证了:把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段。
元组结构体(Tuple Struct)
结构体必须要有名称,但是结构体的字段可以没有名称,这种结构体长得很像元组,因此被称为元组结构体,例如:
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。例如上面的 Point
元组结构体,众所周知 3D 点是 (x, y, z)
形式的坐标点,因此我们无需再为内部的字段逐一命名为:x
, y
, z
。
结构体数据的所有权
在之前的 User
结构体的定义中,有一处细节:我们使用了自身拥有所有权的 String
类型而不是基于引用的 &str
字符串切片类型。这是一个有意而为之的选择:因为我们想要这个结构体拥有它所有的数据,而不是从其它地方借用数据。
你也可以让 User
结构体从其它对象借用数据,不过这么做,就需要引入生命周期这个新概念(也是一个复杂的概念),简而言之,生命周期能确保结构体的作用范围要比它所借用的数据的作用范围要小。
总之,如果你想在结构体中使用一个引用,就必须加上生命周期,否则就会报错:
struct User {
username: &str,
email: &str,
sign_in_count: u64,
active: bool,
}
fn main() {
let user1 = User {
email: "someone@example.com",
username: "someusername123",
active: true,
sign_in_count: 1,
};
}
编译器会抱怨它需要生命周期标识符:
error[E0106]: missing lifetime specifier
--> src/main.rs:2:15
|
2 | username: &str,
| ^ expected named lifetime parameter // 需要一个生命周期
|
help: consider introducing a named lifetime parameter // 考虑像下面的代码这样引入一个生命周期
|
1 ~ struct User<'a> {
2 ~ username: &'a str,
|
error[E0106]: missing lifetime specifier
--> src/main.rs:3:12
|
3 | email: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | username: &str,
3 ~ email: &'a str,
|
在生命周期中会讲到如何修复这个问题以便在结构体中存储引用,在那之前,要尽量避免在结构体使用引用类型
使用 #[derive(Debug)\]
来打印结构体的信息
在前面的代码中我们使用 #[derive(Debug)]
对结构体进行了标记,这样才能使用 println!("{:?}", s);
的方式对其进行打印输出,如果不加,看看会发生什么:
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {}", rect1);
}
首先可以观察到,上面使用了 {}
而不是之前的 {:?}
,运行后报错:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
提示我们结构体 Rectangle
没有实现 Display
特征,这是因为如果我们使用 {}
来格式化输出,那对应的类型就必须实现 Display
特征,以前学习的基本类型,都默认实现了该特征:
fn main() {
let v = 1;
let b = true;
println!("{}, {}", v, b);
}
上面代码不会报错,那么结构体为什么不默认实现 Display
特征呢?原因在于结构体较为复杂,例如考虑以下问题:你想要逗号对字段进行分割吗?需要括号吗?加在什么地方?所有的字段都应该显示?类似的还有很多,由于这种复杂性,Rust 不希望猜测我们想要的是什么,而是把选择权交给我们自己来实现:如果要用 {}
的方式打印结构体,那就自己实现 Display
特征。
接下来继续阅读报错:
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
上面提示我们使用 {:?}
来试试,这个方式我们在本文的前面也见过,下面来试试:
println!("rect1 is {:?}", rect1);
可是依然无情报错了:
error[E0277]: `Rectangle` doesn't implement `Debug`
好在,聪明的编译器又一次给出了提示:
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
让我们实现 Debug
特征,Oh No,就是不想实现 Display
特征,才用的 {:?}
,怎么又要实现 Debug
,但是仔细看,提示中有一行: add #[derive(Debug)] to Rectangle
, 哦?这不就是我们前文一直在使用的吗?
首先,Rust 默认不会为我们实现 Debug
,为了实现,有两种方式可以选择:
- 手动实现
- 使用
derive
派生实现
来看看该如何使用:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {:?}", rect1);
}
此时运行程序,就不再有错误,输出如下:
$ cargo run
rect1 is Rectangle { width: 30, height: 50 }
这个输出格式看上去也不赖嘛,虽然未必是最好的。这种格式是 Rust 自动为我们提供的实现,看上基本就跟结构体的定义形式一样。
当结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?}
来替代 {:?}
,输出如下:
rect1 is Rectangle {
width: 30,
height: 50,
}
还有一个简单的输出 debug 信息的方法,那就是使用 dbg!宏
,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!
dbg!
输出到标准错误输出stderr
,而println!
输出到标准输出stdout
。
下面的例子中清晰的展示了 dbg!
如何在打印出信息的同时,还把表达式的值赋给了 width
:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect1);
}
最终的 debug 输出如下:
$ cargo run
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
width: 60,
height: 50,
}
可以看到,我们想要的 debug 信息几乎都有了:代码所在的文件名、行号、表达式以及表达式的值。
枚举
枚举(enum 或 enumeration)允许你通过列举可能的成员来定义一个枚举类型,例如扑克牌花色:
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}
ps:枚举就不用多说了
总而言之: 枚举类型是一个类型,它会包含所有可能的枚举成员, 而枚举值是该类型中的具体某个成员的实例。
枚举值
现在来创建 PokerSuit
枚举类型的两个成员实例:
let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;
我们通过 ::
操作符来访问 PokerSuit
下的具体成员,从代码可以清晰看出,heart
和 diamond
都是 PokerSuit
枚举类型的,接着可以定义一个函数来使用它们:
fn main() {
let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;
print_suit(heart);
print_suit(diamond);
}
fn print_suit(card: PokerSuit) {
println!("{:?}",card);
}
print_suit
函数的参数类型是 PokerSuit
,因此我们可以把 heart
和 diamond
传给它,虽然 heart
是基于 PokerSuit
下的 Hearts
成员实例化的,但是它是货真价实的 PokerSuit
枚举类型。
接下来,我们想让扑克牌变得更加实用,那么需要给每张牌赋予一个值:A
(1)-K
(13),这样再加上花色,就是一张真实的扑克牌了,例如红心 A。
目前来说,枚举值还不能带有值,因此先用结构体来实现:
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}
struct PokerCard {
suit: PokerSuit,
value: u8
}
fn main() {
let c1 = PokerCard {
suit: PokerSuit::Clubs,
value: 1,
};
let c2 = PokerCard {
suit: PokerSuit::Diamonds,
value: 12,
};
}
这段代码很好的完成了它的使命,通过结构体 PokerCard
来代表一张牌,结构体的 suit
字段表示牌的花色,类型是 PokerSuit
枚举类型,value
字段代表扑克牌的数值。
可以吗?可以!好吗?说实话,不咋地,因为还有简洁得多的方式来实现:
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(u8),
Hearts(u8),
}
fn main() {
let c1 = PokerCard::Spades(5);
let c2 = PokerCard::Diamonds(13);
}
直接将数据信息关联到枚举成员上,省去近一半的代码,这种实现是不是更优雅?
不仅如此,同一个枚举类型下的不同成员还能持有不同的数据类型,例如让某些花色打印 1-13
的字样,另外的花色打印上 A-K
的字样:
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(char),
Hearts(char),
}
fn main() {
let c1 = PokerCard::Spades(5);
let c2 = PokerCard::Diamonds('A');
}
回想一下,遇到这种不同类型的情况,再用我们之前的结构体实现方式,可行吗?也许可行,但是会复杂很多。
再来看一个来自标准库中的例子:
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
这个例子跟我们之前的扑克牌很像,只不过枚举成员包含的类型更复杂了,变成了结构体:分别通过 Ipv4Addr
和 Ipv6Addr
来定义两种不同的 IP 数据。
从这些例子可以看出,任何类型的数据都可以放入枚举成员中: 例如字符串、数值、结构体甚至另一个枚举。
增加一些挑战?先看以下代码:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {
let m1 = Message::Quit;
let m2 = Message::Move{x:1,y:1};
let m3 = Message::ChangeColor(255,255,0);
}
该枚举类型代表一条消息,它包含四个不同的成员:
Quit
没有任何关联数据Move
包含一个匿名结构体Write
包含一个String
字符串ChangeColor
包含三个i32
当然,我们也可以用结构体的方式来定义这些消息:
struct QuitMessage; // 单元结构体
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体
由于每个结构体都有自己的类型,因此我们无法在需要同一类型的地方进行使用,例如某个函数它的功能是接受消息并进行发送,那么用枚举的方式,就可以接收不同的消息,但是用结构体,该函数无法接受 4 个不同的结构体作为参数。
而且从代码规范角度来看,枚举的实现更简洁,代码内聚性更强,不像结构体的实现,分散在各个地方。
同一化类型
最后,再用一个实际项目中的简化片段,来结束枚举类型的语法学习。
例如我们有一个 WEB 服务,需要接受用户的长连接,假设连接有两种:TcpStream
和 TlsStream
,但是我们希望对这两个连接的处理流程相同,也就是用同一个函数来处理这两个连接,代码如下:
fn new (stream: TcpStream) {
let mut s = stream;
if tls {
s = negotiate_tls(stream)
}
// websocket是一个WebSocket<TcpStream>或者
// WebSocket<native_tls::TlsStream<TcpStream>>类型
websocket = WebSocket::from_raw_socket(
stream, ......)
}
此时,枚举类型就能帮上大忙:
enum Websocket {
Tcp(Websocket<TcpStream>),
Tls(Websocket<native_tls::TlsStream<TcpStream>>),
}
Option 枚举用于处理空值
在其它编程语言中,往往都有一个 null
关键字,该关键字用于表明一个变量当前的值为空(不是零值,例如整型的零值是 0),也就是不存在值。当你对这些 null
进行操作时,例如调用一个方法,就会直接抛出null 异常,导致程序的崩溃,因此我们在编程时需要格外的小心去处理这些 null
空值。
Tony Hoare,
null
的发明者,曾经说过一段非常有名的话:我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。
尽管如此,空值的表达依然非常有意义,因为空值表示当前时刻变量的值是缺失的。有鉴于此,Rust 吸取了众多教训,决定抛弃 null
,而改为使用 Option
枚举变量来表述这种结果。
Option
枚举包含两个成员,一个成员表示含有值:Some(T)
, 另一个表示没有值:None
,定义如下:
enum Option<T> {
Some(T),
None,
}
其中 T
是泛型参数,Some(T)
表示该枚举成员的数据类型是 T
,换句话说,Some
可以包含任何类型的数据。
Option<T>
枚举是如此有用以至于它被包含在了 prelude
(prelude 属于 Rust 标准库,Rust 会将最常用的类型、函数等提前引入其中,省得我们再手动引入)之中,你不需要将其显式引入作用域。另外,它的成员 Some
和 None
也是如此,无需使用 Option::
前缀就可直接使用 Some
和 None
。总之,不能因为 Some(T)
和 None
中没有 Option::
的身影,就否认它们是 Option
下的卧龙凤雏。
再来看以下代码:
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
如果使用 None
而不是 Some
,需要告诉 Rust Option<T>
是什么类型的,因为编译器只通过 None
值无法推断出 Some
成员保存的值的类型。
当有一个 Some
值时,我们就知道存在一个值,而这个值保存在 Some
中。当有个 None
值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T>
为什么就比空值要好呢?
简而言之,因为 Option<T>
和 T
(这里 T
可以是任何类型)是不同的类型,例如,这段代码不能编译,因为它尝试将 Option<i8>
(Option<T>
) 与 i8
(T
) 相加:
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
如果运行这些代码,将得到类似这样的错误信息:
error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
-->
|
5 | let sum = x + y;
| ^ no implementation for `i8 + std::option::Option<i8>`
|
数组
在日常开发中,使用最广的数据结构之一就是数组,在 Rust 中,最常用的数组有两种,第一种是速度很快但是长度固定的 array
,第二种是可动态增长的但是有性能损耗的 Vector
,在以后的章节中:我们称 array
为数组,Vector
为动态数组。
不知道你们发现没,这两个数组的关系跟 &str
与 String
的关系很像,前者是长度固定的字符串切片,后者是可动态增长的字符串。其实,在 Rust 中无论是 String
还是 Vector
,它们都是 Rust 的高级类型:集合类型,在后面章节会有详细介绍。
对于本章节,我们的重点还是放在数组 array
上。数组的具体定义很简单:将多个类型相同的元素依次组合在一起,就是一个数组。结合上面的内容,可以得出数组的三要素:
- 长度固定
- 元素必须有相同的类型
- 依次线性排列
这里再啰嗦一句,我们这里说的数组是 Rust 的基本类型,是固定长度的,这点与其他编程语言不同,其它编程语言的数组往往是可变长度的,与 Rust 中的动态数组 Vector
类似。
创建数组
在 Rust 中,数组是这样定义的:
fn main() {
let a = [1, 2, 3, 4, 5];
}
数组语法跟 JavaScript 很像,也跟大多数编程语言很像。由于它的元素类型大小固定,且长度也是固定,因此数组 array
是存储在栈上,性能也会非常优秀。与此对应,动态数组 Vector
是存储在堆上,因此长度可以动态改变。当你不确定是使用数组还是动态数组时,那就应该使用后者。
举个例子,在需要知道一年中各个月份名称的程序中,你很可能希望使用的是数组而不是动态数组。因为月份是固定的,它总是只包含 12 个元素:
let months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];
在一些时候,还需要为数组声明类型,如下所示:
let a: [i32; 5] = [1, 2, 3, 4, 5];
这里,数组类型是通过方括号语法声明,i32
是元素类型,分号后面的数字 5
是数组长度,数组类型也从侧面说明了数组的元素类型要统一,长度要固定。
还可以使用下面的语法初始化一个某个值重复出现 N 次的数组:
let a = [3; 5];
a
数组包含 5
个元素,这些元素的初始化值为 3
,聪明的读者已经发现,这种语法跟数组类型的声明语法其实是保持一致的:[3; 5]
和 [类型; 长度]
。
在元素重复的场景,这种写法要简单的多,否则你就得疯狂敲击键盘:let a = [3, 3, 3, 3, 3];
,不过老板可能很喜欢你的这种疯狂编程的状态。
访问数组元素
因为数组是连续存放元素的,因此可以通过索引的方式来访问存放其中的元素:
fn main() {
let a = [9, 8, 7, 6, 5];
let first = a[0]; // 获取a数组第一个元素
let second = a[1]; // 获取第二个元素
}
与许多语言类似,数组的索引下标是从 0 开始的。此处,first
获取到的值是 9
,second
是 8
。
越界访问
如果使用超出数组范围的索引访问数组元素,会怎么样?下面是一个接收用户的控制台输入,然后将其作为索引访问数组元素的例子:
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Please enter an array index.");
let mut index = String::new();
// 读取控制台的输出
io::stdin()
.read_line(&mut index)
.expect("Failed to read line");
let index: usize = index
.trim()
.parse()
.expect("Index entered was not a number");
let element = a[index];
println!(
"The value of the element at index {} is: {}",
index, element
);
}
使用 cargo run
来运行代码,因为数组只有 5 个元素,如果我们试图输入 5
去访问第 6 个元素,则会访问到不存在的数组元素,最终程序会崩溃退出:
Please enter an array index.
5
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这就是数组访问越界,访问了数组中不存在的元素,导致 Rust 运行时错误。程序因此退出并显示错误消息,未执行最后的 println!
语句。
当你尝试使用索引访问元素时,Rust 将检查你指定的索引是否小于数组长度。如果索引大于或等于数组长度,Rust 会出现 *panic*。这种检查只能在运行时进行,比如在上面这种情况下,编译器无法在编译期知道用户运行代码时将输入什么值。
这种就是 Rust 的安全特性之一。在很多系统编程语言中,并不会检查数组越界问题,你会访问到无效的内存地址获取到一个风马牛不相及的值,最终导致在程序逻辑上出现大问题,而且这种问题会非常难以检查。
数组元素为非基础类型
学习了上面的知识,很多朋友肯定觉得已经学会了Rust的数组类型,但现实会给我们一记重锤,实际开发中还会碰到一种情况,就是数组元素是非基本类型的,这时候大家一定会这样写。
let array = [String::from("rust is good!"); 8];
println!("{:#?}", array);
然后你会惊喜的得到编译错误。
error[E0277]: the trait bound `String: std::marker::Copy` is not satisfied
--> src/main.rs:7:18
|
7 | let array = [String::from("rust is good!"); 8];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::marker::Copy` is not implemented for `String`
|
= note: the `Copy` trait is required because this value will be copied for each element of the array
ps:由于String非原始数据类型,会出现经典的Rust归属性问题,因为在使用这种方法创造多个数组的时候使用的是浅拷贝而非深拷贝,故会出现该问题。
正确的写法,应该调用std::array::from_fn
let array: [String; 8] = std::array::from_fn(|i| String::from("rust is good!"));
println!("{:#?}", array);
数组切片
对于数组来说,数组切片允许我们引用数组的一部分:
let a: [i32; 5] = [1, 2, 3, 4, 5];
let slice: &[i32] = &a[1..3];
assert_eq!(slice, &[2, 3]);
上面的数组切片 slice
的类型是&[i32]
,与之对比,数组的类型是[i32;5]
,简单总结下切片的特点:
- 切片的长度可以与数组不同,并不是固定的,而是取决于你使用时指定的起始和结束位置
- 创建切片的代价非常小,因为切片只是针对底层数组的一个引用
- 切片类型[T]拥有不固定的大小,而切片引用类型&[T]则具有固定的大小,因为 Rust 很多时候都需要固定大小数据类型,因此&[T]更有用,
&str
字符串切片也同理
综合案例
fn main() {
// 编译器自动推导出one的类型
let one = [1, 2, 3];
// 显式类型标注
let two: [u8; 3] = [1, 2, 3];
let blank1 = [0; 3];
let blank2: [u8; 3] = [0; 3];
// arrays是一个二维数组,其中每一个元素都是一个数组,元素类型是[u8; 3]
let arrays: [[u8; 3]; 4] = [one, two, blank1, blank2];
// 借用arrays的元素用作循环中
for a in &arrays {
print!("{:?}: ", a);
// 将a变成一个迭代器,用于循环
// 你也可以直接用for n in a {}来进行循环
for n in a.iter() {
print!("\t{} + 10 = {}", n, n+10);
}
let mut sum = 0;
// 0..a.len,是一个 Rust 的语法糖,其实就等于一个数组,元素是从0,1,2一直增加到到a.len-1
for i in 0..a.len() {
sum += a[i];
}
println!("\t({:?} = {})", a, sum);
}
}
流程控制
80 后应该都对学校的小混混记忆犹新,在那个时代,小混混们往往都认为自己是地下王者,管控着地下事务的流程,在我看来,他们就像代码中的流程控制一样,无处不在,很显眼,但是又让人懒得重视。
言归正传,Rust 程序是从上而下顺序执行的,在此过程中,我们可以通过循环、分支等流程控制方式,更好的实现相应的功能。
使用if来做分支控制
if else 无处不在 – 鲁迅
但凡你能找到一门编程语言没有 if else
,那么一定更要反馈给鲁迅,反正不是我说的:) 总之,只要你拥有其它语言的编程经验,就一定会有以下认知:if else
表达式根据条件执行不同的代码分支:
ps:if else不用多说了吧
if condition == true {
// A...
} else {
// B...
}
先看下面代码:
fn main() {
let condition = true;
let number = if condition {
5
} else {
6
};
println!("The value of number is: {}", number);
}
以上代码有以下几点要注意:
if
语句块是表达式,这里我们使用if
表达式的返回值来给number
进行赋值:number
的值是5
- 用
if
来赋值时,要保证每个分支返回的类型一样
error[E0308]: if and else have incompatible types
--> src/main.rs:4:18
|
4 | let number = if condition {
| __________________^
5 | | 5
6 | | } else {
7 | | "six"
8 | | };
| |_____^ expected integer, found &str // 期望整数类型,但却发现&str字符串切片
|
= note: expected type `{integer}`
found type `&str`
else if多重条件
ps:不用多说了吧,代码中尽量少的出现if else
如果代码中有大量的 else if
会让代码变得极其丑陋,不过不用担心,下一章的 match
专门用以解决多分支模式匹配的问题。
循环控制
循环无处不在,上到数钱,下到数年,你能想象的很多场景都存在循环,因此它也是流程控制中最重要的组成部分之一。
在 Rust 语言中有三种循环方式:for
、while
和 loop
,其中 for
循环是 Rust 循环王冠上的明珠。
for
for
循环是 Rust 的大杀器:
fn main() {
for i in 1..=5 {
println!("{}", i);
}
}
以上代码循环输出一个从 1 到 5 的序列,简单粗暴,核心就在于 for
和 in
的联动,语义表达如下:
for 元素 in 集合 {
// 使用元素干一些你懂我不懂的事情
}
这个语法跟 JavaScript 还蛮像,应该挺好理解。
注意,使用 for
时我们往往使用集合的引用形式,除非你不想在后面的代码中继续使用该集合(比如我们这里使用了 container
的引用)。如果不使用引用的话,所有权会被转移(move)到 for
语句块中,后面就无法再使用这个集合了):
for item in &container {
// ...
}
对于实现了
copy
特征的数组(例如 [i32; 10] )而言,for item in arr
并不会把arr
的所有权转移,而是直接对其进行了拷贝,因此循环之后仍然可以使用arr
。
如果想在循环中,修改该元素,可以使用 mut
关键字:
for item in &mut collection {
// ...
}
总结如下:
使用方法 | 等价使用方式 | 所有权 |
---|---|---|
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() |
可变借用 |
如果想在循环中获取元素的索引:
fn main() {
let a = [4, 3, 2, 1];
// `.iter()` 方法把 `a` 数组变成一个迭代器
for (i, v) in a.iter().enumerate() {
println!("第{}个元素是{}", i + 1, v);
}
}
有同学可能会想到,如果我们想用 for
循环控制某个过程执行 10 次,但是又不想单独声明一个变量来控制这个流程,该怎么写?
for _ in 0..10 {
// ...
}
两种循环方式优劣对比
以下代码,使用了两种循环方式:
// 第一种
let collection = [1, 2, 3, 4, 5];
for i in 0..collection.len() {
let item = collection[i];
// ...
}
// 第二种
for item in collection {
}
第一种方式是循环索引,然后通过索引下标去访问集合,第二种方式是直接循环集合中的元素,优劣如下:
- 性能:第一种使用方式中
collection[index]
的索引访问,会因为边界检查(Bounds Checking)导致运行时的性能损耗 —— Rust 会检查并确认index
是否落在集合内,但是第二种直接迭代的方式就不会触发这种检查,因为编译器会在编译时就完成分析并证明这种访问是合法的 - 安全:第一种方式里对
collection
的索引访问是非连续的,存在一定可能性在两次访问之间,collection
发生了变化,导致脏数据产生。而第二种直接迭代的方式是连续访问,因此不存在这种风险(这里是因为所有权吗?是的话可能要强调一下)
由于 for
循环无需任何条件限制,也不需要通过索引来访问,因此是最安全也是最常用的,通过与下面的 while
的对比,我们能看到为什么 for
会更加安全。
continue / break
使用 continue
可以跳过当前当次的循环,开始下次的循环:
for i in 1..4 {
if i == 2 {
continue;
}
println!("{}", i);
}
上面代码对 1 到 3 的序列进行迭代,且跳过值为 2 时的循环,输出如下:
1
3
使用 break
可以直接跳出当前整个循环:
for i in 1..4 {
if i == 2 {
break;
}
println!("{}", i);
}
上面代码对 1 到 3 的序列进行迭代,在遇到值为 2 时的跳出整个循环,后面的循环不再执行,输出如下:
1
ps:这些都不用废话了吧?
while
fn main() {
let mut n = 0;
while n <= 5 {
println!("{}!", n);
n = n + 1;
}
println!("我出来了!");
}
打印
0!
1!
2!
3!
4!
5!
我出来了!
当然,你也可以用其它方式组合实现,例如 loop
(无条件循环,将在下面介绍) + if
+ break
:
fn main() {
let mut n = 0;
loop {
if n > 5 {
break
}
println!("{}", n);
n+=1;
}
println!("我出来了!");
}
可以看出,在这种循环场景下,while
要简洁的多。
ps:这个也不用说了吧
loop
对于循环而言,loop
循环毋庸置疑,是适用面最高的,它可以适用于所有循环场景(虽然能用,但是在很多场景下, for
和 while
才是最优选择),因为 loop
就是一个简单的无限循环,你可以在内部实现逻辑通过 break
关键字来控制循环何时结束。
使用 loop
循环一定要打起精神,否则你会写出下面的跑满你一个 CPU 核心的疯子代码:
fn main() {
loop {
println!("again!");
}
}
ps:类似while(true)
因此,当使用 loop
时,必不可少的伙伴是 break
关键字,它能让循环在满足某个条件时跳出:
fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2;
}
};
println!("The result is {}", result);
}
以上代码当 counter
递增到 10
时,就会通过 break
返回一个 counter * 2
的值,最后赋给 result
并打印出来。
这里有几点值得注意:
- break 可以单独使用,也可以带一个返回值,有些类似
return
- loop 是一个表达式,因此可以返回一个值
Q.E.D.