Appearance
2.2 基本数据类型
Rust 具有以下内置基本类型(primitive types):
bool
:布尔值,可以为true
或false
。char
:字符,例如e
。
Rust 支持最大 128 位宽的整数。
signed(有符号的) | unsigned(无符号的) |
---|---|
i8 | u8 |
i16 | u16 |
i32 | u32 |
i64 | u64 |
i128 | u128 |
isize
:指针大小的有符号整数类型。取决于操作系统的位数,32 位 CPU 上为 i32,64 位 CPU 上为 i64。usize
:指针大小的无符号整数类型。取决于操作系统的位数,在 32 位系统上通常为 4 字节,在 64 位系统上通常为 8 字节。f32
:32位浮点类型(floating point type)。 实现浮点表示的 IEEE 754 标准。f64
:64位浮点类型。[T; N]
:一个固定大小的数组,用于元素类型T
和非负编译时(non-negative compile-time)常量大小N
。[T]
:将动态大小的视图转换为连续序列,用于任何类型T
。str
:字符串片,主要用作引用,即&str
。()
:单元类型,其唯一的值也是()
fn(i32) -> i32
:接受i32
并返回i32
的函数。 函数也具有类型。
整数类型在编程中是根据其位宽(bit width)来定义的,指的是该类型使用多少个二进制位来表示一个整数值。不同的位宽决定了整数类型能够表示的范围。
每个有符号变量可以存储从 -2^(n-1)
到 2^(n-1) - 1
(含)的数字,其中 n
是变量使用的位数。所以 i8
可以存储从 -2^(8-1)
到 2^(8-1) - 1
的数字,等于 -128
到 127
。 无符号变量可以存储从 0
到 2^(n) - 1
的数字。u8
类型表示无符号的8
位整数。使用8
个比特(bits)(即1
字节)来存储数据。 因为 u8
是一个 8
位的数据类型,所以它的位宽(bit width)为 8
。即一个 u8
变量可以表示256
种不同的值。
示例如下:
rust
fn main() {
let max_value = u8::MAX;
println!("The maximum value of u8 is {}", max_value);
let size = std::mem::size_of::<u8>();
println!("u8 size: {} bytes", size);
}
输出:
The maximum value of u8 is 255
u8 size: 1 bytes
下面是 Rust 中常用的整数类型及其位宽:
i8
:有符号的 8 位整数,表示范围为 -128 到 127。u8
:无符号的 8 位整数,表示范围为 0 到 255。i16
:有符号的 16 位整数,表示范围为 -32_768 到 32_767。u16
:无符号的 16 位整数,表示范围为 0 到 65_535。i32
:有符号的 32 位整数,表示范围为 -2_147_483_648 到 2_147_483_647。u32
:无符号的 32 位整数,表示范围为 0 到 4_294_967_295。i64
:有符号的 64 位整数,表示范围为 -9_223_372_036_854_775_808 到 9_223_372_036_854_775_807。u64
:无符号的 64 位整数,表示范围为 0 到 18_446_744_073_709_551_615。i128
:有符号的 128 位整数,表示范围为 -170_141_183_460_469_231_731_687_303_715_884_105_728 到 170_141_183_460_469_231_731_687_303_715_884_105_727。u128
:无符号的 128 位整数,表示范围为 0 到 340_282_366_920_938_463_463_374_607_431_768_211_455。
这些整数类型可以通过在 Rust 中使用相应的关键字来声明变量,并使用常量(例如 MAX
)来获取每种类型的最大值。
需要注意的是,还有其他位宽的整数类型可用,如 isize
和 usize
,其位宽与所运行的计算机架构有关。 它们可以表示指针大小的整数,并且在不同架构和操作系统上的位宽可能不同。
2.2.1 布尔类型
布尔类型(bool)代表的是“是”和“否”的二值逻辑。它有两个值:true
和false
。一般用在逻辑表达式中,可以执行“与”“或”“非”等运算。
rust
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 类型:
rust
fn logical_op(x: i32, y: i32) {
let z : bool = x < y;
println!("{}", z);
}
bool 类型表达式可以用在 if/while 等表达式中,作为条件表达式。比如:
rust
if a >= b {
...
} else {
...
}
2.2.2 字符类型
char
类型用于表示 Unicode 标量值(Unicode Scalar Value),代表一个 Unicode 字符。 在 Rust 中,char 类型的设计目的是描述任意一个 unicode 字符, 因此它占据的内存空间不是1
个字节,而是4
个字节,即32
位整数。
单个的字符字面量用单引号'
包围。
rust
let love = '❤'; // 可以直接嵌入任何 unicode 字符
字符类型字面量也可以使用转义符:
rust
let c1 = '\n'; // 换行符
let c2 = '\x7f'; // 8 bit 字符变量
let c3 = '\u{7FFF}'; // unicode 字符
在 Rust 中,可以使用 Unicode 转义序列 \u{}
来表示一个具体的字符,其中括号中的数字是该字符的 Unicode 标量值。
以下是一些示例:
rust
let ch: char = 'A'; // 表示字符 'A',其 Unicode 标量值为 U+0041。
let ch: char = '\u{4E2D}'; // 表示汉字 '中',其 Unicode 标量值为 U+4E2D。
let ch: char = '\u{1F600}'; // 表示笑脸符号 '😀',其 Unicode 标量值为 U+1F600。
所以当我们声明一个 char
类型变量时,底层实际上是创建了一个32
位的无符号整数变量,并将其初始化为 Unicode 标量值对应的代码点(codepoint)。
需要注意的是,尽管char
类型在内存中占用的空间比一个单字节的 ASCII 字符更大,但由于 Rust 内置的字符串处理函数针对 UTF-8 编码进行了高度优化, 因此在实践中,char
类型的性能和字节数组的性能并不会有显著的区别。
对于 ASCII 字符其实只需占用一个字节的空间,因此 Rust 还提供了单字节字符字面量来表示 ASCII 字符。 我们可以使用一个字母b
在字符或者字符串前面,代表这个字面量存储在u8
类型数组中,这样占用空间比char
型数组要小一些。
示例如下:
rust
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 有许多的数字类型,主要分为整数类型和浮点数类型。 各种整数类型之间的主要区分特征是:有符号/无符号,占据空间大小。
具体如下:
𤨣数类型 | 有符号 | 无符号 |
---|---|---|
8 bits | i8 | 18 |
16 bits | i16 | u16 |
32 bits | i32 | u32 |
64 bits | i64 | u64 |
128 bits | i128 | u128 |
Pointer size | isize | usize |
所谓有符号/无符号,指的是如何理解内存空间中的 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
等类型,在不同平台上都可能是不同的大小,这给许多程序员带来了不必要的麻烦。 相反,在语言标准中规定好各个类型的大小,让编译器针对不同平台做适配,生成不同的代码,是更合理的选择。
数字类型的字面量表示可以有许多方式:
rust
let var1 : i32 = 32; // 十进制表示
let var2 : i32 = 0xFF; // 以 0x 开头代表十六进制表示
let var3 : i32 = 0o55; // 以 0o 开头代表八进制表示
let var4 : i32 = 0b1001; // 以 0b 开头代表二进制表示
注意!在 C/C++/JavaScript 语言中以0
开头的数字代表八进制坑过不少人,Rust 中设计不一样。
在所有的数字字面量中,可以在任意地方添加任意的下划线,以方便阅读:
rust
let var5 = 0x_1234_ABCD; //使用下划线分割数字,不影响语义,但是极大地提升了阅读体验。
字面量后面可以跟后缀,可代表该数字的具体类型,从而省略掉显示类型标记:
rust
let var6 = 12usize; // var6 变量是 usize 类型
let var7 = 0x_ff_u8; // var7 变量是 u8 类型
let var8 = 32; // 不写类型,默认为 i32 类型
在 Rust 中,我们可以为任何一个类型添加方法,整型也不例外。比如在标准库中,整数类型有一个方法是pow
,它可以计算n
次幂,于是我们可以这么使用:
rust
let x : i32 = 9;
println!("9 power 3 = {}", x.pow(3));
同理,我们甚至可以不使用变量,直接对整型字面量调用函数:
rust
fn main() {
println!("9 power 3 = {}", 9_i32.pow(3));
}
我们可以看到这是非常方便的设计。
对于整数类型,如果 Rust 编译器通过上下文无法分析出该变量的具体类型,则自动默认为i32
类型。比如:
rust
fn main() {
let x = 10;
let y = x * x;
println!("{}", y);
}
在此例中,编译器只知道x
是一个整数,但是具体是 i8 i16 i32 或者 u8 u16 u32 等,并没有足够的信息判断,这些都是有可能的。 在这种情况下,编译器就默认把x
当成 i32 类型处理。这样就不用在每个地方都明确地指定数字类型,那样会很麻烦。
2.2.3.1 浮点数(Float)
说白了浮点数就是通常我们所说的小数,如:1.9
,-99.99
等。浮点数又分为 f32 和 f64 两种类型。
- f32:单精度浮点型。小数点后至少有 6 位有效数字。
- f64:双精度浮点型,如果声明变量时不指定类型,则是 Rust 默认的浮点类型。小数点后至少有 15 位有效数字。
示例代码如下:
rust
// 默认类型
let f1 = 0.0;
// 类型声明
let f2: f32 = 54.0;
// 后缀声明
let f3 = 99.999f32;
Rust中定义浮点数时必须存在小数点,它也是区分整型和浮点数的唯一标准,不得将整型赋值给浮点数。
例如:
rust
let f1: f32 = 1;
否则编译器会提示错误expected f32, found integer
,编译也不会通过。
2.2.4 整数溢出
在整数的算术运算中,有一个比较头疼的事情是“溢出”。在 C 语言中,对于无符号类型,算术运算永远不会 overflow,如果超过表示范围,则自动舍弃高位数据。 对于有符号类型,如果发生了 overflow,标准规定这是 undefined behavior,也就是说随便怎么处理都可以。
未定义行为有利于编译器做一些更激进的性能优化,但是这样的规定有可能导致在程序员不知情的某些极端场景下,产生诡异的 bug。
Rust 的设计思路更倾向于预防 bug,而不是无条件地压榨效率,Rust 设计者希望能尽量减少“未定义行为”。 比如彻底杜绝“Segment Fault”这种内存错误是 Rust 的一个重要设计目标。 当然还有其他许多种类的 bug,即便是无法完全解决,我们也希望能尽量避免。整数溢出就是这样的一种 bug。
Rust 在这个问题上选择的处理方式为:默认情况下,在 debug 模式下编译器会自动插入整数溢出检查,一旦发生溢出,则会引发 panic; 在 release 模式下,不检查整数溢出,而是采用自动舍弃高位的方式。示例如下:
rust
fn arithmetic(m: i8, n: i8) {
// 加法运算,有溢出风险
println!("{}", m + n);
}
fn main() {
let m : i8 = 120;
let n : i8 = 120;
arithmetic(m, n);
}
如果我们编译 debug 版本:
sh
rustc test.rs
执行这个程序,结果为:
txt
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
选项:
sh
rustc -O test.rs
执行时没有错误,而是使用了自动截断策略:
txt
-16
Rust 编译器还提供了一个独立的编译开关供我们使用,通过这个开关,可以设置溢出时的处理策略:
sh
rustc -C overflow-checks=no test.rs
-C overflow-checks=
可以写“yes”或者“no”,打开或者关闭溢出检查。如果我们用上面这个命令编译,执行可见:
txt
-16
虽然它还是 debug 版本,但我们依然有办法关闭溢出检查。
如果在某些场景下,用户确实需要更精细地自主控制整数溢出的行为,可以调用标准库中的checked_*
、saturating_*
和wrapping_*
系列函数。
rust
fn main() {
let i = 100_i8;
println!("checked {:?}", i.checked_add(i));
println!("saturating {:?}", i.saturating_add(i));
println!("wrapping {:?}", i.wrapping_add(i));
}
输出结果为:
txt
checked None
saturating 127
wrapping -56
可以看到:checked_*
系列函数返回的类型是Option<_>
,当出现溢出的时候,返回值是None
; saturating_*
系列函数返回类型是整数,如果溢出,则给出该类型可表示范围的“最大/最小”值;wrapping_*
系列函数则是直接抛弃已经溢出的最高位,将剩下的部分返回。 在对安全性要求非常高的情况下,强烈建议用户尽量使用这几个方法替代默认的算术运算符来做数学运算,这样表意更清晰。 在 Rust 标准库中就大量使用了这几个方法,而不是简单地使用算术运算符,值得大家参考。
在很多情况下,整数溢出应该被处理为截断,即丢弃最高位。为了方便用户,标准库还提供了一个叫作std::num::Wrapping<T>
的类型。 它重载了基本的运算符,可以被当成普通整数使用。 凡是被它包裹起来的整数,任何时候出现溢出都是截断行为。常见使用示例如下:
rust
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,其使用方法与整型差别不大。浮点数字面量表示方式有如下几种:
rust
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
枚举,表示了浮点数可能的状态:
rust
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 状态的浮点数:
rust
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)。
什么情况会产生“无穷大”和“不是数字”呢?举例说明:
rust
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
做一些数学运算的时候,它的结果可能与你期望的不一致:
rust
fn main() {
let inf = std::f32::INFINITY;
println!("{} {} {}", inf * 0.0, 1.0 / inf, inf / inf);
}
编译执行,结果为:
txt
NaN 0 NaN
NaN 这个特殊值有个特殊的麻烦,主要问题还在于它不具备“全序”的特点。示例如下:
rust
fn main() {
let nan = std::f32::NAN;
println!("{} {} {}", nan < nan, nan > nan, nan == nan);
}
编译执行,输出结果为:
txt
false false false
这就很麻烦了,一个数字可以不等于自己。因为 NaN 的存在,浮点数是不具备“全序关系”(total order)的。 关于“全序”和“偏序”的问题,本节就不展开讲解了,后面讲到 trait 的时候,再给大家介绍 PartialOrd 和 Ord 这两个 trait。
2.2.6 指针类型
无 GC 的编程语言,如 C、C++ 以及 Rust,对数据的组织操作有更多的自由度,具体表现为:
同一个类型,某些时候可以指定它在栈上,某些时候可以指定它在堆上。内存分配方式可以取决于使用方式,与类型本身无关。
既可以直接访问数据,也可以通过指针间接访问数据。可以针对任何一个对象取得指向它的指针。
既可以在复合数据类型中直接嵌入别的类型的实体,也可以使用指针,间接指向别的类型。
甚至可能在复合数据类型末尾嵌入不定长数据构造出不定长的复合数据类型。
Rust 里面也有指针类型,而且不止一种指针类型。 常见的几种指针类型:
类型名 | 解释 |
---|---|
Box<T> | 指向类型 T 的、具有所有权的指针,有权释放内存 |
&T | 指向类型 T 的借用指针,也称为引用,无权释放内存,无权写数据 |
&mut T | 指向类型 T 的 mut 型借用指针,无权释放内存,有权写数据 |
*const T | 指向类型 T 的只读裸指针,没有生命周期信息,无权写数据 |
*mut T | 指向类型 T 的可读写裸指针,没有生命周期信息,有权与数据 |
除此之外,在标准库中还有一种封装起来的可以当作指针使用的类型,叫“智能指针”(smart pointer)。 常见的智能指针:
类型名 | 解释 |
---|---|
Rc<T> | 指向类型 T 的引用计数指针,共享所有权,线程不安全 |
ArcT> | 指向类型 T 的原子型引用计数指针,共享所有权,线程安全 |
Cow<'a, T> | Clone-on-write,写时复制指针。可能是借用指针,也可能是具有所有权的指针 |
有关这几种指针的使用方法和设计原理,请参见本书第二部分。
2.2.7 类型转换
Rust 对不同类型之间的转换控制得非常严格。即便是下面这样的程序,也会出现编译错误:
rust
fn main() {
let var1 : i8 = 41;
let var2 : i16 = var1;
}
编译结果为error[E0308]: mismatched types!
i8 类型的变量竟然无法向 i16 类型的变量赋值!这可能对很多用户来说都是一个意外。
Rust 提供了一个关键字 as,专门用于这样的类型转换:
rust
fn main() {
let var1 : i8 = 41;
let var2 : i16 = var1 as i16;
}
也就是说,Rust 设计者希望在发生类型转换的时候不是偷偷摸摸进行的,而是显式地标记出来,防止隐藏的 bug。 虽然在许多时候会让代码显得不那么精简,但这也算是一种合理的折中。
as
关键字也不是随便可以用的,它只允许编译器认为合理的类型转换。任意类型转换是不允许的:
rust
let a = "some string";
let b = a as u32; // 编译错误
有些时候,甚至需要连续写多个 as 才能转成功,比如&i32
类型就不能直接转换为*mut i32
类型,必须像下面这样写才可以:
rust
fn main() {
let i = 42;
// 先转为 *const i32,再转为 *mut i32
let p = &i as *const i32 as *mut i32;
println!("{:p}", p);
}
as
表达式允许的类型转换如下表所示。 对于表达式e as U
,e
是表达式,U
是要转换的目标类型,下表中所示的类型转换是允许的。
Type of e | U |
Integer or Float type | Integer or Float type |
C-like enum | Integer type |
bool or char | Integer type |
u8 | char |
*T | *V where V: Sized * |
*T where T: Sized | Numeric type |
Integer type | *V where V: Sized |
&[T; n] | *const T |
Function pointer | *V where V: Sized |
Function pointer | Integer |
如果需要更复杂的类型转换,一般是使用标准库的 From Into 等 trait,请参见第 26 章。