rust 快速入门——6 原生数据类型

149 阅读43分钟

原生数据类型

普若哥们儿

github.com/wu-hongbing…

gitee.com/wuhongbing/…

原生数据类型

Rust 是 静态类型语言,在编译时就必须知道所有变量的类型。根据值及其使用方式,编译器通常可以推断出我们想要的类型。当多种类型均有可能时,必须增加类型注解,像这样:

let a: u32 = 42;

与其它高级语言(比如 Javacript)类似,Rust 中所有数据类型都是 类 (class) 的概念,都有特定的方法,甚至常量也有方法,比如:

fn main() {
    let a: i32 = 5;
    println!("{}", a.to_string());
    println!("{}", 9.to_string());
}

变量及数据存储

在很多语言中,你并不需要经常考虑到栈与堆。但 Rust 这样的系统编程语言中,值是位于栈上还是堆上在很大程度上影响了语言的行为。

栈和堆都是代码在运行时可供使用的内存,栈中的所有数据都必须占用已知且固定的大小,在编译时大小未知或大小可能变化的数据存储在堆上。

堆内存由操作系统内核管理,操作系统内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针。这个过程称作 在堆上分配内存,有时简称为 分配 。将数据推入栈中并不被认为是分配,因为指向放入堆中数据的指针是已知的并且大小是固定的,你可以将该指针存储在栈上,不过当需要实际数据时,必须通过指针访问。

入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。由于缓存器的工作原理,现代处理器在内存中跳转越少就越快,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。

当代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。

首先强调:Rust 任何类型的变量本身都存储在栈上,都是尺寸已知且固定不变的。虽然引用类型的变量通过指针指向堆内存,堆内存中的容量可变,但是引用类型的变量本身存储在栈上,且尺寸已知且固定。

Rust 中有裸指针、引用和胖指针三种引用类型:

  • 裸指针:一个机器字长
  • 普通引用:一个机器字长
  • 胖指针:胖指针实际上是一个 Struct 结构体,其中包含指向堆内存的指针和其它元数据信息,智能指针也就是一种带有额外功能的胖指针

Rust 除了使用堆栈,还使用全局内存区 (静态变量区和字面量区)。Rust 编译器会将全局内存区的数据直接嵌入在二进制程序文件中,当启动并加载程序时,嵌入在全局内存区的数据被放入内存的某个位置。全局内存区的数据是编译期间就可确定的,且存活于整个程序运行期间。字符串字面量、static 定义的静态变量 (相当于全局变量) 都会硬编码嵌入到二进制程序的全局内存区。

例如:

fn main() {
    let s = "hello"; // (1)
    let _ss = String::from("hello"); // (2)
    let _arr = ["hello"; 3]; // (3)
    let _tuple = ("hello",); // (4)

    println!("{:p}", s.as_ptr());
    println!("{:p}", _ss.as_ptr());
    println!("{:p}", _arr[0].as_ptr());
    println!("{:p}", _tuple.0.as_ptr());
}

输出:

0x7ff6fd45c499 <---
0x23973f6af70
0x7ff6fd45c499 <---
0x7ff6fd45c499 <---

可以看出,上面代码中的几个变量都使用了字符串字面量,且使用的都是相同的字面量"hello",在编译期间,它们会共用同一个"hello",该"hello"会硬编码到二进制程序文件中。当程序被加载到内存时,该被放入到全局内存区,它在全局内存区有自己的内存地址,当运行到以上各行代码时:

  • 代码 (1)、(3)、(4),将根据地址取得其引用,并分别保存到变量 _s_arr 各元素、_tuple 元素中
  • 代码 (2),将根据地址取得数据并将其拷贝到堆中 (转换为 Vec<u8> 的方式存储,它是 String 类型的底层存储方式)

Rust 中允许使用 const 定义常量。常量将在编译期间直接以硬编码的方式内联 (inline) 插入到使用常量的地方。所谓内联,即将它代表的值直接替换到使用它的地方。

比如,定义了常量 ABC=33,在第 100 行和第 300 行处都使用了常量 ABC,那么在编译期间,会将 33 硬编码到第 100 行和第 300 行处。

Rust 中,除了 const 定义的常量会被内联,某些函数也可以被内联。将函数进行内联,表示将该函数对应的代码体直接展开并插入到调用该函数的地方,这样就没有函数调用的开销 (比如没有调用函数时申请栈帧、在寄存器保存某些变量等的行为),效率会更高一些。但只有那些频繁调用的短函数才适合被内联,并且内联会导致程序的代码膨胀。

变量与常量

变量命名

官方详细程序风格指南参考:

一般来说,Rust 倾向于对 类型(type-level) 含义的标识符采用驼峰命名法( CamelCase),对 值( value-level) 含义的标识符采用蛇形命名法(snake_case)。静态变量和常量通常用大写

对于驼峰命名法,复合词的缩略形式我们认为是一个单独的词语,所以只对首字母进行大写:使用 Uuid 而不是 UUIDUsize 而不是 USizeStdin 而不是 StdIn

对于蛇形命名法,缩略词通常用小写:is_xid_start。除了最后一部分,其它部分的词语都不能由单个字母组成: btree_map 而不是 b_tree_mapPI_2 而不是 PI 2.

