Rust:语句、表达式、类型
Rust:语句、表达式、类型
- 语句与表达式
- 一切皆表达式
- 块表达式
- 变量与绑定
- 变量遮蔽
- 初始化
- 延迟初始化
- 类型系统与类型标注
- 类型推导
- 显式类型标注
- 标量类型
- 整数类型
- 整数溢出
- 浮点数类型
- 布尔类型
- 字符类型
- never 类型
- 数值字面量的修饰
- 进制表示
- 下划线分隔符
- 类型后缀
- 科学计数法
- 字节字面量
- 类型转换
- 常量与静态
- 常量
- 静态变量
语句与表达式
在 Rust 中,语法可以分为两大类: 语句 与 表达式,语句是指要执行的一些操作以及产生副作用的表达式,表达式主要用于计算求值。
- 当一个条目没有分号,那么它是一个表达式
- 当一个条目以分号结尾,那么它是一个语句
例如:
x // 表达式
1 // 表达式
1 + 1 // 表达式
1 + x // 表达式
add(x, y) // 表达式let x = 100; // 语句
x; // 语句
1 + x; // 语句
x = 1 + x; // 语句
对于一个表达式,一定会得到一个结果,而对于一个语句,它往往是为了完成某些功能,比如赋值。
比如对于语句 x = 1 + x;,假设初始 x = 100。那么=右侧的1 + x就是一个表达式,它求出一个值 101,对于整个带分号的条目,叫做一个语句,它完成的是赋值的功能。
一切皆表达式
语句可以进一步分为两种:
声明语句:用于声明各种语言项,比如声明变量、声明结构体,函数,引入包和模块等
对于声明语句,它分号前不产生任何值,它用于完成某些 Rust 语言级别的功能,比如声明变量let x = 1;,引入模块 use std::collections;。
表达式语句:以分号结尾的表达式
对于表达式语句,由表达式产生一个值,在产生值的过程中可能导致副作用,在整个表达式末尾加一个分号形成语句,此时语句会把表达式产生的值丢弃,只保留副作用。
例如 x + 1,它是一个表达式,整体返回的是一个加法的结果值。如果我在后面添加一个分号 x + 1;,此时就构成一个语句,这个语句把计算结果丢掉了,但是它没有产生副作用,因此它没有任何意义。
再比如 printl!("hello!");,分号前面是一个函数调用表达式,它会产生一个单元值(),可以理解为一个空值,这个后续会讲解。加了一个分号后形成语句,此时把函数生成的值丢掉了。但是这个函数的副作用是往显示器上打印一个hello!,因此把表达式变成语句,可以只关注他的副作用,而忽略结果。
再比如 x = 1,这是一个赋值表达式,整体返回一个单元值(),由于分号,它被忽略了。但是它的副作用是把x赋值,这就是一个有意义的表达式语句。
现在你可能有一些懵了,我讲这些做什么?这不是Rust的入门博客吗?为什么讲这些不明所以没有实用性内容?
因为:
在 Rust 中,一切皆表达式
对这句话的理解,将很大程度影响后续你对其它模块的学习,它是一切Rust语法的基石。
块表达式
如果你有其它语言的学习经验,那么你很可能听过 if 语句 这样的表述,但是在 Rust 中,if 是一个表达式,例如:
if x == 1 {println!("hello!");
}
也许你还没学到 if,以上代码的含义是:假如 x值为 1,那么打印一个hello!到屏幕上。你可以回看我刚才对声明语句的定义,请问if是一个声明语句吗?很明显不是,所以if是一个表达式。
此处的逻辑为:Rust只有两种语句:声明语句和表达式语句,只要不是声明语句,那么就是表达式语句,表达式语句前面一定是一个表达式,表达式一定会返回一个值。由此得出结论:if 是一个表达式,且会返回一个值!
因此你可以写出以下代码:
let ret = if x == 1 { println!("hello!");
};
看花眼了吧?这里其实就是在之前代码的前面加了一个 let ret = ,也就是进行赋值,用一个变量把if 的返回值接收起来。此处 if 返回的是一个单元值(),因此ret 就是 ()。
对于这种使用 {} 框起来的代码,Rust 把它视为一个表达式,称为块表达式,返回的值等于 { } 最后一个表达式的值。
比如说:
let ret = if x == 1 {println!("hello!"); 2025
};
聪明的你肯定理解了,ret拿到的值是2025,因为它是 { } 内最后一个表达式。
现在有几个需要额外说明的小细节:
- 最后一个
2025可以带分号吗?
不可以,如果2025;带上分号就变成一个表达式语句,此时 { } 最后一个值不是一个表达式,就会返回一个单元值()。
- 为什么
{ }内最后一行是语句的时候,会返回单元值()?
回看这个例子:
let ret = if x == 1 { println!("hello!");
};
我说ret会接受到一个(),可是为什么?
这是因为:当Rust解析代码时,会一条一条语句进行执行,每当看到;就视为一个语句结束,下一条语句开始,此时分为三种情况:
- 假如
;后面是一个表达式,就对表达式求值 - 假如
;后面是一个语句,就执行该语句 - 假如
;后面什么也没有,此时Rust自动补上一个()表达式
因此以上代码等效于:
let ret = if x == 1 { println!("hello!"); ()
};
当Rust解析代码,发现println!("hello!");语句结束,开始解析下一条语句,但是后面啥也没有了,于是最后补上一个()。
这么做的目的,是因为表达式必须返回一个值,{ } 是一个表达式,它必须保证最后有一个值返回给外部。
包括我之前说把2025改成2025;的例子,修改后也会返回(),就是因为在;后面会偷偷加上一个()。
- 为什么
if末尾没有分号
对于if,它可以像下面这样使用:
let x = 1;if x == 1 {x *= 10;
} // 这里没有分号println!("world!");
问题在于,为什么if末尾没有分号?如果它没有分号就是一个表达式,岂不是if 和 println 整体构成一个语句?
这里其实也是一个Rust底层的优化。在很多其它语言中,if本身就是一个语句,它末尾不需要;也会构成语句。因此大部分其他语言的习惯是if末尾不写分号。
对于Rust来说,很明确区分了语句和表达式,有;就是语句,没有就是表达式。如果强制所有用户if末尾写一个分号,可能会有些别扭。因此当if表达式单独出现的时候,Rust会在其末尾偷偷补一个分号,不需要用户自己写。从语法规则看,这是因为 Rust 允许控制流表达式直接作为语句出现,相当于结果被丢弃。
以上代码等效于:
let x = 1;if x == 1 {x *= 10;
}; // 这里有一个分号println!("world!");
但是以下写法是非法的:
let x = 1;let ret = if x == 1 { println!("hello!");
} // 这里没有分号println!("world!");
因为第二行整个区域已经不是一个if表达式了,if并不是单独出现的,而是一个声明,此时Rust不会在末尾补充;,导致println与let挤在一个语句内部,编译器报错。
在rust中还有很多其它使用{ }的地方,它们都可以通过我以上的表达式理论进行理解,比如函数返回值,match的返回值等等,这些会在后续的博客讲解。
最后,以上内容其实并不难理解,它是Rust的基石。我在这里写的非常细致,主要是强调:
Rust只有表达式和语句,语句也只分为声明语句和表达式语句- 换个说法:
rust只由表达式、表达式语句、声明语句构成
- 换个说法:
- 一个表达式有
;就是语句,没有就是表达式,表达式语句去掉分号会变成表达式 - 声明语句去掉分号直接报错,不能当作表达式
- 对于后续的
if、loop之类的特例,它们没有分号,是一个表达式。但是它们单独出现的时候,会被Rust偷偷补上;变成语句,这没有违背前两条规则
变量与绑定
在Rust中,使用let关键字来声明一个变量,这借鉴了其他的函数式语言风格,因为Rust也是带有函数式风格一门语言。
例如:
let x = 1;
就是声明了一个叫做 x 的变量,它指向的内存存储了1这个值。
默认情况下,你的绑定代表某个位置,但这个位置是“只读”的:
let a = 1;
a = 2; // error: a 是不可变绑定
对于直接使用let绑定的变量,是不可修改的,称为不可变绑定。
使用let mut 声明一个可变绑定:
let mut b = 2;
b = 3; // 合法,可变绑定
这其实是Rust安全性的一种体现,默认不可变。
对于大多数情况下,很多程序其实是在访问不会变化的数据,较少的情况才会对数据修改,也就是读的频率大于写的频率。
但是很多其它语言,声明一个不可变的值会比可变的值更复杂,例如C++:
int x = 1; // 可变
const int y = 2; // 不可变
这会导致很多程序员不管值未来是否变化,都直接声明为可变的,从代码逻辑上没什么影响,但是这往往带来安全隐患。
所以Rust把不可变作为默认行为,如果你需要可变,需要付出额外写一个关键字的成本,从心理上就让程序员尽可能把变量声明为不可变,减少了安全隐患。
变量遮蔽
Rust允许用相同的名字声明新变量,新变量会遮蔽(shadow)之前的变量:
遮蔽是重新绑定,不是修改原变量。它常用在更小的作用域里用计算后的新值而不污染外层作用域。与可变不同,遮蔽可以改变类型。
let x = 5;
let x = x + 1; // 遮蔽前一个x,创建新变量
println!("{}", x); // 6{let x = x * 2; // 在内部作用域中遮蔽println!("{}", x); // 12
}println!("{}", x); // 6,外层作用域的x
- 遮蔽:创建新变量,可以改变类型
- 可变性:修改同一变量的值,类型不能改变
// 遮蔽:可以改变类型
let spaces = " "; // &str 类型
let spaces = 10; // usize 类型// 可变性:不能改变类型
let mut spaces = " ";
spaces = 10; // 错误:类型不匹配
初始化
初始化一个变量与大多数语言一致,在声明时通过赋值操作符 = 给变量一个初始值。
let x = 1;
在 Rust 中,一个变量必须进行初始化,否则编译不通过。
延迟初始化
Rust 允许用户延迟初始化一个变量。延迟初始化常用于先声明、后在分支或计算结果确定后再赋值的场景。
let x; // 只声明,不初始化
x = 42; // 稍后初始化
println!("x = {}", x); // 合法
即使变量是不可变的(没有mut),延迟初始化仍然合法,变量的不可变性指的是初始化后不能重新赋值 。
这个延迟初始化可以延迟到同作用域内的任意位置,但是在多分支情况下,必须保证每个分支都会对该变量进行初始化。
示例:
let bl = false;
let x;if bl {x = 10;
} else {x = 101;
}
以上代码中,不论bl的值为多少,最后x一定会完成初始化,因此合法。
此外,对于延迟初始化,还要保证每一个分支的初始值类型是相同的。
if bl {x = 10;
} else {x = 3.14;
}
以上代码就是非法的,因为编译器最后无法确定x的最终类型。10默认为数字类型,而3.14是浮点型,它们类型不一致。
类型系统与类型标注
与绝大多数语言一样,Rust也提供类型系统,每个变量都有指定的类型。Rust是静态类型语言,编译时必须知道所有变量的类型。
·
Rust提供了两种机制在编译期得知变量的类型:
- 类型推断:编译器智能推断类型
- 显式标注:程序员明确指定类型
类型推导
绑定变量时,编译器可以根据值和使用上下文推断类型:
let x = 5; // 推断为 i32(整数默认类型)
let y = 3.14; // 推断为 f64(浮点数默认类型)
let z = true; // 推断为 bool
let s = "hello"; // 推断为 &str
这种推断语法,可以大部分类型简单的场景下简化编码,不用为每一个变量指明类型。
延迟初始化时,也可以触发类型推断:
let number; // 类型未知
number = 42; // 推断为 i32let data; // 类型未知
data = String::from("hello"); // 推断为 String
Rust 的推断是局部的:不会跨函数、跨模块进行全局推断。当上下文不足时,必须显式标注类型或使用类型后缀。
显式类型标注
在部分场景下,编译器无法很好的确定一个变量的最终类型,此时就需要程序员进行手动标注,语法如下:
let 变量名: 类型 = 值;
示例:
let x: i32 = 5; // 明确指定为 i32
let y: f32 = 3.14; // 明确指定为 f32
标量类型
整数类型
Rust 提供了丰富的整数类型,每种都明确指定位数和符号性。你可以根据需要选择合适的类型,既不会浪费内存,又能保证计算精度。
整数类型分为两大类:有符号(i 开头)和无符号(u 开头)。有符号可以表示正数、负数和零,无符号只能表示非负数。
- 固定宽度整数
| 类型 | 长度 | 范围 |
|---|---|---|
i8 | 8-bit | [ − 2 7 , 2 7 − 1 ] [-2^7, 2^7 - 1] [−27,27−1] |
u8 | 8-bit | [ 0 , 2 8 − 1 ] [0, 2^8 - 1] [0,28−1] |
i16 | 16-bit | [ − 2 15 , 2 15 − 1 ] [-2^{15}, 2^{15} - 1] [−215,215−1] |
u16 | 16-bit | [ 0 , 2 16 − 1 ] [0, 2^{16} - 1] [0,216−1] |
i32 | 32-bit | [ − 2 31 , 2 31 − 1 ] [-2^{31}, 2^{31} - 1] [−231,231−1] |
u32 | 32-bit | [ 0 , 2 32 − 1 ] [0, 2^{32} - 1] [0,232−1] |
i64 | 64-bit | [ − 2 63 , 2 63 − 1 ] [-2^{63}, 2^{63} - 1] [−263,263−1] |
u64 | 64-bit | [ 0 , 2 64 − 1 ] [0, 2^{64} - 1] [0,264−1] |
i128 | 128-bit | [ − 2 127 , 2 127 − 1 ] [-2^{127}, 2^{127} - 1] [−2127,2127−1] |
u128 | 128-bit | [ 0 , 2 128 − 1 ] [0, 2^{128} - 1] [0,2128−1] |
- 架构相关整数
| 类型 | 长度 | 范围 |
|---|---|---|
isize | n ∈ { 32 , 64 } n \in \{32, 64\} n∈{32,64} | [ − 2 n − 1 , 2 n − 1 − 1 ] [-2^{n-1}, 2^{n-1} - 1] [−2n−1,2n−1−1], n ∈ { 32 , 64 } n \in \{32, 64\} n∈{32,64} |
usize | n ∈ { 32 , 64 } n \in \{32, 64\} n∈{32,64} | [ 0 , 2 n − 1 ] [0, 2^n - 1] [0,2n−1], n ∈ { 32 , 64 } n \in \{32, 64\} n∈{32,64} |
isize 和 usize 的位数 n n n 取决于目标架构:
- 在 32 位系统(如 x86)上
n = 32 - 在 64 位系统(如 x64)上
n = 64
架构相关的整数,可以在按照系统自适应整形大小。
如果你想知道某个类型到底占用多少字节,可以使用 std::mem::size_of:
use std::mem;
println!("i8: {} bytes", mem::size_of::<i8>()); // 1
println!("i32: {} bytes", mem::size_of::<i32>()); // 4
println!("i64: {} bytes", mem::size_of::<i64>()); // 8
println!("usize: {} bytes", mem::size_of::<usize>()); // 4 或 8
size_of 返回的是字节数,不是位数。1 字节 = 8 位,所以 i32 占用 4 字节 = 32 位。
当直接写整数字面量时,默认使用 i32:
let x = 42; // 推断为 i32
这是因为 i32 在大多数场景下是性能和空间的最佳平衡点,即使在 64 位系统上也是如此。
整数溢出
Rust 的程序构建时,分为两种模式,debug调试构建 和 release发布构建。
在 debug 模式下,会有更多的调试信息输出,一般用于开发环境。而 release 下会对代码进行更大幅度的优化,运行效率更高,一般用语正式发布环境。
当一个整数发生溢出的时候,在两个环境下效果也不同。
在 debug 模式下,如果整数溢出,此时会直接报错,在 Rust 中称为 panic。这会导致整个程序直接终止。
但是在 release 下,如果溢出会发生环绕。
比如:
let mut x: u8 = 255;
x += 1;
println!("x: {}", x);
这段代码在 debug 模式下直接报错退出,但是在 release 下输出 x: 0。因为 255 已经是 u8 的最大值了,当再加一个数,就会重新变回最小的值,这个过程称为环绕。
其实环绕大部分情况下是一个非常危险的操作,在开发的时候,Rust倾向于直接把这个行为作为一个错误报告给开发者,让开发者可以修改代码逻辑,或者更改更大的类型。
但是在实际发布环境,程序崩溃往往会给用户带来不好的体验,那么Rust就不再把它当做一个错误处理了。
浮点数类型
Rust有两种浮点数类型:f32(单精度)和f64(双精度),它们都遵从 IEEE-754 标准。
let x = 2.0; // f64(默认,推荐)
let y: f32 = 3.0; // f32(显式标注)// 科学记数法
let large = 1e6; // 1000000.0 (f64)
let small = 1e-6; // 0.000001 (f64)
一个浮点数类型的字面量默认为 f64,与现代CPU性能相近但精度更高。
布尔类型
布尔类型只表示两种真值,不与数字互转,这能防止很多隐式转换陷阱。与控制流(if/while)结合时,编译器要求条件表达式必须是 bool,从而让代码更清晰、更安全。
let t = true; // bool类型
let f: bool = false; // 显式标注// 布尔运算
let and_result = t && f; // false
let or_result = t || f; // true
let not_result = !t; // false
特点:占用1字节,只能是true或false,不能与数字隐式转换。
字符类型
char 表示一个 Unicode 标量值,它可以存储英文字母,中文字符,拉丁文等等。Rust的char类型占4字节,使用单引号''来声明。
let c = 'z'; // ASCII字符
let unicode = 'ℤ'; // Unicode字符
let chinese = '中'; // 中文
never 类型
Rust 还有一个特殊的类型叫做 !,读作 “never”(永不类型)。这个类型表示计算永远不会正常完成。
! 没有实际的值,因为对应的计算永远不会返回结果,可以转换为任何其他类型,主要用于表示程序会永久阻塞或直接退出的情况。
比如说loop 循环:
loop {// 死循环
}
这是一个表达式,它会返回一个值,而死循环永远不退出,因此外部其实永远得不到这个值,那么死循环返回的值就是一个 ! 表示 never 类型。
可以尝试用变量接收:
let n: ! = loop{ };
变量n会被自动推断为!类型。
此外,! 可以转换为任意其它类型:
let other_num: i32 = loop{};
let other_flo: f32 = loop{};
let other_bol: bool = loop{};
以上代码都是合法的,那么有人就要问:“转换出来的具体值是多少?”。
答案是没有值,因为!表示永远不会达到的地方,那么以上三个变量根本就不可能会被使用,那么它的值是多少也根本不重要。
当然不是所有的loop返回的都是!,只有死循环才是。也有其它情况会返回!,在后续的博客会讲到,现在只要知道存在这样一个类型即可。
数值字面量的修饰
在 Rust 中,数值字面量(包括整数和浮点数)支持多种表示方式,这些方式不仅适用于整数类型,也适用于浮点数类型。
进制表示
Rust 允许通过前缀不同,可以决定一个数值字面量的进制:
- 无前缀:十进制(如
42) 0x:十六进制(如0x2A== 42)0o:八进制(如0o52== 42)0b:二进制(如0b101010== 42)- 大小写均可:
0xFF与0xff等价
示例:
// 进制表示
let decimal = 98; // 十进制
let hex = 0xff; // 十六进制
let octal = 0o77; // 八进制
let binary = 0b111; // 二进制
下划线分隔符
对于一个数值,允许通过 _ 进行分割,仅用于提升可读性,没有数值语义影响。例如 1_000_000 与 1000000 完全相同;二进制/十六进制中也可用来分组位。
示例:
let a = 1_000; // 等价于 1000
let b = 1_000_000; // 等价于 1000000let pi = 3.141_592; // 等价于 3.141592
let c = 10_000.5; // 等价于 10000.5let bin = 0b0001_0010; // 等价于 0b00010010 (十进制18)
let hex = 0x12ab_34cd; // 等价于 0x12ab34cd
let oct = 0o123_456; // 等价于 0o123456
let byte = 0b1111_0000u8; // 等价于 240u8
类型后缀
对于数值字面量,可以通过修改后缀来决定其类型:
// 整数类型后缀
let typed = 123i64; // i64 类型
let unsigned = 456u32; // u32 类型
let long_num = 789i128; // i128 类型
// 浮点数类型后缀
let float32 = 1.0f32; // f32 类型
let float64 = 2.0f64; // f64 类型
类型后缀把字面量本身的类型在语法层面固定为某个具体类型。它发生在编译期,只影响该字面量节点的静态类型。
科学计数法
科学计数法主要用于浮点数,表示非常大或非常小的数:
let large = 1e6; // 1000000.0 (f64)
let small = 1e-6; // 0.000001 (f64)
let middle = 2.5e3; // 2,500.0 (f64)
e前面的数字是系数(有效数字部分)e后面的数字是指数(10的幂次)- 整个表达式表示: 系数 × 1 0 指数 系数 × 10^{指数} 系数×10指数
此处可以使用大写 E 或小写 e,指数部分可以是正数或负数(使用 + 或 -),系数可以是整数或浮点数,默认推断为 f64 类型,同样可以使用后缀指定类型:1e6f32。
字节字面量
字节字面量是整型中 u8 的特殊表示法:
let byte: u8 = b'A'; // 字节字面量(u8)
字节字面量 b'A':表示一个 u8(0~255)的字节值,必须是 ASCII 范围内的单字符。示例:b'A' == 65u8。非 ASCII 字符(如 b'中'、b'国')是非法的。
类型转换
在 Rust 中,类型转换必须是显式的,不能像其他语言那样自动隐式转换。需要使用 as 关键字来明确告诉编译器要进行转换。
- 从小类型转大类型:比如
i32→i64是安全的,因为大类型能完全容纳小类型的值,不会丢失数据。
let small: i32 = 42;
let large: i64 = small as i64; // 安全转换,值不变
- 从大类型转小类型: 比如
i32→u8如果原始值超出了目标类型的范围,会发生截断:
let big: i32 = 300;
let small: u8 = big as u8; // 300 超出了 u8 的范围(0-255),结果变成 44
// 相当于 300 % 256 = 44
- 浮点数转整数:小数部分会被直接丢弃(向零取整):
let pi = 3.99;
let whole = pi as i32; // 变成 3,小数部分没了let negative = -2.7;
let neg_whole = negative as i32; // 变成 -2
如果浮点数太大,超出了目标整数类型的范围,结果是未定义的,要特别小心。
- 字符转整数:字符可以转换成它对应的 Unicode 码点值:
let letter = 'A';
let code = letter as u32; // 65,A 的 Unicode 值let star = '⭐';
let star_code = star as u32; // 11088,星星的 Unicode 值
- 布尔值转整数:布尔值转换很简单,
true变成 1,false变成 0:
let yes = true as u8; // 1
let no = false as i32; // 0
这种转换在需要将逻辑值参与数学运算时很有用。
常量与静态
常量
常量在程序运行期间值永远不变,使用 const 声明:
const MAX_POINTS: u32 = 100_000;
const PI: f64 = 3.14159;
const MESSAGE: &str = "Hello";
常量必须进行类型标注,指明类型。
与 let 的区别:常量必须是编译期可求值的表达式,通常会被内联到使用处,没有固定内存地址;可在任意作用域声明(包括全局),且总是不可变。
比如以下代码就是错误的:
let mut num = 1;
const NUM: i32 = num; // 错误
常量 NUM 依赖了一个变量 num,变量在运行时才能确定值,导致 NUM 无法在编译期得到确定值,因此报错。
静态变量
静态变量具有'static生命周期,在程序整个运行期间有效:
static GLOBAL_COUNT: i32 = 0;
static mut COUNTER: i32 = 0;
static 具有固定内存地址,可通过引用取地址。static 也要求必须进行类型标注。static可以声明为mut,定义一个全局可变的变量。
