Rust 笔记 - 基础编程概念、Ownership

The Rust Programming Language

Rust 编程语言笔记。

来源:The Rust Programming Language By Steve Klabnik, Carol Nichols

翻译参考:Rust 语言术语中英文对照表


Rustaceans

Rustaceans 指的是使用过、贡献过或对 Rust 语言有兴趣的用户。


安装

使用 rustup 来安装 Rust。

Rust 源文件以 .rs 作为扩展名。

几个常用的命令:

  • 编译:rustc .rs-file
  • 运行:./compiled-file
  • 检查 Rust 编译器版本:rustc --version
  • 检查 rustup 版本:rustup --version
  • 更新 Rust:rustup update
  • 卸载 Rust 和 rustuprustup self uninstall
  • 本地文档:rustup doc

Rust 是预(ahead-of-time)编译语言,意思是可以把编译后生成的可执行文件发送给别人,他们可以在不安装 Rust 的前提下运行该文件。


Cargo

Cargo 是 Rust 的构建系统和包管理工具。

几个常用的命令:

  • 检查 Cargo 的版本:cargo --version
  • 新建项目:cargo new
  • 项目构建:cargo build
  • 运行项目:cargo run
  • 项目检查:cargo check:该命令确保项目可以编译,但不生成可执行文件,速度比 c--argo build 更快
  • 发布:cargo build --release

在运行 cargo build 后,Rust 会把编译后的二进制文件放在 target/debug 文件夹下。

在 Rust 中,包被称为 crates(也可以不翻译)。

