Rust学习笔记-常见的编程概念-变量和数据类型
Rust学习笔记
常见的编程概念
变量和可变性
变量默认是不可改变的(immutable),当变量不可变时,一旦值被绑定一个名称上,你就不能改变这个值。
在尝试改变预设为不可变的值时,产生编译时错误是很重要的,因为这种情况可能导致 bug。如果一部分代码假设一个值永远也不会改变,而另一部分代码改变了这个值,第一部分代码就有可能以不可预料的方式运行。不得不承认这种 bug 的起因难以跟踪,尤其是第二部分代码只是 有时 会改变值。
Rust 编译器保证,如果声明一个值不会变,它就真的不会变,所以你不必自己跟踪它。这意味着你的代码更易于推导。
例如,下面代码会检查到不可变错误(immutability error):
fn main() {
let x = 5;
println!("The value of x is: {x}");
x = 6; // 不能对不可变变量 x 二次赋值(cannot assign twice to immutable variable `x` )
println!("The value of x is: {x}");
}
尽管变量默认是不可变的,你仍然可以在变量名前添加 mut
来使其可变
例如:
fn main() {
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}
[常量
类似于不可变变量,常量 (constants) 是绑定到一个名称的不允许改变的值,不过常量与变量还是有一些区别。
首先,不允许对常量使用 mut
。常量不光默认不可变,它总是不可变。声明常量使用 const
关键字而不是 let
,并且 必须 注明值的类型。
常量可以在任何作用域中声明,包括全局作用域。
常量只能被设置为常量表达式,而不可以是其他任何只能在运行时计算出的值。
下面是一个声明常量的例子:
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
Rust 对常量的命名约定是在单词之间使用全大写加下划线。
有关声明常量时可以使用哪些操作的详细信息,请参阅 Rust Reference 的常量求值部分。
在声明它的作用域之中,常量在整个程序生命周期中都有效,此属性使得常量可以作为多处代码使用的全局范围的值。
隐藏
可以定义一个与之前变量同名的新变量。Rustacean 们称之为第一个变量被第二个 隐藏(Shadowing) 了,这意味着当您使用变量的名称时,编译器将看到第二个变量。实际上,第二个变量“遮蔽”了第一个变量,此时任何使用该变量名的行为中都会视为是在使用第二个变量,直到第二个变量自己也被隐藏或第二个变量的作用域结束。可以用相同变量名称来隐藏一个变量,以及重复使用 let
关键字来多次隐藏,如下所示:
文件名:src/main.rs
fn main() {
let x = 5;
let x = x + 1;
{
let x = x * 2;
println!("The value of x in the inner scope is: {x}");
}
println!("The value of x is: {x}");
}
隐藏与将变量标记为 mut
是有区别的。当不小心尝试对变量重新赋值时,如果没有使用 let
关键字,就会导致编译时错误。通过使用 let
,我们可以用这个值进行一些计算,不过计算完之后变量仍然是不可变的。
mut
与隐藏的另一个区别是,当再次使用 let
时,实际上创建了一个新变量,我们可以改变值的类型,并且复用这个名字。
例如,假设程序请求用户输入空格字符来说明希望在文本之间显示多少个空格,接下来我们想将输入存储成数字(多少个空格):
let spaces = " ";
let spaces = spaces.len();
第一个 spaces
变量是字符串类型,第二个 spaces
变量是数字类型。隐藏使我们不必使用不同的名字,如 spaces_str
和 spaces_num
;相反,我们可以复用 spaces
这个更简单的名字。然而,如果尝试使用 mut
,将会得到一个编译时错误,如下所示:
let mut spaces = " ";
spaces = spaces.len();
这个错误说明,我们不能改变变量的类型:
数据类型
在 Rust 中,每一个值都属于某一个 数据类型(data type),这告诉 Rust 它被指定为何种数据,以便明确数据处理方式。我们将看到两类数据类型子集:标量(scalar)和复合(compound)。
记住,Rust 是 静态类型(statically typed)语言,也就是说在编译时就必须知道所有变量的类型。根据值及其使用方式,编译器通常可以推断出我们想要用的类型。当多种类型均有可能时,比如第二章的 “比较猜测的数字和秘密数字” 使用 parse
将 String
转换为数字时,必须增加类型注解,像这样:
let guess: u32 = "42".parse().expect("Not a number!");
标量类型
标量(scalar)类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。
整型
整数 是一个没有小数部分的数字。我们在第二章使用过 u32
整数类型。该类型声明表明,它关联的值应该是一个占据 32 比特位的无符号整数(有符号整数类型以 i
开头而不是 u
)。表格 3-1 展示了 Rust 内建的整数类型。我们可以使用其中的任一个来声明一个整数值的类型。
表格 3-1: Rust 中的整型
长度 | 有符号 | 无符号 |
---|---|---|
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch | isize | usize |
每一个变体都可以是有符号或无符号的,并有一个明确的大小。有符号 和 无符号 代表数字能否为负值,换句话说,这个数字是否有可能是负数(有符号数),或者永远为正而不需要符号(无符号数)。这有点像在纸上书写数字:当需要考虑符号的时候,数字以加号或减号作为前缀;然而,可以安全地假设为正数时,加号前缀通常省略。有符号数以补码形式(two’s complement representation) 存储。
每一个有符号的变体可以储存包含从 -(2n - 1) 到 2n - 1 - 1 在内的数字,这里 n 是变体使用的位数。所以 i8 可以储存从 -(27) 到 27 - 1 在内的数字,也就是从 -128 到 127。无符号的变体可以储存从 0 到 2n - 1 的数字,所以 u8 可以储存从 0 到 28 - 1 的数字,也就是从 0 到 255。
另外,isize 和 usize 类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。
可以使用表格 3-2 中的任何一种形式编写数字字面值。请注意可以是多种数字类型的数字字面值允许使用类型后缀,例如 57u8
来指定类型,同时也允许使用 _
做为分隔符以方便读数,例如1_000
,它的值与你指定的 1000
相同。
表格 3-2: Rust 中的整型字面值
数字字面值 | 例子 |
---|---|
Decimal (十进制) | 98_222 |
Hex (十六进制) | 0xff |
Octal (八进制) | 0o77 |
Binary (二进制) | 0b1111_0000 |
Byte (单字节字符)(仅限于u8 ) | b'A' |
那么该使用哪种类型的数字呢?如果拿不定主意,Rust 的默认类型通常是个不错的起点,数字类型默认是 i32
。isize
或 usize
主要作为某些集合的索引。
整型溢出
比方说有一个
u8
,它可以存放从零到255
的值。那么当你将其修改为256
时会发生什么呢?这被称为 “整型溢出”(“integer overflow” ),这会导致以下两种行为之一的发生。当在 debug 模式编译时,Rust 检查这类问题并使程序 panic,这个术语被 Rust 用来表明程序因错误而退出。第九章 “panic!
与不可恢复的错误” 部分会详细介绍 panic。使用
--release
flag 在 release 模式中构建时,Rust 不会检测会导致 panic 的整型溢出。相反发生整型溢出时,Rust 会进行一种被称为二进制补码 wrapping(two’s complement wrapping)的操作。简而言之,比此类型能容纳最大值还大的值会回绕到最小值,值256
变成0
,值257
变成1
,依此类推。程序不会 panic,不过变量可能也不会是你所期望的值。依赖整型溢出 wrapping 的行为被认为是一种错误。为了显式地处理溢出的可能性,可以使用这几类标准库提供的原始数字类型方法:
- 所有模式下都可以使用
wrapping_*
方法进行 wrapping,如wrapping_add
- 如果
checked_*
方法出现溢出,则返回None
值- 用
overflowing_*
方法返回值和一个布尔值,表示是否出现溢出- 用
saturating_*
方法在值的最小值或最大值处进行饱和处理
浮点型
Rust 也有两个原生的 浮点数(floating-point numbers)类型,它们是带小数点的数字。Rust 的浮点数类型是 f32
和 f64
,分别占 32 位和 64 位。默认类型是 f64
,因为在现代 CPU 中,它与 f32
速度几乎一样,不过精度更高。所有的浮点型都是有符号的。
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
浮点数采用 IEEE-754 标准表示。f32
是单精度浮点数,f64
是双精度浮点数。
数值运算
Rust 中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向零舍入到最接近的整数。下面的代码展示了如何在 let
语句中使用它们:
fn main() {
// addition
let sum = 6 + 9;
println!("6 + 9 = {sum}");
// subtraction
let difference = 95.3 - 69.5;
println!("95.3 - 69.5 = {difference}");
// multiplication
let product = 8 * 2;
println!("8 * 2 = {product}");
// division
let quotient = 2.0 / 14.0;
println!("2.0 / 14.0 = {quotient}");
let truncate = -12 / 5;
println!("-12 / 5 = {truncate}");
// remainder
let remainder = 16 % 5;
println!("16 % 5 = {remainder}");
}
运行结果:
附录 B 包含 Rust 提供的所有运算符的列表。
布尔型
正如其他大部分编程语言一样,**Rust 中的布尔类型有两个可能的值:true
和 false
。Rust 中的布尔类型使用 bool
表示。**例如:
fn main() {
let t = true;
let f: bool = false;
}
字符类型
Rust 的 char
类型是语言中最原生的字母类型。下面是一些声明 char
值的例子:
fn main() {
let c = 'c';
println!("{c}");
let z: char = 'ℤ';
println!("{z}");
let emoji = '😻';
println!("{emoji}");
}
运行结果:
注意,我们用单引号声明 char
字面量,而与之相反的是,使用双引号声明字符串字面量。Rust 的 char
类型的大小为四个字节 (four bytes),并代表了一个 Unicode 标量值(Unicode Scalar Value),这意味着它可以比 ASCII 表示更多内容。在 Rust 中,带变音符号的字母(Accented letters),中文、日文、韩文等字符,emoji(绘文字)以及零长度的空白字符都是有效的 char
值。**Unicode 标量值包含从 U+0000
到 U+D7FF
和 U+E000
到 U+10FFFF
在内的值。**不过,“字符” 并不是一个 Unicode 中的概念,所以人直觉上的 “字符” 可能与 Rust 中的 char
并不符合。第八章的 “使用字符串存储 UTF-8 编码的文本” 中将详细讨论这个主题。
复合类型
复合类型(Compound types)可以将多个值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。
元组类型
元组是一个将多个其他类型的值组合进一个复合类型的主要方式。元组长度固定:一旦声明,其长度不会增大或缩小。
我们使用包含在圆括号中的逗号分隔的值列表来创建一个元组。元组中的每一个位置都有一个类型,而且这些不同值的类型也不必是相同的。这个例子中使用了可选的类型注解:
fn main() {
let tup: (i32, f64, u8) = (12, 6.6, 2);
}
tup
变量绑定到整个元组上,因为元组是一个单独的复合元素。为了从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值,像这样:
fn main() {
let tup: (i32, f64, u8) = (12, 6.6, 2);
let (x, y, z) = tup;
println!("The value of y is {y}");
}
程序首先创建了一个元组并绑定到 tup
变量上。接着使用了 let
和一个模式将 tup
分成了三个不同的变量,x
、y
和 z
。这叫做 解构(destructuring),因为它将一个元组拆成了三个部分。最后,程序打印出了 y
的值,也就是 6.6
。
我们也可以使用点号(.
)后跟值的索引来直接访问它们。例如:
fn main() {
let tup: (i32, f64, u8) = (12, 6.6, 2);
let x = tup.0;
let y = tup.1;
let z = tup.2;
println!("{x}, {y}, {z}");
}
这个程序创建了一个元组,tup
,然后使用其各自的索引访问元组中的每个元素。跟大多数编程语言一样,元组的第一个索引值是 0。
不带任何值的元组有个特殊的名称,叫做 单元(unit) 元组。这种值以及对应的类型都写作 ()
,表示空值或空的返回类型。如果表达式不返回任何其他值,则会隐式返回单元值。
数组类型
另一个包含多个值的方式是 数组(array)。与元组不同,数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,Rust 中的数组长度是固定的。
我们将数组的值写成在方括号内,用逗号分隔:
fn main() {
let a = [1, 2, 3, 4, 5];
}
当你想要在栈(stack)而不是在堆(heap)上为数据分配空间(第四章将讨论栈与堆的更多内容),或者是想要确保总是有固定数量的元素时,数组非常有用。但是数组并不如 vector 类型灵活。**vector 类型是标准库提供的一个 允许 增长和缩小长度的类似数组的集合类型。**当不确定是应该使用数组还是 vector 的时候,那么很可能应该使用 vector。第八章会详细讨论 vector。
然而,当你确定元素个数不会改变时,数组会更有用。例如,当你在一个程序中使用月份名字时,你更应趋向于使用数组而不是 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
表明该数组包含五个元素。
你还可以通过在方括号中指定初始值加分号再加元素个数的方式来创建一个每个元素都为相同值的数组:
let a = [3; 5];
变量名为 a
的数组将包含 5
个元素,这些元素的值最初都将被设置为 3
。这种写法与 let a = [3, 3, 3, 3, 3];
效果相同,但更简洁。
访问数组元素
数组是可以在栈 (stack) 上分配的已知固定大小的单个内存块。可以使用索引来访问数组的元素,像这样:
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
在这个例子中,叫做 first
的变量的值是 1
,因为它是数组索引 [0]
的值。变量 second
将会是数组索引 [1]
的值 2
。
无效的数组元素访问
让我们看看如果我们访问数组结尾之后的元素会发生什么呢?比如你执行以下代码,它使用类似于第 2 章中的猜数字游戏的代码从用户那里获取数组索引:
use std::io;
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
println!("Please input a number:");
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 {index} is: {element}");
}
此代码编译成功。如果您使用 cargo run
运行此代码并输入 0
、1
、2
、3
或 4
,程序将在数组中的索引处打印出相应的值。如果你输入一个超过数组末端的数字,如 10,你会看到这样的输出:
程序在索引操作中使用一个无效的值时导致 运行时 错误。程序带着错误信息退出,并且没有执行最后的 println!
语句。当尝试用索引访问一个元素时,Rust 会检查指定的索引是否小于数组的长度。如果索引超出了数组长度,Rust 会 panic,这是 Rust 术语,它用于程序因为错误而退出的情况。这种检查必须在运行时进行,特别是在这种情况下,因为编译器不可能知道用户在以后运行代码时将输入什么值。
这是第一个在实战中遇到的 Rust 安全原则的例子。在很多底层语言中,并没有进行这类检查,这样当提供了一个不正确的索引时,就会访问无效的内存。通过立即退出而不是允许内存访问并继续执行,Rust 让你避开此类错误。第九章会更详细地讨论 Rust 的错误处理机制,以及如何编写可读性强而又安全的代码,使程序既不会 panic 也不会导致非法内存访问。
创作不易,喜欢的话加个关注点个赞,❤谢谢谢谢❤