所有权概念

2021-12-14 1694点热度 0人点赞 0条评论

什么是所有权

所有权是Rust特有的核心概念,这个特性让Rust即使没有垃圾回收机制也能够编写出内存安全的程序。因而理解所有权的工作机制对于学习Rust非常重要。与所有权相关的内容还有:借用切片数据的内存布局

计算机的内存资源非常宝贵,所有的程序运行的时候都需要某种方式来合理地利用计算机的内存资源。常见的几种语言是如何利用内存的:

语言 内存的使用方案
Java、Go 垃圾回收机制,不停地查看一些内存是否没有在使用了,如果不再需要就将其释放,占用更多的内存和CPU资源
C/C++ 程序员自己手动申请和释放内存,容易出错且难以排查
Rust 所有权机制,内存由所有权系统根据一系列的规则来管理,这些规则只会在程序编译期间检查

堆栈
可能在你之前使用的开发语言中很少需要关注栈和堆的差异,但像Rust这样的语言中,一个变量放在栈或堆上会直接影响到程序最终的行为。堆和栈都是内存区域,只是程序对它们的使用方式有不同。栈组织成后进先出队列,就像一叠盘子,当我们读写栈的时候总是访问到其最上层的数据,需要读写多少数据量也是在编译期间就确定了的,所以它访问的速度很快。而堆上的变量呢,编译期间是无法获知它需要多大空间的,当你的程序需要存放数据的时候,就通过某种方式向操作系统要,开始使用的时候操作系统登记下来(申请内存),用完归还操作系统注销这块内存(释放内存),这样后面的变量就可以继续使用这块内存了。

所有权规则

  • Rust中的每个有值的变量就叫做所有权人
  • 每一个时刻只有一个所有权人
  • 当一个变量(所有权人)离开作用域后,它的值就会被释放

变量作用域范围

{ // s is not valid here; it's not yet declared
    let s = "hello"; // s is valid from this point forward
    // do stuff with s
} // this scope is now over, and s is no longer valid

String类型

为了说明所有权,我们需要一个更加复杂的类型。

之前我们已经看到过字符串常量,是不能修改的,除此之外,Rust还支持另一种字符串类型String,它可以修改。我们可以使用字符串常量来构建String类型的变量:

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

操作符::让我们可以访问String类型下的from函数。

let mut s = String::from("hello"); // 可修改的
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // this will print `hello, world!`

内存分配

在字符串常量这样的场景下,字符串数据是在编译时硬编码到程序中的,因而字符串常量的访问比较高效。但我们需要可变的String字符串类型来存储在运行时才知道大小和内容的数据,甚至还需要字符串的存储空间能够动态地扩充,所以String的存储就不能在栈上(因为栈上的变量是固定大小、编译器确认的)。所以String只能在存放在堆上,也就是说:程序可以在运行时从操作系统上请求内存。

当我们的String用完了之后可以将内存还给操作系统重新利用,否则内存会耗尽。
第一部分比较简单,由我们自己完成,这儿就是调用String::from()函数!
第二部分,有些语言用GC方案,没有GC的语言就需要我们自己在合适的实际释放内存。这是程序编写过程中最容易出错的地方!如果我们忘记释放了,内存就会浪费(不能再利用),如果我们释放早了,指向它的代码就会访问不存在的变量,就会出错。

Rust采用了不一样的方式:一旦变量离开了它的作用域,内存就会自动释放!

{
    let s = String::from("hello"); // s is valid from this point forward
    // do stuff with s
}
// this scope is now over, and s is no
// longer valid

当一个变量离开了作用域后,Rust会帮我们调用一个特别的函数。这个函数叫做drop,String的作者将释放内存的操作放在这个函数里面。Rust在执行到}时自动调用drop。

在C++语言里面,这种在变量离开作用之后释放资源的模式叫做RAII。
目前看来这种模式还挺简单的,当我们有多个变量,这些变量从堆中分配内存的时候,就变得复杂起来了,我们接着。

变量和数据交互的方式:move

先看一个简单的例子:

fn main() {
    let x = 5;
    let y = x;
    println!("x is {}, y is {}", x, y);

}

首先定义了一个变量x,赋值了5,然后定义了变量y,赋值了y之前先拷贝一份x的值,然后赋值给y。x的改变不会影响到y的最终值。这儿有一点需要注意!x、y是整型的,在编译期间是知道它的大小的,因而它会被放在上!这种简单的类型没有复杂的内存分配的事情。

