点击我的视频网校,学习编程课程,或者关注我的微信公众号“编程之路从0到1”,了解课程更新

Rust 基础入门(二)

基础入门

变量与常量

通过let关键字声明变量,变量遵循先声明后使用的原则。

// 类型自动推断(整型字面量默认推断为i32类型)
let num = 18;

// 后跟冒号,显式指定变量的类型
let l :i16 = 2021;

注意,let声明的变量默认是不可变的,在第一次赋值后不能通过再次赋值来改变它的值,即声明的变量是只读的。如需声明可变的变量,则需加mut关键字,告诉编译器这个变量是可以重新赋值的:

let mut n = 10;
// 可重新赋值
n = 20;

Rust允许在同一个代码块中声明一个已存在的同名变量,这个新变量会遮蔽之前的变量,即无法再去访问前一个同名变量,这称为变量遮蔽。其实质是声明了一个新变量,只是名称恰巧与前一个变量名相同而已,但它们是两个完全不同的变量,处于不同的内存空间,值不同,值的类型也可以不同:

// 变量遮蔽
let a = 8;
let a = a + 1;
let a = a * 2;
println!("a is {}",a);

let a = "Hello,World!";
println!("a is {}",a)

Rust中使用const关键字来声明常量。常量名通常是大写字母,且必须显式指定常量的数据类型:

// 声明常量
const MIN_VALUE:u8 = 0;
const PI : f32 = 3.1415;

小结,不可变变量与常量的区别:

  • 变量遮蔽的方式可以模仿出不可变变量被重新赋值的形式(本质上是新的变量,只是变量同名而已)。但是,常量不能遮蔽,不能重复定义,常量一旦定义后就永远不可变更和重新赋值。
  • 常量只能被赋值为常量表达式或数学表达式,不能是函数返回值,或是其他在运行时才能确定的值。

数据类型

标量类型是单个值类型的统称。Rust中内建了4种基础的标量类型:整数、浮点数、布尔值及字符。其中,isizeusize是两种特殊的整数类型,它们的长度取决于程序运行的目标平台。在64位架构上,它们就是64位的,而在32位架构上,它们就是32位的。注意,它们主要用作某些集合的索引。

Rust对于整数字面量的默认推导类型是i32。除了整数,Rust还提供了两种基础的浮点数类型,分别是f32f64,它们分别占用32位和64位空间。在Rust中,默认会将浮点数字面量的类型推导为f64

Rust也提供了相应的字符类型支持——charchar类型使用单引号指定,而不同于字符串使用的双引号。注意,Rust中的char类型占4字节,是一个Unicode标量值,这也意味着它可以表示比ASCII多得多的字符内容。拼音字母、中文、日文、韩文、零长度空白字符,甚至是emoji表情都可以作为一个有效的char类型值。

复合类型可以将多个不同类型的值组合为一个类型。Rust提供了两种内置的基础复合类型:元组(tuple)和数组(array)。

元组

元组是一种相当常见的复合类型,它可以将其他不同类型的多个值组合进一个复合类型中。元组还拥有一个固定的长度:你无法在声明结束后增加或减少其中的元素数量。

// 一个元组也被视作一个单独的复合元素
let tup:(i32,f64,u8) = (100,3.14,1);
// 解构元组
let (x,y,z) = tup;

除了解构,还可以通过使用点号.加索引来访问元组中的值:

println!("{} {} {}",tup.0,tup.1,tup.2);

数组

与元组不同,数组中的每一个元素都必须是相同的类型。并且Rust中的数组是固定长度的,一旦声明就不能更改大小。

// 冒号后显式标注该数组的类型
let arr:[i32;5] = [1,2,3,4,5];
// 自动推断
let a = [1,3,5,7];

为了标注出数组的类型,你需要使用一对方括号,并在方括号中填写数组内所有元素的类型、一个分号及数组内元素的数量。

还有另一种初始化数组的语法:

let array = [9;3];
// 等价于 let array = [9, 9, 9];
// 分号前指定初始化数组的值,分号后指定数组长度

注意,数组由一整块分配在栈上的内存组成,你可以通过索引来访问一个数组中的所有元素。

切片

切片是一个没有所有权的数据类型,它允许你引用序列中一段连续的元素,而不用引用整个序列。Rust中的切片用法和其他其他语言类似:

fn main() {
    let arr = [1, 3, 5, 7, 9,11];
    // 对数组切片
    let slice = &arr[1..4];
    println!("{:?}",slice);   // [3, 5, 7]
}

切片中的索引语法,参见Range,我们可以对数组、字符串等序列类型进行切片。

结构体

结构体,是一种自定义数据类型,它允许我们命名多个相关的值并将它们组成一个有机的结合体。

// 定义一个结构体
struct User{
    username:String,
    email:String,
    active:bool
}

// 创建一个实例
let user = User{
    username:String::from("Bruce"),
    email:String::from("xxx@163.com"),
    active:true
};

当参数与结构体字段拥有完全一致的名称时,我们可以使用简化的结构体字段初始化语法:

// 结构体作为返回值
fn new_user(email:String,username:String)->User{
    User{
        email,
        username,
        active:true
    }
}

元组结构体

除了上面的定义方式,还可以使用另外一种类似于元组的方式定义结构体,这种结构体也被称作元组结构体。元组结构体同样拥有表明自身含义的名称,但无须在声明它时对其字段进行命名,仅保留字段的类型即可。

// 定义元组结构体
struct Point(i32,i32,i32);
struct Color(i32,i32,i32);

// 实例化
let origin = Point(0,0,0);
let black = Color(0,0,0);

注意,这里的blackorigin是不同的类型,因为它们两个分别是不同元组结构体的实例。我们定义的每一个结构体都拥有自己的类型,即便结构体中的字段拥有完全相同的类型。

Range

Rust中的所谓Range表示一个左闭右开区间,是std::ops::Range结构体的一个实例。区间中的值满足:start <= x < end

fn main() {
    // 快速声明一个Range变量
    let dozen = 0..9;
    // 显式指定变量类型
    let range1: std::ops::Range<usize> = 1..8;
    
    // 从结构体实例化一个Range
    let range2 = std::ops::Range{ start: 3, end: 5 };

    println!("{:?},{},{},{}",dozen,dozen.start,dozen.end,dozen.len());
    println!("{:?},{},{},{}",range1,range1.start,range1.end,range1.len());
    println!("{:?},{},{},{}",range2,range2.start,range2.end,range2.len());
}

可以看到,..其实是一个语法糖,我们可以直接实例化std::ops::Range结构体获得一个Range对象。它实际上是一个步长为1的迭代器,常用于循环语句:

fn main() {
    for i in 0..5 {
        print!("{} ", i);
    }
}

std::ops中除了Range之外,还有一些其他的表示区间的结构体:

  • RangeFrom:只包括开始边界的区间 (start..)
  • RangeFull:无边界区间 (..)
  • RangeInclusive:包括开始和结束边界的区间 (start..=end)
  • RangeTo:一个大于结束边界的区间 (..end)
  • RangeToInclusive:只包括结束边界的区间 (..=end)
// 等价于 r1 = std::ops::RangeFrom{ start:0 }
// 从0开始无限迭代
let r1 = 0..;

// 等价于 r2 = std::ops::RangeFull
// 无限迭代
let r2 = ..;

// 等价于 r3 = std::ops::RangeInclusive::new(0, 9)
// 全闭区间
let r3 = 0..=9;

// 等价于 r4 = std::ops::RangeTo{end: 5}
// 不能用于迭代,主要用于切片索引
let r4 = ..5;

// 等价于 r5 = std::ops::RangeToInclusive{ end: 8 }
// 同上,但包含end
let r5 = ..=8;

