Rust 的数据类型,以及与众不同的变量声明

发布时间 2023-03-29 22:12:07作者: 古明地盆

楔子

Rust 中每一个变量的值,都有其特定的数据类型,Rust 会根据数据的类型来决定如何处理它们,例如分配空间。而 Rust是一门静态语言,这意味着它在编译程序的过程中需要知道所有变量的具体类型。

Rust 的数据类型分为两类:标量类型(scalar)和复合类型(compound),我们先来说一下标量类型。

标量类型

标量类型是单个值类型的统称,Rust 内建了 4 种基础的标量类型:整数、浮点数、布尔值及字符。

整数

整数是指那些没有小数部分的数字,Rust 中的整数类型分为以下几种:

Rust 里面的类型名称设计的非常精简,i32 就是 int32,u16 就是 uint16。而 isize 和 usize 则取决于当前的系统,如果是 32 位,那么 isize、usize 就等价于 i32、u32,如果是 64 位,那么 isize、usize 就等价于 i64、u64。

fn main() {
    // 声明变量需要使用 let 关键字
    // 语法格式为:let 变量: 类型 = 值
    // 这里就类似于 Go 里面的 var a int32 = 666
    let a: i32 = 666;
    // 打印的时候使用 {} 作为占位符
    println!("a = {}", a);  // a = 666

    // 如果数字比较多,还可以使用 _ 进行分隔,增强可读性
    // 注意的是,我们这里的 b 没有指定类型
    // 那么默认是 i32,因为 i32 相对来说速度最快
    let b = 10_00_00_00;
    println!("b = {}", b);  // b = 10000000
}

另外整数在 Rust 里面还有一种特殊的表达方式,比如:let a = 33u16,因为 u8, i8, u16, i32 等等都可以表示 33。所以不指定类型的话,光有 33,Rust 就不知道它的精度是多少,于是 let a = 33 会自动将 a 推断成 int32。

但如果在整数后面加上类型,比如 33u16,那么 Rust 就知道这是一个 u16 类型的整数。于是 let a = 33u16,就会知道 a 是一个 u16 类型的变量,和 let a: u16 = 33 的作用相同。

当然 let a: u16 = 33u16 也可以,只不过有点多此一举,但是 let a: u16 = 33u32 这种方式则不行,因为前后矛盾了。

以上整数都是用十进制表示,我们也可以使用二进制、八进制、十六进制创建整数:

fn main() {
    let a = 33u16;
    let b: i32 = 0b11_01_10_11;  // 二进制
    let c = 0o567i64;            // 八进制
    let d = 0xFFFFu32;           // 十六进制
    println!("{} {} {} {}",
             a, b, c, d);  // 33 219 375 65535
}

最后在 Rust 里面,u8 类型的变量还有另一种表达方式:

fn main() {
    let a = b'A';
    println!("a = {}", a);  // a = 65
}

打印出来的是整数,因为本质上就是个 u8。但注意:这里和别的语言有点不同,Rust 里面需要有一个前缀 b,但是在其它语言中没有。

整数溢出

然后聊一聊整数溢出的问题,假设你有一个 u8 类型的变量,它可以存储从 0 到 255 的数字。当你尝试将该变量修改为某个超出范围的值(比如 256)时,就会发生整数溢出。

Rust 对这一行为也有相应的处理规则,如果你在调试(debug)模式下进行编译,那么 Rust 就会在程序中包含整数溢出的运行时检测代码,并在整数溢出发生时触发程序 panic。

如果你在编译时使用了带有 --release 标记的发布(release)模式,那么 Rust 就不会包含那些可能会触发 panic 的检查代码。作为替代,Rust 会在溢出发生时执行二进制补码环绕。简而言之,任何超出类型最大值的数值都会被环绕为类型最小值。以 u8 为例,256 会变成 256 - 2 ^ 8、也就是 0,257 会变成 257 - 2^ 8、也就是 1。

浮点数

浮点数就是带小数的数字,Rust 提供了两种基础的浮点数类型:f32 和 f64,它们分别占用 32 位和 64 位空间。由于在现代 CPU 中 f64 与 f32 的运行效率相差无几,却拥有更高的精度,所以在 Rust 中,默认会将浮点数字面量的类型推导为 f64。