ItemConvention
Cratessnake_case (but prefer single word)
Modulessnake_case
TypesCamelCase
TraitsCamelCase
Enum variantsCamelCase
Functionssnake_case
Methodssnake_case
General constructorsnew or with_more_details
Conversion constructorsfrom_some_other_type
Local variablessnake_case
Static variablesSCREAMING_SNAKE_CASE
Constant variablesSCREAMING_SNAKE_CASE
Type parametersconcise CamelCase, usually single uppercase letter: T
Lifetimesshort, lowercase: 'a

原始标识符

原始标识符(Raw identifiers)允许你使用通常不能使用的关键字,其带有 r# 前缀。例如,match 是关键字。如果将 match 作为名字的函数:

fn match (needle: &str, haystack: &str) -> bool {
    haystack.contains (needle)
}

会得到这个错误:

error: expected identifier, found keyword `match`
 --> src/main.rs: 4:4
  |
4 | fn match (needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

该错误表示你不能将关键字 match 用作函数标识符。你可以使用原始标识符将 match 作为函数名称使用:

fn r#match (needle: &str, haystack: &str) -> bool {
haystack.contains (needle)
}

fn main () {
    assert! (r#match ("foo", "foobar"));
}

此代码编译没有任何错误。注意 r# 前缀需同时用于函数名定义和 main 函数中的调用。

原始标识符允许使用你选择的任何单词作为标识符,即使该单词恰好是保留关键字。这给予了我们更大的自由来选择名字,这样与其他语言交互时就不用考虑到关键字问题(在要交互的语言中这个名字不是关键字)。此外,原始标识符允许你使用以不同于你的 crate 使用的 Rust 版本编写的库。比如,try 在 2015 edition 中不是关键字,而在 2018 edition 则是。所以如果用 2015 edition 编写的库中带有 try 函数,在 2018 edition 中调用时就需要使用原始标识符语法,在这里是 r#try

变量绑定

在有些语言中,我们用 var a = "hello world" 的方式给 a 赋值,也就是把等式右边的 "hello world" 字符串赋值给变量 a ,而在 Rust 中,我们这样写: let a = "hello world" ,同时给这个过程起了另一个名字:变量绑定

虽然也可以称之为赋值,但是绑定的含义更清晰准确。这里就涉及 Rust 最核心的原则——所有权,简单来讲,任何内存对象有且只有一个主人,绑定就是把这个对象绑定给一个变量,让这个变量成为它的主人。

变量可变性

Rust 的变量在默认情况下是不可变的,这是 Rust 团队精心设计的语言特性之一,让我们编写的代码更安全,性能也更好。当然你可以通过 mut 关键字让变量变为可变的,让设计更灵活。

如果变量 a 不可变,那么一旦为它绑定值,就不能再修改 a。比如下面的例子编译错误,原因是变量 a 被修改。

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

这种错误是为了避免无法预期的错误发生在变量上:一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。这种默认规则让我们的代码变得更加清晰,也给阅读代码带来便利。

在 Rust 中,可变性很简单,只要在变量名前加一个 mut 即可,而且这种显式的声明表达的信息是:这个变量在后面代码部分会发生改变。

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

常量

常量(constant)。与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:

  • 常量不允许使用 mut常量不仅仅默认不可变,而且自始至终不可变,因为常量在编译完成后,已经确定它的值。
  • 常量使用 const 关键字而不是 let 关键字来声明,并且值的类型必须标注。

下面是一个常量声明的例子,其常量名为 MAX_POINTS,值设置为 100,000。Rust 常量的命名约定是全部字母都使用大写,并使用下划线分隔单词,另外对数字字面量可插入下划线以提高可读性

const MAX_POINTS: u32 = 100_000;

常量可以在任意作用域内声明,包括全局作用域。

使用下划线开头忽略未使用的变量

如果创建了一个变量却不在任何地方使用它,Rust 通常会给你一个警告,因为这可能会是个 BUG。但是有时创建一个不会被使用的变量是有用的,比如你正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头

fn main() {
    let _x = 5;
    let y = 10;
}

变量解构

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);
}

在 Rust 1.59 版本后,可以在赋值语句的左式中使用元组、切片和结构体模式。

struct Struct {
    f: i32,
    g: i32,
}

fn main() {
    let (a, b, c, d, e, f, g);

    (a, b) = (1, 2);

    // '_' 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 '_'
    [c, .., e, _] = [1, 2, 3, 4, 5, 6];

    // '..' 表示只对结构体部分成员赋值,剩余的成员忽略
    Struct { f, .. } = Struct { f: 6, g: 7 };

    d = 4;
    g = 7;

    assert_eq!([1, 2, 1, 4, 5, 6, 7], [a, b, c, d, e, f, g]);
}

这种使用方式跟之前的 let 保持了一致性,但是 let 会重新绑定,而这里仅仅是对之前绑定的变量进行再赋值。需要注意的是,使用 += 的赋值语句还不支持解构式赋值。

第 12 行,_ 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 _

第 15 行,.. 表示只对结构体部分成员赋值,剩余的成员忽略。

变量遮蔽 (shadowing)

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 声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。

变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用取更多的名字。

标量类型

