4、初识Rust – 变量

目录 编程

一、基础认识

Rust中普通变量由let声明,变量名称不能使用大小驼峰,而是使用下划线过渡。

fn main() {
    let x = 1;
    println!("{x}");
}

上面的代码即可进行变量声明与附值。但是,如果我们需要变更变量中的值时,就需要在变量创建时,增加一个声明mut,因为rust创建的变量总是不可变的。

fn main() {
    let mut a = 1;
    a = 2;
    println!("{a}");
}

当然,常量总是不可变的,它不允许添加mut,使用const声明,并且推荐使用大写字母命名否则编译时会报警。

fn main() {
    const ABC = 1;
    println!("{ABC}");
}

二、作用域

与大多数编程语言一样,rust也存在作用域,{ }就是一个作用域,在作用域内的变量作用域执行完毕之后就会被Rust自动调用drop函数释放。作用域内可以使用作用域外的变量。但是,常量可以写在最外层,而变量必须写在{}内。

const A = 1;
let x = 1;  <- 变量必须在{}内声明,所以这是错误的
fn main() {
    let x = 1;
    {
        let b = 0; <- 内部的b变量出了该作用域就会消失。
    }
    println!("{A},{x},{b}"); <- b是无法被打印的,因为它在内部作用域 
}

那么,该如何获取到作用域内的变量呢?请看下面的代码。

fn main() {
    let x = 0;
    let b = {
        let b = 1;
        b
    };
    println!("{x},{b}");
}

在作用域内生成变量后,在尾部直接填写该变量的名称,并且不加分号,那么该变量就会传出作用域,所有权交给外面接收的变量。

三、变量所有权

所有权在Rust中是非常重要的一个功能,所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时不断地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。

要了解所有权,得先明白堆与栈的概念

栈(Stack)与堆(Heap)

在很多语言中,你并不需要经常考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。我们会在本章的稍后部分描述所有权与栈和堆相关的内容,所以这里只是一个用来预热的简要解释。

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 进栈pushing onto the stack),而移出数据叫做 出栈popping off the stack)。栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。 堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针pointer)。这个过程称作 在堆上分配内存allocating on the heap),有时简称为 “分配”(allocating)。将数据推入栈中并不被认为是分配。因为指向放入堆中数据的指针是已知的并且大小是固定的,你可以将该指针存储在栈上,不过当需要实际数据时,必须访问指针。想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

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

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。

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

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的主要目的就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。

所有权的规则有3个

  1. Rust 中的每一个值都有一个被称为其 所有者owner)的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

一个简单的例子

fn main() {
    let x = 5;
    let y = x;
}

看上面的代码,一般理解下,变量x绑定数值5,然后将数值复制并交给y,然后你就有了2个变量x和y他们的值都是5。

Rust也正常输出了2个5,因为整数是已只固定大小的简单值,所以这2个5被存在了栈中

看下面这个例子

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

这段代码和上面的代码非常相似,按道理应该也是输出2个hello,但可惜的是s1并不存在。

为什么会这样呢?

首先看下图

这是String底层发生的。String由3部分组成,一个指向存放字符串内容的指针,一个指向长度,另一个指向容量,这一组数据存储在栈上,图右侧则是存储在堆上的内容的内存部分。

长度表示String内容当前使用了多少字节的内存。容量是String从分配器总共获取了多少字节的内存。长度和容量的区别非常重要,不过在当前文中并不是特别重要,所以暂时忽略容量。

当我们将s1赋值给s2s时,String的数据被复制了,这说明我们从栈上拷贝了它的指针,长度和容量,但是我们并没有拷贝堆上的数据,下面为图示

上图为Rust处理的拷贝,它并没有像下图那样处理,因为如果这样做了,那么操作

s2 = s1 时如果堆上的数据量特别大时,会对性能造成非常大的影响。

前面在说作用域时提到过,当作用域执行完成,Rust会自动调用drop函数并清理变量的堆内存,不过按上面第一幅图所示,Rust这样做后,2个指针指向同一位置。这样就会出现一个问题,当s2和s1离开作用域时,他们都会尝试释放相同的内存。这样就发生了叫做 二次释放 的错误,这是会影响内存安全的BUG之一。两次释放相同的内存会导致内存污染,是一个潜在的安全漏洞。

Rust为了确保安全,在let s2 = s1后,在离开作用域触发drop清理前,释放s1。也就无法打印出s1并且还会抛出错误。

像其它语言中也许有 浅拷贝深拷贝 的概念。Rust的拷贝指针,长度和容量的操作貌似非常像 浅拷贝,其实不然,因为Rust经过拷贝后第一个变量失效了,所以其实算是一个移动操作,而不是浅拷贝。

这样就解决了二次释放和 对比变量时性能变差的问题。另外这里也隐喻了,Rust永远也不会自动创建数据 深拷贝 。因此,任何的自动的复制操作,对Rust影响都不大。

不过在实际生产过程中,我们也许要主动使用深拷贝,那么,如何实现?这时我们可以使用一个叫clone的函数来克隆变量。

fn main() {
   let s1 = String::from("hello");
   let s2 = s1.clone();
}

这段代码运行过后,s1与s2都能正常打印。不过使用clone时就会比移动更加消耗资源。

经过了解上面的Rust对变量数据的管理操作之后,问题又来了。为什么第一个案例,没有影响变量x?这样岂不是和后面说的互相矛盾了么?

fn main() { 
   let x = 5; 
   let y = x; 
}

其实很简单,像这样的已知大小的数据类型的数据被整个的存储在栈上,所以拷贝栈上的数据时并不会太大的影响运行速度,所以不需要在附值y后使x无效。