【Rust】所有权

所有权

所有权是Rust最独特的特性,它让Rust无需GC(Garbage Collection)就可保证内存安全。Rust的核心特性就是所有权,所有程序在运行时都必须管理它们使用计算机内存的方式。有些语言有垃圾回收机制,在程序运行时会不断地寻找不再使用的内存。在其他语言中,程序员必须显式地分配和释放内存。

Rust采用了第三种方式,内存是通过一个所有权系统来管理的,其中包含一组编译器在编译时检查的规则。当程序运行时,所有权特性不会减慢程序的运行速度。

stack与heap

在像Rust这样的系统级编程语言里,一个值在stack上还是在heap上对语言的行为和你为什么要做某些决定是有更大的影响的。在你的代码运行的时候,stack和heap都是你可用的内存,但是它们的结构很不相同。

  • stack按值的接收顺序来存储,按相反的顺序将它们移除(后进先出,LIFO),添加数据叫压入栈,移除数据叫做弹出栈。把值压到stack上不叫分配。因为指针是固定大小的,可以把指针存放在stack上。
  • 所有存储在stack上的数据必须拥有已知的固定的大小。编译时大小未知的数据或运行时大小可能发生变化的数据必须存放在heap上。
  • Heap 内存组织性差一些,一当你把数据放入heap时,你会请求一定数量的空间,操作系统在heap里找到一块足够大的空间,把它标记为在用,并返回一个指针,也就是这个空间的地址。这个过程叫做在heap上进行分配,有时仅仅称为“分配”。
  • 入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。
  • 访问heap 中的数据要比访问stack 中的数据慢,因为需要通过指针才能找到heap中的数据。对于现代的处理器来说,由于缓存的缘故,如果指令在内存中跳转的次数越少,那么速度就越快。
  • 如果数据存放的距离比较近,那么处理器的处理速度就会更快一些(stack 上)。如果数据之间的距离比较远,那么处理速度就会慢一些(heap 上)。在heap上分配大量的空间也是需要时间的。
  • 当你的代码调用函数时,值被传入到函数(也包括指向 heap 的指针)。函数本地的变量被压到stack 上。当函数结束后,这些值会从stack 上弹出。

所有权存在的原因

所有权解决的问题:跟踪代码的哪些部分正在使用heap 的哪些数据;最小化 heap 上的重复数据量;清理heap上未使用的数据以避免空间不足。一旦懂了所有权,就不需要经常去想stack或heap了,但是知道管理heap数据是所有权存在的原因,这有助于解释它为什么会这样工作。

所有权规则

  • 每个值都有一个变量,这个变量是该值的所有者。
  • 每个值同时只能有一个所有者。
  • 当所有者超出作用域(scope)时,该值将被删除。

变量作用域

Scope就是程序中一个项目的有效范围。

fn main() {
    
    
    //s 不可用
    let s = "hello";//s 可用
                    //可以对 s 进行相关操作
}//s 作用域到此结束,s 不再可用

String类型

String比那些基础标量数据类型更加复杂。字符串字面值:程序里手写的那些字符串值,它们是不可变的。Rust还有第二种字符串类型:String。在heap上分配,能够存储在编译时未知数量的文本。

fn main() {
    
    
    let mut s = String::from("Hello");
    s.push_str(",World");
    println!("{}",s);
}

为什么String类型的值可以修改,而字符串字面值却不能修改,因为它们处理内存的方式不同。

内存和分配

字符串字面值,在编译时就知道它的内容了,其文本内容直接被硬编码到最终的可执行文件里,速度快、高效。是因为其不可变性。

String类型为了支持可变性,需要在heap上分配内存来保存编译时未知的文本内容:操作系统必须在运行时来请求内存,这步通过调用String::from来实现。当用完 String之后,需要使用某种方式将内存返回给操作系统。这步,在拥有GC的语言中,GC会跟踪并清理不再使用的内存。没有GC,就需要我们去识别内存何时不再使用,并调用代码将它返回。―如果忘了,那就浪费内存;如果提前做了,变量就会非法;如果做了两次,也是 Bug。必须一次分配对应一次释放。