枚举

枚举类型是一个自定义数据类型,通过enum关键字加自定义命名来定义。其包含若干枚举值,可以使用“枚举名::枚举值”访问枚举值:

// 定义枚举
enum Auth {
    Enabled(i32),
    Disabled(i32)
}

用值初始化枚举

#[derive(Debug)]
enum Auth {
    Enabled(i32),
    Disabled(i32)
}

fn main() {
    let yes = Auth::Enabled(1);
    let no = Auth::Disabled(0);

    println!("yes: {:?}", yes);
    println!("no: {:?}", no);
}

上例,在变量yesno中,分别把1赋给Enabled,0赋给Disabled。我们不能直接打印枚举。需要实现std::fmt::Debug ,枚举上面的#[derive(Debug)]语句会自动做这件事。

通用枚举Option

Option 枚举是Rust中预定义的通用枚举,允许枚举返回一个值。正因为如此,Option 枚举是一个泛型,这意味着它有一个类型的占位符。Option 是预定义的,只有两个值:

  • Some,它返回一个值
  • None,基本上是返回NULL

注意,Rust不支持关键字NULL,所以None被用来允许一个函数返回一个空值。

enum Option<T> {
    Some(T), // 返回T的类型
    None	 // 返回一个空值
}

在尖括号中,指定Option枚举可能返回的类型。 Some之后的(T)是我们指定要返回值的位置。

enum Option<bool> {
    Some(true),
    None
}

fn is_even(num:i32) -> Option<bool> {
    if num % 2 == 0 {
        return Some(true);
    } else {
        return None;
    }
}

fn main() {
    println!("{:?}", is_even(5));
    println!("{:?}", is_even(88));
}

字符串

Rust中的字符串是极为复杂的,其中常用的字符串类型有两种:一种是语言核心字符串类型,即字符串切片str,通常以借用形式&str出现,用于表示固定长度的字符串字面量;另一种是可变长度的字符串对象String。该类型被定义在了Rust标准库中而没有被内置在语言的核心部分,它本质是一个字段为Vec<u8>类型的结构体。当在Rust中提到“字符串”时,通常指的是String与字符串切片&str这两种类型,而不仅仅只是其中的一种。

// 创建字符串&str类型变量
let greet:&str = "hello";
// 转换为String类型变量
let data:String = greet.to_string();

// 声明空String对象
let mut s1 = String::new();
s1.push_str("hi,Bob");

// 从给定字面量创建String对象
let s2= String::from("content");

注意,Rust字符串是基于UTF-8编码的,我们可以将任何合法的数据编码进字符串中。

此外,Rust还包含CStr CString OsStr OsString 等等类型字符串。

运算符

算术运算符:

运算符 描述
+ 加法
- 减法
* 乘法
/ 除法
% 取模

按位运算符:

运算符 描述
& 按位与
| 按位或
^ 按位异或
! 按位非
<< 左移
>> 右移

比较运算符:

运算符 描述
> 大于
< 小于
>= 大于等于
<= 小于等于
== 等等于
!= 不等于

赋值运算符:

运算符 描述
= 等于
+= 加等于
-= 减等于
*= 乘等于
/= 除等于
%= 模等于

逻辑运算符:

运算符 描述
&& 逻辑与
|| 逻辑或
! 逻辑非

条件分支与循环

let age = 19;

// 复合if判断
if age > 18 {
    println!("age > 18");
}else if age == 18 {
    println!("age == 18");
}else {
    println!("age < 18");
}

let is_birthday = true;
// 获取返回值
let message = if is_birthday {
    "Happy birthday"
} else {
    "No cake for you"
};

println!("{}", message);

// 由于Rust没有三目表达式语法,可以使用如下语法模拟
print("if execution") if 1==1 else print("else execution")

// 等价于以下代码
if 1==1:
    print("if execution")
else:
    print("else execution")