Rust 使用 TOML(Tom's Obvious Minimal Language) 格式来管理依赖。

使用 cargo new 创建新项目后,在该项目的目录下会有名为 Cargo.toml 的文件来管理依赖和包。

可以使用 cargo add 来添加依赖,也可以在 .toml 文件的 [dependencies] 字段下添加包名。

在第一次使用 cargo build 后,Rust 会在根目录生成名为 Cargo.lock 的文件用于追踪包/依赖的版本。

例如:

[dependencies]
rand = "0.8.5"

包的版本使用三位的标准包标准

.rs 文件中使用关键字 use 来导入包。


规范

  • Rust 的缩进采用的是 4 个 space,而不是 tab
  • Rust 使用 snake case 对变量和函数命名,也就是全部使用小写字母,单词中间使用下划线 _ 连接,例如:var_name
  • Rust 使用 全大写 对常量命名,单词之间使用下划线 _ 连接,例如:MINUTES_WITHIN_A_DAY

Hello World

fn main() {
    println!("Hello, World!");
}
  • fn 是函数(function)的关键词
  • main() 是 Rust 的主函数、类似于 C、C++
  • 每行结束需要用分号 ; 表示

基础编程概念

注释

Rust 有三种注释:

  • 单行注释: //
  • 多行注释:/* */
  • 文档(DocString)注释://///!

变量和可变

变量

Rust 是静态类型语言,在声明变量时需要使用关键词 let 并在冒号 : 后指明变量的类型。这点类似于 Python 的 Type-Hint 以及 TypeScript

let var: u8 = 0;

可变

Rust 中的所有变量都有可变(mutable)或不可变(immutable)。

如果在声明变量时不明确指明,那么默认为不可变。

使用关键字 mut 使得变量可变。

let mut var: u8 = 2;
u8 = 3; // That's OK

let ano_var: u8 = 2;
u8 = 3; // Error

隐藏、覆盖 Shadow

fn main() {
    let x: u32 = 5;
  	let x = x + 1;
	  {
    	let x = x * 2;
	    println!("The inner x: {x}");
  	}
	  println!("The outer x: {x}");
}

// The inner x: 12
// The outer x: 6

如上述代码所示:声明了多个名为 x 的变量,在 Rust 中,我们称 第一个 x 被第二个 x shadow(隐藏、覆盖),或者 第二个 x overshadow 了第一个 x,这意味着:当使用 x 这个变量时,编译器总是使用第二个 x,即第二个 x 拿走了第一个 x 的所有,直到程序流离开第二个 x 的作用域或者第二个 x 也被 shadow。

shadow 和 mut 的区别

  1. shadow 使得可以改变同名变量的类型,但是 mut 会在编译时出错

    fn main() {
        let x: &str = "     ";
    	  let x: usize = x.len(); // 5
      
        let mut x: &str = "     ";
        x = x.len()             // Error
    }
    
  2. shadow 使得可以变不可变的同名变量的值

    fn main() {
        let x: u32 = 5;
        x = 6;             // Error, 因为没有变量默认是不可变的
        
        let x: u32 = 5;
        let x = 6;
      	println!("{}", x)  // 6
    }
    

常量

Rust 使用 const 关键字声明常量。通常来说,需要用大写字母声明常量。

const THE_DAY_I_START_LEARNING_RUST: String = "2023.5.9";

不可变的变量和常量的区别在于:

  1. 常量只能是不可变的,不能通过 mut 关键字使得常量可变
  2. 常量通常只赋值给常量声明的表达式,而不是运行时的计算表达式
  3. 常量可以在任何作用域(scope) 中声明,在程序运行期间,在其声明的作用域中是有效的

数据类型

Rust 是静态类型语言,因此在编译时就需要知道所有的变量类型。

例如在数据转换中:

fn main() {
    let var = "100".parse().expect("Not a number!"); // Error
    let var: u32 = "100".parse().expect("Not a number"); // OK, var == 100
}

基本类型

Rust 的基本数据类型有 4 种:

  • 布尔型 bool
  • 整型 integer
  • 浮点型 float
  • 字符型 char

布尔型

布尔型有两个值:

  1. true
  2. false

整型

整型有 6 种长度,每种长度分为有符号无符号两类。

长度 有符号 无符号
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize

整型字面量(literal)

字面量 举例
十进制 98_222
十六进制 0xff
八进制 0o77
二进制 0b1111_0000
字节(u8) b'A'
  • 整型的默认类型为 i32
  • 在使用集合类数据类型的索引时,使用 usizeisize

浮点型

浮点型有两类,而且都是有符号的:

  • f32
  • f64:默认类型

字符型

字符型用 c (character)标识。

复合类型

Rust 的复合类型包括:

  • 元组
  • 数组

元组

元组 tuple:用括号 () 声明,元组元素的数据类型可以不同

元组是定长的,一旦声明后就不能改变大小。

let tup: (u32, f32, bool) = (1, 2.2, true);

Python 不同,Rust 中的元组可以使用 . 运算符来访问。

let tup: (u32, f32, bool) = (1, 2.2, true);
println!("{}", tup.0); // 1
println!("{}", tup.2); // 2.2
println!("{}", tup.3); // true

没有元素的元组被称为单元(Unit),写作 (),表示空值和空返回类型。

数组

数组 array: 用方括号 [] 声明,数组元素的数据类型必须相同

数组也是定长的。

let arr: [u8; 5] = [1, 2, 3, 4, 5];
let arr_2: [3; 5]; // [5, 5, 5]

; 的前后分别表示数组元素的类型和数组元素的个数(如果前面用数字表示,则重复后面的数字前面次)。

可以使用索引 [],访问数组元素,因为数组以定长形式存储在栈中:

let first = arr[0];

如果使用越界索引访问数组:

let arr: [u8; 5] = [1, 2, 3, 4, 5];
println!("{}", arr[5]); // runtime error

会导致 运行时 错误。Rust 会在运行时检查是否有越界行为。


控制流

条件

if, else if, else 等关键字控制程序流。

Rust 的条件语句不需要外嵌括号 (),但是也不同于 Python 的 :,条件执行体需要用花括号:{}

fn main() {
  let var: u32 = 8;
  if var > 0 {
    println!("{var} is greater than 0!");
  } else if var < 0 {
    println!("{var} is less than 0!");
  } else {
    println!("{var} is equal to 0!");
  }
}

注:与 JavaScript、Ruby 等语言不同,Rust 不会在条件判断时自动转换变量的类型,例如:

fn main() {
	let var: bool = true;
  if var == 0 {
    println!("You can't get there in Rust");
  }
}

更简洁的写法是在 let 中使用 if

fn main() {
  let condition: bool = true;
  let number: u32 = if condition { 5 } else { 6 };
  println!("{}", number) // 5
}

循环

Rust 中有 3 种循环:

  • 无限循环 loop
  • while 循环
  • for 循环

结束、跳出循环的关键字:

  • break 跳出当前循环
  • continue 结束本次循环(开始下一次循环)

loop 循环

Rust 可以使用关键字 loop 来创建无限循环,例如:

loop {
  println!("This is a infinite loop");
}

// 等价于
while true {
  println!("This is a infinite loop");
}

continueloop 循环无效。

可以在 break 后添加跳出循环的返回值。

fn main() {
  let mut count = 0;
  let reture_value = loop {
    println!("{}", count);
    count += 1;
    if count == 5 {
    	break count * 2;
    }
  }
}

可以用 ``nameloop` 循环命名(标识符)以跳出多层循环,例如:

fn main() {
  let mut count: u32 = 10;
	`outer: loop {
    let mut remaining: u32 = 2;
    loop {
      println!("{}", count * remaining);
      remaining -= 1;
      if remaining == 0 {
        break outer;
      }
    }
    count -= 1;
    println!("{}", count);
    if count == 0 {
      break;
    }
  }
}