fn main() {
    // 默认是 f64
    let a = 3.14;  
    // 可以显式指定为 f32
    let b: f32 = 3.14;
    // 浮点数也支持将类型写在数值的后面
    let c = 3.14f32;  
    println!("{} {} {}", a, b, c);  // 3.14 3.14 3.14
}

比较简单,没啥可说的。

另外对于所有的数值类型,Rust 都支持常见的数学运算,逻辑运算、位运算等等。下面的代码展示了如何在 let 语句中使用这些运算进行求值:

fn main() {
    let add = 1 + 2;
    let sub = 5 - 3;
    let mul = 3 * 4;
    let div = 8 / 2;
    let modular = 10 % 4;
    println!(
        "{} {} {} {} {}", 
        add, sub, mul, div, modular
    );  // 3 2 12 4 2

    // 位运算
    let lshift = 2 << 3;
    let rshift = 64 >> 3;
    let invert = !64;
    let bitwise_and = 15 & 37;
    let bitwise_or = 16 | 32;
    let bitwise_xor = 16 ^ 32;
    println!(
        "{} {} {} {} {} {}", 
        lshift, rshift, invert,
        bitwise_and, bitwise_or, bitwise_xor
    );  // 16 8 -65 5 48 48
}

这些运算和其它语言没有什么本质的区别,需要注意的是取反操作,Rust 用的是 !,而 C 用的是 ~

布尔

正如其它大部分编程语言一样,Rust 的布尔类型也只拥有两个可能的值:true 和 false,它会占据一个字节的空间大小。你可以使用 bool 来表示一个布尔类型,例如:

fn main() {
    let flag1 = true;
    let flag2: bool = false;
    println!(
        "flag1 = {}, flag2 = {}", 
        flag1, flag2
    );  // flag = true, flag2 = false
}

布尔类型最主要的用途是在 if 表达式内作为条件使用,关于 if, for, while 等控制流我们后面会说。

字符

到目前为止,我们接触到的大部分类型都只和数字有关,但 Rust 也同样提供了相应的字符类型。在 Rust 中,char 类型被用于描述语言中最基础的单个字符,但需要注意的是,char 类型使用单引号指定,而不同于字符串使用双引号指定。

fn main() {
    let a: char = 'A';
    let b = '憨';
    let c = '?';
    let d = '?';
    println!("{} {} {} {}", 
             a, b, c, d);  // A 憨 ? ?
}

注意 Rust 里面的 char 和 C 里面的 char 是有区别的,比如 'A',它在 C 和 Go 里面就是一个整数,是可以直接进行算术运算的。但在 Rust 里面不行,'A' 在 Rust 里面是一个字符,类似于长度为 1 的字符串。

所以 Rust 里面的 'A' 和 Go 里面的 'A' 是截然不同的,但 Rust 里面的 b'A' 和 Go 里面的 'A' 是相似的,因为都是无符号 8 位整数。

然后 Rust 中的 char 类型占 4 字节,是一个 Unicode 标量值,这也意味着它可以表示比 ASCII 多得多的字符内容。拼音字母、中文、日文、韩文、零长度空白字符,甚至是 emoji 表情都可以作为一个有效的 char 类型值。实际上,Unicode 标量可以描述从 U+0000 到 U+D7FF、以及从 U+E000 到 U+10FFFF 范围内的所有值。

以上就是 Rust 的标量类型,有其它编程语言基础的话很容易理解。另外可能你会感到好奇,为啥没有字符串?原因是 Rust 的字符串非常重要,牵扯到了一些目前还没有接触到的知识,所以我们将字符串留到后面再说。

变量与可变性

了解完标量类型之后,我们先不急着看复合类型,先来了解一下 Rust 和其它语言截然不同的地方,也就是变量的可变性。举个例子:

fn main() {
    let num = 123;
    num = num + 1;
    println!("num = {}", num);
}

你觉得这段代码有什么问题吗?有过 C 语言或 Go 语言经验的人会觉得这是一段再正常不过的代码了,先创建一个变量 num 等于 123,然后将 num 的值自增 1。确实如此,类似的代码放在 C 或 Go 里面是完全正确的,但在 Rust 里面则不行。

