rust入坑小记-02-基础语法
1 常量
使用const声明常量,通常情况下,常量命名是全大写字母,可以用下划线_分隔单词。
1 | const PI:f64 = 3.1415926; |
- 常量可以声明在任何作用域,且在声明的作用域内一直有效
- 声明常量需要指定类型
- 常量不可以使用
mut关键字 - 常量只可以绑定到常量表达式,而不能绑定到函数的调用结果或者是运行时才计算出的值上
2 变量
2.1 声明变量
与ts类似,我们使用let声明变量。
1 | let a = 5; |
rust是强类型语言,由于编译器带类型推断功能,我们可以不显式指定类型,但有些情况需要显式指定:
1 | let b: i32 = 5; |
在rust中,还可以直接在数值后直接标注类型,或使用下划线_隔开:
1 | let c = 5i32; |
2.2 可变与不可变
在默认情况下,rust的变量是不可变的。
1 | let a = 5; |
提示你无法对不可变的变量进行两次绑定。
如需可变的变量,则需要用mut进行显式指定:
1 | let mut a = 5; |
2.3 所有权与变量绑定
上文提到了变量绑定。rust是一个无GC的语言,且又不像C/C++一样不关心内存安全,它采用“所有权系统“来保证内存安全。
简单来说,当我们声明一个变量并为它“赋值”的时候
1 | let a = String::from("hello world"); |
称这个值被变量a所拥有。在rust中,一个值只能同时被一个变量拥有。因此,当把一个变量“赋值”给另一变量时,就会发生所有权转移,以维护该规则
1 | let a = String::from("hello world"); |
所以,使用绑定而不是赋值可以更好地描述上述过程。
但是,对于简单的数据类型,比如数字类型:
1 | fn main() { |
a和b都能被正确打印,这是由于在第三行let b = a时,b的值是直接通过内存拷贝的方式实现的,即a和b都被分配了内存(二者都可用),就自然没有所有权转移一说。由于数字类型直接保存在栈中,无需在堆上分配内存,这种拷贝速度非常快。但是对于上面的字符串String,不是基本类型,它保存在堆中,对于这种复杂类型的数据不能自动拷贝,这种情况才会进行所有权的转移。
实际上,rust中的自动拷贝也叫做浅拷贝,它只发生在栈上。后面介绍特征的时候,会提到浅拷贝实际上是由于类型实现了Copy特征;相对应的,深拷贝不会被自动调用,当我们需要深拷贝一个在堆上的数据,比如String,需要使用Clone:
1 | let s1 = String::from("hello"); |
2.4 shadowing
shadowing翻译为变量隐藏(变量遮蔽)。在rust中允许使用同名的变量声明
1 | fn main() { |
使用let新声明的同名变量会将之前声明的变量遮蔽。与mut不同的是,这两个x都进行了内存的分配,而mut则没有第二次的内存分配。
变量的遮蔽与作用域有关,并且声明的同名新变量类型可以与之前不同,比如
1 | fn main() { |
这个特性很好地解决了起名困难这个千古难题。
2.5 引用
rust中的引用,可以避免所有权频繁发生转移所带来的代码复杂性。与C语言的语法很接近,创建引用就是绑定变量的内存地址:
1 | let x = 5; |
在rust中,引用可以理解为:可以获取到值,但并不获取所有权。因此,当引用离开作用域之后,被引用的值不会被回收。
在默认情况下,引用是不可变的:
1 | fn main() { |
要想解决这个问题,需要使用可变引用(报错信息中,编译器也会给予提示):
1 | fn main() { |
注意,变量的可变引用在同一作用域内最多只能有一个,这是为了使rust在编译期就避免数据竞争,数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
通过在编译期就检查这种错误,我们可以将潜在的运行时难以追踪,并且难以诊断和修复的问题扼杀在摇篮里:rust根本不会编译存在数据竞争的代码:
1 | fn main() { |
同理,可变引用与不可变引用不能同时存在,这也是为了避免使用不可变引用的数据莫名其妙地被修改:
1 | fn main() { |
总结一下借用规则:
- 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)
- 引用必须总是有效的
在后续的章节中,会频繁提到引用的概念。
3 数据类型
3.1 基本类型
数值类型
整型
整数是一个没有小数部分的数字。在rust中分为有符号整型和无符号整型两类,其中有符号整型使用i开头,无符号整型使用u开头,具体分类如下表:
| 长度 | 有符号 | 无符号 |
|---|---|---|
| 8-bit | i8 | u8 |
| 16-bit | i16 | u16 |
| 32-bit | i32 | u32 |
| 64-bit | i64 | u64 |
| 128-bit | i128 | u128 |
| arch(与架构相关) | isize | usize |
同大多属于语言一样,有符号整数采用补码方式存储,每一个有符号类型可以储存包含从n是指所使用的位数。比如i8可以储存从-128~127;无符号数可以储存包含从u8 可以储存从0~255。
isize 和 usize 类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。
可以使用下表的形式编写数字字面值:
| 数字字面值 | 例子 |
|---|---|
| Decimal (十进制) | 98_222 |
| Hex (十六进制) | 0xff |
| Octal (八进制) | 0o77 |
| Binary (二进制) | 0b1111_0000 |
Byte (单字节字符)(仅限于u8) | b'A' |
如果不确定该使用哪种类型的数字,可以使用rust的默认类型。数字类型默认是i32,isize 或 usize 主要作为某些集合的索引。
下划线_ 仅做为分隔符方便观察,比如字面量1_000,它的值与 1000 相同。
另外,假设当前有一个u8,可以存放0~255的值,如果将其修改为 256,那么会发生整型溢出(integer overflow)。这可能会导致以下后果:
- 当在
debug模式编译时,rust检查这类问题并使程序panic(运行时发生错误而退出) - 当在
release模式中编译时,rust不会检查这类整型溢出,而是会进行一种二进制补码循环溢出(two’s complement wrapping)的操作,大于该类型最大值的数值会回绕到最小值,值256变成0,值257变成1,依此类推。程序不会panic,不过变量可能也不会是你所期望的值。依赖补码循环溢出的行为被认为是一种错误。
使用
cargo build时,默认构建方式为debug模式;当项目最终准备好发布时,可以使用cargo build --release来优化编译项目。这会在target/release而不是target/debug下生成可执行文件。这些优化可以让最终的可执行代码运行得更快,不过启用这些优化也需要消耗更长的编译时间。这也就是为什么会有两种不同的配置:一种是为了开发,你需要经常快速重新构建;另一种是为用户构建最终程序,它们不会经常重新构建,并且希望程序运行得越快越好。如果为了测试代码的运行时间,请确保运行
cargo build --release并使用target/release下的可执行文件进行测试。
为了显式地处理溢出的可能性,可以使用这几类标准库提供的原始数字类型方法:
- 所有模式下都可以使用
wrapping_*方法进行循环溢出,如wrapping_add - 如果
checked_*方法出现溢出,则返回None值 - 用
overflowing_*方法返回值和一个布尔值,表示是否出现溢出 - 用
saturating_*方法在值的最小值或最大值处进行饱和处理
比如使用wrapping_* 方法:
1 | fn main() { |
浮点型
rust拥有两个浮点型:f32和f64,f32 是单精度浮点数,f64 是双精度浮点数。默认类型是 f64,这是因为在现代CPU中,它与 f32 速度几乎一样,不过精度更高。所有的浮点型都是有符号的,浮点型根据IEEE-754 标准实现。
下面的示例展示了浮点数的定义:
1 | fn main() { |
数值运算
rust中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向下舍入到最接近的整数。
1 | fn main() { |
这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,然后绑定给一个变量。
NaN
对于数学上未定义的结果,例如对负数取平方根 -42.1.sqrt() ,会产生一个特殊的结果,rust的浮点数类型使用 NaN (not a number)来处理这些情况。
所有跟 NaN 交互的操作,都会返回一个 NaN,而且 NaN 不能用来比较,下面的代码会崩溃:
1 | fn main() { |
出于防御性编程的考虑,可以使用 is_nan() 等方法,可以用来判断一个数值是否是 NaN :
1 | fn main() { |
位运算
rust中的位运算符如下,与大多数语言是相同的:
| 运算符 | 说明 |
|---|---|
| & | 相同位置均为1时则为1,否则为0 |
| | | 相同位置只要有1时则为1,否则为0 |
| ^ | 相同位置不相同则为1,相同则为0 |
| ! | 把位中的0和1相互取反,即0置为1,1置为0 |
| << | 所有位向左移动指定位数,右位补0 |
| >> | 所有位向右移动指定位数,带符号移动(正数补0,负数补1) |
另外,这些运算符加上=都可以用来赋值(取反!除外,与!=冲突),比如:
1 | fn main() { |
布尔型
正如其他大部分编程语言一样,rust中的布尔类型有两个值:true 和 false,布尔类型使用 bool 表示。
1 | fn main() { |
字符类型
char 类型是rust中最原生的字母类型。所有的 Unicode 值都可以作为字符,由于 Unicode 都是 4 个字节编码,因此字符类型也是占用 4 个字节。
下面是一些例子:
1 | fn main() { |
我们用单引号声明 char 字面量,而与之相反的是,使用双引号声明字符串字面量。中文、日文、韩文等字符,emoji、以及零长度的空白字符都是有效的 char 值。Unicode标量值包含从 U+0000 到 U+D7FF 和 U+E000 到 U+10FFFF 在内的值。
单元类型
单元类型就是 (),它的类型和值都是()。比如fn main() 入口函数的返回值就是(),println!() 的返回值也是单元类型 ()。
再比如,你可以用 () 作为 map 的值,表示我们不关注具体的值,只关注 key。 这种用法和 Go 语言的struct{}类似,可以作为一个值用来占位,但是完全不占用任何内存。
3.2 复合类型
元组类型
元组(tuple)可以将多个类型组合到一起,且一旦创建,长度固定不可再变化,元组中元素的顺序也是固定的。
创建元组使用(),用逗号,分隔开各个元素,元素的数据类型可以不相同:
1 | fn main() { |
获取元组中的值可以按位置.0、.1,也可以通过解构:
1 | fn main() { |
即:使用相同形式的x,y,z三个变量来接收元组中的值。
数组类型
rust中的数组分为两类,一种是长度固定的 array,另一种是动态长度数组 Vector。在内存分配方面,前者直接分配在栈上,效率非常高;后者则分配在堆上,具有更大的灵活性,但效率比前者要低。
对于array,它的性质与大部分语言的数组性质一样,长度固定、元素的数据类型必须相同:
1 | fn main() { |
有些时候,还需要显式指定数组的类型与长度:
1 | fn main() { |
当然,对于初始化一个元素相同的数组,有一种简便写法:
1 | fn main() { |
访问数组中的元素与其他语言无异,且越界访问数组中的元素会在运行时导致panic。
1 | fn main() { |
与go语言的切片类似,rust也可以取数组中的一部分连续片段:
1 | fn main() { |
有关动态数组的讲解放到后面,详见:动态数组 Vector
结构体
(1)结构体定义与初始化
结构体通过struct定义,与C/C++非常类似:
1 | struct MysqlConnection { |
根据结构体定义,可以创建实例,创建实例时我们需要为每个字段都初始化:
1 | fn main() { |
通过.操作符访问结构体,也可以直接修改这些值,如果要修改结构体,需要用mut,rust目前不支持为结构体的每个字段设置mut:
1 | fn main() { |
我们还可以从一个结构体中解构一些字段创建另一个结构体:
1 | fn main() { |
如果你了解typescript,这种..语法应该不会感到陌生,但是在rust中,..必须在结构体的结尾使用,并且未实现Copy特征的类型会进行所有权转移。
(2)使用字段初始化简写语法
看一个例子:
1 | struct User { |
这里我们声明了一个结构体,并创建了一个 build_user 函数,它返回一个带有给定的 email和 User 的结构体实例。active 字段的值为 true,并且 sign_in_count 的值为 1。
为函数参数起与结构体字段相同的名字是可以理解的,但是不得不重复 email 和 username 字段名称与变量有些啰嗦。如果结构体有更多字段,重复每个名称就更加烦人了。对于参数名与字段名都完全相同的结构体,我们可以使用字段初始化简写语法(field init shorthand)来重写 build_user。
1 | fn build_user(email: String, username: String) -> User { |
这里我们创建了一个新的 User 结构体实例,它有一个叫做 email 的字段。我们想要将 email 字段的值设置为 build_user 函数 email 参数的值。因为 email 字段与 email 参数有着相同的名称,则只需编写 email 而不是 email: email。
(3)元组结构体
也可以定义与元组类似的结构体,称为元组结构体(tuple structs)。元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余和形式化了。
要定义元组结构体,以 struct 关键字和结构体名开头并后跟元组中的类型。例如,下面是两个分别叫做 Color 和 Point 元组结构体的定义和用法:
1 | struct Color(i32, i32, i32); |
注意 black 和 origin 值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段可能有着相同的类型。例如,一个获取 Color 类型参数的函数不能接受 Point 作为参数,即便这两个类型都由三个 i32 值组成。在其他方面,元组结构体实例类似于元组,你可以将它们解构为单独的部分,也可以使用 . 后跟索引来访问单独的值,等等。
(4)没有任何字段的类单元结构体
我们也可以定义一个没有任何字段的结构体。它们被称为类单元结构体(unit-like structs)因为它们类似于单元类型()。类单元结构体常常在你想要在某个类型上实现特征但不需要在类型中存储数据的时候发挥作用:
1 | struct AlwaysEqual; // 类单元结构体 |
我们将在后面介绍特征,见特征Trait。
(5)打印结构体
为了更方便地观察结构体,我们需要打印结构体。但直接打印会报错:
1 | struct Socket { |
编译器提示Socket没有实现 std::fmt::Display特征,特征的概念将会在后续讲到。继续往下看,编译器提示:
尝试使用{:?}来代替{},我们按照提示试一试,还是会报错:
1 | fn main() { |
这次的错误是:Socket没有实现Debug特征,并给出了解决方案。一个是添加 #[derive(Debug)],另外一个就是手动为Socket实现这个Debug特征。我们尝试使用前者使用 derive 派生:
1 | // ...省略其它代码 |
运行,终于将结构体打印出来了。另外,编译器刚刚还提到使用{:#?},这样会pretty-print,也就是会有更好的输出样式:
1 | // ...省略其它代码 |
枚举
相比C,rust的枚举更加好用了。首先是定义,看例子:
1 |
|
使用enum关键字来定义枚举,Animal是自定义的枚举类型名,枚举类型下面可以包含多种不同的类型,比如:猫和狗都属于动物,获取枚举中成员的实例使用::操作符。
接下来,我们需要让枚举更有意义,可以给每个子类型设置一个名字:
1 |
|
现在我们有一只名叫crookshanks的猫和一只名叫cookie的狗。当然,我们还可以在同一个枚举类型中使用不同的数据类型,并且并不止限于基本类型,任何类型的数据都可以放入枚举成员中:
1 |
|
Option枚举类是rust标准库中的枚举类,这个类用于填补rust不支持null引用的空白。像是java、C/C++、javascrpit这些语言中,都有一个关键字null,表示变量当前的值为空。null可以高效地解决少量的问题,但是带来的缺点也是致命的:在开发者把一切都当作不是null的时候给予程序致命一击,一旦对 null 进行操作,程序的运行就要彻底终止。因此我们在编程时需要格外的小心去处理这些 null 空值。
托尼·霍尔(Tony Hoare)是快速排序算法的创造者,也是图灵奖(计算机领域的诺贝尔奖)的获得者。他把 Null 添加到了 ALGOL 语言中,因为它看起来很实用而且容易实现。但几十年后,他后悔了。
Tony 表示,1965 年把 Null 引用加进 ALGOL W 时的想法非常简单,“就是因为这很容易实现。”
但如今再次谈到当初的决定时,他表示这是个价值十亿美元的大麻烦:
“ 我称之为我的十亿美元错误……当时,我正在设计第一个全面的类型系统,用于面向对象语言的引用。我的目标是确保所有对引用的使用都是绝对安全的,由编译器自动执行检查。但是我无法拒绝定义一个 Null 引用的诱惑,因为它实在太容易实现了。这导致了无法计数的错误、漏洞和系统崩溃。在过去的四十年里,这些问题可能已经造成了十亿美元的损失。”
rust在语言层面彻底不允许空值null的存在,而改为使用 Option 枚举变量来处理需要用到null的这部分问题。
在rust程序运行前,会自动引入prelude标准库的内容,包括了一些常用的类型、函数等,Option也包括在内,因此我们的rust程序可以直接使用它(也可以直接使用Some,None,无需使用 Option::限定符)。
1 | enum Option<T> { |
好了,为什么说使用Option比null更安全呢?个人理解是这样的,null把可能为空的问题隐藏掉而不去处理,使用Option则编译器会限制你:如果你使用Option里的值不处理空值,编译器就不会通过编译。举个例子:
1 | fn main() { |
这里我们声明了一个变量b为Some(3),表示b可能为空,也可能为3(虽然这里肯定为3,但你可以把它想象成获取用户的输入)。当我们尝试相加a、b,就会因为类型不同而无法相加。事实上,a这样的值在rust中一定不为空(编译器确保它总是有一个有效的值),这意味着你无需对它们做空值防护,只有Option类型你才需要去关心值是否为空。如果你想计算a+b,那就必须将Some中的值提取出来,在提取的过程中,这个值可能有效,可能为空,我们需要分别进行处理。通过这样的处理,你就可以保证a+b是安全的。
那么该如何提取Some(T)中的T呢,这里需要用到模式匹配match:
1 | fn main() { |
我们会在后面继续介绍match相关内容(见模式匹配)。但现在,需要首先关注一下Option::None,如果使用 None,即你定义你的变量刚开始是空值,那你需要体谅一下编译器,它怎么知道值不为空的时候变量是什么类型的呢?所以需要需要手动指定:
1 | let v : Option<i32> = None; |
3.3 如何获取一个值的类型
rust目前还没有类似于typeof或者type这样判断变量类型的关键字。
参考@Boiethios 的回答,你可以使用 std::any::type_name,示例代码如下:
1 | fn print_type_of<T>(_: T) { |