标量类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型,这与其它语言基本类似。

整型

下面的表格展示了 Rust 内建的整数类型。

表格 1: Rust 中的整型

长度有符号无符号
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

isizeusize 类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。

数字字面值可以使用表格中的任何一种,例如 57u8 来指定类型,同时也允许使用 _ 做为分隔符以方便读数,例如 1_000,它的值与你指定的 1000 相同。

表格: Rust 中的整型字面值

数字字面值例子
Decimal (十进制)98_222
Hex (十六进制)0xff
Octal (八进制)0o77
Binary (二进制)0b1111_0000
Byte (单字节字符)(仅限于 u8)b'A'

数字类型默认是 i32isizeusize 主要作为某些集合的索引。

整型数存在溢出问题,这会导致以下两种行为之一的发生:

  • 当在 debug 模式编译时,Rust 检查这类问题并使程序 panic,Rust 用这个术语表示程序因错误而退出。
  • 使用 --release flag 在 release 模式中构建时,Rust 不会检测会导致 panic 的整型溢出。相反发生整型溢出时,Rust 会进行一种被称为二进制补码 wrapping(two’s complement wrapping)的操作。简而言之,比此类型能容纳最大值还大的值会回绕到最小值,值 256 变成 0,值 257 变成 1,依此类推。程序不会 panic,不过变量可能也不会是你所期望的值。

浮点型

Rust 有两个原生的 浮点数floating-point numbers)类型,它们是带小数点的数字。Rust 的浮点数类型是 f32f64,分别占 32 位和 64 位。f32 是单精度浮点数,f64 是双精度浮点数。默认类型是 f64,因为在现代 CPU 中,它与 f32 速度几乎一样,不过精度更高。所有的浮点型都是有符号的。

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

布尔型

Rust 中的布尔类型有两个可能的值:truefalse。Rust 中的布尔类型使用 bool 表示。例如:

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

字符类型

Rust 用单引号声明 char 字面量,使用双引号声明字符串字面量。Rust 的 char 类型的大小为四个字节,并代表了一个 Unicode 标量值。在 Rust 中,带变音符号的字母、中文、日文、韩文等字符,emoji(绘文字)以及零长度的空白字符都是有效的 char 值。Unicode 标量值包含从 U+0000U+D7FFU+E000U+10FFFF 在内的值。可以直接使用 Unocde 标量值,形式为:'\u{d007}'

fn main() {
    let a='\u{d007}';
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
    println!("{} {} {} {}", a, c, z, heart_eyed_cat);
}

字面量

数值字面量可以使用类型类型后缀,比如 42i32 是 i32 类型,数值为 42。无后缀的数值字面量的类型取决于怎样使用它们。如果没有限制,编译器会对整数使用 i32,对浮点数使用 f64

fn main() {
    // 带后缀的字面量,其类型在初始化时已经知道了。
    let x = 1u8;
    let y = 2u32;
    let z = 3f32;

    // 无后缀的字面量,其类型取决于如何使用它们。
    let i = 1;
    let f = 1.0;
}

复合类型

复合类型是将多个值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。

元组

元组是一个将多个其他类型的值组合进一个复合类型的一种方式。元组长度固定:一旦声明,其长度不会增大或缩小。

我们使用包含在圆括号中的逗号分隔的值列表来创建一个元组。元组中的每一个位置都有一个类型,而且这些不同值的类型也不必是相同的。这个例子中使用了可选的类型注解:

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

元组没有专门的类型名(i32, f64, u8) 相当于类项名。tup 变量绑定到整个元组上,因为元组是一个单独的复合元素。为了从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值:

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

程序首先创建了一个元组并绑定到 tup 变量上。接着使用了 let 和一个模式将 tup 分成了三个不同的变量:xyz,这叫做 解构destructuring),因为它将一个元组拆成了三个部分。最后,程序打印出了 y 的值,也就是 6.4

我们也可以使用点号 . 后跟值的索引来直接访问它们:

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;
}

不带任何值的元组有个特殊的名称,叫做 单元(unit)元组 。这种值以及对应的类型都写作 (),表示空值或空的返回类型。如果表达式不返回任何其他值,则会隐式返回单元值

数组

定义数组

另一个包含多个值的方式是 数组array)。与元组不同,数组中的每个元素的类型必须相同Rust 中的数组长度是固定的

数组(array)是一组拥有相同类型 T 的对象的集合,在内存中是连续存储的。数组使用中括号 [] 来创建,且它们的大小在编译时会被确定。数组的类型标记为 [T; length]T 为元素类型,length 表示数组大小。

// 定长数组(类型标记是多余的)
let a: [i32; 5] = [1, 2, 3, 4, 5];

列出数组元素的值,rust 就能够推断出数组元素的类型和长度,可以省略类型标记:

let a = [1, 2, 3, 4, 5];

对于多维数组的定义,以二维数组为例,其它更高维的类似:

let a: [[i32; 2]; 3] = [[1, 2], [3, 4], [5, 6]];    // 三行两列的二维数组

当你想要在栈(stack)而不是在堆(heap)上为数据分配空间,或者是想要确保总是有固定数量的元素时,数组非常有用。但是数组并不如标准库中的 vector 类型灵活,vector 是允许增长和缩小长度的类似数组的集合类型。