while 循环

类似于其他语言的 while 循环。

同样,Rust 的循环条件语句不需要外嵌圆括号 (),但是也不同于 Python 的 :,循环体需要用花括号:{}

fn main() {
  let mut count = 10;
  while count > 0 {
    println!("{}", count);
    count -= 1;
  }
}

for 循环

for in

for in 循环对遍历对象中的每一个元素都执行遍历:

fn main() {
  let arr: [u32; 5] = [1, 2, 3, 4, 5];
  for element in arr {
    println!("{}", element);
  }
}

类似 Python 的同名函数。

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

使用 .rev() 倒序输出:

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

函数

使用关键字 fn 声明函数,Rust 总是首先执行 main 函数。

fn main() {
  let var: u32 = 3;
  increment_and_print(var);
}

fn increment_print(num: u32) -> u32 {
	num += 1;
  println!("{}", num);
  return num;
  // num
}

如上述代码所示:Rust 并不关心函数定义的位置,因此在 main 函数前后定义其他函数都不会错误。

函数的参数需要声明名称和类型,返回值用 -> 表示,只需要声明类型。

可以使用关键字 return 显式返回值,也可以把返回值写在最后一行(不要使用 ; 结尾)。Rust 默认把最后一行的值作为返回值。

语句 VS 表达式

Rust 是基于表达式的语言。在 Rust 中,语句(statements)和表达式(expressions)有明显的区别:

  • 语句:一段完成行为的指令,返回值,以分号结尾。
  • 表达式:给结果返回值,可以是语句的一部分,以分号结尾(通过添加分号把表达式转化为语句,同时失去返回值)
let y  = (let x = 6); // Error

上面的代码中,赋值语句并不返回任何值,因此,不能把 let x = 6 赋值给 y。这不同于 C、Ruby 等语言,其赋值语句会返回赋予的值。

而对于表达式:

{
  let x: u32 = 6;
  x + 1 // 返回 7
}

输入和输出

输入

使用标准库 std::io 来导入标准库的 io。

use std::io;

fn main() {
  let mut inp: String = String::new();
 	io::stdin.read_line(&mut inp).expect("Failed to read"); 
  println!("{inp}");
}
  • 使用 stdin 引入标准输入流,
  • 使用 read_line(&) 来读取输入,
  • 使用 expect() 来处理异常情况。

输出

使用 println!() 来输出内容。

println!() 表示调用 Rust 宏(macro)println() 表示调用函数

Rust 不支持直接输出变量的值,必须格式化(format)后输出。在引号 "" 中使花括号 {} 来输出变量。

fn main() {
    let var: u32 = 15;
  	println!("{}", var); // 15
  	// println!("var equals {var}")  Also ok!
  	// println!(var)  Error!
}

所有权 Ownership

Ownership 是 Rust 语言管理内存的规则。

Ownership 是 Rust 语言最重要的特性之一,也是为什么 Rust 比 C++ 更安全的原因。

Ownership

栈内存和堆内存

要理解什么是 Ownership,首先要了解栈内存(stack)和堆内存(heap)的区别。

简单来讲,栈内存和堆内存都是程序在运行时使用的内存。

栈最重要的操作是入栈和出栈,入栈时把元素放在栈顶,出栈时把栈顶元素弹出,因此栈内存中的元素是先后顺序的。栈内存中存储的是定长的数据,而在编译时未知大小或者大小可能改动的数据会存储在堆内存中。

堆内存中的元素没有先后顺序,每次都要给元素分配足够大的空间。当数据需要存放在堆中时,内存分配器会分配给数据一块空闲区域,并返回一个指针,指向这块区域的地址。该指针是定长的,因此可以存储在栈中,但是如果要访问堆中的数据,必须要沿着指针指向/存储的地址。

因此,内存存储数据和访问数据时速度都要更,而堆内存则正好相反,因为其分别需要寻找空闲的内存空间以及沿着指针寻找地址。

垃圾回收和动态分配