我们把x改变成另外一种类型String试试!String是可以可以动态扩展的,其内存放在上分配!

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

看上去没什么问题,编译的时候却报错了:

error[E0382]: borrow of moved value: `s1` 
value borrowed here after move

s1已经被move了,不能在被借用!

一个String类型的变量由三部分组成:字符串内容,字符串长度和容量,内存布局是这样的:

当我们运行到s2=s1时,String的数据就被拷贝了,包括指针、长度和容量,这三个值是在栈上的(大小固定),而指针所指向的内存区域是在堆上的(编译期间大小无法确定),Rust不会去拷贝它,这是因为该内存区域有可能很大,拷贝需要耗费很长的时间!

假设我们将内存的区域按当前没有move操作的场景画出来的话,就会像这样:

然而,前面我们有说过所有权的问题,当一个变量离开了它的作用域的时候会释放,这样s1和s2就会被释放,一个内存区域释放了2次,会导致系统内存严重出错。
为了解决这样被多次释放的问题,当s2=s1的时候,s1就变成不可用了,这样s1离开作用域之后也将不再做内存释放的操作!上面的程序不能再使用s1!

y = x后还能再使用是因为赋值给y的是x的拷贝,所以离开作用域之后还能正常释放。s2 = s1后却不能,是因为Rust仅拷贝了s1的部分内容(存放在栈空间上的指针+长度+容量),指针指向的内存区域(在堆空间)没有被拷贝,所以,为了防止多次释放导致系统问题,我们只能释放其中一个s2,另外一个s1不能再做释放操作了。

变量和数据交互的方式:clone

如果我们明确想拷贝String变量,而不仅仅是栈上的数据,还有堆上的数据呢?我们可以用一个常用的方法:叫做clone。
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 is {}, s2 is {}", s1, s2);
}

仅适用于全在栈上的数据:copy

前面我们的例子,x和y的。

let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);

x没有明确调用clone函数却自动进行了复制操作,和我们上面看到的String需要主动调用clone的现象似乎有点冲突。
这是因为像整型这样的类型,在编译期间就有确定的大小的是存在于栈上的,所以容易拷贝。创建y之后让x不能访问这样的场景就不复存在了,因为拷贝太简单了。深拷贝和浅拷贝的差异根本不存在,因为调不调用clone都不对这两种拷贝形式的最终结果造成什么影响!

如果一个类型实现了Copy trait(特性),那么调用它之后它的旧的变量是依然可用使用!如果一个类型实现了Drop trait的话,它就不能再实现Copy Trait了,二者只能取其一。

有哪些类型会自动带有Copy特性的呢?
- 所有的整型类型,如i32,u32
- 布尔类型,bool
- 字符类型,char
- 所有的浮点类型
- 由可用Copy的元素组成的元组。比如(i32, i32)是可以Copy的,(i32, String)是不可以Copy的;

所有权和函数

传递一个参数到函数中,参数可能是copy或者move中,规则和赋值类似。

fn main() {
    let s = String::from("hello"); // s comes into scope
    takes_ownership(s); // s's value moves into the function...
                        // ... and so is no longer valid here
    let x = 5; // x comes into scope
    makes_copy(x); // x would move into the function,
                   // but i32 is Copy, so it's okay to
                   // still use x afterward
} // Here, x goes out of scope, then s. But because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) {
    // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) {
    // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

返回值和作用域

返回值也会传递所有权!

fn main() {
    let s1 = gives_ownership(); // gives_ownership moves its return
                                // value into s1
    let s2 = String::from("hello"); // s2 comes into scope
    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was
  // moved, so nothing happens. s1 goes out of scope and is dropped.
fn gives_ownership() -> String {
    // gives_ownership will move its
    // return value into the function
    // that calls it
    let some_string = String::from("hello"); // some_string comes into scope
    some_string // some_string is returned and
                // moves out to the calling
                // function
}
// takes_and_gives_back will take a String and return one
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope
    a_string // a_string is returned and moves out to the calling function
}

变量的所有权遵循固定的转移准则:一个变量赋值给另外一个变量后会导致所有权的转移(有Copy特性的变量不需要Drop,所以不会有所有权转移)。当一个包含有堆内存区域数据的变量离开作用域时,如果这个值没有被转移,就会触发drop函数清理,如果转移了就不会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(); // len() returns the length of a String
    (s, length)
}

每次都进行所有权的转移有时也挺麻烦的。好在Rust支持引用,能够有效地解决这样的问题!下面是一个简单的例子

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()
}