通过在方括号中指定初始值加分号再加元素个数的方式可以创建一个元素为相同值的数组:

let a = [3; 5];

这种写法与 let a = [3, 3, 3, 3, 3]; 效果相同,但更简洁。

访问数组元素

可以使用索引来访问数组的元素,像这样:

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}
无效的数组元素访问

让我们看看如果我们访问数组结尾之后的元素会发生什么呢,比如你执行以下代码:

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 {index} is: {element}");
}

程序使用了标准库。如果您使用 cargo run 运行此代码并输入 01234,程序将在数组中的索引处打印出相应的值。如果你输入一个超过数组末端的数字,如 10,你会看到这样的输出:

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

程序在索引操作中使用一个无效的值时导致 运行时 错误。程序带着错误信息退出,并且没有执行最后的 println! 语句。每次通过索引访问一个元素时,Rust 都会检查指定的索引是否小于数组的长度,数组长度是硬编码到检查程序片段中的。如果索引超出了数组长度,Rust 程序会 panic,这是 Rust 术语,它用于程序因为错误而退出的情况。这种检查必须在运行时进行,因为编译器不可能知道用户在以后运行代码时将输入什么值。

反编译,得到类似如下代码片段:

cmpq   $0x5, %rax   ;0x5 就是数组长度的硬编码            
setb   %al                      
testb  $0x1, %al                
jne    0x1400010d1                ; <+81> at main.rs:5
jmp    0x140001165                ; 越界跳转到panic
movq   0x30(%rsp), %rax    

在很多底层语言中,并没有进行这类检查,这样当提供了一个不正确的索引时,就会访问无效的内存。操作系统通常会发现这种异常,并终止程序。需要强调的是,这是操作系统的行为,而不是程序本身的功能

遇到 Rust 程序通过立即退出而不是允许内存访问并继续执行,注意,是 Rust 程序本身让你避开此类错误,而不是依赖操作系统

切片

切片(slice)是一种动态内存宽度类型 (DST,dynamically sized type),它代表类型为 T 的元素组成的数据序列的一个“视图”。切片类型写为 [T]

切片不是固定内存尺寸的类型,因此无法实例化,也就是说无法定义切片类型的变量,只能切片的引用来使用,标记为 &[T]

[! note] Rust 中任何变量都存储在栈上,因此要求内存尺寸已知且固定

通常切片的引用也称为切片,注意上下文,分清是切片还是切片的引用。

切片的引用是一个双字对象(two-word object),第一个字是一个指向数据的指针,第二个字是切片的长度,这样,编译后的程序访问切片时,很容易检测越界问题。比如下例通过切片来借用数组的一部分:

let a: [i32; 5] = [10, 20, 30, 40, 50];
let slice: &[i32] = &a[1..3];
assert_eq!(slice, &[20, 30]);

上面的数组切片的引用 slice 的类型是 &[i32],与之对比,数组的类型是 [i32;5]。 slice 的内存结构如图 1 所示:

59db045574a4daddeda212eb54d24f22.svg 图 1 数组切片的引用类型变量的内存结构

特别注意,引用 往往被理解为一个指针,即引用是一个类型的变量,该变量内容是一个单字,含义为地址,指向对应类型的数据的首字节。但是在Rust 中,切片的引用是双字结构,这个结构从长度确定且固定。

字符串

字符串类型 str 的值的表示方法与 [u8] 类似,也一个 8-bit 无符号字节类型的切片。但是,Rust 标准库对 str 做了额外的假定:str 上的方法会假定并确保其中的数据是有效的 UTF-8。调用 str 的方法来处理非 UTF-8 缓冲区上的数据可能或早或晚地出现未定义行为。

同样,由于 str 没有固定的内存尺寸,无法实例化,所以它只能通过引用实例化,比如 &str,这同样是一个双字节对象,第一个字是指向字符串的指针,第二个字是字符串长度。

字符串字面量是字符串切片的引用。

let s = "Hello, world!";

s 的类型是 &str,因此也可以这样声明:

let s: &str = "Hello, world!";

Hello, world! 存储在程序的静态存储区,而不是存储在堆上,不能扩展长度。该字符串切片的引用指向了字符串字面量的起始地址,长度值为 11。

处理长度可变的字符串使用 Rust 原生类型显然是很不方便的,标准库中提供了 String 类型专门用于处理长度变化的字符串。String 是一个结构体,其中包含 3 个字段:prt 为指向存储在堆中字符串数据;len 为当前字符串长度;capacity 为容量。

fn main() {
    let s:String = String::from("hello world");

    let hello:&str = &s[0..5];
    let world:&str = &s[6..11];
}

hello 是一个切片,是对部分 String 的引用,由 [0..5] 部分指定范围。其中 .. 是 Rust 范围操作符,....= 操作符会根据下表中的规则构造 std::ops::Range(或 core::ops::Range)的某一变体类型的对象:

产生式/句法规则句法类型区间语义
RangeExprstart .. end[std::ops:: Range]start ≤ x < end
RangeFromExprstart ..[std::ops:: RangeFrom]start ≤ x
RangeToExpr.. end[std::ops:: RangeTo]x < end
RangeFullExpr..[std::ops:: RangeFull]-
RangeInclusiveExprstart ..= end[std::ops:: RangeInclusive]start ≤ x ≤ end
RangeToInclusiveExpr..= end[std::ops:: RangeToInclusive]x ≤ end

比如,0..5 表示 0、1、2、3、4 范围内的数,注意不包括 5!0..=5 表示 0、1、2、3、4、5 范围内的数,包括 5!

可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_index 是 slice 的第一个位置,ending_index 则是 slice 最后一个位置的后一个值。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 索引 6 的指针和长度值 5 的 slice。

图 2 展示了一个图例。

bca3b709f4fbe9c119fa76988a724e62.svg

图 2:引用了部分 String 的字符串 slice

结构体

结构体定义与实例化

结构体包含多个字段(field),每个字段可以是不同的类型,需要描述字段名和响应的数据类型。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

通过点号 . 访问结构体某个字段,比如 user1.email。如果结构体的实例是可变的,我们可以使用点号 . 为对应的字段赋值。注意整个实例必须是可变的,Rust 并不允许只将某个字段标记为可变。

字段初始化简写语法
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

注意,由于 usernameemail 参数与结构体字段同名,因此 Rust 编译器能够推断出应当如何赋值。

使用结构体更新语法从其他实例创建实例

.. 语法指定了剩余未显式设置值的字段应有与给定实例对应字段相同的值。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}

..user1 必须放在最后,以指定其余的字段应从 user1 的相应字段中获取其值,但我们可以选择以任何顺序为任意字段指定值,而不用考虑结构体定义中字段的顺序。

使用没有命名字段的元组结构体来创建不同的类型

也可以定义与元组类似的结构体,称为 元组结构体tuple structs)。元组结构体有结构体名称,但没有字段名,只有字段的类型。元组是没有类型名字的,当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余了。下面是两个分别叫做 ColorPoint 元组结构体的定义和用法:

 struct Color(i32, i32, i32);  
 struct Point(i32, i32, i32);  
   
 fn main() {  
     let black = Color(0, 0, 0);  
     let origin = Point(0, 0, 0);  
 }

注意 blackorigin 值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段可能有着相同的类型。例如,一个获取 Color 类型参数的函数不能接受 Point 作为参数,即便这两个类型都由三个 i32 值组成。在其他方面,元组结构体实例类似于元组,你可以将它们解构为单独的部分,也可以使用 . 后跟索引来访问单独的值,等等。

没有任何字段的类单元结构体

我们也可以定义一个没有任何字段的结构体!它们被称为 类单元结构体unit-like structs)因为它们类似于 (),即元组类型一节中提到的 unit 类型。类单元结构体常常在你想要在某个类型上实现 trait (参考后面关于 trait 的章节)但不需要在类型中存储数据的时候发挥作用。下面是一个声明和实例化一个名为 AlwaysEqual 的 unit 结构的例子。

struct AlwaysEqual;  
   
fn main() {  
    let subject = AlwaysEqual;  
}

枚举

枚举的基本使用

枚举使用关键字 enum 来声明,通过列举可能的 成员variants)来定义一个类型。

enum Animal {
    Dog,
    Cat,
}

let mut a: Animal = Animal::Dog;
a = Animal::Cat;

Animal 是枚举类型,枚举类型的成员 Animal::Dog 是枚举类型实例,也就是枚举值。

枚举的成员可以带关联数据,关联的数据可以没有字段名,用元组表示;也可以有字段名,用结构体表示。

enum Animal {
    Dog(String, f64),  // 关联数据没有字段名
    Cat { name: String, weight: f64 }, //关联数据有字段名
}

let mut a: Animal = Animal::Dog("Cocoa".to_string(), 37.2);
a = Animal::Cat { name: "Spotty".to_string(), weight: 2.7 };

没有关联数据的枚举可以认为关联了 类单元结构体单元元组

enum Fieldless {
    Tuple(),
    Struct{},
    Unit,
}
获取关联数据的值

使用 match 运算符可以获取枚举的关联数据,match 在后面会专门讲到。

fn main() {  
    enum Book {  
        Papery {index: u32},  
        Electronic {url: String},  
    }  
     
    let book = Book::Papery{index: 1001};  
    let ebook = Book::Electronic{url: String::from("url...")};  
     
    match book {  
        Book::Papery { index } => {  
            println!("Papery book {}", index);  
        },  
        Book::Electronic { url } => {  
            println!("E-book {}", url);  
        }  
    }  
}  

运行结果:

Papery book 1001
判别值

如果枚举的任何成员都没有关联数据,则可以为成员设置判别值。判别值是一个逻辑上与枚举实例关联的整数,用于确定枚举实例持有的是哪个成员。

可以使用操作符 as 将这些枚举类型转换为整型。枚举可以可选地指定每个判别值的具体值,方法是在变体名后面追加 = 和常量表达式。如果声明中的第一个变体未指定,则将其判别值设置为零。对于其他未指定的判别值,它比照前一个变体的判别值按 1 递增。

fn main() {
	enum Foo {
	    Bar,            // 0
	    Baz = 123,      // 123
	    Quux,           // 124
	}
	
	let baz_discriminant = Foo::Baz as u32;
	assert_eq!(baz_discriminant, 123);
}