对于静态类型语言来说,所有变量都在内存中占有一定的空间,该空间位置在 C、C++ 等语言中使用指针来表示。对于不使用的变量所占有的空间的释放是编程语言设计时的关键问题。通常应该及时释放其占有的空间,否则可能导致堆内存溢出,引起难以预料的安全问题;但如果释放太早,则可能导致后续对该变量的引用出错。

对于该问题,有一些语言(例如:Java)使用一种被称为“垃圾回收”(Garbage Collection / GC)的机制来追踪并释放不使用变量的内存。另一些语言(例如:C、C++)则需要用配对的 malloc(), free() 等函数来动态分配和释放空间。

Rust 则采用了 Ownership 机制。

Ownership 规则

  • Rust 中的每个值有一个 owner
  • 在同一时刻只能有一个 owner
  • 当 owner 超出作用域时,该值会被丢弃

变量 ownership 的转换

来看一个例子:如果两个变量指向同一段内存空间,此时如果释放第一个变量指向的空间(也是第二个变量的指向),那么引用第二个变量是否会导致错误?

fn main() {
  let string_in_stack: &str = "This string is stored in stack";
  let ano_string_in_stack: &str = string_in_stack;
  println!("{}", string_in_stack);
  println!("{}", ano_string_in_stack);
  
  let string_in_heap: String = String::from("This string is stored in heap");
  let ano_string_in_heap: String = string_in_heap;
  println!("{}", string_in_heap); // value borrowed here after move
  println!("{}", ano_string_in_heap);
}

上面的例子分别声明了两类字符串变量:

  • string_in_stack 存储在内存中。也称为“字符串字面量”,指的是通过双引号来表示的字符串,这类字符串是不可变的(这里的不可变指的是不能更改字符串中某个字符,因为其存储是连续的),但可以变整个字符串。这类字符串更加高效,原因是在编译时就已经知道了其大小,而且是定长的。

  • string_in_heap 存储在内存中。是由 String 类型创建的字符串,可以变。由于这类字符串是变长的,因此在编译时是不知道其内容的,只能在运行时请求在堆中分配内存(通过 String::from 完成)。

    当使用完该类字符串后,需要一种机制把内存还给分配器:

    • 带有 GC 的语言会追踪并清理不使用的内存空间
    • 没有 GC 的语言需要程序员去识别并释放内存
    • Rust 的做法是:一旦变量超出其作用域,该变量占据的内存将被自动回收(调用 drop 函数)

深、浅拷贝,移动和克隆

回到代码,存储在上的字符串 string_in_heap 的内存会被收回。这有些类似于浅拷贝(shallow copy),其只复制指针,长度和 capacity,但是并不复制原数据;但是 Rust 同时还释放了被拷贝变量的空间,所以该操作被称为移动(move)

因此,Rust 从来不会自动执行深拷贝(deep copy)

如果确实要“深拷贝”堆中的数据,可以使用 .clone()

fn main() {
    let string_in_heap: String = String::from("Hello");
  	let ano_string_in_heap: String = string_in_heap.clone();
    println!("{} {}", string_in_heap, ano_string_in_heap);
}

// Hello Hello

因此,变量 ownership 的变化模式是相同的:

  • 每当赋值给另一变量时,ownership move
  • 当堆中的变量超出作用域时,ownership drop,除非 ownership 又被移交给其他的变量

Copy、Drop

Rust 中有一个被称为 Copy 的 特质(trait),可用于某种数据类型。实现了 Copy 的数据类型的变量可以赋值给另一个变量,而不会导致 move 操作。

Rust 不允许在实现了 Drop 特质(trait) 的数据类型上实现 Copy

实现 Copy 特质(trait) 的数据类型包括:

  • 所有的整型
  • 所有的布尔型
  • 所有的浮点型
  • 所有的字符型
  • 只包含上述四种的类型作为成员的元组

函数和返回值的 Ownership

函数和变量类似,给函数传递值会导致 move 或者 copy。

返回值也会转移 ownership。

如果要让函数使用变量但是不转移 ownership,可以使用引用(reference)

引用

引用和借用

引用(reference)类似于指针,它指向存储某个数据的内存。与指针不同的是,引用指向的数据总是有效的。

引用使得我们不需要带走 ownership 就可以获取数据。

使用引用的行为称为借用(borrowing)

使用引用

使用 & 来创建一个引用。

在使用引用类型时,需要修改函数签名,传参方式等,例如:

fn main() {
		let s1 = String::from("Hello");
  	let length = calculate_length(&s1); // 传入引用 &
  	// The length of s1: 5
  	println!("The length of s1: {}", length); 
}