注意,在Rust中,match模式匹配也可用于流程控制,检查当前值是否匹配一系列模式中的某一个。模式可由字面值、变量、通配符和其他内容构成。每一个模式都是一个分支,程序根据匹配的模式执行相应的代码。

let num = 10;
match num {
    0 => println!("num error!"),
    1..=10 => println!("num in 1~10"),
    11..=20 => println!("num in 11~20"),
    21..=30 => println!("num in 21~30"),
    _ => println!("No match!"),
}

let grade = "B";
// 获取返回值
let _result = match grade {
    "A" => { println!("Fantastic, you got an A!"); },
    "B" => { println!("Great job, you got a B!"); },
    "C" => { println!("Good job, you got a C"); },
    "D" => { println!("You got a D, you passed"); },
    "F" => { println!("Sorry, you failed"); },

    _ => { println!("Unknown grade, please see the teacher"); }
};

Rust没有switch分支,但它的match模式匹配与之有些相似。需要注意,match模式匹配是穷尽式的,即必须穷举所有的可能性,否则会导致程序错误。switch语法会使用default分支来处理所有条件都匹配不上的情况,match模式匹配也有类似做法,将通配符_放置在最后,那么通配符_就会匹配之前没有指定的所有可能的模式。

Rust 有3种循环

  • loop循环。这种循环没有循环条件,会无限次重复执行一段代码,直到调用break语句退出循环。break语句是循环控制语句,用于退出循环并将返回值返回
  • while循环。在每次执行代码之前进行条件判断,只要条件表达式的值为true就会重复执行代码
  • for循环。使用for…in…语法格式,是一种重复执行指定次数的循环。常用于对范围类型或集合类型的遍历。
let mut i = 0;
// 1. loop循环
loop {
    if i > 10 {
        break;
    }
    println!("loop: i = {}",i);
    i += 1;
};


let mut i = 0;
// 2. while循环
while i < 5 {
    println!("while: i = {}",i);
    i += 1;
}

// 3. for循环
for j in 1..=5 {
    println!("for: j = {}",j);
}

需要注意,break关键字也可以返回一个值

  let n = loop {
      break 123;
  };
  println!("{}", n);    // 输出“123”

可以使用循环标签,来退出嵌套循环

'outer: for x in 0.. {
  for y in 0.. {
    for z in 0.. {
      if x + y + z > 1000 {
        break 'outer;
      }
      // …
    }
  }
}

函数

Rust 代码使用蛇形命名法(snake case)来作为规范函数和变量名称的风格。蛇形命名法只使用小写的字母进行命名,并以下画线分隔单词。

Rust 中的函数体由若干条语句组成,并可以以一个表达式作为结尾。由于Rust是一门基于表达式的语言,所以它将语句(statement)与表达式(expression)区别为两个不同的概念。区别在于,语句指那些执行操作但不返回值的指令,而表达式则是指会进行计算并产生一个值作为结果的指令。

let x = 3;

let y = {
    let x = 1;
    x + 1
};

如上例,y右边的代码块是一个表达式,它会计算一个结果并返回。需要注意x+1后面是没有分号的,如果添加分号,则变为一个语句,不会有任何返回值。

在Rust 函数签名中,必须显式地声明每个形参的类型。

Rust中函数定义的格式:

fn 函数名(形参列表)->返回值元组{

}

示例

fn add(a:i32,b:i32)-> i32 {
    return a + b;
}

fn sub(a:i32,b:i32)->i32 {
    a - b
}

// 定义多个返回值的函数
fn sum(a:i32,b:i32,c:i32)->(i32,i32){
    return (a + b + c,3);
}

println!("{}",add(10,8));
println!("{}",sub(10,8));

let r = sum(1,2,3);
println!("result is {},number is {}",r.0,r.1);

函数的类型

在Rust中,函数是被当作值来使用的,这就意味着它们也有自己的类型 :

fn add(a:i32,b:i32)-> i32 {
    return a + b;
}