默认情况下判别值会被解释为一个 isize 值,也可以使用 #[repr(inttype)] 注解告诉编译器设置判别值为何种整型类型:

fn main() {
    #[repr(u8)]
    enum Foo {
        Bar,            // 0
        Baz = 123,      // 123
        Quux,           // 124
    }

    let baz_discriminant = Foo::Baz as u8;
    assert_eq!(baz_discriminant, 123);
}

同一枚举中,两个变体使用相同的判别值是错误的。

fn main() {
	enum SharedDiscriminantError {
	    SharedA = 1,
	    SharedB = 1
	}
	
	enum SharedDiscriminantError2 {
	    Zero,       // 0
	    One,        // 1
	    OneToo = 1  // 1 (和前值冲突!)
	}
}

当前一个变体的判别值是当前指定类型允许的的最大值时,再使用默认判别值就也是错误的。

fn main() {
	#[repr(u8)]
	enum OverflowingDiscriminantError {
	    Max = 255,
	    MaxPlusOne // 应该是256,但枚举溢出了
	}
	
	#[repr(u8)]
	enum OverflowingDiscriminantError2 {
	    MaxMinusOne = 254, // 254
	    Max,               // 255
	    MaxPlusOne         // 应该是256,但枚举溢出了。
	}
}

由于没有关联数据的枚举可以认为关联了 类单元结构体单元元组,因此包含单元 的枚举也可以使用判别值:

fn main() {  
    enum Foo {  
        Bar(),  
        Baz{},  
        Quux,  
    }  
  
    let baz_discriminant = Foo::Baz{} as isize;  
    assert_eq!(baz_discriminant, 1);  
}

但是不能为 单元 显式设置判别值,并且,如果为任何无关联数据的成员显式设置判别值则必须在枚举定义前使用 #[repr(inttype)] 注解:

fn main() {
    #[repr(u8)]
    enum Foo {
        Bar(),
        Baz{},
        Quux=9,
    }

    let baz_discriminant = Foo::Baz{} as u8;
    assert_eq!(baz_discriminant, 1);
}
无变体枚举

没有变体的枚举称为零变体枚举/无变体枚举。因为它们没有有效的值,所以不能被实例化。

零变体枚举与 never 类型等效,但它不能被强转为其他类型。

fn main() {
	enum ZeroVariants {}
	let x: ZeroVariants = panic!();
	let y: u32 = x; // 类型不匹配错误
}

联合体

这部分内容涉及一些后面才会讲到的内容,但从分类上讲,联合体属于数据类型的内容。建议学习完后面的内容后再回头深化这部分内容的理解。从学习方法上讲,学习本就是反复迭代的过程。

除了用 union 代替 struct 外,联合体声明使用和结构体声明相同的句法。

#[repr(C)]
union MyUnion {
    f1: u32,
    f2: f32,
}

联合体的关键特性是联合体的所有字段共享同一段存储。因此,对联合体的一个字段的写操作会覆盖其他字段,而联合体的内存宽度由其内存宽度最大的字段的内存宽度所决定。

联合体字段类型仅限于以下类型子集:

  • Copy 类型
  • 引用 (任意 T 上的 &T&mut T)
  • ManuallyDrop<T> (任意 T)
  • 仅包含了以上被允许的联合体字段类型的元组或数组

当联合体被销毁时,无法知道需要销毁哪个字段。这些限制专门用来确保联合体的字段永远不需要销毁操作。与结构体和枚举一样,联合体也可以通过 impl Drop 来自定义被销毁时发生的操作。

联合体的初始化

可以使用与结构体类型相同的句法创建联合体类型的值,但只能指定一个字段:

let u = MyUnion { f1: 1 };

上面的表达式创建了一个类型为 MyUnion 的值,并使用字段 f1 初始化了其存储。可以使用与结构体字段相同的句法访问联合体:

fn main() {
    union MyUnion {
        f1: u32,
        f2: f32,
    }
    let u = MyUnion { f1: 1 };
    let f = unsafe { u.f1 };
}

访问联合体是不安全操作,需要放在 unsafe 块中。unsafe 关键字将在后面讲到。

读写联合体字段

读取联合体的字段就是以当前读取字段的类型来解读此联合体的存储位。程序员有责任确保此数据在当前字段类型下有效。否则会导致未定义行为 (undefined behavior)。例如,在 bool 类型的字段下读取到数值 3 是未定义行为。因此,所有的联合体字段的读取必须放在 unsafe 块里:

union MyUnion { f1: u32, f2: f32 }
let u = MyUnion { f1: 1 };

unsafe {
    let f = u.f1;
}

相反,对联合体字段的写入操作是安全的,因为它们只是覆盖任意数据,不会导致未定义行为。注意,联合体字段类型不会关联到 drop 操作,因此联合字段的写入永远不会隐式销毁任何内容。

// 这些都不是必须要放在 `unsafe` 里的
u.f1 = 2;
u.f2 = ManuallyDrop::new(String::from("example"));

对已经初始化的变量再去覆写的时候要先去读一下这个变量代表的地址上的值的状态,如果有值,Rust 为防止内存泄漏会先执行变量的析构行为(drop ()),清空那个地址上的关联堆数据,再写入。

