从C++到Rust (二):所有权详解

系列文章目录

 

Rust入门手册 - 《C++ :从入门到放弃》(一)

目录

所有权 ownership

C++的指针安全:从智能指针谈起

所有权ownership、引用reference

浅拷贝、深拷贝与移动语义

Copy trait


所有权 ownership

        任何程序都需要在内存上运行,并且在运行时动态的分配和释放内存。不同的语言对于内存的管理采用了不同的机制,大致分为了三类:

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

C++的指针安全:从智能指针谈起

        智能指针的出现,使得C++程序员不必手动的管理内存的释放,这解决了【野指针】问题:一个指针总是能够指向一块有效的内存区域。对于unique_ptr而言,它似乎总是可靠的;然而,对于shared_ptr而言,它仍然具有下列的安全隐患:

  1. 多重所有权:多个shared_ptr持有一块内存,而其中的一个shared_ptr迟迟不释放自身,从而导致这块内存一直得不到释放
  2. 数据竞争:shared_ptr允许对内存的修改,在某些情况下,内存的数据的写入和读取会产生竞争——例如经典的多线程并发情况

        当然,上述的问题都是可控的,但是在庞大的代码量级面前,开发者难免要向未知的第三方(或许是某个三方库、或许是其他开发者)分享自己的shared_ptr指针,而一旦你将宝贵的指针分享给外部,那么这个指针上产生的操作就不再受控,也不再可信,毕竟,谁能保证第三方在使用你的指针时保证不会出错呢?(eg:一个第三方拿到了指针,但是由于自身bug迟迟不释放,垃圾内存一直存在)

        那么,这个问题如何解决?我们自然想到了shared_ptr的孪生兄弟weak_ptr,通过weak_ptr,我们可以在分享数据的同时,避免上述的潜在风险;但是首先,我们需要与所有开发者制定如下的编码原则:

  1. 为了解决多重所有权的问题,一个对象只允许有一个shared_ptr类型的强引用,当需要分享数据时,使用weak_ptr进行分享;
  2. 为了解决数据竞争的问题,当weak_ptr需要修改时,允许被短暂的转换为shard_ptr进行数据修改,但是同一时间只允许存在一个shared_ptr;

        在遵循上述原则进行编码后,令人恼火的多重所有权和数据的竞争访问问题,可以彻底被解决了。接下来,我们更进一步,将这些编码规范加入到编译器中——恭喜你,我们创建了一门新的编程语言,而这也正是Rust语言所有权机制的本质。

        简单来说的话,rust中每一个值(或者说分配的内存)都会有其对应的“owner”,并秉持着“谁创建,谁负责”的管理原则;其他人只能从owner手中“借用”访问权限,而无权对其进行管理(正如上文分析到的,分享出去的指针并不可信)。

所有权ownership、引用reference

        在理解了所有权与引用的本质之后,我们来详细的看一下它们详细的规则。对于所有权,它拥有如下的规则:

  1. Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。
fn main() {
    let s1 = String::from("hello");  // s1 进入作用域,接管"hello"这个字符串的所有权

    let s2 = s1;    //s1的所有权被转移给了s2,s1在此失效
    
    takes_ownership(s2);             // s2 的所有权移动到函数takes_ownership里
                                    //  s2在此失效
} 

fn takes_ownership(s: String) { //进入作用域
    println!("{}", s);  // s在这里接管了 main函数中s2传递过来的所有权
} // 函数退出,s离开作用域,“hello”这个字符串在这里被丢弃

来简单分析一下上述的代码过程:

  • 在main的第一行,创建了一个String类型的字符串,这将会在内存里分配一块新的区域。s1接管了这个字符串,成为它的owner;接下来,通过一个赋值过程,将s1的所有权转移给了s2;(有关赋值和所有权转移的内容,我们会在稍后探讨)
  • 调用了一个takes_ownership方法,并传递s2;一个与众不同的地方在于,在函数调用进行传参时以及函数的返回值传递时,也会发生所有权的转移。

之所以采取这样的设计,是因为rust并不信任第三方(或者说除了当前函数之外的任何代码);当你尝试传递一个对象时,你并不清楚第三方代码会对你的数据做什么,因此索性就将管理权一并交付出去,将内存的管理责任抛给第三方。

  • 在进入takes_ownership的函数栈后,s接管了来自传参的所有权;当函数退出,s释放后,其管理的内存被直接释放。(因为此时的管理权限在s上!)

        在上述的例子中,当一个变量的所有权失效后,任何对其尝试发起的访问都将导致编译错误;

        在进行函数调用传参后,原本的所有权被释放,如果我们仍然想在main函数中保留所有权,一个可选的方式是通过返回值归还所有权:

fn main() {
    let s1 = String::from("hello"); // s1取得所有权
    let s2 = return_ownship(s1); //s1的所有权转移给return_ownship函数,并且在这个函数中将所有权转移回s2
    println!("{}", s2);//s2拥有所有权
}// s2离开作用域,所有权释放,hello这个值被释放

fn return_ownship( s : String) -> String{
    println!("take_comon: {}", s); // s接管了来自s1的所有权
    return s; // 所有权传递,s的所有权被转移给了s2
}

        这个场景在实际的开发中非常常见,如果每次都需要进行这样一次所有权的改变,未免太过于啰嗦;为此,rust加入了【引用】,用来在不进行所有权转移的情况下分享数据:

  1. 引用可以在不改变所有权的情况下进行数据的访问;
  2. Rust使用 & 符号来表示一个值的引用
fn main() {
    let s1 = String::from("hello"); // s1取得所有权
    let s2 = reference(&s1); //创建了s1的引用,此时s1仍然持有所有权,未发生所有权转移
    println!("{}", s1); //s1仍然有效
}