编译的时候报错,提示我们不能给不可变的变量 num 二次赋值,原因是 Rust 中的变量一旦声明,默认就是不可变的。当一个变量不可变时,就意味着一旦它被绑定到某个值上面,后续就再也无法改变。

那么我们可不可以让强行让它改变呢?答案是可以的,只需要在声明变量的时候在 let 后面加上一个 mut 关键字即可。意思就是告诉 Rust 编译器,我这个变量是可变的,别给我报错了。

fn main() {
    let mut num = 123;
    num = num + 1;
    println!("num = {}", num);  
    // num = 124
}

因为 mut 出现在了变量绑定的过程中,所以我们现在可以合法地将 num 绑定的值从 123 修改为 124 了。

关于 Rust 为什么将变量默认设计成不可变的,原因是当我们的代码逻辑依赖于某个值不可变时,如果这个值发生变化,程序就无法继续按照期望的方式运行下去,并且这种 bug 往往难以追踪,特别是当修改操作只在某些条件下偶然发生的时候。

而 Rust 编译器能够保证那些声明为不可变的变量一定不会发生改变,这也意味着你无须在阅读和编写代码时追踪一个变量会如何变化,从而使代码逻辑更加易于理解和推导。当然变量如果不能变的话,那还叫变量吗?只不过 Rust 将变量是否可变的权利交给了程序猿。

变量与常量

变量的不可变性可能会让你联想到另外一个概念:常量(constant),就像不可变变量一样,绑定到常量上的值也无法被其它代码修改,但常量和变量之间还是存在着一些细微的差别的。

首先我们不能用 mut 关键字来修饰一个常量,其次常量不仅是默认不可变的,它还总是不可变的。并且在常量声明的时候我们要使用 const 关键字,并显式地指定类型。

fn main() {
    // 常量在 Rust 当中一般大写,变量则是小写
    // 多个单词之间用下划线分割,并遵循蛇形命名法
    // 注意:这里不可以写成 const NUM = 33u8;
    // 必须要将类型写在常量的后面,变量的话是可以的
    const NUM: u8 = 33;
    println!("NUM = {}", NUM);  // NUM = 33
}

常量可以被声明在任何作用域中,甚至包括全局作用域,这在一个值需要被不同部分的代码共同引用时十分有用。最后需要注意的是:我们只能将普通的字面量或者编译阶段就能确定的表达式绑定在常量上,而无法将一个函数的返回值,或其它需要在运行时计算的值绑定到常量上。

fn main() {
    const A: u8 = 123;
    // A 是常量,A + 1 也是常量
    // 所以这行语句合法
    const B: u8 = A + 1;
    // 不管表达式再复杂,都可以编译时就计算出来
    // 等价于 const C: u32 = 37
    const C: u32 = 1 + 3 * 5 + 3 * 7;
    print!("{}", C);

    // 但下面是不合法的,因为 x 是一个变量
    // 它不能赋值给一个常量
    let x = 123;
    const Y: i32 = x + 1;
}

关于变量和常量之间的区别,还是很好理解的。

变量的隐藏

在 Rust 中声明的变量,如果不使用 mut 修饰,那么变量默认是不可变的。我们无法修改它,但是却可以隐藏它,举个例子:

fn main() {
    let num = 123;
    let num = num + 1;
    println!("num = {}", num); 
    // num = 124
}

?,刚接触 Rust 的话可能会好奇,这不是将同一个变量重复声明了吗?在 C 和 Go 里面是这样的,但在 Rust 里面则不是,在 Rust 里面这被称为变量的隐藏(shadow)。

我们连续声明了两个同名变量,那么第一个变量会被第二个变量隐藏(shadow),这意味着我们后续使用这个名称时,它对应的将会是第二个变量。我们可以重复使用 let 关键字并配以相同的名称来不断地隐藏变量:

fn main() {
    let age = 18;
    let age = age + 1;
    let age = age * 2;
    println!("age = {}", age);  // age = 38
}