联合体的预设条件是此联合体值有 Copy特性,对值的直接覆写不会造成内存泄漏,就不必调用析构行为,也不需要 unsafe 的读操作了。

联合体上的模式匹配

访问联合体字段的另一种方法是使用模式匹配。联合体字段上的模式匹配与结构体上的模式匹配使用相同的句法,只是这种模式只能一次指定一个字段。由于模式匹配就像使用特定字段来读取联合体,所以它也必须被放在非安全 (unsafe) 块中。

union MyUnion { f1: u32, f2: f32 }

fn f(u: MyUnion) {
    unsafe {
        match u {
            MyUnion { f1: 10 } => { println!("ten"); }
            MyUnion { f2 } => { println!("{}", f2); }
        }
    }
}

模式匹配可以将联合体作为更大的数据结构的一个字段进行匹配。特别是,当使用 Rust 联合体通过 FFI 实现 C 兼容联合体时,即使用 #[repr(C)] 注解时,这允许同时在这种数据结构多个成员上进行匹配:

#[repr(u32)]
enum Tag { I, F }

#[repr(C)]
union U {
    i: i32,
    f: f32,
}

#[repr(C)]
struct Value {
    tag: Tag,
    u: U,
}

fn is_zero(v: Value) -> bool {
    unsafe {
        match v {
            Value { tag: Tag::I, u: U { i: 0 } } => true,
            Value { tag: Tag::F, u: U { f: num } } if num == 0.0 => true,
            _ => false,
        }
    }
}
引用联合体字段

由于联合体字段共享存储,因此拥有对联合体一个字段的写访问权就同时拥有了对其所有其他字段的写访问权。因此,引用的借用检查规则必须调整。如果联合体的一个字段所有权被出借,那么在相同的生存期内它的所有其他字段也都处于所有权出借状态。

union MyUnion { f1: u32, f2: f32 }
// 错误: 不能同时对 `u` (通过 `u.f2`)拥有多余一次的可变借用
fn test() {
    let mut u = MyUnion { f1: 1 };
    unsafe {
        let b1 = &mut u.f1;
//                      ---- 首次可变借用发生在这里 (通过 `u.f1`)
        let b2 = &mut u.f2;
//                    ^^^^ 二次可变借用发生在这里 (通过 `u.f2`)
        *b1 = 5;
    }
//    - 首次借用在这里结束
    assert_eq!(unsafe { u.f1 }, 5);
}

指针类型

引用

对变量的引用指向该变量值拥有的内存的地址,和普通类型的变量一样,引用分为不可变引用 (&) 和可变引用 (&mut)。

不可变引用 (&)

对变量的不可变引用可以防止对该值的直接更改,对一个变量的不可变引用的次数没有限制。不可变引用类型被写为 &type。不可变引用也称为共享引用。

fn main() {  
    let a: i32 = 5;  
    let s1: &i32 = &a;  
    let s2: &i32 = &a; // 可以同时有同一个变量的多个不可变引用  
    println!("0x{:p}\n0x{:p}", s1,s2);  //输出2个相同的地址值  
    // *s1 = 9; //错误,不能通过不可变引用修改引用的值  
}

第 3、4 行,声明了同一个变量的多个不可变引用,这是合法的。

第 6 行,语句 *s=9; 中的 * 号是 解引用运算符 ,含义是取出引用指向的值。通过不可变引用修改引用指向的值会引发编译错误:Cannot assign a new value to immutable borrowed content

可变引用 (&mut)

可变引用类型被写为 &mut type 。

fn main() {  
    let mut a: i32 = 5;  
    let s1: &mut i32 = &mut a;  
    *s1 = 9; //通过可变引用修改引用的值  
    // let s2: &i32 = &a; // 错误,不能同时有同一个变量的可变引用和不可变引用  
    println!("{:p}",s1);  
    // let s3: &mut i32 = &mut a; // 错误,不能同时有同一个变量的多个可变引用  
}

第 4 行,通过可变引用修改引用的值。

第 5、7 行错误,对同一变量的可变引用只能有一个,并且一旦有了变量的一个可变引用,则不能再有该变量的不可变引用。这些内容在后面关于所有权的章节中有详细的描述,为了完整讲述引用的相关要点,这里提前做了说明。

裸指针 (*const 和 *mut)

和引用一样,裸指针是不可变或可变的,分别写作 *const T*mut T。这里的星号不是解引用运算符,它是类型名称的一部分。在裸指针的上下文中,不可变 意味着指针解引用之后不能直接赋值。下例展示了如何从引用同时创建不可变和可变裸指针。

 fn main() {  
     let mut num = 5;  
   
     let r1 = &num as *const i32;  
     let r2 = &mut num as *mut i32;  
 }

这里使用 as 类型转换关键字将不可变和可变引用强转为对应的裸指针类型,因为直接从引用来创建它们,可以知道这些裸指针是有效,但是不能对任何裸指针做出如此假设。比如下例创建一个不能确定其有效性的裸指针。尝试使用任意内存是危险的,此地址可能是无效地址,引起段访问错误。

 fn main() {
    let address = 0x012444usize;
    let r = address as *const i32;
    unsafe {
        let v = *r;
        println!("{}", v)
    }
}