Rust采用了不同的方式:对于某个值来说,当拥有它的变量走出作用范围时,内存会立即自动的交还给操作系统。Rust会在变量超出作用域时调用一个特殊的函数drop释放其内存。

变量与数据交互的方式

1.Move

多个变量可以与同一个数据使用一种独特的方式来交互。

let x = 5;
let y = x;

整数是已知固定大小的简单的值,这两个5被压到了stack中。

let s1 = String::from("hello");
let s2 = s1;

一个String由3部分组成:一个指向存放字符串内容的指针,一个长度,一个容量。这些存放在stack中,存放字符串内容的部分在heap上,长度len,就是存放字符串内容所需的字节数。容量capacity是指String从操作系统欧冠总共获得内存的字节数。

在这里插入图片描述

当把s1赋给s2,String的数据被赋值了一份,在stack上复制了一份指针、长度、容量,并没有复制指针所指向的heap上的数据。当变量离开作用域时,Rust会自动调用drop 函数,并将变量使用的heap内存释放。当s1、s2离开作用域时,它们都会尝试释放相同的内存,这就是二次释放(double free)bug。

为了保证内存安全,Rust并没有尝试复制被分配的内存,而是选择让s1失效,当s1离开作用域的时候,Rust不需要释放任何东西。

在这里插入图片描述

如果你在其他语言中听说过术语浅拷贝(shallow copy)和深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为Rust同时使第一个变量无效了,这个操作被称为移动(move),而不是叫做浅拷贝。隐含的设计原则:Rust不会自动创建数据的深拷贝,就运行时性能而言,任何自动赋值的操作都是廉价的。

2.Clone

如果真想对heap上面的String数据进行深度拷贝,而不仅仅是stack上的数据,可以使用clone方法。

let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);

在这里插入图片描述

3.Copy

let x = 5;
let y = x;

这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过 x 依然有效且没有被移动到 y 中。

原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量y后使x无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同。

Rust提供了Copy trait,可以用于像整数这样完全存放在stack上面的类型。如果一个类型实现了Copy这个trait,那么旧的变量在赋值后仍然可用;如果一个类型或者该类型的一部分实现了Drop trait,那么,Rust不允许让它再去实现Copy trait了。

一些拥有Copy trait的类型:任何简单标量的组合类型都可以是Copy的,任何需要分配内存或某种资源的都不是Copy的。

  • 所有的整数类型,例如u32
  • bool
  • char
  • 所有的浮点类型,例如f64
  • Tuple(元组),如果其所有的字段都是Copy的

所有权与函数

在语义上,将值传递给函数和把值赋给变量是类似的,将值传递给函数将发生移动或复制。

fn main() {
    
    
    let mut s = String::from("Hello,World");

    take_ownership(s);//s 被移动 不再有效

    let x = 5;

    makes_copy(x);//复制

    println!("x:{}",x);
}

fn take_ownership(some_string: String){
    
    
    println!("{}",some_string);
}

fn makes_copy(some_number: i32){
    
    
    println!("{}",some_number);
}

返回值与作用域

函数在返回值的过程中同样也会发生所有权的转移。

fn main() {
    
    
    let s1 = gives_ownship();gives_ownership 将返回值转移给s1

    let s2 = String::from("hello");

    let s3 = takes_and_give_back(s2);//s2 被移动到takes_and_gives_back 中,它也将返回值移给 s3
}

fn gives_ownship()->String{
    
    
    let some_string = String::from("hello");
    some_string
}

fn takes_and_give_back(a_string:String)->String{
    
    
    a_string
}

一个变量的所有权总是遵循同样的模式:把一个值赋给其它变量时就会发生移动。当一个包含heap数据的变量离开作用域时,它的值就会被drop函数清理,除非数据的所有权移动到另一个变量上了。

如何让函数使用某个值,但不获得其所有权?

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

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    
    
    let length = s.len(); 

    (s, length)
}

但是这传进来传出去很麻烦,Rust有一个特性,叫做引用(references)。

引用

参数的类型是&String而不是String,&符号就表示引用:允许你引用某些值而不取的其所有权。

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

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    
    
    s.len()
}

在这里插入图片描述