传递的实参是&s1,注意需要带&,表示创建一个s1的引用,而不是转移所有权,所以传递给了calculate_length之后不会引发drop函数操作。形参s: &String,也需要带&,表示仅接受String类型的引用,由于s变量没有取得所有权,所以它离开作用域之后不会触发drop
指针的示意图是这样的:

我们把函数中参数引用行为叫做借用。

如果你希望在函数中修改引用变量,那么你需要将原变量、实参和形参变量都变成mut,mut放在&和变量名之间。

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}
fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

引用的可变属性有一个很大的限制,对一个变量在同一作用域内只能运行一个引用是可变的!这是为了防止数据竞争而设置的!
下面的代码编译会报错!

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // error[E0499]: cannot borrow `s` as mutable more than once at a time
println!("r1 is {}, r2 is {}", r1, r2); // 这一句写上才报错

当下面三个行为发生时就会产生数据竞争:
- 同一时间有至少两个甚至更多的指针对同一个数据进行操作;
- 至少有一个指针在进行写操作;
- 没有用于同步访问数据的方法;

数据竞争引发未定义的行为,而且在运行过程中难以诊断和修复。Rust不会让这种疑难杂症蔓延到运行时期,而是在编译期间就会由编译器发现并指出。

通常,我们可以新建一对括号{}来创建一个新的作用域,这样就能够支持多个可变的引用了。

let mut s = String::from("hello");
{
    let r1 = &mut s;
} // r1 goes out of scope here, so we can make a new reference with no
// problems.
let r2 = &mut s;

还有一个相似的规则用来规定对一个变量同时进行可变的引用和不可变引用的行为。

let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 有问题,但是如果不打印的话不报错
println!("r1 is {}", r1);
println!("r3 is {}", r3); // 报错 error[E0502]

报错信息:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable

如果一个变量已经被不可变的引用了,那么这个变量就不再能够变成可变引用。
因为引用变量认为它所引用的变量值是一直不变的,而可变引用破坏了它的不可变属性,因而这是不允许的。

悬挂引用

在有指针的语言中,有一个常见的错误就是悬挂指针。什么是悬挂指针,是指指针指向的内存已经分配给了其它变量或者已经被释放了。相比较而言,在Rust中编译器保证引用不会变成悬挂引用。
比如下面的代码:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // 报错 error[E0106]: missing lifetime specifier
    let s = String::from("hello");
    &s
}

这儿报的错误是一个Rust的新特性——生命周期。后续会详细讲解。

我们看看为什么会产生这个错误?s在dangle函数中定义的,函数的返回值是一个引用,但是问题在于dangle函数退出的时候s会离开作用域,因而会被释放。这就造成了悬挂引用的错误。

目前已经学习到的Rust用法,可以这么改:

fn main() {
    let reference_to_nothing = dangle();
}
fn dangle() -> String {
    let s = String::from("hello");
    s
}

我们来总结一下引用的规则:
任何时候,可以选择下面中的一种:仅有一个可变的引用或者任意个数的不可变引用;
不管什么时候,引用必须是可用的;

切片类型

切片类型是另一个不需要考虑所有权的类型,和整数类型一样。切片是一个集合中的一段元素的引用。

String切片

fn main() {
    let s = String::from("Hello world.");

    let hello = &s[0..5];
    let world = &s[6..11];
    println!("{}   and {}", hello, world);
}

引用0~5的字符,不包括下标为5的字符。
我们可以通过中括号指定范围来创建切片:[start..end],包含开始的字符,不包含结束的字符。长度就是end-start
下面的相同:

let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2]; // 开始的0省略

//下面的相同:
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..]; // 结尾的省略

//下面的相同:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..]; // 开始和结尾的省略

需要额外注意的是UTF8编码的String,如果不小心把index放在了UTF8字符的中间位置,那么会报错的!

字符串常量就是切片

let s = "Hello, world!";

s的类型是&str,是一个不可变的引用。

String切片作为参数

fn first_word(s: &String) -> &str {
    // ...
}

fn first_word(s: &str) -> &str {
    // ...
}

还可以将字符串常量直接往里传:

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

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);
    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

其它切片
String切片是字符串的特例。还有更多普通类型的切片,比如数组

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

slice的类型是&[i32],字符串的类型叫做&str,比较不一样!

教头Lily

铁汉柔情

文章评论

您需要 登录 之后才可以评论