这段程序首先将变量 age 绑定到 18 这个值上,然后又声明了新的变量 age,此时第二个变量 age 会将第一个变量 age 隐藏,并绑定在第一个变量 age 加 1 之后的值上,此时 age 的值就是 19;然后又声明了第三个变量 age,此时第三个变量 age 又会隐藏第二个 age,然后绑定在第二个 age 乘 2 之后的值上,此时 age 的值就是 38。

因此这和其它编译型语言完全是相反的,所以学习 Rust 需要调整我们的三观。

隐藏机制不同于为一个变量重新赋值,因为重新为变量赋值,在变量不可变的时候会导致编译错误。而使用 let,我们相当于创建了新的变量,可以执行一系列的变换操作。

隐藏机制与重新赋值还有一个区别:由于重复使用 let 关键字会创建出新的变量,所以我们可以在复用变量名称的同时改变它的类型。

fn main() {
    // 创建整型变量
    let num = 123;
    // 重新声明同名变量 num,会隐藏上一个 num
    // 因为是新声明的变量,所以它的类型、是否可变都与上一个 num 无关
    let mut num = 3.14;
    println!("num = {}", num);  // num = 3.14
    num = 3.15;   
    println!("num = {}", num);  // num = 3.15
}

如果是重新赋值的话,那么即使将变量声明为可变的,我们也只能改变它的值,却改变不了它的类型。之前变量是什么类型,重新赋值之后变量还是什么类型,换言之我们在赋值的时候,要根据变量的类型进行赋值。

比如一开始声明的变量的时候,类型为整型,那么重新赋值也必须赋一个整数,给一个字符串是肯定不行的。因为对于编译型语言来说,变量一旦声明,它的类型就固定了。

fn main() {
    // 不使用 mut,值无法修改
    let mut num = 123;
    // 使用 mut,我们可以修改值,但是类型不会变
    // 换言之,我们重新赋的值必须还是一个整数才行
    // 否则编译器报错
    num = 3.14;
}

代码执行之后,编译器就会报错:expected integer, found floating-point number,意思是期望一个整数,但我们传了一个浮点数过去。如果在 C 里面的话则不会报错,而是会将浮点数进行截断得到整数,并在编译的时候发出警告。

说到这再多提一句,Rust 对类型的检测和 Go 语言一样严格,不同类型的变量不能相互赋值。比如 i16 和 i32,尽管都表示整数,但它们是不同类型,所以不可以相互赋值。

因此上面的代码会报错,因为将浮点数赋值给了一个 i32 类型的变量,那问题来了,如果想让它不报错该怎么办呢?很简单,在 num = 3.14 的前面加上 let 关键字就行了。因为此时相当于重新创建了一个变量 num,并将第一个 num 给隐藏掉了。而既然是新创建的变量,那么它的类型、可变性都可以自由指定,与上一个 num 无关。

结合上下文的类型推断

我们说 Rust 对类型的检测非常严格,即使是相同类型,但如果精度不同,也不能相互赋值。

fn main() {
    let mut a: i16 = 123;
    a = 234u8;
}

这种做法是错误的,因为 a 是 i16,所以不可以将 u8 的整数赋给它。

但 Rust 有一个智能的地方,就是它类型推断会结合上下文。

fn main() {
    let mut a = 123;
    a = 234u8;
}

上面这种做法是可以的,咦,不是说不同类型不能相互赋值吗?这里的 a 应该是一个 i32,为什么能赋一个 u8 类型的整数呢?

原因是我们在创建 a 的时候没有指定类型,那么理论上 Rust 会根据 123 将其推断成 i32 类型,但 Rust 编译器检测到我们后续将一个 u8 类型的整数赋值给了 a,于是在声明变量时就将 a 推断成了 u8 类型。

而第一个示例之所以没有通过,是因为我们在声明变量的时候显式地指定了类型为 i16,那么它的类型就已经确定为 i16,所以后续再赋值 u8 类型的整数就会报错。

复合类型

复合类型(compound type)可以将多个不同类型的值组合为一个类型,Rust提供了两种内置的基础复合类型:元组(tuple)和数组(array)。

元组

元组是一种相当常见的复合类型,它可以将其它不同类型的多个值组合在一起。此外元组还拥有一个固定的长度:我们无法在声明结束后增加或减少其中的元素数量。