fn main() {
    let f:fn(i32,i32)->i32 = add;
    println!("{}",f(3,3));
}

如上,add函数的类型就是fn(i32,i32)->i32。Rust中的fn值是函数在内存中的地址,它就相当于C语言中的函数指针。因此函数可以保存到一个变量中,也可以作为参数传递到另一个函数:

fn calc(f :fn(i32,i32)->i32){
    println!("{}",f(3,3));
}

fn main() {
    calc(add);
}

小结

  • 函数体由一系列语句和一个可选的结尾表达式构成。如add函数,使用return关键字返回计算结果;return是可以省略的,如sub函数,a - b是一个表达式而非语句,函数会自动将表达式的值作为返回值。但要注意,表达式的结尾是没有分号的!如果加上分号,它就变成了语句,而语句没有返回值,因此会报错。
  • Rust中每个函数都有返回值,即使是没有显式返回值的函数,也会隐式地返回一个空元组()。当我们需要返回多个值时,返回值列表应加上圆括号。
  • 函数可以被当作值使用,保存到变量中。

错误处理

Rust将错误分为两个主要类别:可恢复错误和不可恢复错误。

可恢复错误是指可以被捕获的且能够合理解决的问题,比如读取不存在文件、权限被拒绝等情况。一旦捕捕获到可恢复错误,Rust可以通过选择一个备用的操作来矫正错误让程序继续运行。不可恢复错误是指会导致程序崩溃的错误,可视为程序的漏洞,比如数组的越界访问。一旦发生不可恢复错误,程序就会立即停止。

Rust提供了分层式错误处理方案:

  • Option<T>:用于处理有值和无值的情况。比如在HashMap中指定一个键,但不存在对应的值,此时应返回None,开发者应该对None进行相应的处理
  • Result<T, E>:用于处理可恢复错误的情况
  • Panic:用于处理不可恢复错误的情况。如果在主线程中引发了Panic,则会造成应用程序以非零退出码退出进程,即发生崩溃
  • Abort:用于处理会发生灾难性后果的情况。使用 abort 函数可以将进程正常中止。

Option

主要用于消除了空指针问题。在枚举章节已经简单介绍过Option:

// unwrap_or 返回包装的值,若为None,则返回
assert_eq!(Some("Car").unwrap_or("Bike"), "Car");

Result

程序中的大部分错误并不需要停止程序,比如打开一个不存在的文件所产生的错误,可能更期望的是创建一个新文件,而不是中止程序。标准库提供的Result<T, E>可用于处理这类可恢复错误,它使用枚举来封装正常返回的值和错误信息。

Result<T, E>枚举包含两个值——OkErr。当值为Ok时,泛型类型T作为调用成功返回的值的数据类型。当值为Err时,泛型类型E作为调用失败返回的错误类型。

Result<T, E>的常规处理方法是使用match模式匹配:

use std::fs::File;

fn main(){
    let f = File::open("test.txt");
    let file = match f{
        Ok(file) => file;
        Err(error)=> {
            panic!("{:?}",error);
        }
    }
}

match虽然能对返回值进行处理,但是代码有些冗长。因此Result<T, E>类型提供了unwrapexpect方法可以实现相似的功能:

use std::fs::File;

fn main(){
    let f = File::open("test.txt").unwrap()
    // let f = File::open("test.txt").expect("Failed to open!")
}

若Result的值是Ok,unwrap方法会返回Ok中的值。反之值是Errunwrap方法会自动做Panic处理并输出默认的错误消息。expect函数功能类似,但它可以自定义错误消息。

使用match处理不同类型的错误:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => panic!("Problem opening the file: {:?}", other_error),
        },
    };
}