解引用裸指针的操作必须在 unsafe 块中,因为裸指针不一定是有效的,解引用裸指针是不安全的。如下例所示:

 fn main() {  
     let mut num = 5;  
   
     let r1 = &num as *const i32;  
     let r2 = &mut num as *mut i32;  
   
     unsafe {  
         println!("r1 is: {}", *r1);  
         println!("r2 is: {}", *r2);  
     }  
 }

unsafe 关键字及其相关内容在后面的章节中详细介绍。

注意,创建一个指针不会造成任何危险;只有当访问其指向的值时才有可能遇到无效的值。

还需注意示例中创建了同时指向相同内存位置 num 的裸指针 *const i32*mut i32。相反如果尝试同时创建 num 的不可变和可变引用,将无法通过编译,因为 Rust 的所有权规则不允许在拥有任何不可变引用的同时再创建一个可变引用。通过裸指针,就能够同时创建同一地址的可变指针和不可变指针,但是,若通过可变指针修改数据,则可能潜在造成数据竞争。

引用与裸指针的区别

引用和裸指针,本质完全一致。但是,Rust 编译器保证引用的安全,而裸指针不保证安全。对裸指针的解引用需要 unsafe 关键字。

裸指针存在危险,而且 Rust 已经有引用了,为什么还要有裸指针?

简单来说,一是为了 FFI(Foreign Function Interface),FFI 是一种编程框架,它允许在一个编程语言中调用另一个编程语言编写的函数;二是编写开发人员能够保证安全而 Rust 借用检查器无法理解代码,提高 Rust 语言的底层开发能力。

引用在一些方面做不到与指针完全兼容,保留指针类型是有意义的:

  1. 存在空指针和悬挂指针(比如很多 FFI 的中空指针是合法的参数值),但不存在空引用和悬挂引用(语义上不存在,尽管事实上可以在 unsafe 中通过指针强转得到)
  2. 指针除了有 引用数据 的语义,还有 内存地址 的语义在,体现在: ① 指针的比较只比较内存地址,引用的比较则会比较引用的数据; ② 指针可以参与加减计算
  3. 引用在 引用数据 时,其内存地址始终是对齐的,而指针在引用数据 时,其内存地址可以是不对齐的
  4. 引用是有生命期的,而指针是没有生命期的。为 FFI 中每个指针添加生命期的泛型参数是不合适的
  5. 引用有借用检查(不能存在多个可变引用,不能同时存在不可变引用和可变引用),但指针是不接受检查的

never

never 类型 (!) 是一个没有值的类型,表示永远不会完成计算的结果。! 的类型表达式可以强转为任何其他类型。

目前 ! 类型仍是实验性的,只能出现在函数返回类型中,该函数永远不会返回,比如死循环,或者调用了 panic!() 退出程序。比如:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("divide by zero!"); //返回never类型,也就是永远不返回,在语法上可以转换为i32类型
    }
    a / b
}
fn test1() -> ! { 
    loop {} // 永不返回
}
fn test2() -> ! {
    panic!(); // 永不返回
}
fn main() {
    let x=divide(5, 0);
    let x: u32 = test1(); //可以转化为任何类型
    let y: f32 = test2(); //可以转化为任何类型
}

当参数 b 等于 0 时,函数 divide 不返回,返回值为 !,由于 ! 可以转化为任何类型,因此 divide 函数签名 -> i32 是合法的。

test1 函数和 test2 函数都不会返回,因此更不会有返回值,虽然第 15、16 行符合语法,但是实际上不会被执行。

类型别名

可以用 type 语句给已有的类型取个新的名字。类型的名字必须遵循驼峰命名法(像是 CamelCase 这样),否则编译器将给出警告,但原生类型是例外,比如: usizef32,等等。

// `NanoSecond` 是 `u64` 的新名字。
type NanoSecond = u64;
type Inch = u64;

// 通过这个属性屏蔽警告。
#[allow(non_camel_case_types)]
type u64_t = u64;
// 试一试 ^ 移除上面那个属性

fn main() {
    // `NanoSecond` = `Inch` = `u64_t` = `u64`.
    let nanoseconds: NanoSecond = 5 as u64_t;
    let inches: Inch = 2 as u64_t;

    // 注意类型别名*并不能*提供额外的类型安全,因为别名*并不是*新的类型。
    println!("{} nanoseconds + {} inches = {} unit?",
             nanoseconds,
             inches,
             nanoseconds + inches);
}

别名的主要用途是避免写出冗长的模板化代码(boilerplate code)。如 IoResult<T>Result<T, IoError> 类型的别名。

类型转换

Rust 不提供原生类型之间的隐式类型转换,但可以使用 as 关键字进行显式类型转换

输出变量类型

利用标准库中的 std::any::type_name_of_val 能够获取变量的类型,这个函数对于调试程序或者学习 Rust 语法非常有用,注意,使用时需要在变量名前加上引用符号 &

use std::any::type_name_of_val;  
  
fn main() {  
    let str1 = "Rust language";  
    println!("the type of str1 is {}.", type_name_of_val(&str1));  
}

输出:

the type of str1 is &str.