《Rust编程与项目实战》(朱文伟,李建英)【摘要 书评 试读】- 京东图书 (jd.com)
3.7 常数的数据类型
在Rust语言中,变量有类型,常量也有类型。我们知道,在定义const常量的时候,就要确定数据类型,这个问题不大,而直接常量如何确定类型呢?首先想个问题,为什么要把常量分为不同的类型呢?这是因为在程序中出现的常量需要存放在计算机内存的存储单元中。如果确定了数据的类型,也就能确定应该分配给它多少字节,按什么方式存储。例如,程序中有整数100,默认情况下,Rust编译器会分配给它4字节,按补码方式存储。那么怎样确定常量的类型呢?
从常量的表示形式即可判定其类型。对于字符常量很简单,只要看到由单撇号引起来的单个字符或转义字符就是字符常量。下面我们来讲一下数值常量。
(1)整数。整数在Rust中有一种特殊的表达方式,比如let a =33u16,因为u8、i8、u16、i32等都可以表示33,所以不指定类型的话,只有33,Rust就不知道它的精度是多少,于是let a=33会自动将a推断成i32。总之,对于没有明确指定整数类型的整数,且其值在i32范围内,Rust默认就认为它是i32类型。如果超出i32范围,且在i64范围内,则默认它是i64类型。
如果在整数后面加上u16类型,那么Rust就知道这是个u16类型的整数。对于let a=33u16,可以知道a是一个u16类型的变量,这和let a:u16=33的作用相同。当然,let a:u16=33u16也可以,只不过有点多此一举。但是let a:u16=33u32这种方式不可行,因为前后矛盾了。同样,如果在整数后面加上i8,且该整数没超出i8范围,就认为它是i8类型。
以上整数都是用十进制表示的,我们也可以使用二进制、八进制或十六进制创建整数,比如:
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
}
(2)浮点数。凡以小数形式或指数形式出现的实数,都是浮点型常量,在内存中都以指数形式存储。例如,10是整型常量,10.0是浮点型常量。那么对浮点型常量是按f32处理还是按f64处理呢?Rust编译器把浮点型常量都按f64处理,分配8字节。
如果要明确指定浮点数类型,可以在后面加上类型,比如3.14159f32,这样就按f32处理了。
3.8 作 用 域
Rust的所有权系统和作用域息息相关,因此有必要先理解Rust的作用域规则。在Rust中,任何一个可用来包含代码的大括号都是一个单独的作用域。类似于Struct{}这样用来定义数据类型的大括号,不在讨论范围之内,本章后面所说的大括号也都不考虑这种大括号。以下几种结构中的大括号都有自己的作用域:
(1)if、while等流程控制语句中的大括号。
(2)match模式匹配的大括号。
(3)单独的大括号。
(4)函数定义的大括号。
(5)mod定义模块的大括号。
例如,可以单独使用一个大括号来开启一个作用域:
{ // s在这一行无效,因为它尚未声明
let s = "hello"; // 从这行起,s是有效的
println!("{}", s); // 使用s
println!("hello,world"); // 这行没有用到s,但s依然是有效的
} // 到了这行,此作用域已结束,s不再有效
上面的代码中,变量s绑定了字符串字面值,在跳出作用域后,变量s失效,变量s所绑定的值会自动被销毁。
实际上,变量跳出作用域失效时,会自动调用Drop trait的drop函数来销毁该变量绑定在内存中的数据,这里特指销毁堆和栈上的数据,而字符串字面量是存放在全局内存中的,它会在程序启动到程序终止期间一直存在,不会被销毁。可通过如下代码验证:
fn main(){
{
let s = "hello";
println!("{:p}", s); // 0x7ff6ce0cd3f8
}
let s = "hello";
println!("{:p}", s); // 0x7ff6ce0cd3f8
}
因此,上面的示例中只是让变量s失效了,仅此而已,并没有销毁s所绑定的字符串字面量。但一般情况下不考虑这些细节,而是照常描述为跳出作用域时,会自动销毁变量所绑定的值。
任意大括号之间都可以嵌套。例如,可以在函数定义的内部再定义函数,在函数内部使用单独的大括号,在函数内部使用mod定义模块,等等。示例如下:
fn main(){
fn ff(){
println!("hello world");
}
ff();
let mut a = 33;
{
a += 1;
}
println!("{}", a); // 结果输出:34
}
虽然任何一种大括号都有自己的作用域,但函数作用域比较特别。在函数作用域内无法访问函数外部的变量,而其他大括号的作用域可以访问大括号外部的变量。比如:
fn main() {
let x = 32;
fn f(){
// 编译错误,不能访问函数外面的变量x和y
// println!("{}, {}", x, y);
}
let y = 33;
f();
let mut a = 33;
{
// 可以访问大括号外面的变量a
a += 1;
}
println!("{}", a); //结果输出:34
}
在Rust中,能否访问外部变量称为捕获环境。比如,函数是不能捕获环境的,而大括号可以捕获环境。对于可捕获环境的大括号作用域,要注意Rust的变量遮盖行为。分析下面的代码:
fn main(){
let mut a = 33;
{
a += 1; // 访问并修改外部变量a的值
// 又声明变量a,这会发生变量遮盖现象
// 从此开始,大括号内访问的变量a都是该变量
let mut a = 44;
a += 2;
println!("{}", a); // 输出46
} // 大括号内声明的变量a失效
println!("{}", a); // 输出34
}
这种行为和其他语言不太一样,因此这种行为需要引起注意。
3.9 所 有 权
3.9.1 让我们回忆栈和堆
在学习C/C++时,老师经常出某变量被分配在栈上还是堆上的题目,几乎每次都有很大一批同学在这种题目上失手。栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈是一种后进先出(Last In First Out,LIFO)的数据结构,栈中的所有数据都必须占用已知且固定大小的内存。堆就好理解了,它是一个没有组织的结构,想怎么使用就怎么使用,只要堆够大,就可以申请一段内存空间,然后把这段内存标记为已使用,并得到指向这段内存开头的指针;当不再使用时,再将这段内存标记为未使用。当声明一个指针但并没有分配空间时,这个指针是空指针;当内存已经标记为未使用,而指针依然指向这段空间时,这个指针就是野指针。
C++中的堆和栈定义如下。
- 堆:由程序员手动分配和释放,完全不同于数据结构中的堆,分配方式类似于链表。由malloc或者new来分配,由free和delete来释放。若程序员不释放,则程序结束时由系统释放。
- 栈:由编译器自动分配和释放,存放函数的参数值、局部变量的值等。栈里面变量的内存必须是已知且固定大小的。函数调用时,参数、本地变量、指向堆的指针都压入一个栈,函数完成时退出。操作方式类似于数据结构中的栈(C和Python中也有,只要基于C的都有这个概念)。
其实分辨起来很容易,动态分配的变量就是在堆上,其他的都在栈上。
栈是一个成熟的结构,基本不会引发内存的问题,而没有组织的堆却很容易引发内存问题。垃圾回收和所有权都是为了解决堆的内存管理问题。
这就是C++相比于垃圾回收机制语言的优势,灵活高效,但是也会带来内存安全问题。
3.9.2 什么是所有权
计算机程序必须在运行时管理它们所使用的内存资源。大多数编程语言都有管理内存的功能:C/C++这样的语言主要通过手动方式管理内存,开发者需要手动申请和释放内存资源。但为了提高开发效率,只要不影响程序功能的实现,许多开发者没有及时释放内存的习惯。所以手动管理内存的方式常常造成资源浪费。
Java语言编写的程序在Java虚拟机(Java Virtual Machine,JVM)中运行,JVM具备自动回收内存资源的功能。但这种方式常常会降低运行时效率,所以JVM会尽可能少地回收资源,这样也会使程序占用较大的内存资源。
所有权对大多数开发者而言是一个新颖的概念,它是Rust语言为高效使用内存而设计的语法机制。所有权概念是为了让Rust在编译阶段更有效地分析内存资源的有用性以实现内存管理而诞生的概念。
Rust的所有权是一个跨时代的理念,是内存管理的第二次革命。较低级的语言依赖程序员分配和释放内存,一不小心就会出现空指针、野指针破坏内存;较高级的语言使用垃圾回收机制管理内存,在程序运行时不断地寻找不再使用的内存,虽然安全,却加重了程序的负担。Rust的所有权理念横空出世,通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查,在运行时,所有权系统的任何功能都不会减慢程序,把安全的内存管理推向了零开销的新时代。
所有权概念是Rust语言的一个重要特性,因为通过它才使得Rust的“安全”“高并发”得以发挥出优势。因为它让Rust无须垃圾回收,即可保障内存安全。对于C/C++程序员来说,可能一直在跟内存安全打交道,如内存泄露、智能指针等。对于别的语言来说,会有垃圾回收机制。例如Python的垃圾回收机制,有“标记清除”“分代回收”等方式。这两种方式各有优缺点。Rust则是通过所有权和借用来保证内存安全的。很多人不理解为什么Rust是内存安全的,其实就是在默认情况下,是写不出内存不安全的代码的。
Rust的所有权并不难理解,它有且只有如下三条规则:
(1)Rust中的每个值都有一个被称为其所有者的变量(即值的所有者是某个变量)。
(2)值在任一时刻有且只有一个所有者。
(3)当所有者(变量)离开作用域时,这个值将被销毁。
这里对第三点做一些补充性的解释,所有者离开作用域会导致值被销毁,这个过程实际上是调用一个名为drop的函数来销毁数据释放内存的。在前面解释作用域规则时曾提到过,销毁的数据特指堆栈中的数据,如果变量绑定的值是全局内存区内的数据,则数据不会被销毁。例如:
fn main(){
{
let mut s = String::from("hello");
} // 跳出作用域,栈中的变量s将被销毁,其指向的堆中的数据也被销毁
// 但全局内存区的字符串字面量仍被保留
}
Rust中的每个值都有一个所有者,但这个说法比较容易产生误会。例如:
#![allow(unused)]
fn main() {
let s = String::from("hello");
}
很多人可能会误以为变量s是堆中字符串数据hello的所有者,但实际上不是。String字符串的实际数据在堆中,但是String大小不确定,所以在栈中使用一个胖指针结构来表示这个String类型的数据,这个胖指针中的指针指向堆中的String的实际数据。也就是说,变量s的值是那个胖指针,而不是堆中的实际数据。因此,变量s是那个胖指针的所有者,而不是堆中实际数据的所有者。但是,由于胖指针是指向堆中数据的,很多时候为了简化理解,简化描述方式,经常会说s是哪个堆中实际数据的所有者。但无论如何描述,都需要理解所有者和值之间的真相。