为了创建元组,我们需要把一系列的值使用逗号分隔后放置到一对圆括号中,元组每个位置的值都有一个类型,这些类型不需要是相同的。为了演示,下面的例子中手动添加了不必要的类型注解:

fn main() {
    // 类型注解可以去掉,只不过在去掉之后
    // Rust 会默认将 44 推断成 i32 类型
    // 注意:类型和值要匹配
    let tpl: (i32, f64, u8) = (33, 3.14, 44);
    // println! 不能直接打印元组
    // 因为元组内部没有实现 std::fmt::Display
    // 我们需要将 {} 改成 {:?} 才能打印
    // 或者改成 {:#?} 还可以美观打印
    println!("tpl = {:?}", tpl);  
    println!("tpl = {:#?}", tpl);
    /*
    tpl = (33, 3.14, 44)
    tpl = (
        33,
        3.14,
        44,
    )    
     */
}

由于一个元组也被视作一个单独的复合元素,所以这里的变量 tpl 被绑定到了整个元组上。而为了从元组中获得单个的值,我们可以像下面这样使用模式匹配来解构元组:

fn main() {
    // 类型注解也可以去掉,Rust 会自动推断
    let tpl = (33, 3.14, 44);
    // 此时 x、z 被推断成 i32,y 被推断成 f64
    let (x, y, z) = tpl;
    println!(
        "x = {}, y = {}, z = {}", x, y, z
    );  // x = 33, y = 3.14, z = 44
}

这段程序将变量 tpl 绑定在了元组上,随后 let 关键字的右侧使用了一个模式将 tpl 拆分为 3 个不同的部分:x、y 和 z,这个操作也被称为解构(destructuring)。在解构的时候,左边的几个变量必须使用括号括起来,否则会出现语法错误。

fn main() {
    let tpl = (33, 3.14, 44);
    // let x, y, z = tpl; 是不合法的
    // 必须写成 let (x, y, z) = tpl
    // 此外我们还可以指定变量的可变性
    // 上面的 x、y、z 都是不可变的,我们将其改为可变
    let (mut x, mut y, mut z) = tpl;
    println!(
        "x = {}, y = {}, z = {}", x, y, z
    );  // x = 33, y = 3.14, z = 44
    x = 66;
    y = 4.44;
    z = 88;
    println!(
        "x = {}, y = {}, z = {}", x, y, z
    );  // x = 66, y = 4.44, z = 88
}

注意:在解构的时候我们不能这样做,let mut (x, y, z) = tpl,这么做是不符合 Rust 语法的。如果希望变量可变,那么需要单独给指定变量的前面加上 mut,比如我希望 x 可变,那么就在 x 的前面加上 mut 即可。如果希望所有变量都可变,那么所有变量前面都要加上 mut。

除了解构,我们还可以通过索引并使用点号来访问元组中的值:

fn main() {
    let tpl = (33, 3.14, 44);
    // 通过 tpl.index 即可访问元组内的元素
    // 只不过使用的是 . 而不是 []
    let x = tpl.0;
    let y = tpl.1;
    let z = tpl.2;
    println!(
        "x = {}, y = {}, z = {}", x, y, z
    );  // x = 33, y = 3.14, z = 44
    
    // 当然更加方便的做法还是这种:let (x, y, z) = tpl;
    // 到这里,我们可以发现 Rust 是支持多元赋值的
    // 但是要通过元组的方式、也就是用小括号括起来(等号两边都需要)
    // 此时 x、z 可变,y 不可变
    let (mut x, y, mut z) = (1u8, 66, 3.14);
    println!(
        "x = {}, y = {}, z = {}", x, y, z
    );  // x = 1, y = 66, z = 3.14
}

所以还是很简单的,使用小括号创建元组,并且元组里面的元素没有类型要求、数量不限;只是一旦创建,元组的大小就固定了,我们不可以再往里面添加元素、删除元素。但修改元素是可以的:

fn main() {
    // 如果想修改,还是要使用 mut 对变量进行修饰
    // 不管是对 tpl 重新赋值,还是修改 tpl 的某个元素
    // 都意味着 tpl 发生改变,都要使用 mut 进行声明
    let mut tpl = (33, 3.14, 44);
    println!(
        "tpl = {:?}", tpl); // tpl = (33, 3.14, 44)
    // 元组一旦创建,大小固定、并且每个元素的类型也固定
    // tpl.0 被推断为 i32,那么修改之后必须还是 i32
    tpl.0 = 3333;
    println!(
        "tpl = {:?}", tpl); // tpl = (3333, 3.14, 44)
    
    // 如果希望将第一个元素改成 i64,那么只能重新赋值
    // 并且赋值的时候显式指定类型,即 tpl: (i64, f64, i32)
    // 但比较麻烦,因为我们只对第一个元素的类型有要求
    // 所以也可以使用下面这种方式
    let mut tpl = (123i64, tpl.1, tpl.2);
    println!(
        "tpl = {:?}", tpl);  // tpl = (123, 3.14, 44)
}

这就是 Rust 的元组,和 Python 的元组有着相似之处,但又不完全一样。

数组

我们同样可以在数组中存储多个值,与元组不同,数组中的每一个元素都必须是相同的类型。Rust 中的数组拥有固定的长度,一旦声明就不能再随意更改大小,这和其它静态语言是比较相似的。所以当想要确保元素类型相同、且数量固定时,那么数组是一个非常有用的工具。

在 Rust 中,我们可以将以逗号分隔的值放置在一对方括号内来创建一个数组:

fn main() {
    let arr = [1, 2, 3, 4];
    println!(
        "arr = {:?}", arr
    );  // arr = [1, 2, 3, 4]
}

Rust 标准库也提供了一个更加灵活的动态数组(vector)类型。动态数组是一个类似于数组的集合结构,但它允许用户自由地调整数组长度,后续会说。

那么数组在创建的时候如何指定元素的类型呢?

fn main() {
    // 声明数组元素类型的同时,也要指定数组元素的个数
    // 可以看到做法还是蛮怪异的,另外记得个数、类型要匹配
    let arr: [i8; 4] = [1, 2, 3, 4];
    println!(
        "arr = {:?}", arr);  // arr = [1, 2, 3, 4]
}

Rust 还提供了一种初始化数组的方式,当创建一个所有元素都相同的数组时会非常方便。

fn main() {
    // [m;n] 表示创建一个含有 n 个元素的数组
    // 里面的元素都为 m
    let arr = [3;4];
    println!(
        "arr = {:?}", arr
    );  // arr = [3, 3, 3, 3]
}

此外也可以创建多维数组:

fn main() {
    // 表示数组里面有两个元素
    // 每个元素都是含有三个 i32 的数组
    let arr: [[i32;3]; 2] = [[1, 2, 3], 
                             [2, 3, 4]];
    println!(
        "arr = {:?}", arr
    );  // arr = [[1, 2, 3], [2, 3, 4]]
}

然后是数组元素的访问,数组由一整块分配在栈上的内存组成,可以通过索引来访问一个数组中的所有元素,比如:

fn main() {
    let arr = [[1, 2, 3], [11, 22, 33]];
    println!(
        "arr[1] = {:?}, arr[1][1] = {}",
        arr[1], arr[1][1]
    );  // arr[1] = [11, 22, 33], arr[1][1] = 22
}

访问数组必然会伴随索引越界的问题,假设数组中有 5 个元素,但我们尝试访问第 6 个元素,那么显然是会报错的。

fn main() {
    let arr = [1, 2, 3];
    println!("arr[4] = {}", arr[4]);
}

上述代码会发生编译错误,也就是在编译的时候就会得到如下错误信息:

index out of bounds: the length is 3 but the index is 4

但如果我们稍微动一下手脚:

fn main() {
    let arr = [1, 2, 3];
    let indexes = [4, 5, 6];
    // arr[indexes[0]] -> arr[4]
    println!("arr[4] = {}", arr[indexes[0]]);
}

此时依然会报错,只不过这个错误发生在运行时期,也就是说编译是可以通过的。

小结

以上就是 Rust 的基本数据类型,至于更复杂的类型我们后续再聊。然后是 Rust 的变量,和其它静态语言有着很大的不同,比如变量的隐藏(或者说遮蔽),以及 mut 关键字。这些新特性都会使得 Rust 看起来与众不同,并且具有不一样的魅力。