第2章 变量和类型
2.1 变量声明
Rust的变量必须先声明后使用。对于局部变量,最常见的声明语法为:
let variable : i32 = 100;
与传统的C/C++语言相比,Rust的变量声明语法不同。这样设计主要有以下几个方面的考虑。
1.语法分析更容易
从语法分析的角度来说,Rust的变量声明语法比C/C++语言的简单,局部变量声明一定是以关键字let开头,类型一定是跟在冒号:的后面。语法歧义更少,语法分析器更容易编写。
2.方便引入类型推导功能
Rust的变量声明的一个重要特点是:要声明的变量前置,对它的类型描述后置。这也是吸取了其他语言的教训后的结果。因为在变量声明语句中,最重要的是变量本身,而类型其实是个附属的额外描述,并非必不可少的部分。如果我们可以通过上下文环境由编译器自动分析出这个变量的类型,那么这个类型描述完全可以省略不写。Rust一开始的设计就考虑了类型自动推导功能,因此类型后置的语法更合适。
3.模式解构
let语句不光是局部变量声明语句,而且具有pattern destructure(模式解构)的功能。关于“模式解构”的内容在后面的章节会详细描述。
实际上,包括C++ / C# / Java等传统编程语言都开始逐步引入这种声明语法,目的是相似的。
Rust中声明变量缺省是“只读”的,比如如下程序:
fn main() { let x = 5; x = 10; }
会得到“re-assignment of immutable variable `x`”这样的编译错误。
如果我们需要让变量是可写的,那么需要使用mut关键字:
let mut x = 5; // mut x: i32 x = 10;
此时,变量x才是可读写的。
实际上,let语句在此处引入了一个模式解构,我们不能把let mut视为一个组合,而应该将mut x视为一个组合。
mut x是一个“模式”,我们还可以用这种方式同时声明多个变量:
let (mut a, mut b) = (1, 2); let Point { x: ref a, y: ref b} = p;
其中,赋值号左边的部分是一个“模式”,第一行代码是对tuple的模式解构,第二行代码是对结构体的模式解构。所以,在Rust中,一般把声明的局部变量并初始化的语句称为“变量绑定”,强调的是“绑定”的含义,与C/C++中的“赋值初始化”语句有所区别。
Rust中,每个变量必须被合理初始化之后才能被使用。使用未初始化变量这样的错误,在Rust中是不可能出现的(利用unsafe做hack除外)。如下这个简单的程序,也不能编译通过:
fn main() { let x: i32; println!("{}", x); }
错误信息为:
error: use of possibly uninitialized variable: `x`
编译器会帮我们做一个执行路径的静态分析,确保变量在使用前一定被初始化:
fn test(condition: bool) { let x: i32; // 声明 x,不必使用 mut 修饰 if condition { x = 1; // 初始化 x,不需要 x 是 mut 的,因为这是初始化,不是修改 println!("{}", x); } // 如果条件不满足,x 没有被初始化 // 但是没关系,只要这里不使用 x 就没事 }
类型没有“默认构造函数”,变量没有“默认值”。对于let x: i32;如果没有显式赋值,它就没有被初始化,不要想当然地以为它的值是0。
Rust里的合法标识符(包括变量名、函数名、trait名等)必须由数字、字母、下划线组成,且不能以数字开头。这个规定和许多现有的编程语言是一样的。Rust将来会允许其他Unicode字符做标识符,只是目前这个功能的优先级不高,还没有最终定下来。另外还有一个raw identifier功能,可以提供一个特殊语法,如r#self,让用户可以以关键字作为普通标识符。这只是为了应付某些特殊情况时迫不得已的做法。
Rust里面的下划线是一个特殊的标识符,在编译器内部它是被特殊处理的。它跟其他标识符有许多重要区别。比如,以下代码就编译不过:
fn main() { let _ = "hello"; println!("{}", _); }
我们不能在表达式中使用下划线来作为普通变量使用。下划线表达的含义是“忽略这个变量绑定,后面不会再用到了”。在后面讲析构的时候,还会提到这一点。
2.1.1 变量遮蔽
Rust允许在同一个代码块中声明同样名字的变量。如果这样做,后面声明的变量会将前面声明的变量“遮蔽”(Shadowing)起来。
fn main() { let x = "hello"; println!("x is {}", x); let x = 5; println!("x is {}", x); }
上面这个程序是可以编译通过的。请注意第5行的代码,它不是x=5;,它前面有一个let关键字。如果没有这个let关键字,这条语句就是对x的重新绑定(重新赋值)。而有了这个let关键字,就是又声明了一个新的变量,只是它的名字恰巧与前面一个变量相同而已。
但是这两个x代表的内存空间完全不同,类型也完全不同,它们实际上是两个不同的变量。从第5行开始,一直到这个代码块结束,我们没有任何办法再去访问前一个x变量,因为它的名字已经被遮蔽了。
变量遮蔽在某些情况下非常有用,比如,我们需要在同一个函数内部把一个变量转换为另一个类型的变量,但又不想给它们起不同的名字。再比如,在同一个函数内部,需要修改一个变量绑定的可变性。例如,我们对一个可变数组执行初始化,希望此时它是可读写的,但是初始化完成后,我们希望它是只读的。可以这样做:
// 注意:这段代码只是演示变量遮蔽功能,并不是Vec类型的最佳初始化方法 fn main() { let mut v = Vec::new(); // v 必须是mut修饰,因为我们需要对它写入数据
v.push(1); v.push(2); v.push(3); let v = v; // 从这里往下,v成了只读变量,可读写变量v已经被遮蔽,无法再访问 for i in &v { println!("{}", i); } }
反过来,如果一个变量是不可变的,我们也可以通过变量遮蔽创建一个新的、可变的同名变量。
fn main() { let v = Vec::new(); let mut v = v; v.push(1); println!("{:? }", v); }
请注意,这个过程是符合“内存安全”的。“内存安全”的概念一直是Rust关注的重点,我们将在第二部分详细讲述。在上面这个示例中,我们需要理解的是,一个“不可变绑定”依然是一个“变量”。虽然我们没办法通过这个“变量绑定”修改变量的值,但是我们重新使用“可变绑定”之后,还是有机会修改的。这样做并不会产生内存安全问题,因为我们对这块内存拥有完整的所有权,且此时没有任何其他引用指向这个变量,对这个变量的修改是完全合法的。Rust的可变性控制规则与其他语言不一样。更多内容请参阅本书第二部分内存安全。
实际上,传统编程语言C/C++中也存在类似的功能,只不过它们只允许嵌套的区域内部的变量出现遮蔽。而Rust在这方面放得稍微宽一点,同一个语句块内部声明的变量也可以发生遮蔽。
2.1.2 类型推导
Rust的类型推导功能是比较强大的。它不仅可以从变量声明的当前语句中获取信息进行推导,而且还能通过上下文信息进行推导。
fn main() { // 没有明确标出变量的类型,但是通过字面量的后缀, // 编译器知道elem的类型为u8 let elem = 5u8; // 创建一个动态数组,数组内包含的是什么元素类型可以不写 let mut vec = Vec::new(); vec.push(elem); // 到后面调用了push函数,通过elem变量的类型, // 编译器可以推导出vec的实际类型是 Vec<u8> println!("{:? }", vec); }
我们甚至还可以只写一部分类型,剩下的部分让编译器去推导,比如下面的这个程序,我们只知道players变量是Vec动态数组类型,但是里面包含什么元素类型并不清楚,可以在尖括号中用下划线来代替:
fn main() {
let player_scores = [
("Jack", 20), ("Jane", 23), ("Jill", 18), ("John", 19),
];
// players 是动态数组,内部成员的类型没有指定,交给编译器自动推导
let players : Vec<_> = player_scores
.iter()
.map(|&(player, _score)| {
player
})
.collect();
println!("{:? }", players);
}
自动类型推导和“动态类型系统”是两码事。Rust依然是静态类型的。一个变量的类型必须在编译阶段确定,且无法更改,只是某些时候不需要在源码中显式写出来而已。这只是编译器给我们提供的一个辅助工具。
Rust只允许“局部变量/全局变量”实现类型推导,而函数签名等场景下是不允许的,这是故意这样设计的。这是因为局部变量只有局部的影响,全局变量必须当场初始化而函数签名具有全局性影响。函数签名如果使用自动类型推导,可能导致某个调用的地方使用方式发生变化,它的参数、返回值类型就发生了变化,进而导致远处另一个地方的编译错误,这是设计者不希望看到的情况。
2.1.3 类型别名
我们可以用type关键字给同一个类型起个别名(type alias)。示例如下:
type Age = u32; fn grow(age: Age, year: u32) -> Age { age + year } fn main() { let x : Age = 20; println!("20 years later: {}", grow(x, 20)); }
类型别名还可以用在泛型场景,比如:
type Double<T> = (T, Vec<T>); // 小括号包围的是一个 tuple,请参见后文中的复合数据类型
那么以后使用Double<i32>的时候,就等同于(i32, Vec<i32>),可以简化代码。
2.1.4 静态变量
Rust中可以用static关键字声明静态变量。如下所示:
static GLOBAL: i32 = 0;
与let语句一样,static语句同样也是一个模式匹配。与let语句不同的是,用static声明的变量的生命周期是整个程序,从启动到退出。static变量的生命周期永远是’static,它占用的内存空间也不会在执行过程中回收。这也是Rust中唯一的声明全局变量的方法。
由于Rust非常注重内存安全,因此全局变量的使用有许多限制。这些限制都是为了防止程序员写出不安全的代码:
❏ 全局变量必须在声明的时候马上初始化;
❏ 全局变量的初始化必须是编译期可确定的常量,不能包括执行期才能确定的表达式、语句和函数调用;
❏ 带有mut修饰的全局变量,在使用的时候必须使用unsafe关键字。
示例如下:
fn main() { //局部变量声明,可以留待后面初始化,只要保证使用前已经初始化即可 let x; let y = 1_i32; x = 2_i32; println!("{} {}", x, y); //全局变量必须声明的时候初始化,因为全局变量可以写到函数外面,被任意一个函数使用 static G1 : i32 = 3; println!("{}", G1); //可变全局变量无论读写都必须用 unsafe修饰 static mut G2 : i32 = 4; unsafe { G2 = 5; println!("{}", G2); } //全局变量的内存不是分配在当前函数栈上,函数退出的时候,并不会销毁全局变量占用的内存空间,程序 退出才会回收 }
Rust禁止在声明static变量的时候调用普通函数,或者利用语句块调用其他非const代码:
// 这样是允许的 static array : [i32; 3] = [1,2,3]; // 这样是不允许的 static vec : Vec<i32> = { let mut v = Vec::new(); v.push(1); v };
调用const fn是允许的:
#![feature(const_fn)] fn main() { use std::sync::atomic::AtomicBool; static FLAG: AtomicBool = AtomicBool::new(true); }
因为const fn是编译期执行的。这个功能在编写本书的时候目前还没有stable,因此需要使用nightly版本并打开feature gate才能使用。
Rust不允许用户在main函数之前或者之后执行自己的代码。所以,比较复杂的static变量的初始化一般需要使用lazy方式,在第一次使用的时候初始化。在Rust中,如果用户需要使用比较复杂的全局变量初始化,推荐使用lazy_static库。
2.1.5 常量
在Rust中还可以用const关键字做声明。如下所示:
const GLOBAL: i32 = 0;
使用const声明的是常量,而不是变量。因此一定不允许使用mut关键字修饰这个变量绑定,这是语法错误。常量的初始化表达式也一定要是一个编译期常量,不能是运行期的值。它与static变量的最大区别在于:编译器并不一定会给const常量分配内存空间,在编译过程中,它很可能会被内联优化。因此,用户千万不要用hack的方式,通过unsafe代码去修改常量的值,这么做是没有意义的。以const声明一个常量,也不具备类似let语句的模式匹配功能。
2.2 基本数据类型
2.2.1 bool
布尔类型(bool)代表的是“是”和“否”的二值逻辑。它有两个值:true和false。一般用在逻辑表达式中,可以执行“与”“或”“非”等运算。
fn main() { let x = true; let y: bool = !x; // 取反运算 let z = x && y; // 逻辑与,带短路功能 println!("{}", z); let z = x || y; // 逻辑或,带短路功能 println!("{}", z); let z = x & y; // 按位与,不带短路功能 println!("{}", z); let z = x | y; // 按位或,不带短路功能 println!("{}", z);
let z = x ^ y; // 按位异或,不带短路功能
println!("{}", z);
}
一些比较运算表达式的类型就是bool类型:
fn logical_op(x: i32, y: i32) { let z : bool = x < y; println!("{}", z); }
bool类型表达式可以用在if/while等表达式中,作为条件表达式。比如:
if a >= b { ... } else { ... }
2.2.2 char
字符类型由char表示。它可以描述任何一个符合unicode标准的字符值。在代码中,单个的字符字面量用单引号包围。
let love = '❤'; // 可以直接嵌入任何unicode字符
字符类型字面量也可以使用转义符:
let c1 = '\n'; // 换行符
let c2 = '\x7f'; // 8 bit字符变量
let c3 = '\u{7FFF}'; // unicode字符
因为char类型的设计目的是描述任意一个unicode字符,因此它占据的内存空间不是1个字节,而是4个字节。
对于ASCII字符其实只需占用一个字节的空间,因此Rust提供了单字节字符字面量来表示ASCII字符。我们可以使用一个字母b在字符或者字符串前面,代表这个字面量存储在u8类型数组中,这样占用空间比char型数组要小一些。示例如下:
let x :u8 = 1; let y :u8 = b'A'; let s :&[u8;5] = b"hello"; let r :&[u8;14] = br#"hello \n world"#;
2.2.3 整数类型
Rust有许多的数字类型,主要分为整数类型和浮点数类型。本节讲解整数类型。各种整数类型之间的主要区分特征是:有符号/无符号,占据空间大小。具体见表2-1。
表2-1
所谓有符号/无符号,指的是如何理解内存空间中的bit表达的含义。如果一个变量是有符号类型,那么它的最高位的那一个bit就是“符号位”,表示该数为正值还是负值。如果一个变量是无符号类型,那么它的最高位和其他位一样,表示该数的大小。比如对于一个byte大小(8 bits)的数据来说,如果存的是无符号数,那么它的表达范围是0~255,如果存的是有符号数,那么它的表达范围是-128~127。
关于各个整数类型所占据的空间大小,在名字中就已经表现得很明确了,Rust原生支持了从8位到128位的整数。需要特别关注的是isize和usize类型。它们占据的空间是不定的,与指针占据的空间一致,与所在的平台相关。如果是32位系统上,则是32位大小;如果是64位系统上,则是64位大小。在C++中与它们相对应的类似类型是int_ptr和uint_ptr。Rust的这一策略与C语言不同,C语言标准中对许多类型的大小并没有做强制规定,比如int、long、double等类型,在不同平台上都可能是不同的大小,这给许多程序员带来了不必要的麻烦。相反,在语言标准中规定好各个类型的大小,让编译器针对不同平台做适配,生成不同的代码,是更合理的选择。
数字类型的字面量表示可以有许多方式:
let var1 : i32 = 32; // 十进制表示 let var2 : i32 = 0xFF; // 以0x开头代表十六进制表示 let var3 : i32 = 0o55; // 以0o开头代表八进制表示 let var4 : i32 = 0b1001; // 以0b开头代表二进制表示
注意!在C/C++/JavaScript语言中以0开头的数字代表八进制坑过不少人,Rust中设计不一样。
在所有的数字字面量中,可以在任意地方添加任意的下划线,以方便阅读:
let var5 = 0x_1234_ABCD; // 使用下划线分割数字,不影响语义,但是极大地提升了阅读体验。
字面量后面可以跟后缀,可代表该数字的具体类型,从而省略掉显示类型标记:
let var6 = 123usize; // i6变量是usize类型 let var7 = 0x_ff_u8; // i7变量是u8类型 let var8 = 32; // 不写类型,默认为 i32 类型
在Rust中,我们可以为任何一个类型添加方法,整型也不例外。比如在标准库中,整数类型有一个方法是pow,它可以计算n次幂,于是我们可以这么使用:
let x : i32 = 9; println!("9 power 3 = {}", x.pow(3));
同理,我们甚至可以不使用变量,直接对整型字面量调用函数:
fn main() { println!("9 power 3 = {}", 9_i32.pow(3)); }
我们可以看到这是非常方便的设计。
对于整数类型,如果Rust编译器通过上下文无法分析出该变量的具体类型,则自动默认为i32类型。比如:
fn main() { let x = 10; let y = x * x; println!("{}", y); }
在此例中,编译器只知道x是一个整数,但是具体是i8 i16 i32或者u8 u16 u32等,并没有足够的信息判断,这些都是有可能的。在这种情况下,编译器就默认把x当成i32类型处理。这么做的好处是,很多时候,我们不想在每个地方都明确地指定数字类型,这么做很麻烦。给编译器指定一个在信息不足情况下的“缺省”类型会更方便一点。
2.2.4 整数溢出
在整数的算术运算中,有一个比较头疼的事情是“溢出”。在C语言中,对于无符号类型,算术运算永远不会overflow,如果超过表示范围,则自动舍弃高位数据。对于有符号类型,如果发生了overflow,标准规定这是undefined behavior,也就是说随便怎么处理都可以。
未定义行为有利于编译器做一些更激进的性能优化,但是这样的规定有可能导致在程序员不知情的某些极端场景下,产生诡异的bug。
Rust的设计思路更倾向于预防bug,而不是无条件地压榨效率,Rust设计者希望能尽量减少“未定义行为”。比如彻底杜绝“Segment Fault”这种内存错误是Rust的一个重要设计目标。当然还有其他许多种类的bug,即便是无法完全解决,我们也希望能尽量避免。整数溢出就是这样的一种bug。
Rust在这个问题上选择的处理方式为:默认情况下,在debug模式下编译器会自动插入整数溢出检查,一旦发生溢出,则会引发panic;在release模式下,不检查整数溢出,而是采用自动舍弃高位的方式。示例如下:
fn arithmetic(m: i8, n: i8) {
// 加法运算,有溢出风险
println!("{}", m + n);
}
fn main() {
let m : i8 = 120;
let n : i8 = 120;
arithmetic(m, n);
}
如果我们编译debug版本:
rustc test.rs
执行这个程序,结果为:
thread 'main' panicked at 'attempt to add with overflow', test.rs:3:20
note: Run with `RUST_BACKTRACE=1` for a backtrace.
可以看到,程序执行时发生了panic。有关panic的详细解释,需要参见第18章,此处无须深入细节。
如果编译一个优化后的版本,加上-O选项:
rustc -O test.rs
执行时没有错误,而是使用了自动截断策略:
$ ./test -16
Rust编译器还提供了一个独立的编译开关供我们使用,通过这个开关,可以设置溢出时的处理策略:
$ rustc -C overflow-checks=no test.rs
“-C overflow-checks=”可以写“yes”或者“no”,打开或者关闭溢出检查。如果我们用上面这个命令编译,执行可见:
$ ./test -16
虽然它还是debug版本,但我们依然有办法关闭溢出检查。
如果在某些场景下,用户确实需要更精细地自主控制整数溢出的行为,可以调用标准库中的checked_*、saturating_*和wrapping_*系列函数。
fn main() { let i = 100_i8; println!("checked {:?}", i.checked_add(i)); println!("saturating {:?}", i.saturating_add(i)); println!("wrapping {:?}", i.wrapping_add(i)); }
输出结果为:
checked None saturating 127 wrapping -56
可以看到:checked_*系列函数返回的类型是Option<_>,当出现溢出的时候,返回值是None; saturating_*系列函数返回类型是整数,如果溢出,则给出该类型可表示范围的“最大/最小”值;wrapping_*系列函数则是直接抛弃已经溢出的最高位,将剩下的部分返回。在对安全性要求非常高的情况下,强烈建议用户尽量使用这几个方法替代默认的算术运算符来做数学运算,这样表意更清晰。在Rust标准库中就大量使用了这几个方法,而不是简单地使用算术运算符,值得大家参考。
在很多情况下,整数溢出应该被处理为截断,即丢弃最高位。为了方便用户,标准库还提供了一个叫作std::num::Wrapping<T>的类型。它重载了基本的运算符,可以被当成普通整数使用。凡是被它包裹起来的整数,任何时候出现溢出都是截断行为。常见使用示例如下:
use std::num::Wrapping; fn main() { let big = Wrapping(std::u32::MAX); let sum = big + Wrapping(2_u32); println!("{}", sum.0); }
不论用什么编译选项,上述代码都不会触发panic,任何情况下执行结果都是一致的。
标准库中还提供了许多有用的方法,在此不一一赘述,请大家参考标准API文档。
2.2.5 浮点类型
Rust提供了基于IEEE 754-2008标准的浮点类型。按占据空间大小区分,分别为f32和f64,其使用方法与整型差别不大。浮点数字面量表示方式有如下几种:
let f1 = 123.0f64; // type f64
let f2 = 0.1f64; // type f64
let f3 = 0.1f32; // type f32
let f4 = 12E+99_f64; // type f64 科学计数法
let f5 : f64 = 2.; // type f64
与整数类型相比,Rust的浮点数类型相对复杂得多。浮点数的麻烦之处在于:它不仅可以表达正常的数值,还可以表达不正常的数值。
在标准库中,有一个std::num::FpCategory枚举,表示了浮点数可能的状态:
enum FpCategory { Nan, Infinite, Zero, Subnormal, Normal, }
其中Zero表示0值、Normal表示正常状态的浮点数。其他几个就需要特别解释一下了。
在IEEE 754标准中,规定了浮点数的二进制表达方式:x = (-1)^s * (1 + M) *2^e。其中s是符号位,M是尾数,e是指数。尾数M是一个[0, 1)范围内的二进制表示的小数。以32位浮点为例,如果只有normal形式的话,0表示为所有位数全0,则最小的非零正数将是尾数最后一位为1的数字,就是(1+2^(-23))*2^(-127),而次小的数字为(1+2^(-22))*2^(-127),这两个数字的差距为2^(-23)*2^(-127)= 2^(-150),然而最小的数字和0之间的差距有(1+2^(-23))*2^(-127),约等于2^(-127),也就是说,数字在渐渐减少到0的过程中突然降到了0。为了减少0与最小数字和最小数字与次小数字之间步长的突然下跌,subnormal规定:当指数位全0的时候,指数表示为-126而不是-127(和指数为最低位为1一致)。然而公式改成(-1)^s * M * 2^e, M不再+1,这样最小的数字就变成2^(-23)*2^(-126),次小的数字变成2^(-22)*2^(-126),每两个相邻subnormal数字之差都是2^(-23)*2^(-126),避免了突然降到0。在这种状态下,这个浮点数就处于了Subnormal状态,处于这种状态下的浮点数表示精度比Normal状态下的精度低一点。我们用一个示例来演示一下什么是Subnormal状态的浮点数:
fn main() { // 变量 small 初始化为一个非常小的浮点数 let mut small = std::f32::EPSILON; // 不断循环,让 small 越来越趋近于 0,直到最后等于0的状态 while small > 0.0 { small = small / 2.0; println!("{} {:?}", small, small.classify()); } }
编译,执行,发现循环几十次之后,数值就小到了无法在32bit范围内合理表达的程度,最终收敛到了0,在后面表示非常小的数值的时候,浮点数就已经进入了Subnormal状态。
Infinite和Nan是带来更多麻烦的特殊状态。Infinite代表的是“无穷大”, Nan代表的是“不是数字”(not a number)。
什么情况会产生“无穷大”和“不是数字”呢?举例说明:
fn main() { let x = 1.0f32 / 0.0; let y = 0.0f32 / 0.0; println!("{} {}", x, y); }
编译执行,打印出来的结果分别为inf NaN。非0数除以0值,得到的是inf,0除以0得到的是NaN。
对inf做一些数学运算的时候,它的结果可能与你期望的不一致:
fn main() { let inf = std::f32::INFINITY; println!("{} {} {}", inf * 0.0, 1.0 / inf, inf / inf); }
编译执行,结果为:
NaN 0 NaN
NaN这个特殊值有个特殊的麻烦,主要问题还在于它不具备“全序”的特点。示例如下:
fn main() { let nan = std::f32::NAN; println!("{} {} {}", nan < nan, nan > nan, nan == nan); }
编译执行,输出结果为:
false false false
这就很麻烦了,一个数字可以不等于自己。因为NaN的存在,浮点数是不具备“全序关系”(total order)的。关于“全序”和“偏序”的问题,本节就不展开讲解了,后面讲到trait的时候,再给大家介绍PartialOrd和Ord这两个trait。
2.2.6 指针类型
无GC的编程语言,如C、C++以及Rust,对数据的组织操作有更多的自由度,具体表现为:
❏ 同一个类型,某些时候可以指定它在栈上,某些时候可以指定它在堆上。内存分配方式可以取决于使用方式,与类型本身无关。
❏ 既可以直接访问数据,也可以通过指针间接访问数据。可以针对任何一个对象取得指向它的指针。
❏ 既可以在复合数据类型中直接嵌入别的类型的实体,也可以使用指针,间接指向别的类型。
❏ 甚至可能在复合数据类型末尾嵌入不定长数据构造出不定长的复合数据类型。
Rust里面也有指针类型,而且不止一种指针类型。常见的几种指针类型见表2-2。
表2-2
除此之外,在标准库中还有一种封装起来的可以当作指针使用的类型,叫“智能指针”(smart pointer)。常见的智能指针见表2-3。
表2-3
有关这几种指针的使用方法和设计原理,请参见本书第二部分。
2.2.7 类型转换
Rust对不同类型之间的转换控制得非常严格。即便是下面这样的程序,也会出现编译错误:
fn main() { let var1 : i8 = 41; let var2 : i16 = var1; }
编译结果为mismatched types! i8类型的变量竟然无法向i16类型的变量赋值!这可能对很多用户来说都是一个意外。
Rust提供了一个关键字as,专门用于这样的类型转换:
fn main() { let var1 : i8 = 41; let var2 : i16 = var1 as i16; }
也就是说,Rust设计者希望在发生类型转换的时候不是偷偷摸摸进行的,而是显式地标记出来,防止隐藏的bug。虽然在许多时候会让代码显得不那么精简,但这也算是一种合理的折中。
as关键字也不是随便可以用的,它只允许编译器认为合理的类型转换。任意类型转换是不允许的:
let a = "some string";
let b = a as u32; // 编译错误
有些时候,甚至需要连续写多个as才能转成功,比如&i32类型就不能直接转换为*mut i32类型,必须像下面这样写才可以:
fn main() { let i = 42; // 先转为 *const i32,再转为 *mut i32 let p = &i as *const i32 as *mut i32; println!("{:p}", p); }
as表达式允许的类型转换如表2-4所示。对于表达式e as U, e是表达式,U是要转换的目标类型,表2-4中所示的类型转换是允许的。
表2-4
如果需要更复杂的类型转换,一般是使用标准库的From Into等trait,请参见第26章。
2.3 复合数据类型
复合数据类型可以在其他类型的基础上形成更复杂的组合关系。
本章介绍tuple、struct、enum等几种复合数据类型。数组留到第6章介绍。
2.3.1 tuple
tuple指的是“元组”类型,它通过圆括号包含一组表达式构成。tuple内的元素没有名字。tuple是把几个类型组合到一起的最简单的方式。比如:
let a = (1i32, false); // 元组中包含两个元素,第一个是i32类型,第二个是bool类型 let b = ("a", (1i32, 2i32)); // 元组中包含两个元素,第二个元素本身也是元组,它又包含了两个元素
如果元组中只包含一个元素,应该在后面添加一个逗号,以区分括号表达式和元组:
let a = (0, ); // a是一个元组,它有一个元素 let b = (0); // b是一个括号表达式,它是i32类型
访问元组内部元素有两种方法,一种是“模式匹配”(pattern destructuring),另外一种是“数字索引”:
let p = (1i32, 2i32); let (a, b) = p; let x = p.0; let y = p.1; println!("{} {} {} {}", a, b, x, y);
在第7章中会对“模式匹配”做详细解释。
元组内部也可以一个元素都没有。这个类型单独有一个名字,叫unit(单元类型):
let empty : () = ();
可以说,unit类型是Rust中最简单的类型之一,也是占用空间最小的类型之一。空元组和空结构体struct Foo;一样,都是占用0内存空间。
fn main() { println!("size of i8 {}" , std::mem::size_of::<i8>()); println!("size of char {}" , std::mem::size_of::<char>()); println!("size of '()' {}" , std::mem::size_of::<()>()); }
上面的程序中,std::mem::size_of函数可以计算一个类型所占用的内存空间。可以看到,i8类型占用1 byte, char类型占用4 bytes,空元组占用0 byte。
与C++中的空类型不同,Rust中存在实打实的0大小的类型。在C++标准中,有明确的规定,是这么说的:
Complete objects and member subobjects of class type shall have nonzero size.
class Empty {}; Empty emp; assert(sizeof(emp) ! = 0);
2.3.2 struct
结构体(struct)与元组类似,也可以把多个类型组合到一起,作为新的类型。区别在于,它的每个元素都有自己的名字。举个例子:
struct Point { x: i32, y: i32, }
每个元素之间采用逗号分开,最后一个逗号可以省略不写。类型依旧跟在冒号后面,但是不能使用自动类型推导功能,必须显式指定。struct类型的初始化语法类似于json的语法,使用“成员-冒号-值”的格式。
fn main() { let p = Point { x: 0, y: 0}; println!("Point is at {} {}", p.x, p.y); }
有些时候,Rust允许struct类型的初始化使用一种简化的写法。如果有局部变量名字和成员变量名字恰好一致,那么可以省略掉重复的冒号初始化:
fn main() { // 刚好局部变量名字和结构体成员名字一致 let x = 10; let y = 20; // 下面是简略写法,等同于 Point { x: x, y: y },同名字的相对应 let p = Point { x, y }; println!("Point is at {} {}", p.x, p.y); }
访问结构体内部的元素,也是使用“点”加变量名的方式。当然,我们也可以使用“模式匹配”功能:
fn main() { let p = Point { x: 0, y: 0}; // 声明了px 和 py,分别绑定到成员 x 和成员 y let Point { x : px, y : py } = p; println!("Point is at {} {}", px, py); // 同理,在模式匹配的时候,如果新的变量名刚好和成员名字相同,可以使用简写方式 let Point { x, y } = p; println!("Point is at {} {}", x, y); }
Rust设计了一个语法糖,允许用一种简化的语法赋值使用另外一个struct的部分成员。比如:
struct Point3d { x: i32, y: i32, z: i32, } fn default() -> Point3d { Point3d { x: 0, y: 0, z: 0 }
} // 可以使用default()函数初始化其他的元素 // ..expr 这样的语法,只能放在初始化表达式中,所有成员的最后最多只能有一个 let origin = Point3d { x: 5, ..default()}; let point = Point3d { z: 1, x: 2, ..origin };
如前所说,与tuple类似,struct内部成员也可以是空:
//以下三种都可以,内部可以没有成员
struct Foo1;
struct Foo2();
struct Foo3{}
2.3.3 tuple struct
Rust有一种数据类型叫作tuple struct,它就像是tuple和struct的混合。区别在于,tuple struct有名字,而它们的成员没有名字:
struct Color(i32, i32, i32); struct Point(i32, i32, i32);
它们可以被想象成这样的结构体:
struct Color{ 0: i32, 1: i32, 2: i32, } struct Point { 0: i32, 1: i32, 2: i32, }
因为这两个类型都有自己的名字,虽然它们的内部结构是一样的,但是它们是完全不同的两个类型。有时候我们不需要特别关心结构体内部成员的名字,可以采用这种语法。
tuple、struct、struct tuple起的作用都是把几个不同类型的成员打包组合成一个类型。它们的区别如表2-5所示。
表2-5
它们除了在取名上有这些区别外,没有其他区别。它们有一致的内存对齐策略、一致的占用空间规则,也有类似的语法。从下面这个例子可以看出它们的语法是很一致的:
// define struct
struct T1 { v: i32 } // define tuple struct struct T2(i32); fn main() { let v1 = T1 { v: 1 }; let v2 = T2(1); // init tuple struct let v3 = T2 { 0: 1 }; // init tuple struct let i1 = v1.v; let i2 = v2.0; let i3 = v3.0; }
tuple struct有一个特别有用的场景,那就是当它只包含一个元素的时候,就是所谓的newtype idiom。因为它实际上让我们非常方便地在一个类型的基础上创建了一个新的类型。举例如下:
fn main() {
struct Inches(i32);
fn f1(value : Inches) {}
fn f2(value : i32) {}
let v : i32 = 0;
f1(v); // 编译不通过,'mismatched types'
f2(v);
}
以上程序编译不通过,因为Inches类型和i32是不同的类型,函数调用参数不匹配。
但是,如果我们把以上程序改一下,使用type alias(类型别名)实现,那么就可以编译通过了:
fn type_alias() { type I = i32; fn f1(v : I) {} fn f2(v : i32) {} let v : i32 = 0; f1(v); f2(v); }
从上面的讲解可以看出,通过关键字type,我们可以创建一个新的类型名称,但是这个类型不是全新的类型,而只是一个具体类型的别名。在编译器看来,这个别名与原先的具体类型是一模一样的。而使用tuple struct做包装,则是创造了一个全新的类型,它跟被包装的类型不能发生隐式类型转换,可以具有不同的方法,满足不同的trait,完全按需而定。
2.3.4 enum
如果说tuple、struct、tuple struct在Rust中代表的是多个类型的“与”关系,那么enum类型在Rust中代表的就是多个类型的“或”关系。
与C/C++中的枚举相比,Rust中的enum要强大得多,它可以为每个成员指定附属的类型信息。比如说我们可以定义这样的类型,它内部可能是一个i32型整数,或者是f32型浮点数:
enum Number { Int(i32), Float(f32), }
Rust的enum中的每个元素的定义语法与struct的定义语法类似。可以像空结构体一样,不指定它的类型;也可以像tuple struct一样,用圆括号加无名成员;还可以像正常结构体一样,用大括号加带名字的成员。
用enum把这些类型包含到一起之后,就组成了一个新的类型。
要使用enum,一般要用到“模式匹配”。模式匹配是很重要的一部分,用第7章来详细讲解。这里我们给出一个用match语句读取enum内部数据的示例:
enum Number { Int(i32), Float(f32), } fn read_num(num: &Number) { match num { // 如果匹配到了 Number::Int 这个成员,那么value的类型就是 i32 &Number::Int(value) => println!("Integer {}", value), // 如果匹配到了 Number::Float 这个成员,那么value的类型就是 f32 &Number::Float(value) => println!("Float {}", value), } } fn main() { let n: Number = Number::Int(10); read_num(&n); }
Rust的enum与C/C++的enum和union都不一样。它是一种更安全的类型,可以被称为“tagged union”。从C语言的视角来看Rust的enum类型,重写上面这段代码,它的语义类似这样:
#include <stdio.h> #include <stdint.h> // C 语言模拟 Rust 的 enum struct Number { enum {Int, Float} tag;
union { int32_t int_value; float float_value; } value; }; void read_num(struct Number * num) { switch(num->tag) { case Int: printf("Integer %d", num->value.int_value); break; case Float: printf("Float %f", num->value.float_value); break; default: printf("data error"); break; } } int main() { struct Number n = { tag : Int, value: { int_value: 10} }; read_num(&n); return 0; }
Rust的enum类型的变量需要区分它里面的数据究竟是哪种变体,所以它包含了一个内部的“tag标记”来描述当前变量属于哪种类型。这个标记对用户是不可见的,通过恰当的语法设计,保证标记与类型始终是匹配的,以防止用户错误地使用内部数据。如果我们用C语言来模拟,就需要程序员自己来保证读写的时候标记和数据类型是匹配的,编译器无法自动检查。当然,上面这个模拟只是为了通俗地解释Rust的enum类型的基本工作原理,在实际中,enum的内存布局未必是这个样子,编译器有许多优化,可以保证语义正确的同时减少内存使用,并加快执行速度。如果是在FFI场景下,要保证Rust里面的enum的内存布局和C语言兼容的话,可以给这个enum添加一个#[repr(C, Int)]属性标签(目前这个设计已经通过,但是还未在编译器中实现)。
我们可以试着把前面定义的Number类型占用的内存空间大小打印出来看看:
fn main() { // 使用了泛型函数的调用语法,请参考第21章泛型 println!("Size of Number: {}", std::mem::size_of::<Number>()); println!("Size of i32: {}", std::mem::size_of::<i32>()); println!("Size of f32: {}", std::mem::size_of::<f32>()); }
编译执行可见:
Size of Number: 8 Size of i32: 4 Size of f32: 4
Number里面要么存储的是i32,要么存储的是f32,它存储数据需要的空间应该是max(sizeof(i32), sizeof(f32))= max(4 byte, 4 byte)= 4 byte。而它总共占用的内存是8 byte,多出来的4 byte就是用于保存类型标记的。之所以用4 byte,是为了内存对齐。
Rust里面也支持union类型,这个类型与C语言中的union完全一致。但在Rust里面,读取它内部的值被认为是unsafe行为,一般情况下我们不使用这种类型。它存在的主要目的是为了方便与C语言进行交互。
在Rust中,enum和struct为内部成员创建了新的名字空间。如果要访问内部成员,可以使用::符号。因此,不同的enum中重名的元素也不会互相冲突。例如在下面的程序中,两个枚举内部都有Move这个成员,但是它们不会有冲突。
enum Message { Quit, ChangeColor(i32, i32, i32), Move { x: i32, y: i32 }, Write(String), } let x: Message = Message::Move { x: 3, y: 4 }; enum BoardGameTurn { Move { squares: i32 }, Pass, } let y: BoardGameTurn = BoardGameTurn::Move { squares: 1 };
我们也可以手动指定每个变体自己的标记值:
fn main() { enum Animal { dog = 1, cat = 200, tiger, } let x = Animal::tiger as isize; println!("{}", x); }
Rust标准库中有一个极其常用的enum类型Option<T>,它的定义如下:
enum Option<T> { None, Some(T), }
由于它实在是太常用,标准库将Option以及它的成员Some、None都加入到了Prelude中,用户甚至不需要use语句声明就可以直接使用。它表示的含义是“要么存在、要么不存在”。比如Option<i32>表达的意思就是“可以是一个i32类型的值,或者没有任何值”。
Rust的enum实际上是一种代数类型系统(Algebraic Data Type, ADT),本书第8章简要介绍什么是ADT。enum内部的variant只是一个名字而已,恰好我们还可以将这个名字作为类型构造器使用。意思是说,我们可以把enum内部的variant当成一个函数使用,示例如下:
fn main() { let arr = [1,2,3,4,5]; // 请注意这里的map函数 let v: Vec<Option<&i32>> = arr.iter().map(Some).collect(); println!("{:? }", v); }
有关迭代器的知识,请各位读者参考第24章的内容。在这里想说明的问题是,Some可以当成函数作为参数传递给map。这里的Some其实是作为一个函数来使用的,它输入的是&i32类型,输出为Option<&i32>类型。可以用如下方式证明Some确实是一个函数类型,我们把Some初始化给一个unit变量,产生一个编译错误:
fn main() { let _ : () = Some; }
编译错误是这样写的:
error[E0308]: mismatched types --> test.rs:3:18 | 3 | let _ : () = Some; | ^^^^ expected (), found fn item | = note: expected type `()` found type `fn(_) -> std::option::Option<_> {std::option::Option<_>::Some}`
可见,enum内部的variant的类型确实是函数类型。
2.3.5 类型递归定义
Rust里面的复合数据类型是允许递归定义的。比如struct里面嵌套同样的struct类型,但是直接嵌套是不行的。示例如下:
struct Recursive { data: i32, rec: Recursive, }
使用rustc --crate-type=lib test.rs命令编译,可以看到如下编译错误:
error[E0072]: recursive type `Recursive` has infinite size --> test.rs:2:1 | 2 | struct Recursive { | ^^^^^^^^^^^^^^^^ recursive type has infinite size
3 | data: i32, 4 | rec: Recursive, | -------------- recursive without indirection | = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `Recursive` representable
以上编译错误写得非常人性化,不仅写清楚了错误原因,还给出了可能的修复办法。Rust是允许用户手工控制内存布局的语言。直接使用类型递归定义的问题在于,当编译器计算Recursive这个类型大小的时候:
size_of::<Recursive>() == 4 + size_of::<Recursive>()
这个方程在实数范围内无解。
解决办法很简单,用指针间接引用就可以了,因为指针的大小是固定的,比如:
struct Recursive { data: i32, rec: Box<Recursive>, }
我们把产生了递归的那个成员类型改为了指针,这个类型就非常合理了。