借用

我们把引用作为函数参数这个行为叫做借用。不可以修改借用的变量。和变量一样,引用默认也是不可变的。

可变引用

fn main() {
    
    
    let mut s1 = String::from("Hello");

    let len = calculate_length(&mut s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &mut String) -> usize {
    
    
    s.push_str(",World");
    s.len()
} 

可变引用有一个重要的限制:在特定作用域内,对某一块数据,只能有一个可变的引用。这样的好处是可在编译时防止数据竞争。以下三种行为会发生数据竞争,两个或多个指针同时访问一个数据,至少有一个指针用于写入数据,没有使用任何机制来同步对数据的访问。我们可以创建新的作用域,来允许非同时的创建多个可变引用。

    let mut s = String::from("hello");
    {
    
    
        let r1 = &mut s;
    }
    let r2 = &mut s;

另一个限制是不可以同时拥有一个可变引用和一个不变的引用。多个不可变的引用是可以的。

悬空引用Dangling References

悬空指针(Dangling Pointer):一个指针引用了内存中的某个地址,而这块内存可能己经释放并分配给其它人使用了。

在Rust里,编译器可保证引用永远都不是悬空引用:
如果你引用了某些数据,编译器将保证在引用离开作用域之前数据不会离开作用域。

引用的规则

在任何给定的时刻,只能满足下列条件之一:

  • 一个可变的引用
  • 任意数量不可变的引用引用必须一直有效

引用必须一直有效。

切片

Rust的另外一种不持有所有权的数据类型:切片(slice)。

编写一个函数,该函数接收一个用空格分隔单词的字符串,并返回在该字符串中找到的第一个单词。如果函数在该字符串中并未找到空格,则整个字符串就是一个单词,所以应该返回整个字符串。

fn main() {
    
    
    let mut s = String::from("Hello world");
    let wordIndex = first_word(&s);

    s.clear();
    println!("{}", wordIndex);
}

fn first_word(s: &String) -> usize {
    
    
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
    
    
        if item == b' ' {
    
    
            return i;
        }
    }
    s.len()
}

这个程序编译时没有任何错误,但是wordIndex与s状态完全没有联系。s被清空后wordIndex仍返回s传给函数时状态的值。Rust为这种情况提供了解决方案。字符串切片。

字符串切片

字符串切片是指向字符串中一部分内容的引用。形式:

[开始索引..结束索引]

开始索引是切片起始位置的索引值,结束索引是切片终止位置的所有值。

let s = String::from("Hello World");

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

let hello2 = &s[..5];
let world2 = &s[6..];

在这里插入图片描述

字符串切片的范围索引必须发生在有效的UTF-8字符边界内。如果尝试从一个多字节的字符中创建字符串切片,程序会报错并退出。

重写firstworld:


fn main() {
    
    
    let mut s = String::from("Hello World");

    let word = first_word(&s);

    //s.clear(); // 错误!
    println!("the first word is: {}", word);
}

fn first_word(s: &String) -> &str {
    
    
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
    
    
        if item == b' ' {
    
    
            return &s[0..i];
        }
    }
    &s[..]
}

字符串字面值是切片,字符串字面值被直接存储在二进制程序中。

将字符串切片作为参数传递

有经验的Rust开发者会采用&str作为参数类型,因为这样就可以同时接收String和&str类型的参数了:

fn first_word(s: &str) -> &str {
    
    

使用字符串切片直接调用该函数,使用String可以创建一个完整的String切片来调用该函数。

定义函数时使用字符串切片来代替字符串引用会使我们的API更加通用,且不会损失任何功能。

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

    let word = first_word(&s);

    let mut s2 = "hello world";

    let word2 = first_word(s2);
    //s.clear(); // 错误!
    println!("the first word of s is: {}", word);
    println!("the first word of s2 is: {}", word2);
}

fn first_word(s: &str) -> &str {
    
    
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
    
    
        if item == b' ' {
    
    
            return &s[0..i];
        }
    }

    &s[..]
}

其他类型的切片

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

let slice = &a[1..3];

它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。

猜你喜欢

转载自blog.csdn.net/weixin_43912621/article/details/131430630