如上,大量嵌套match匹配的代码很冗长,可以使用unwrap_or_else方法处理,它可以执行一个闭包(语法详见闭包章节):

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt").unwrap_or_else(|error|{
        if error.kind() == ErrorKind::NotFound{
            File::crate("hello.txt").unwrap_or_else(|error|{
                panic!("Failed to create")
            })
        }else{
            panic!("Failed to open")
        }
    });
}

传播错误

当编写的函数中包含可能会失败的操作时,除了在这个函数中处理错误外,还可以把错误交给该函数的调用者处理,这被称为传播错误。

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username() -> Result<String, io::Error> {
    let f = File::open("hello.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    }
}

传播错误是很常见的,Rust提供了?操作符来简化代码。它与match匹配有着类似的工作方式,若Result的值是Ok,返回Ok中的值并继续执行下面代码;若Result的值是Err,则Err中的值将作为整个函数的返回值传给调用者。

?操作符重写上例:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}

?操作符之后,我们还可以使用链式方法调用进一步简化代码:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username() -> Result<String, io::Error> {
    let mut s = String::new();
    File::open("hello.txt")?.read_to_string(&mut s)?;
    Ok(s)
}

Panic

某些bug或无法合理处理,会导致程序崩溃,对于这种情况,程序会自动调用panic打印错误信息并清理栈数据后退出。

fn main() {
    panic!("crash and burn");
}

捕获Panic

Rust提供了panic::catch_unwind函数让开发者捕获Panic,以便程序可以继续执行而不被中止。需要注意,我们应避免滥用catch_unwind方法,因为可能会导致内存不安全。

use std::panic

fn main(){
	let vc = vec![1,2,3];
	println!("{}",vc[0]);
	let r = panic::catch_unwind(||{
		println!("{}",v[9]);
	});
	assert!(result.is_err());
	println!("{}",v[1]);
}

所有权体系

所有权机制的核心:

  • 变量是值的所有者。每块内存空间都有其所有者,所有者具有这块内存空间的释放和读写权限。

  • 每个值在任一时刻有且仅有一个所有者。

  • 当所有者(变量)离开作用域,这个变量将被丢弃。

Rust使用let关键字声明变量,这里的“变量”不是传统意义上的变量,本质上是一种绑定语义。将一个变量与一个值绑定在一起,这个变量就拥有了这个值的所有权。也就是将变量与存储这个值的内存空间绑定,从而让变量对这块内存空间拥有所有权。并且Rust确保对于每块内存空间都只有一个绑定变量与之对应,不允许有两个变量同时指向同一块内存空间。

一个变量的生命周期是指它从创建到销毁的整个过程。每个let关键字声明的变量都会创建一个默认的词法作用域,变量在作用域内有效。当变量离开作用域,它所绑定的资源就会被释放,变量也会随之无效并被销毁。

所有权转让

Rust一个值只允许有一个所有者,那么当我们把一个变量的值分配给另一个变量时会发生什么?

fn main() {
    let a = String::from("Hello");
    let b = a;
    
    println!("A: {}", a);
    println!("B: {}", b);
}

当我们将a赋值给b时,该值被移到了b上,而不是被复制。因为一个值只能有一个所有者,当我们试图访问a时,编译器将引发一个错误:

 let a = String::from("Hello");
  |         - move occurs because `a` has type `std::string::String`, which does not implement the `Copy` trait
4 |     let b = a;
  |             - value moved here
5 |
6 |     println!("A: {}", a);
  |                       ^ value borrowed here after move

上面的错误解释了String类型不能被复制,所以值被移到了b上,即所有权从a转移到了b。

fn main() {
    let a = 10;
    let b = a;

    println!("A: {}", a);
    println!("B: {}", b);
}

看上例,当我们将a赋值给b时,它创建了一个值10的副本并将其赋值给a。它们并不共享值10,每个值都在内存中有自己的空间。当我们尝试用String类型做同样的事情时,值被移动,而不是被复制。