fn reference( s : &String) {
    println!("take_comon: {}", s); //s接收了传入的引用对象,注意这里的s只是一个引用,没有任何的所有权
} // s退出,但是由于s并没有任何的所有权,所以这里不会发生数据的释放

        在这个示例代码中,函数的参数列表由 String类型变为了 &String类型,表示这个函数将会接受一个String类型的引用作为参数。在调用时,传入的参数也是 &s1,表示传入的是s1对象的引用。       

         Rust将创建一个引用的行为称为 借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。

        当我们尝试在reference函数中修改字符串中的内容时,上述代码将无法工作,因为一个引用无权对数据进行修改;为了修改这个字符串,rust加入了【可变引用】

  1. 一个可变引用,其所有权对象本身必须是可变的;
  2. Rust使用 &mut 关键字 表示一个可变引用
fn main() {
    let mut s1 = String::from("hello");
    mut_reference(&mut s1); //创建了s1的可变引用,将其传递
    println!("{}",s1); //打印“helloworld”
}

fn mut_reference( s : &mut String) {
    s.push_str("world"); //s是一个可变引用,在这里修改数据的值
}

        一个可变引用的所有权对象必须是可变的——毕竟,如果一个变量本身就不可变,那么它的引用也自然无法改变它的数据。

        在上文中对于C++指针安全的探讨中,我们已经讨论了关于数据竞争带来的问题;因此,为了防止数据竞争,rust对于可变引用引入了一系列严格的规则:

  1. 在同一个作用域中,只允许存在一个同一变量的可变引用;
  2. 对同一变量,可以同时拥有多个不可变引用,但无法同时拥有一个可变引用与不可变的对象;

        规则1避免了写入时的数据竞争问题,而规则2则保证了数据在同时读写时的竞争问题。需要注意的是,Rust对于引用的检测并不是发生在运行期,而是发生在编译期。下述的代码都将直接导致编译错误:

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

let r1 = &mut s; // 没问题

let r2 = &mut s; // 大问题
let mut s = String::from("hello");

let r1 = &s; // 没问题

let r2 = &s; // 没问题

let r3 = &mut s; // 大问题

浅拷贝、深拷贝与移动语义

        熟悉C++ 及C++11的开发者对于拷贝和移动的概念应该都非常熟悉。让我们简单回顾一下概念:

  1. 浅拷贝:只拷贝数据的指针,不拷贝数据本身
  2. 深拷贝:拷贝数据指针及数据
  3. 移动:将数据指向为另一个指针,数据本身不发生移动

        以上的概念都是对于C++的指针来说的,但是如果我们将其套用到rust的所有权上呢?来看一下可能发生的场景:

  1. 浅拷贝:数据本身只有一份,但是拷贝所有权给另一个对象;这样就会有两个对象拥有同一块内存的管理权限,当这个两个对象分别退出后,极有可能导致数据的多重释放。因此所有权的浅拷贝在rust中是禁止的。
  2. 移动:将所有权转移给另一个对象。这个是合理的,在上文示例中我们也多次看到了所有权的转移过程。在rust中,由于不允许所有权的浅拷贝,因此任何的赋值操作都将被视为一次移动操作
  3. 深拷贝:数据和所有权均发生拷贝;这个操作也是合理的,它产生了两个独立的对象,分别持有独立的所有权。在Rust中,使用clone方法以显式的指定深拷贝过程。

        而对于引用而言,由于引用无论怎么变化,都不会影响到数据的生命周期,可以认为对于引用的操作均是浅拷贝——你复制的仅仅是一个指向原数据的引用对象,对于原数据本身没有任何影响。

fn main() {
    let s1 = String::from("hello"); //s1 持有所有权

    let s2 = s1; //s1的所有权被转移给s2,在这里s1已经无法访问
    
    let s3 = s2.clone(); // s2和s3发生拷贝,各持有一份独立的数据
}

Copy trait

        细心的读者可能已经注意到,前文中与所有权相关的示例代码中,都是使用String类型展示的;如果我们使用i32等基本数据类型编写代码,那结果会如何呢?

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

        令人惊奇的结果——上述代码是可以通过编译并正常运行的。这与我们上文中分析的所有权系统似乎矛盾,x的所有权应该已经转移给了y,但是在上述的示例中,x和y都是可以正常访问的。

        要解释这个现象,让我们先暂时回到C++,思考这样一个问题:在什么情况下,深拷贝和浅拷贝是没有区别的?答案是:当数据都在栈区的时候,或者说,数据都是基本数据类型,没有指针指向堆区的情况。在C++中,移动、浅拷贝、深拷贝的讨论均是由指针引起的,由于在赋值时【栈上的指针】和【堆上的数据】行为不一致才产生了深浅拷贝的区别。

        同样的法则在Rust中也同样生效:如果对象的数据只储存在栈区,对象的赋值操作和拷贝操作将是等价的。因此在上述的示例中,第二行代码实际上等价于 let y = x.clone(); 因此x和y将仍然保持可用。

        那么为何在前文中的String类型不行?原因是Rust中的String类型其本质是一个类似于指针的数据结构,由两部分组成:

从C++到Rust (二):所有权详解_第1张图片

        String类型存储着字符串长度、地址等信息,这部分数据是存储在栈上的;而实际的字符串内容则是存储在堆上。因此对于String类型对象,赋值和拷贝是两个不同的行为。

        在Rust中,对于赋值的行为控制是由一个叫做Copy trait的特性实现的。我们会在后文详细讲解trait的相关内容,在这里我们只需要简单的知道:如果一个数据类型实现了Copy trait,那么它的赋值和拷贝操作将是等价的。

        那么哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

你可能感兴趣的:(开发语言,rust,c++)