fn calculate_length(s: &String) -> usize {  // 函数参数类型为引用 &
		s.len()
}

引用原则

在 Rust 中,引用的原则是:

  1. 只能同时一个可变引用或者多个不可变引用
  2. 引用必须有效(valid)

可变和不可变引用

上述的引用是不可变的。类似于变量,如果想改变变量的值,需要使用可变引用

& 后添加 mut 关键字来使用可变引用。

fn main() {
		let mut s1 = String::from("Hello");
  	change(&mut s1); // 添加 mut
  	// s1: "Hello, World"
  	println!("s1: {}", s1); 
}

fn change(s: &mut String) {  // 添加 mut
		s.push_str(", World")
}

可以同时使用多个不可变引用:

fn main() {
		let mut s1 = String::from("Hello");
  	let r1 = &s1; // Ok
  	let r2 = &s1; // Ok
}

但是只能同时使用一个可变引用:

fn main() {
		let mut s1 = String::from("Hello");
  	let r1 = &mut s1; // Ok (first borrow)
  	let r2 = &mut s1; // Err (cannot borrow)
}

也不能同时使用可变和不可变引用:

fn main() {
		let mut s1 = String::from("Hello");
  	let r1 = &s1; // Ok (first borrow)
  	let r2 = &mut s1; // Err (cannot borrow)
}

除非作用域不同:

fn main() {
		let mut s1 = String::from("Hello");
  	{
    		let r1 = &mut s1; // Ok (first borrow) 
  	}
  	let r2 = &mut s1; // Ok
}

引用的作用域

引用的作用域从声明开始到最后一次使用该引用

例如下面的代码没有问题

fn main() {
		let mut s1 = String::from("Hello");
    let r1 = &s1; // Ok (first borrow) 
	  let r2 = &s1; // Ok (second borrow) 
  	println!("r1 and r2 {} {}", r1, r2); // r1 and r2 no longer used
  
  	let r3 = &mut s1; // Ok!
}

数据竞争

Rust 的引用原则避免了数据竞争可能导致的编译时错误。

数据竞争指的是下述三种行为:

  1. 两个或以上只能同时指向同一个数据
  2. 至少一个指针被用于写入数据
  3. 没有同步机制读取数据

数据竞争可能导致未定义的行为而且在运行时难以诊断和调试。Rust 的引用限制能够有效避免这类问题。

有效引用和悬垂指针

悬垂指针(dangling point) 是指向了已经被 free 内存区域的指针。

在 Rust 中,编译器保证引用必须有效。也就是引用必须发生在数据超出作用域前。

fn main() {
		let mut s1 = return_string(); // error
}

fn return_string() -> &String {
		let s = String::from("Hello");
  	&s	
} // s out of scope

/* correct the above code
fn return_string() -> String {
		let s = String::from("Hello");
  	s	// return string not reference
}
*/

切片类型

切片(slice) 指的是对集合的部分引用

语法

切片的语法为:[start_index..end_index],其中:

  • .. 是范围(range)运算符
  • start_index 是开始的索引。(如果忽略该项,默认从 0 开始)
  • end_index 是最后的索引(不包括该索引位置)。(如果忽略该项,默认以集合的末尾截止)

字符串切片

下面是字符串切片:

fn main() {
		let s1 = String::from("Hello, World");
  	let slice1 = &s1[0..5];
  	let slice2 = &s1[7..12];
  	println!("{} {}", slice1, slice2);
}

// Hello World

字符串切片的类型为 &str,因为是不可变引用 & (非 & mut),因此,字符串字面量是不可变的。

解引用强制转化

如果已知字符串切片,我们可以直接把它作为参数传入;如果已知 String 类型,我们可以传入该类型的切片或者对 String 的引用。这种灵活性称为解引用强制转化(deref coercion)

有经验的 Rustaceans 会把 &String 类型为参数的函数签名更改为 &str

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

这样的改动使得函数更加通用(general)同时并不损失任何功能。

例如:

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

    // `first_word` works on slices of `String`s, whether partial or whole
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or whole
    let word = first_word(&my_string_literal[0..6]);
    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);
}

如果 first_word 函数的参数类型为 &String:

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[..]
}

那么上述代码就会报错!。

如果为 &str:

fn first_word(s: &str) -> &str { // 只修改函数参数的类型
		// --snip--
}

代码就能成功执行。


posted @ 2023-05-10 13:29  Mitchell_C  阅读(78)  评论(0编辑  收藏  举报