一个值的所有权被转移给另外一个变量绑定的过程,叫作所有权转移。上例中,String类型变量a赋值给变量b时,就发生了所有权转移。当发生所有权转移时,原来的变量a就不能再被访问,这种类型被称为移动语义类型。相对的,如果一个类型实现了Copy,则称为复制语义类型。对于实现 Copy 的复制语义类型来说,所有权不会发生转移。上例中 let a = 10,则变量a是复制语义类型,将a赋值给b不会发生所有权转移,所以变量ab都可以被访问。

注意,对于自定义类型(枚举、结构体等),默认是没有实现Copy的,因此属于移动语义类型,如果有必要,可以手动实现Copy,使之成为复制语义类型。但并不是所有的类型都可以实现Copy,Rust规定,对于自定义类型,只有所有成员都实现了Copy,这个类型才有资格实现Copy 。

小结:

  • Rust的内存管理使用所有权和借用来代替运行时的垃圾收集,并在编译时做出内存安全保证
  • 有三个所有权规则:
    • 一个变量拥有一个值。
    • 在任何时候,只允许有一个所有者
    • 如果所有者超出了作用域,值就会丢失
  • 复制语义类型(像i32这样的)是在内存中复制的。它们不会转移所有权
  • 当值不支持复制语义时,将转移所有权

借用和引用

变量对其管理的内存拥有所有权。这个所有权不仅可以被转移,还可以被借用。

如果我们想在一个函数中使用一个值,而不把所有权转移给该函数,我们可以从其所有者那里暂时借用该值。当函数用完这个值后,它就会被还给所有者。借用允许我们在不破坏 "单一所有者 "概念的情况下对一个单一的值有一个或多个引用。

当我们借用一个值时,我们用&操作符引用它的内存地址。Rust中的引用是一个地址,它被作为参数传递给一个函数。

引用(Reference)是一种语法(本质上是Rust提供的一种指针语义),而借用(Borrowing)是对引用行为的一种描述。引用分为不可变引用和可变引用,对应着不可变借用和可变借用。

如何引用和借用一个变量的值?

通过一个例子来看看这是如何工作的:

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

    // 使用 & 符引用
    let str_len = get_str_length(&a);
    println!("String length: {}", str_len);
}

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

在上面的例子中,变量a是字符串 "Hello "的所有者。如果我们在一个函数中使用这个变量,它将把所有权传递给函数。在这种情况下,我们不希望这样,所以解决方案是引用所有者a来代替。

在函数定义的参数列表中,我们在类型前添加了&参数,以告诉Rust,进来的值将是一个值的引用,而不是实际的值。这个函数只是借用了这个值,并在函数执行完毕后将其归还。当我们调用这个函数时,我们必须再次添加&运算符,这次是在我们传递的值前面添加。

如何改变一个借来的值?

借用值的作用几乎和普通变量一样,它是不可变的。但是,可以用mut关键字使它可变。

fn main() {
    let mut a = 5;
    mutate_value(&mut a);
    println!("{}", a);
}

fn mutate_value(num:&mut i32) {
    *num = 3;
}

可变借用必须在变量初始化、函数定义(在参数列表中)和函数调用中添加mut关键字。&符号表示只读借用,&mut符号则表示可读可写借用。

注意,可变借用需要原有变量自身使用关键字mut进行修饰声明。如上例中,如果变量a未被mut修饰,则&mut a操作是不允许的。

如何解引用(获取其值)?

当我们使用引用 & 操作符时,我们得到该值的内存地址。为了获得该地址的实际值,可以使用解引用操作符*

fn mutate_value(num:&mut i32) {
    *num = 3;
}

在函数定义中,使用解引用运算符来获取参数的内存地址对应的值,并对其进行修改。

借用必须遵循三个规则:

  1. 借用的生命周期不能比出借方(拥有所有权的对象)的生命周期更长
  2. 可变借用&mut只能指向本身具有mut修饰的变量,对于只读变量,不可以有&mut借用
  3. 可变借用存在的时候,被借用的变量本身会处于“冻结”状态,因为可变借用具有独占性

借用只能临时地拥有对这个变量读或写的权限,没有义务管理这个变量的生命周期。因此,借用的生命周期绝对不能大于它所引用的原来变量的生命周期,否则就出现了悬空指针。对于第2、3条,可以概括为一句话:共享不可变,可变不共享

不可变借用和可变借用就相当于内存的读写锁,同一时刻,只能拥有一个写锁,或者多个读锁,不能同时拥有。

生命周期标记

Rust 在编译的时候能够自动推断变量的生命周期之间是否合理覆盖。如果变量 a 需要引用变量 b 的数据,那么 b 的生命周期必须覆盖a。否则 b 释放了,a 就成了非法引用了。然而有时候编译器无法做出正确的生命周期推断,如下例:

fn get_str(s1: &str, s2: &str) -> &str {
    if s2.len() > s1.len() {
        s2
    } else {
        s1
    }
}

fn main() {
    let s1 = String::from("abc");
    let s2 = String::from("de");
    let r = get_str(&s1, &s2);
    println!("get_str:{} ", r);
}

编译以上代码会产生报错,这是因为get_str函数返回值的生命周期,可能与 s1 相同,也可能与 s2 相同。因此,该函数返回值的生命周期在编译阶段是无法推断的。于是,编译器报错提示我们添加相关标记说明,帮助编译器计算该函数返回值的生命周期。

为解决此问题,Rust 引入了生命周期标记,其实就是帮助编译器在函数内部,及函数调用两个地方进行生命周期分析。

生命周期标记的语法与泛型语法类似,标记则使用单引号开头,后跟一个合法的名称:

fn get_str<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s2.len() > s1.len() {
        s2
    } else {
        s1
    }
}

上例中,使用函数泛型的尖括号,声明了一个生命周期标记 'a 。参数变量 s1s2 指定了相同的生命周期标记 'a。如果多个输入变量(函数参数)拥有相同的生命周期标记,那么最终生命周期取其交集。此处,'a 代表 s1s2 生命周期的交集。接着输出变量(函数返回值)的生命周期也指定了'a。因此,函数的返回值的生命周期就是 s1s2生命周期的交集。如此,编译器就能确定返回值的生命周期,再次编译报错解决。

小结:

  • 生命周期标记并不能改变任何引用的生命周期长短,它只用于编译器的借用检查,来防止悬垂指针

  • 生命周期标记参数位于引用符号&之后,并使用空格来分隔生命周期标记和类型

  • 禁止在没有任何输入参数的情况下返回引用,因为会造成悬垂指针

    // 编译会报错,因为s在离开函数作用域后就被释放了
    fn get_str2<'a>()-> &'a str{
        let s = String::from("rust");
        &s
    }
    
  • 从函数中返回一个引用,其生命周期必须与函数参数的生命周期相关联,否则,生命周期标记毫无意义

    // 返回值的生命周期与参数并无关联
    fn get_str3<'a>(s1: &'a str, s2: &'a str)-> &'a str{
        let s = String::from("rust");
        s.as_str()
    }
    
  • 生命周期可以存在包含关系,比如 'a 可以包含 'b,标记为 'a : 'b

    // s1 包含 s2, s2 包含返回值
    fn get_str<'a, 'b, 'c>(s1: &'a str, s2: &'b str) -> &'c str  where 'a : 'b, 'b : 'c {
        if s2.len() > s1.len() {
            s2
        } else {
            s1
        }
    }
    

    如果'a : 'b, 'b : 'a,这就表示 s1、s2 生命周期相同

  • 全局变量的生命周期为 'static,它是一个特殊的生命周期,比其他任何生命周期都长,对于任意生命周期'a,都有 'static : 'a


关注公众号:编程之路从0到1

了解更多技术干货

编程之路从0到1

# Rust  

评论

公众号:编程之路从0到1

公众号

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×